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, MaternBasisSpec, MaternIdentifiability,
17 MaternNu, MeasureJetBasisSpec, MeasureJetIdentifiability, OneDimensionalBoundary,
18 SpatialIdentifiability, SphereMethod, SphereWahbaKernel, SphericalSplineBasisSpec,
19 SphericalSplineIdentifiability, ThinPlateBasisSpec, auto_spatial_center_strategy,
20 default_num_centers, default_spatial_center_strategy, default_spherical_harmonic_degree,
21 plan_spatial_basis, thin_plate_penalty_order,
22};
23use crate::inference::formula_dsl::{
24 ParsedTerm, SmoothKind, option_bool, option_f64, option_f64_strict, option_usize,
25 option_usize_any, option_usize_any_strict, option_usize_strict, strip_quotes,
26};
27use crate::smooth::{
28 BySmoothKind, ByVarKind, ByVariableSpec, FactorSmoothFlavour, FactorSmoothSpec,
29 LinearCoefficientGeometry, LinearTermSpec, RandomEffectTermSpec, ShapeConstraint,
30 SmoothBasisSpec, SmoothTermSpec, TensorBSplineIdentifiability,
31 TensorBSplinePenaltyDecomposition, TensorBSplineSpec, TermCollectionSpec,
32};
33use gam_data::{ColumnKindTag, DataError, EncodedDataset as Dataset};
34use gam_problem::types::ColIdx;
35use gam_runtime::resource::ResourcePolicy;
36
37/// Default B-spline degree when a smooth's `degree=` option is absent. Cubic
38/// (degree 3) is the standard GAM convention: C² continuity with a low knot
39/// count.
40const DEFAULT_BSPLINE_DEGREE: usize = 3;
41
42/// Default difference-penalty order when a smooth's `penalty_order=` (alias
43/// `m=`) option is absent. Second-order (curvature) is the standard P-spline
44/// convention.
45const DEFAULT_PENALTY_ORDER: usize = 2;
46
47/// Default basis dimension for one-dimensional cyclic cubic P-splines.
48///
49/// Periodic smooths spend no coefficients on free endpoints, so they should not
50/// inherit the larger open B-spline knot ceiling by default. This is still only
51/// a default: callers can request a richer periodic space with `k=`.
52const CYCLIC_DEFAULT_BASIS_DIM: usize = 12;
53
54/// Default shared-marginal basis dimension for `bs="fs"`/`bs="sz"` factor smooths,
55/// matching mgcv's factor-smooth default `k=10`. A factor smooth shares one
56/// marginal across all levels; a modest basis recovers the shared signal without
57/// over-fitting each group's within-group noise (gam#903). Overridden by an
58/// explicit `k`/`basis_dim`.
59const FACTOR_SMOOTH_DEFAULT_BASIS_DIM: usize = 10;
60
61/// Default row-chunk size for the out-of-core PCA-basis smooth when the
62/// `chunk_size=` option is absent. Streams the design in row blocks to bound
63/// peak memory independent of the dataset row count.
64const DEFAULT_PCA_CHUNK_SIZE: usize = 4096;
65
66// ---------------------------------------------------------------------------
67// Typed errors
68// ---------------------------------------------------------------------------
69
70/// Typed errors emitted by term-builder helpers. `Display` reproduces the exact
71/// pre-refactor `format!(...)` text byte-for-byte, so callers that string-match
72/// on the message (tests, log assertions) keep working unchanged. Public-API
73/// functions still return `Result<_, String>` and use `.to_string()` shims at
74/// their boundary to stay compatible with callers in protected modules.
75#[derive(Clone, Debug)]
76pub enum TermBuilderError {
77 /// Column-resolution / column-kind lookup failures whose context is purely
78 /// internal (column-kind table out-of-sync, alias map missing an entry,
79 /// etc.). User-facing "this formula references a column that doesn't
80 /// exist" diagnostics use the dedicated `ColumnNotFound` variant so the
81 /// FFI boundary can lift the structured payload into a Python
82 /// `ColumnNotFoundError` without parsing prose.
83 MissingColumn { reason: String },
84 /// A formula referenced a column that is not present in the input data.
85 /// Mirrors `DataError::ColumnNotFound` field-for-field so the conversion
86 /// across module boundaries is a pure data move (no re-derivation, no
87 /// string re-parsing). Public callers see byte-identical `Display`
88 /// output to the legacy `missing_column_message` text.
89 ColumnNotFound {
90 name: String,
91 role: Option<String>,
92 available: Vec<String>,
93 similar: Vec<String>,
94 tsv_hint: bool,
95 },
96 /// User-specified configuration is internally inconsistent (e.g. too few
97 /// variables for a smooth type, conflicting size options, requested basis
98 /// dimension below the polynomial nullspace).
99 IncompatibleConfig { reason: String },
100 /// Option parsing failure: malformed numeric expression, unknown option
101 /// key, out-of-range integer, list-length mismatch, etc.
102 InvalidOption { reason: String },
103 /// User requested a feature that is intentionally not supported (unknown
104 /// smooth type / method / kernel / identifiability, non-zero anchor,
105 /// internal-only token, etc.).
106 UnsupportedFeature { reason: String },
107 /// Input data is degenerate for the requested term (constant column,
108 /// non-finite categorical entries, ...).
109 DegenerateData { reason: String },
110 /// Term-collection-stage formula error — a node that the caller was
111 /// supposed to resolve upstream reached the builder.
112 MalformedFormula { reason: String },
113}
114
115impl std::fmt::Display for TermBuilderError {
116 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117 match self {
118 TermBuilderError::MissingColumn { reason }
119 | TermBuilderError::IncompatibleConfig { reason }
120 | TermBuilderError::InvalidOption { reason }
121 | TermBuilderError::UnsupportedFeature { reason }
122 | TermBuilderError::DegenerateData { reason }
123 | TermBuilderError::MalformedFormula { reason } => f.write_str(reason),
124 // Delegate to the canonical `DataError::ColumnNotFound` formatter
125 // so a single source of truth defines the human text. The
126 // intermediate `DataError` constructed here owns its strings only
127 // for the duration of the Display call — no allocation cost
128 // beyond the original payload that this variant already holds.
129 TermBuilderError::ColumnNotFound {
130 name,
131 role,
132 available,
133 similar,
134 tsv_hint,
135 } => {
136 let canonical = DataError::ColumnNotFound {
137 name: name.clone(),
138 role: role.clone(),
139 available: available.clone(),
140 similar: similar.clone(),
141 tsv_hint: *tsv_hint,
142 };
143 std::fmt::Display::fmt(&canonical, f)
144 }
145 }
146 }
147}
148
149impl From<TermBuilderError> for String {
150 fn from(err: TermBuilderError) -> String {
151 err.to_string()
152 }
153}
154
155/// Catchall lift for the term-builder's internal `Result<_, String>` helpers
156/// (numeric expression parsing, option lookup, boundary-condition parsing,
157/// ...) that flow into `build_termspec` via `?`. Maps to
158/// `IncompatibleConfig`, which is the most appropriate generic bucket for
159/// option/config-style failures — leaf sites that emit structured payloads
160/// (`From<DataError>` for column-not-found) bypass this fallback.
161impl From<String> for TermBuilderError {
162 fn from(reason: String) -> Self {
163 Self::IncompatibleConfig { reason }
164 }
165}
166
167/// Typed lift from data-layer errors. `DataError::ColumnNotFound` becomes
168/// `TermBuilderError::ColumnNotFound` field-for-field — no stringification,
169/// no information loss — so the FFI boundary downstream can dispatch on
170/// the typed variant. Other `DataError` variants degrade into
171/// `MissingColumn` since they describe column-resolution-time failures
172/// without a dedicated structured destination.
173impl From<DataError> for TermBuilderError {
174 fn from(err: DataError) -> Self {
175 match err {
176 DataError::ColumnNotFound {
177 name,
178 role,
179 available,
180 similar,
181 tsv_hint,
182 } => Self::ColumnNotFound {
183 name,
184 role,
185 available,
186 similar,
187 tsv_hint,
188 },
189 DataError::SchemaMismatch { reason }
190 | DataError::ParseError { reason }
191 | DataError::EncodingFailure { reason }
192 | DataError::EmptyInput { reason }
193 | DataError::InvalidValue { reason } => Self::MissingColumn { reason },
194 }
195 }
196}
197
198// Constructor helpers — keep error-site code compact and consistent.
199impl TermBuilderError {
200 #[inline]
201 fn missing_column(reason: impl Into<String>) -> Self {
202 TermBuilderError::MissingColumn {
203 reason: reason.into(),
204 }
205 }
206 #[inline]
207 fn incompatible_config(reason: impl Into<String>) -> Self {
208 TermBuilderError::IncompatibleConfig {
209 reason: reason.into(),
210 }
211 }
212 #[inline]
213 fn invalid_option(reason: impl Into<String>) -> Self {
214 TermBuilderError::InvalidOption {
215 reason: reason.into(),
216 }
217 }
218 #[inline]
219 fn unsupported_feature(reason: impl Into<String>) -> Self {
220 TermBuilderError::UnsupportedFeature {
221 reason: reason.into(),
222 }
223 }
224 #[inline]
225 fn degenerate_data(reason: impl Into<String>) -> Self {
226 TermBuilderError::DegenerateData {
227 reason: reason.into(),
228 }
229 }
230 #[inline]
231 fn malformed_formula(reason: impl Into<String>) -> Self {
232 TermBuilderError::MalformedFormula {
233 reason: reason.into(),
234 }
235 }
236}
237
238// ---------------------------------------------------------------------------
239// Column resolution
240// ---------------------------------------------------------------------------
241
242/// Resolve a bare column name to its index, returning a typed
243/// `DataError::ColumnNotFound` on miss so the FFI boundary can surface a
244/// structured `gamfit.ColumnNotFoundError(column=…, available=…)` rather
245/// than rely on string-classification of human prose. Internal callers that
246/// still flow `Result<_, String>` get byte-identical text via
247/// `From<DataError> for String`.
248pub fn resolve_col(col_map: &HashMap<String, usize>, name: &str) -> Result<usize, DataError> {
249 col_map
250 .get(name)
251 .copied()
252 .ok_or_else(|| DataError::column_not_found(col_map, name, None))
253}
254
255/// Like `resolve_col` but tags the missing-column payload with a role label
256/// (`"response"`, `"entry"`, `"exit"`, `"event"`, `"z"`, `"id"`, …) so the
257/// boundary-side Python exception can disambiguate which formula slot held
258/// the bad reference.
259pub fn resolve_role_col(
260 col_map: &HashMap<String, usize>,
261 name: &str,
262 role: &str,
263) -> Result<usize, DataError> {
264 col_map
265 .get(name)
266 .copied()
267 .ok_or_else(|| DataError::column_not_found(col_map, name, Some(role)))
268}
269
270fn encoded_levels_for_column(ds: &Dataset, col: ColIdx) -> Vec<(u64, String)> {
271 let mut seen = BTreeSet::<u64>::new();
272 for value in ds.values.column(col.get()) {
273 if value.is_finite() {
274 seen.insert(value.to_bits());
275 }
276 }
277 let schema_levels = ds
278 .schema
279 .columns
280 .get(col.get())
281 .map(|column| column.levels.as_slice())
282 .unwrap_or(&[]);
283 seen.into_iter()
284 .enumerate()
285 .map(|(idx, bits)| {
286 let fallback = format!("level{}", idx + 1);
287 let label = schema_levels.get(idx).cloned().unwrap_or(fallback);
288 (bits, label)
289 })
290 .collect()
291}
292
293pub fn column_map_with_alias(
294 col_map: &HashMap<String, usize>,
295 alias: &str,
296 target_column: &str,
297) -> HashMap<String, usize> {
298 let mut aliased = col_map.clone();
299 if let Some(idx) = col_map.get(target_column).copied() {
300 aliased.entry(alias.to_string()).or_insert(idx);
301 }
302 aliased
303}
304
305// ---------------------------------------------------------------------------
306// ParsedTerm[] + Dataset → TermCollectionSpec
307// ---------------------------------------------------------------------------
308
309pub fn build_termspec(
310 terms: &[ParsedTerm],
311 ds: &Dataset,
312 col_map: &HashMap<String, usize>,
313 inference_notes: &mut Vec<String>,
314 policy: &ResourcePolicy,
315) -> Result<TermCollectionSpec, TermBuilderError> {
316 let mut linear_terms = Vec::<LinearTermSpec>::new();
317 let mut random_terms = Vec::<RandomEffectTermSpec>::new();
318 let mut smooth_terms = Vec::<SmoothTermSpec>::new();
319 let smooth_coordinate_count = terms
320 .iter()
321 .map(|term| match term {
322 ParsedTerm::Smooth { vars, .. } => vars.len(),
323 _ => 0,
324 })
325 .sum::<usize>();
326
327 for t in terms {
328 match t {
329 ParsedTerm::Linear {
330 name,
331 explicit,
332 coefficient_min,
333 coefficient_max,
334 } => {
335 let col = resolve_col(col_map, name)?;
336 let auto_kind = ds.column_kinds.get(col).copied().ok_or_else(|| {
337 TermBuilderError::missing_column(format!(
338 "internal column-kind lookup failed for '{name}'"
339 ))
340 .to_string()
341 })?;
342 if *explicit {
343 linear_terms.push(LinearTermSpec {
344 name: name.clone(),
345 feature_col: col,
346 feature_cols: vec![col],
347 categorical_levels: vec![],
348 // Parametric linear terms are unpenalized by default
349 // (MLE, matching mgcv/glm); see #749.
350 double_penalty: false,
351 coefficient_geometry: LinearCoefficientGeometry::Unconstrained,
352 coefficient_min: *coefficient_min,
353 coefficient_max: *coefficient_max,
354 });
355 } else {
356 match auto_kind {
357 ColumnKindTag::Continuous | ColumnKindTag::Binary => {
358 linear_terms.push(LinearTermSpec {
359 name: name.clone(),
360 feature_col: col,
361 feature_cols: vec![col],
362 categorical_levels: vec![],
363 // Unpenalized parametric effect by default (#749).
364 double_penalty: false,
365 coefficient_geometry: LinearCoefficientGeometry::Unconstrained,
366 coefficient_min: *coefficient_min,
367 coefficient_max: *coefficient_max,
368 });
369 }
370 ColumnKindTag::Categorical => {
371 if coefficient_min.is_some() || coefficient_max.is_some() {
372 return Err(TermBuilderError::incompatible_config(format!(
373 "coefficient constraints are not supported for categorical auto-random-effect term '{name}'; use group({name}) or an unconstrained numeric term"
374 )));
375 }
376 random_terms.push(RandomEffectTermSpec {
377 name: name.clone(),
378 feature_col: col,
379 drop_first_level: false,
380 penalized: true,
381 frozen_levels: None,
382 // A BARE categorical main effect (`+ g`) is a FIXED
383 // parametric factor. Although it is auto-promoted to
384 // a penalized random block above, an *unseen* level
385 // at predict must raise a schema mismatch rather than
386 // be mapped to the factor's centering point (#2102).
387 lenient_unseen: false,
388 });
389 }
390 }
391 }
392 }
393 ParsedTerm::BoundedLinear {
394 name,
395 min,
396 max,
397 prior,
398 } => {
399 let col = resolve_col(col_map, name)?;
400 let auto_kind = ds.column_kinds.get(col).copied().ok_or_else(|| {
401 TermBuilderError::missing_column(format!(
402 "internal column-kind lookup failed for '{name}'"
403 ))
404 .to_string()
405 })?;
406 if !matches!(auto_kind, ColumnKindTag::Continuous | ColumnKindTag::Binary) {
407 return Err(TermBuilderError::incompatible_config(format!(
408 "bounded() currently supports only numeric columns, got categorical '{name}'"
409 )));
410 }
411 linear_terms.push(LinearTermSpec {
412 name: name.clone(),
413 feature_col: col,
414 feature_cols: vec![col],
415 categorical_levels: vec![],
416 double_penalty: false,
417 coefficient_geometry: LinearCoefficientGeometry::Bounded {
418 min: *min,
419 max: *max,
420 prior: prior.clone(),
421 },
422 coefficient_min: None,
423 coefficient_max: None,
424 });
425 }
426 ParsedTerm::RandomEffect { name } => {
427 let col = resolve_col(col_map, name)?;
428 random_terms.push(RandomEffectTermSpec {
429 name: name.clone(),
430 feature_col: col,
431 drop_first_level: false,
432 penalized: true,
433 frozen_levels: None,
434 // An EXPLICIT random effect (`group(g)`/`re(g)`/`factor(g)` /
435 // `s(g, bs="re")`) deliberately tolerates unseen levels: a
436 // held-out group is shrunk to the population mean (#2102).
437 lenient_unseen: true,
438 });
439 }
440 ParsedTerm::Smooth {
441 label,
442 vars,
443 kind,
444 options,
445 } => {
446 let smooth_vars = vars.clone();
447 let by_name = options.get("by").cloned();
448 // `bs="sz"` (sum-to-zero), like `bs="fs"`/`bs="re"`, is a
449 // factor-smooth family handled natively by `build_smooth_basis`'s
450 // fs/sz/re path: it detects the categorical factor among the
451 // variables and emits a `SmoothBasisSpec::FactorSmooth { Sz }`
452 // with the correct single-penalty marginal and modest default
453 // basis. Route sz straight through `build_smooth_basis` rather
454 // than intercepting it into a legacy `FactorSumToZero` envelope
455 // here (which left `sz(fac, x)` mis-typed as `FactorSumToZero`
456 // instead of the expected `FactorSmooth { Sz }`).
457 let cols = smooth_vars
458 .iter()
459 .map(|v| resolve_col(col_map, v))
460 .collect::<Result<Vec<_>, _>>()?;
461 let mut inner_options = options.clone();
462 inner_options.remove("by");
463 // `ordered=` is consumed here (ByVarKind::Factor routing) and
464 // must not propagate to the inner basis builder, which has no
465 // allow-list entry for it and would reject it as an unknown option.
466 inner_options.remove("ordered");
467 // Pop the shape constraint before `build_smooth_basis` runs so
468 // it never reaches the per-kind `validate_known_options`
469 // allow-lists (the constraint is a property of the smooth term,
470 // not of any one basis kind). Basis-incompatible requests still
471 // fail loudly downstream via `shape_supports_basis`.
472 let shape = match inner_options.remove("shape") {
473 None => ShapeConstraint::None,
474 Some(raw) => crate::smooth::parse_shape_constraint(&raw)
475 .map_err(TermBuilderError::invalid_option)?,
476 };
477 let inner_basis = build_smooth_basis(
478 *kind,
479 &smooth_vars,
480 &cols,
481 &inner_options,
482 ds,
483 inference_notes,
484 policy,
485 smooth_coordinate_count,
486 )?;
487 let inner_basis = match inner_basis {
488 SmoothBasisSpec::FactorSmooth {
489 spec:
490 FactorSmoothSpec {
491 continuous_cols,
492 group_col,
493 marginal,
494 flavour: FactorSmoothFlavour::Sz,
495 frozen_global_orthogonality,
496 ..
497 },
498 } => {
499 if continuous_cols.len() != 1 {
500 return Err(TermBuilderError::incompatible_config(format!(
501 "sz factor-smooth currently expects exactly one continuous covariate, found {}",
502 continuous_cols.len()
503 )));
504 }
505 SmoothBasisSpec::FactorSumToZero {
506 inner: Box::new(SmoothBasisSpec::BSpline1D {
507 feature_col: continuous_cols[0],
508 spec: marginal,
509 }),
510 by_col: group_col,
511 levels: encoded_levels_for_column(ds, ColIdx::new(group_col))
512 .into_iter()
513 .map(|(bits, _)| bits)
514 .collect(),
515 frozen_global_orthogonality,
516 }
517 }
518 other => other,
519 };
520 if let Some(by_name) = by_name {
521 let by_col = resolve_col(col_map, &by_name)?;
522 match ds.column_kinds.get(by_col).copied().ok_or_else(|| {
523 format!("internal column-kind lookup failed for by variable '{by_name}'")
524 })? {
525 ColumnKindTag::Categorical => {
526 let levels = encoded_levels_for_column(ds, ColIdx::new(by_col));
527 // A penalized random block for this factor already
528 // owns its full level offsets when EITHER an explicit
529 // `group(factor)` appears, OR a *bare* categorical
530 // `+ factor` does — the latter is auto-promoted to a
531 // penalized random-effect block (see the
532 // `ParsedTerm::Linear` / `ColumnKindTag::Categorical`
533 // arm above, `penalized: true`). Both representations
534 // carry the same per-level offsets, so #1457: the
535 // `by=` branch must NOT additionally add its own
536 // unpenalized treatment-coded main effect, which would
537 // double-represent the factor (two `g` design blocks +
538 // a spurious extra smoothing parameter).
539 let penalized_group_owner_present =
540 terms.iter().any(|other| match other {
541 ParsedTerm::RandomEffect { name } => name == &by_name,
542 ParsedTerm::Linear {
543 name,
544 explicit: false,
545 ..
546 } if name == &by_name => col_map
547 .get(name)
548 .and_then(|c| ds.column_kinds.get(*c).copied())
549 .map(|kind| matches!(kind, ColumnKindTag::Categorical))
550 .unwrap_or(false),
551 _ => false,
552 });
553 // Add an unpenalized treatment-coded fixed main
554 // effect for a standalone factor-by smooth, unless
555 // the same factor already has an explicit
556 // `group(factor)` term OR a bare categorical `+
557 // factor` that was auto-promoted to a penalized
558 // random block (#1457). In those mixed-model forms
559 // the penalized random intercept is the coherent
560 // owner of level offsets; adding a no-pooling fixed
561 // factor effect would bypass random-effect
562 // shrinkage and degrade BLUP-style predictions.
563 if !random_terms.iter().any(|rt| rt.name == by_name)
564 && !penalized_group_owner_present
565 {
566 random_terms.push(RandomEffectTermSpec {
567 name: by_name.clone(),
568 feature_col: by_col,
569 drop_first_level: true,
570 penalized: false,
571 frozen_levels: None,
572 // Unpenalized treatment-coded FIXED factor main
573 // effect for a factor-by smooth: an unseen level
574 // is out of contract and must raise, not center
575 // (#2102).
576 lenient_unseen: false,
577 });
578 }
579 // Unordered factor-by smooths are independent
580 // level-specific smooths. Preserve that
581 // term-spec structure explicitly so later
582 // hierarchy/identifiability passes can see the
583 // per-level ownership rather than a generic
584 // BySmooth envelope.
585 for (level_bits, level_label) in levels {
586 smooth_terms.push(SmoothTermSpec {
587 name: format!("{label}:by={by_name}[{level_label}]"),
588 basis: SmoothBasisSpec::ByVariable {
589 inner: Box::new(inner_basis.clone()),
590 by_col,
591 kind: BySmoothKind::Level { level_bits },
592 by: ByVariableSpec::Level {
593 value_bits: level_bits,
594 label: level_label,
595 },
596 },
597 shape: shape.clone(),
598 joint_null_rotation: None,
599 });
600 }
601 }
602 ColumnKindTag::Binary | ColumnKindTag::Continuous => {
603 smooth_terms.push(SmoothTermSpec {
604 name: label.clone(),
605 basis: SmoothBasisSpec::ByVariable {
606 inner: Box::new(inner_basis),
607 by_col,
608 kind: BySmoothKind::Numeric,
609 by: ByVariableSpec::Numeric,
610 },
611 shape,
612 joint_null_rotation: None,
613 });
614 }
615 }
616 } else {
617 smooth_terms.push(SmoothTermSpec {
618 name: label.clone(),
619 basis: inner_basis,
620 shape,
621 joint_null_rotation: None,
622 });
623 }
624 }
625 ParsedTerm::LinkWiggle { .. }
626 | ParsedTerm::TimeWiggle { .. }
627 | ParsedTerm::LinkConfig { .. }
628 | ParsedTerm::SurvivalConfig { .. } => {
629 // Consumed at formula level, not design terms.
630 }
631 ParsedTerm::LogSlopeSurface { .. } => {
632 return Err(TermBuilderError::malformed_formula(
633 "logslope(...) declarations must be resolved by the marginal-slope formula path before building a term spec",
634 ));
635 }
636 ParsedTerm::Interaction { vars } => {
637 // A linear `:` interaction realizes one design column equal to
638 // the elementwise product of its operands. Numeric (continuous/
639 // binary) operands multiply directly; a categorical operand is
640 // a factor, so the product is expanded factor-aware: one design
641 // column per surviving cell of the factor(s), each an indicator
642 // `1[factor == level]` gating the numeric product.
643 //
644 // Coding is MARGINALITY-AWARE (gam#1158, gam#1159). A categorical
645 // operand `g` is treatment-coded (its lexicographically first
646 // reference level dropped) ONLY when the lower-order term obtained
647 // by removing `g` from this interaction is also present in the
648 // model — that lower-order term is what makes the dropped level
649 // identifiable, exactly mgcv's marginality rule. When that parent
650 // is ABSENT (the interaction-only form), dropping the reference
651 // level instead pins a group to the reference fit (a rank-deficient
652 // design), so we keep ALL levels (full dummy coding) and rely on a
653 // single intercept cell-drop below for identifiability:
654 // * `y ~ x:g` with no `x` main effect → "common intercept,
655 // separate slopes": every group keeps its own x-slope.
656 // * `y ~ g:h` with no `g`/`h` main effects → the saturated
657 // cell-means model: full cross of all levels minus one
658 // reference cell absorbed by the intercept.
659 // When the parents ARE present (`x + x:g`, or `g*h` = `g + h +
660 // g:h`), the historical treatment coding is preserved so those
661 // forms stay correct.
662 //
663 // A main effect for var V is a `Linear`/`BoundedLinear`/
664 // `RandomEffect` ParsedTerm whose referenced name is V (an
665 // auto-detected categorical `Linear` becomes a RandomEffect main
666 // effect; either spelling counts). We only treat such standalone
667 // main-effect terms as parents — not V appearing inside another
668 // interaction.
669 let main_effect_present = |target: &str| -> bool {
670 terms.iter().any(|other| match other {
671 ParsedTerm::Linear { name, .. }
672 | ParsedTerm::BoundedLinear { name, .. }
673 | ParsedTerm::RandomEffect { name } => name == target,
674 _ => false,
675 })
676 };
677 // The lower-order parent of dropping operand `drop_var` from this
678 // interaction is present iff EVERY other operand is a main effect.
679 // For the two cases we care about (`x:g`, `g:h`) the interaction
680 // has two operands, so this reduces to "is the single remaining
681 // operand a main effect"; the general form handles any arity.
682 let parent_present = |drop_var: &str| -> bool {
683 vars.iter()
684 .filter(|v| v.as_str() != drop_var)
685 .all(|v| main_effect_present(v))
686 };
687
688 let mut numeric_cols = Vec::<usize>::new();
689 // Per categorical operand: (var name, col, kept levels, was the
690 // reference level dropped / treatment-coded?).
691 let mut categorical_factors =
692 Vec::<(String, usize, Vec<(u64, String)>, bool)>::new();
693 for var in vars {
694 let col = resolve_col(col_map, var)?;
695 let kind = ds.column_kinds.get(col).copied().ok_or_else(|| {
696 TermBuilderError::missing_column(format!(
697 "internal column-kind lookup failed for '{var}'"
698 ))
699 .to_string()
700 })?;
701 match kind {
702 ColumnKindTag::Continuous | ColumnKindTag::Binary => numeric_cols.push(col),
703 ColumnKindTag::Categorical => {
704 let mut levels = encoded_levels_for_column(ds, ColIdx::new(col));
705 // Treatment-code (drop the reference level) only when
706 // the marginal parent that identifies it is present;
707 // otherwise keep every level (full dummy coding).
708 let treatment_coded = parent_present(var);
709 if treatment_coded && levels.len() > 1 {
710 levels.remove(0);
711 }
712 if levels.is_empty() {
713 return Err(TermBuilderError::incompatible_config(format!(
714 "interaction `{}` references categorical column `{var}` with no usable levels",
715 vars.join(":")
716 )));
717 }
718 categorical_factors.push((var.clone(), col, levels, treatment_coded));
719 }
720 }
721 }
722
723 let label = vars.join(":");
724
725 if categorical_factors.is_empty() {
726 // Pure numeric `:` interaction — single product column,
727 // identical to the historical behaviour.
728 linear_terms.push(LinearTermSpec {
729 name: label,
730 feature_col: numeric_cols[0],
731 feature_cols: numeric_cols,
732 categorical_levels: vec![],
733 // Parametric `:` interaction column is unpenalized by
734 // default, same as any other linear term (#749).
735 double_penalty: false,
736 coefficient_geometry: LinearCoefficientGeometry::Unconstrained,
737 coefficient_min: None,
738 coefficient_max: None,
739 });
740 inference_notes.push(format!(
741 "wired linear interaction `{}` as product of numeric columns",
742 vars.join(":")
743 ));
744 } else {
745 // Factor-aware expansion: cartesian product over the kept
746 // levels of every categorical operand. Each cell yields one
747 // column gating the numeric product (or, with no numeric
748 // operand, a pure cell indicator).
749 let mut cells: Vec<Vec<(usize, u64, String)>> = vec![Vec::new()];
750 for (_var, col, levels, _treatment_coded) in &categorical_factors {
751 let mut next = Vec::with_capacity(cells.len() * levels.len());
752 for cell in &cells {
753 for (bits, level_label) in levels {
754 let mut extended = cell.clone();
755 extended.push((*col, *bits, level_label.clone()));
756 next.push(extended);
757 }
758 }
759 cells = next;
760 }
761
762 // Intercept-identifiability cell drop. When the cells are PURE
763 // INDICATORS (no numeric operand) and at least one factor was
764 // dummy-coded (kept all its levels), the full set of cell
765 // columns sums to the all-ones intercept and is rank-deficient
766 // against it. Drop exactly ONE reference cell — the cell where
767 // every factor sits at its reference (lexicographically first)
768 // level — so the remaining saturated cells are identifiable
769 // (rank n_g*n_h - 1 cells + intercept). With a numeric operand
770 // the cells gate `x` and sum to `x`, not the intercept, so no
771 // cell is dropped (the collinearity there is with the absent
772 // `x` main effect, which is exactly why full coding is right).
773 let any_dummy_coded = categorical_factors
774 .iter()
775 .any(|(_, _, _, treatment_coded)| !*treatment_coded);
776 if numeric_cols.is_empty() && any_dummy_coded {
777 // The reference cell pairs each factor's column with the
778 // bits of its lexicographically-first (index 0) level.
779 let reference_cell: Vec<(usize, u64)> = categorical_factors
780 .iter()
781 .map(|(_, col, _, _)| {
782 let levels = encoded_levels_for_column(ds, ColIdx::new(*col));
783 (*col, levels[0].0)
784 })
785 .collect();
786 cells.retain(|cell| {
787 !reference_cell.iter().all(|(rcol, rbits)| {
788 cell.iter()
789 .any(|(col, bits, _)| col == rcol && bits == rbits)
790 })
791 });
792 }
793
794 let n_cells = cells.len();
795 for cell in cells {
796 let cell_suffix = cell
797 .iter()
798 .map(|(_, _, level_label)| level_label.as_str())
799 .collect::<Vec<_>>()
800 .join(":");
801 let categorical_levels =
802 cell.iter().map(|(col, bits, _)| (*col, *bits)).collect();
803 // `feature_col` is required to point at a real column;
804 // use the first numeric operand when present, otherwise
805 // the first categorical column (its raw value is never
806 // multiplied — `realized_design_column` starts from ones
807 // and only gates by the level indicators).
808 let feature_col = numeric_cols
809 .first()
810 .copied()
811 .unwrap_or(categorical_factors[0].1);
812 linear_terms.push(LinearTermSpec {
813 name: format!("{label}:{cell_suffix}"),
814 feature_col,
815 feature_cols: numeric_cols.clone(),
816 categorical_levels,
817 double_penalty: false,
818 coefficient_geometry: LinearCoefficientGeometry::Unconstrained,
819 coefficient_min: None,
820 coefficient_max: None,
821 });
822 }
823 let all_treatment_coded = !any_dummy_coded;
824 let coding = if all_treatment_coded {
825 "treatment-coded"
826 } else {
827 "marginality-aware (full dummy / saturated)"
828 };
829 inference_notes.push(format!(
830 "wired factor-aware linear interaction `{}` as {} {} cell column(s)",
831 vars.join(":"),
832 n_cells,
833 coding
834 ));
835 }
836 }
837 }
838 }
839
840 Ok(TermCollectionSpec {
841 linear_terms,
842 random_effect_terms: random_terms,
843 smooth_terms,
844 })
845}
846
847fn split_list_option(raw: &str) -> Vec<String> {
848 let t = raw.trim();
849 // Accept the Python/JSON list form `[a, b]` AND mgcv's R-vector forms
850 // `c(a, b)` / `(a, b)` as bracketed wrappers around a comma-separated body.
851 // mgcv-style formulas pass per-margin numeric options as `k=c(5,5)` /
852 // `period=c(2*pi, pi)`; without R-vector peeling here those entries were
853 // split into `["c(5", "5)"]` and the downstream numeric parser then
854 // misreported the leading garbage as the invalid digit.
855 let inner = t
856 .strip_prefix('[')
857 .and_then(|u| u.strip_suffix(']'))
858 .or_else(|| {
859 t.strip_prefix("c(")
860 .or_else(|| t.strip_prefix("C("))
861 .or_else(|| t.strip_prefix('('))
862 .and_then(|u| u.strip_suffix(')'))
863 })
864 .unwrap_or(t);
865 inner
866 .split(',')
867 .map(|v| v.trim().to_string())
868 .filter(|v| !v.is_empty())
869 .collect()
870}
871
872fn parse_numeric_expr(raw: &str) -> Result<f64, String> {
873 let mut acc = 1.0f64;
874 let normalized = raw.replace(' ', "");
875 if normalized.eq_ignore_ascii_case("none") {
876 return Err("None is not numeric".to_string());
877 }
878 for factor in normalized.split('*') {
879 if factor.is_empty() {
880 return Err(format!("invalid numeric expression '{raw}'"));
881 }
882 let value = if factor.eq_ignore_ascii_case("pi") || factor == "π" {
883 std::f64::consts::PI
884 } else if factor.eq_ignore_ascii_case("tau") || factor == "τ" {
885 std::f64::consts::TAU
886 } else if let Some(prefix) = factor
887 .strip_suffix("pi")
888 .or_else(|| factor.strip_suffix("π"))
889 {
890 let coefficient = if prefix.is_empty() {
891 1.0
892 } else {
893 prefix
894 .parse::<f64>()
895 .map_err(|err| format!("invalid numeric expression '{raw}': {err}"))?
896 };
897 coefficient * std::f64::consts::PI
898 } else if let Some(prefix) = factor
899 .strip_suffix("tau")
900 .or_else(|| factor.strip_suffix("τ"))
901 {
902 let coefficient = if prefix.is_empty() {
903 1.0
904 } else {
905 prefix
906 .parse::<f64>()
907 .map_err(|err| format!("invalid numeric expression '{raw}': {err}"))?
908 };
909 coefficient * std::f64::consts::TAU
910 } else {
911 factor
912 .parse::<f64>()
913 .map_err(|err| format!("invalid numeric expression '{raw}': {err}"))?
914 };
915 acc *= value;
916 }
917 Ok(acc)
918}
919
920/// Read an endpoint/period option as a numeric *expression* (`2*pi`, `tau`,
921/// `0.5*tau`, `6.283185307179586`, ...) — the same grammar that `period=` and
922/// `origin=` already accept via [`parse_numeric_expr`].
923///
924/// Returns `Ok(None)` when the key is absent, `Ok(Some(v))` when it parses, and
925/// a hard `Err` when the key is *present but unparseable*. The crucial contrast
926/// is with the lenient [`option_f64`], which collapses an unparseable value to
927/// `None` and lets the caller silently substitute the data range — wrapping a
928/// cyclic smooth at the wrong period with no diagnostic (the #815 failure mode).
929fn option_numeric_expr(
930 options: &BTreeMap<String, String>,
931 key: &str,
932) -> Result<Option<f64>, String> {
933 match options.get(key) {
934 None => Ok(None),
935 Some(raw) => parse_numeric_expr(raw)
936 .map(Some)
937 .map_err(|err| format!("option `{key}={raw}` is not a valid numeric value: {err}")),
938 }
939}
940
941fn parse_periods_option(
942 options: &BTreeMap<String, String>,
943 dim: usize,
944) -> Result<Option<Vec<Option<f64>>>, String> {
945 let Some(raw) = options.get("period") else {
946 return Ok(None);
947 };
948 let values = split_list_option(raw);
949 let mut periods = vec![None; dim];
950 if values.len() == 1 && dim == 1 {
951 periods[0] = Some(parse_numeric_expr(&values[0])?);
952 } else {
953 if values.len() != dim {
954 return Err(format!(
955 "period list length {} must match smooth dimension {}",
956 values.len(),
957 dim
958 ));
959 }
960 for (i, v) in values.iter().enumerate() {
961 if v.eq_ignore_ascii_case("none") {
962 continue;
963 }
964 periods[i] = Some(parse_numeric_expr(v)?);
965 }
966 }
967 Ok(Some(periods))
968}
969
970fn parse_periodic_axes_option(
971 options: &BTreeMap<String, String>,
972 dim: usize,
973) -> Result<Option<Vec<Option<f64>>>, String> {
974 let Some(raw_axes) = options.get("periodic") else {
975 return Ok(None);
976 };
977 let mut periods = parse_periods_option(options, dim)?.unwrap_or_else(|| vec![None; dim]);
978 // Scalar boolean form (`periodic=true` / `false`, `yes` / `no`) applies to
979 // every axis — the documented per-axis-flag broadcast (see the doc on
980 // `parse_periodic_axes`, the tensor sibling that already accepts it). A
981 // 1-D `duchon(x, periodic=true)` lands here: the cyclic *domain* is then
982 // resolved from the data range by `parse_cyclic_boundary` (the 1-D builder
983 // consults `boundary` first), so a finite explicit period is NOT required —
984 // we only need to NOT mis-read "true" as an axis index (#1074). `false`
985 // means no axis is periodic.
986 let lowered = raw_axes.trim().to_ascii_lowercase();
987 match lowered.as_str() {
988 "true" | "yes" | "y" => return Ok(Some(periods)),
989 // `false` means NO axis is periodic. Return `None` — NOT
990 // `Some(vec![None; dim])` — because the radial 1-D consumer treats a
991 // `Some([None])` as "periodicity requested, derive the wrap period from
992 // the data range" (see the Duchon builder arm below, which back-fills
993 // `axes[0] = data_span` for a lone `None`) and the 1-D builder routes on
994 // `spec.periodic.is_some()`. Emitting `Some([None])` here therefore
995 // silently produced a *periodic* smooth for an explicit `periodic=false`
996 // — the exact regression this arm now avoids, matching the bracketed
997 // `[false]` form handled by the per-axis boolean block below.
998 "false" | "no" | "n" => return Ok(None),
999 _ => {}
1000 }
1001 let axes = split_list_option(raw_axes);
1002 if axes.is_empty() {
1003 return Ok(Some(periods));
1004 }
1005
1006 // Boolean forms `periodic=true` / `periodic=[true, false, ...]`, mirroring
1007 // `parse_tensor_periodic_axes`. The radial 1-D builders (`duchon`/`tps`/
1008 // `matern`) intentionally DERIVE the wrap period from the closed center
1009 // lattice when none is supplied (`prepare_periodic_duchon_centers_1d_with_period`,
1010 // gam#580: `None => span`), so a boolean-selected periodic axis legitimately
1011 // omits `period`. Without this branch, `duchon(x, periodic=true)`-style
1012 // radial formulas failed with the misleading "invalid periodic axis 'true'".
1013 let is_bool = |t: &str| {
1014 matches!(
1015 t.to_ascii_lowercase().as_str(),
1016 "true" | "yes" | "y" | "false" | "no" | "n"
1017 )
1018 };
1019 let is_truthy = |t: &str| matches!(t.to_ascii_lowercase().as_str(), "true" | "yes" | "y");
1020
1021 // Scalar boolean: `periodic=true` / `periodic=false`.
1022 if axes.len() == 1 && is_bool(&axes[0]) {
1023 if !is_truthy(&axes[0]) {
1024 // Non-periodic: return None so the 1-D builder (which routes on
1025 // `spec.periodic.is_some()`) does NOT take the periodic path.
1026 return Ok(None);
1027 }
1028 // Every axis periodic; honor any explicit per-axis period, else leave
1029 // `None` for the caller (formula arm) / builder to derive the span.
1030 return Ok(Some(periods));
1031 }
1032
1033 // Per-axis boolean list: `periodic=[true, false, ...]` (length must match dim).
1034 if axes.iter().all(|a| is_bool(a)) {
1035 if axes.len() != dim {
1036 return Err(format!(
1037 "periodic flag list length {} must match smooth dimension {dim}",
1038 axes.len()
1039 ));
1040 }
1041 if !axes.iter().any(|a| is_truthy(a)) {
1042 return Ok(None);
1043 }
1044 for (i, a) in axes.iter().enumerate() {
1045 if !is_truthy(a) {
1046 periods[i] = None;
1047 }
1048 }
1049 return Ok(Some(periods));
1050 }
1051
1052 // Index-list form: `periodic=[0, 2]`. Each listed axis must carry an
1053 // explicit finite period — an index gives no per-axis span-derive hint.
1054 for a in &axes {
1055 let axis = a
1056 .parse::<usize>()
1057 .map_err(|err| format!("invalid periodic axis '{a}': {err}"))?;
1058 if axis >= dim {
1059 return Err(format!(
1060 "periodic axis {axis} out of range for {dim}D smooth"
1061 ));
1062 }
1063 if periods[axis].is_none() {
1064 return Err(format!(
1065 "periodic axis {axis} requires period[{axis}] to be finite"
1066 ));
1067 }
1068 }
1069 // Axes not listed are non-periodic even if period list has a finite placeholder.
1070 let listed: std::collections::BTreeSet<usize> = axes
1071 .iter()
1072 .filter_map(|a| a.parse::<usize>().ok())
1073 .collect();
1074 for i in 0..dim {
1075 if !listed.contains(&i) {
1076 periods[i] = None;
1077 }
1078 }
1079 Ok(Some(periods))
1080}
1081
1082// ---------------------------------------------------------------------------
1083// Smooth basis spec construction
1084// ---------------------------------------------------------------------------
1085
1086fn parse_option_list(raw: &str) -> Vec<String> {
1087 let trimmed = raw.trim();
1088 // Accept both the Python/JSON list form `[a, b]` and mgcv's R vector form
1089 // `c(a, b)` (and a bare `(a, b)`) as the bracketed wrapper around a
1090 // comma-separated option list. mgcv writes per-margin options as
1091 // `bs=c('tp','tp')` / `m=c(2,2)`, so the `c(...)` form must round-trip
1092 // through the same splitter the `[...]` form uses.
1093 let inner = trimmed
1094 .strip_prefix('[')
1095 .and_then(|v| v.strip_suffix(']'))
1096 .or_else(|| {
1097 trimmed
1098 .strip_prefix("c(")
1099 .or_else(|| trimmed.strip_prefix("C("))
1100 .or_else(|| trimmed.strip_prefix('('))
1101 .and_then(|v| v.strip_suffix(')'))
1102 })
1103 .unwrap_or(trimmed);
1104 inner
1105 .split(',')
1106 .map(|v| {
1107 v.trim()
1108 .trim_matches('"')
1109 .trim_matches('\'')
1110 .to_ascii_lowercase()
1111 })
1112 .filter(|v| !v.is_empty())
1113 .collect()
1114}
1115
1116fn parse_periodic_axes(
1117 options: &BTreeMap<String, String>,
1118 dim: usize,
1119) -> Result<Vec<bool>, String> {
1120 let mut axes = vec![false; dim];
1121 if let Some(raw) = options.get("periodic").or_else(|| options.get("cyclic")) {
1122 let lowered = raw.trim().to_ascii_lowercase();
1123 match lowered.as_str() {
1124 "true" | "yes" | "y" => {
1125 axes.fill(true);
1126 return Ok(axes);
1127 }
1128 "false" | "no" | "n" => return Ok(axes),
1129 _ => {}
1130 }
1131 for axis_raw in parse_option_list(raw) {
1132 let axis = axis_raw
1133 .parse::<usize>()
1134 .map_err(|err| format!("invalid periodic axis '{axis_raw}': {err}"))?;
1135 if axis >= dim {
1136 return Err(format!(
1137 "periodic axis {axis} out of range for {dim}D smooth"
1138 ));
1139 }
1140 axes[axis] = true;
1141 }
1142 }
1143 if let Some(raw) = options.get("boundary").or_else(|| options.get("bc")) {
1144 let boundary = parse_option_list(raw);
1145 if boundary.len() == dim {
1146 for (axis, value) in boundary.iter().enumerate() {
1147 if matches!(value.as_str(), "periodic" | "cyclic" | "cc") {
1148 axes[axis] = true;
1149 }
1150 }
1151 } else if dim == 1
1152 && matches!(
1153 boundary.first().map(String::as_str),
1154 Some("periodic" | "cyclic" | "cc")
1155 )
1156 {
1157 axes[0] = true;
1158 }
1159 }
1160 Ok(axes)
1161}
1162
1163fn parse_optional_numeric_list(
1164 options: &BTreeMap<String, String>,
1165 keys: &[&str],
1166 dim: usize,
1167) -> Result<Vec<Option<f64>>, String> {
1168 let Some(raw) = keys.iter().find_map(|key| options.get(*key)) else {
1169 return Ok(vec![None; dim]);
1170 };
1171 let values = split_list_option(raw);
1172 let mut out = vec![None; dim];
1173 if values.len() == 1 && dim == 1 {
1174 if !values[0].eq_ignore_ascii_case("none") {
1175 out[0] = Some(parse_numeric_expr(&values[0])?);
1176 }
1177 return Ok(out);
1178 }
1179 if values.len() != dim {
1180 return Err(format!(
1181 "numeric option list length {} must match smooth dimension {}",
1182 values.len(),
1183 dim
1184 ));
1185 }
1186 for (i, value) in values.iter().enumerate() {
1187 if !value.eq_ignore_ascii_case("none") {
1188 out[i] = Some(parse_numeric_expr(value)?);
1189 }
1190 }
1191 Ok(out)
1192}
1193
1194fn parse_periods(
1195 options: &BTreeMap<String, String>,
1196 periodic_axes: &[bool],
1197) -> Result<Vec<Option<f64>>, String> {
1198 let dim = periodic_axes.len();
1199 // Broadcast a single-element `period=[v]` onto the lone periodic axis
1200 // of a multi-axis smooth (e.g. `te(th, h, bc=['periodic','natural'],
1201 // period=[2*pi])`): with only one periodic margin, the value can only
1202 // belong there.
1203 let lone_periodic_broadcast = options
1204 .get("period")
1205 .or_else(|| options.get("periods"))
1206 .and_then(|raw| {
1207 let values = split_list_option(raw);
1208 if values.len() != 1 || dim <= 1 {
1209 return None;
1210 }
1211 let mut iter = periodic_axes.iter().enumerate().filter(|(_, p)| **p);
1212 let first = iter.next()?;
1213 if iter.next().is_some() {
1214 return None;
1215 }
1216 Some((first.0, values.into_iter().next().unwrap()))
1217 });
1218 let periods = if let Some((axis, value)) = lone_periodic_broadcast {
1219 let mut out = vec![None; dim];
1220 if !value.eq_ignore_ascii_case("none") {
1221 out[axis] = Some(parse_numeric_expr(&value)?);
1222 }
1223 out
1224 } else {
1225 parse_optional_numeric_list(options, &["period", "periods"], dim)?
1226 };
1227 for (axis, (periodic, period)) in periodic_axes.iter().zip(periods.iter()).enumerate() {
1228 if *periodic
1229 && let Some(value) = period
1230 && (!value.is_finite() || *value <= 0.0)
1231 {
1232 return Err(format!(
1233 "period for periodic axis {axis} must be finite and positive, got {value}"
1234 ));
1235 }
1236 }
1237 Ok(periods)
1238}
1239
1240fn parse_period_origins(
1241 options: &BTreeMap<String, String>,
1242 periodic_axes: &[bool],
1243) -> Result<Vec<Option<f64>>, String> {
1244 parse_optional_numeric_list(
1245 options,
1246 &[
1247 "origin",
1248 "origins",
1249 "period_origin",
1250 "period-origin",
1251 "domain_origin",
1252 ],
1253 periodic_axes.len(),
1254 )
1255}
1256
1257/// Parse a per-axis periodic flag list for tensor smooths. Accepts three forms:
1258/// - `periodic=true` / `periodic=false` (scalar applied to every axis),
1259/// - `periodic=[true, false, ...]` (one flag per axis, length `dim`),
1260/// - `periodic=c(1, 1)` / `c(0, 0)` (a length-`dim` 0/1 mask, mgcv's
1261/// per-margin spelling — distinguished from an axis-index list by the
1262/// repeated 0/1 value), and
1263/// - `periodic=[0, 2, ...]` (axis indices that are periodic; others are not).
1264///
1265/// `boundary=[..., "periodic"/"cyclic"/"cc", ...]` may also flip individual
1266/// axes on; non-matching tokens leave the existing flag unchanged.
1267fn parse_tensor_periodic_axes(
1268 options: &BTreeMap<String, String>,
1269 dim: usize,
1270) -> Result<Vec<bool>, String> {
1271 let mut axes = vec![false; dim];
1272 if let Some(raw) = options.get("periodic").or_else(|| options.get("cyclic")) {
1273 let lowered = raw.trim().to_ascii_lowercase();
1274 match lowered.as_str() {
1275 "true" | "yes" | "y" => {
1276 axes.fill(true);
1277 }
1278 "false" | "no" | "n" => {
1279 // Already false; allow `boundary=` below to flip axes if set.
1280 }
1281 _ => {
1282 let entries = parse_option_list(raw);
1283 let all_bool = !entries.is_empty()
1284 && entries.iter().all(|v| {
1285 matches!(
1286 v.as_str(),
1287 "true" | "yes" | "y" | "false" | "no" | "n" | "none"
1288 )
1289 });
1290 // mgcv writes per-margin flag vectors as `periodic=c(1,1)` /
1291 // `periodic=c(0,0)` — a length-`dim` mask where each entry is a
1292 // 0/1 flag for THAT margin, not an axis index. A bare axis-index
1293 // list (`periodic=[0,1]`, `periodic=[0]`) lists DISTINCT margin
1294 // indices to turn on. The two collide only when the list is all
1295 // 0/1 of length `dim`; disambiguate by the repeated-value
1296 // signature `c(1,1)`/`c(0,0)` (a valid axis-index set never
1297 // repeats an index), which is the canonical mask spelling. This
1298 // is what makes the leading tensor margin honor its periodic flag
1299 // (#1751: `periodic=c(1,1)` previously parsed `1,1` as axis
1300 // indices, marking only axis 1 and dropping axis 0).
1301 let all_zero_one =
1302 !entries.is_empty() && entries.iter().all(|v| v == "0" || v == "1");
1303 let has_repeat = {
1304 let mut seen = std::collections::BTreeSet::new();
1305 !entries.iter().all(|v| seen.insert(v.clone()))
1306 };
1307 let numeric_mask = all_zero_one && entries.len() == dim && has_repeat;
1308 if all_bool || numeric_mask {
1309 if entries.len() != dim {
1310 return Err(format!(
1311 "periodic list length {} must match smooth dimension {}",
1312 entries.len(),
1313 dim
1314 ));
1315 }
1316 for (i, v) in entries.iter().enumerate() {
1317 axes[i] = matches!(v.as_str(), "true" | "yes" | "y" | "1");
1318 }
1319 } else {
1320 for axis_raw in entries {
1321 let axis = axis_raw
1322 .parse::<usize>()
1323 .map_err(|err| format!("invalid periodic axis '{axis_raw}': {err}"))?;
1324 if axis >= dim {
1325 return Err(format!(
1326 "periodic axis {axis} out of range for {dim}D smooth"
1327 ));
1328 }
1329 axes[axis] = true;
1330 }
1331 }
1332 }
1333 }
1334 }
1335 if let Some(raw) = options.get("boundary").or_else(|| options.get("bc")) {
1336 let boundary = parse_option_list(raw);
1337 if boundary.len() == dim {
1338 for (axis, value) in boundary.iter().enumerate() {
1339 if matches!(value.as_str(), "periodic" | "cyclic" | "cc") {
1340 axes[axis] = true;
1341 }
1342 }
1343 }
1344 }
1345 // A per-margin basis vector (`bs=c('cc','ps')` / `type=[...]`) declares each
1346 // margin's basis family, and a cyclic family (`cc`/`cp`/`cyclic`) makes THAT
1347 // margin periodic — exactly as the 1-D `s(x, bs='cc')` smooth wraps its lone
1348 // axis. Without this, the per-margin `cc` token was validated but discarded:
1349 // every `bs=c(...)` spelling collapsed to the same open B-spline tensor
1350 // (#1752). Only honor the vector form here; a scalar `bs='cc'` on a tensor is
1351 // ambiguous about which margins wrap, so it does not flip any axis on.
1352 if let Some(raw) = options.get("bs").or_else(|| options.get("type"))
1353 && bs_selector_is_vector(raw)
1354 {
1355 let per_margin = parse_option_list(raw);
1356 if per_margin.len() == dim {
1357 for (axis, margin_bs) in per_margin.iter().enumerate() {
1358 if matches!(canonicalize_smooth_type(margin_bs), "cc" | "cp" | "cyclic") {
1359 axes[axis] = true;
1360 }
1361 }
1362 }
1363 }
1364 Ok(axes)
1365}
1366
1367/// Validate the per-margin `boundary=`/`bc=` tokens on a tensor-product smooth.
1368///
1369/// The tensor `boundary`/`bc` list selects, per margin, whether the margin
1370/// *wraps* (a `periodic`/`cyclic`/`cc` token, consumed by
1371/// [`parse_tensor_periodic_axes`]) or is an ordinary non-periodic margin. In the
1372/// tensor DSL a *non-periodic* margin is spelled `clamped` — in the B-spline
1373/// sense of a **clamped knot vector**, i.e. the standard open spline that is
1374/// free at its two ends and does not wrap (exactly how the callers document it:
1375/// "non-periodic / clamped … free at the two ends, no wrap"). It is therefore an
1376/// inert marker here, not a zero-derivative endpoint reparameterization: a
1377/// cylinder `te(theta, z, boundary=['periodic','clamped'], …)` is a cyclic θ
1378/// margin tensor-producted with an ordinary open z margin, the direct analog of
1379/// mgcv `te(bs=c("cc","ps"))` / `te(bs=c("cc","cr"))`.
1380///
1381/// The periodic selectors and the inert non-periodic markers
1382/// (`clamped`/`open`/`natural`/`free`/`none`/empty) are accepted; anything else
1383/// (e.g. a genuine `anchored` zero-value endpoint constraint, which has no
1384/// ordinary-margin meaning in a tensor) is surfaced as a clean
1385/// unsupported-feature error rather than silently dropped. Previously `clamped`
1386/// itself was rejected, so the cylinder/torus mixed-boundary tensors — the exact
1387/// construction the manifold quality suite builds — could not be fit at all.
1388fn validate_tensor_boundary_tokens(
1389 options: &BTreeMap<String, String>,
1390 dim: usize,
1391) -> Result<(), String> {
1392 let Some(raw) = options.get("boundary").or_else(|| options.get("bc")) else {
1393 return Ok(());
1394 };
1395 let entries = parse_option_list(raw);
1396 for (axis, value) in entries.iter().enumerate() {
1397 let inert = matches!(
1398 value.trim().to_ascii_lowercase().as_str(),
1399 "clamped" | "open" | "natural" | "free" | "none" | "" | "periodic" | "cyclic" | "cc"
1400 );
1401 if !inert {
1402 return Err(TermBuilderError::unsupported_feature(format!(
1403 "tensor smooth margin {axis} boundary token '{value}' is not supported \
1404 (got bc/boundary={raw:?} on a {dim}-D tensor); tensor margins accept the periodic \
1405 selectors (periodic/cyclic/cc) or the non-periodic markers (clamped/open/natural/free). \
1406 Apply anchored/zero-value endpoint constraints with a 1-D s(x, bc=...) term instead."
1407 ))
1408 .to_string());
1409 }
1410 }
1411 Ok(())
1412}
1413
1414fn tensor_k_axis_option_axis(
1415 key: &str,
1416 cols: &[usize],
1417 ds: &Dataset,
1418) -> Result<Option<usize>, String> {
1419 let Some(suffix) = key.strip_prefix("k_") else {
1420 return Ok(None);
1421 };
1422 if suffix.is_empty() {
1423 return Err("tensor k axis option must be named k_<axis> or k_<variable>".to_string());
1424 }
1425 if let Ok(axis) = suffix.parse::<usize>() {
1426 return if axis < cols.len() {
1427 Ok(Some(axis))
1428 } else {
1429 Err(format!(
1430 "tensor k axis option `{key}` references axis {axis}, but the smooth has {} margins",
1431 cols.len()
1432 ))
1433 };
1434 }
1435
1436 let mut matches = cols
1437 .iter()
1438 .enumerate()
1439 .filter(|(_, col)| ds.headers.get(**col).is_some_and(|name| name == suffix))
1440 .map(|(axis, _)| axis);
1441 let first = matches.next();
1442 if matches.next().is_some() {
1443 return Err(format!(
1444 "tensor k axis option `{key}` matches more than one margin named `{suffix}`"
1445 ));
1446 }
1447 first.map(Some).ok_or_else(|| {
1448 let margin_names = cols
1449 .iter()
1450 .enumerate()
1451 .map(|(axis, col)| {
1452 let name = ds
1453 .headers
1454 .get(*col)
1455 .map(String::as_str)
1456 .unwrap_or("<unnamed>");
1457 format!("{axis}:{name}")
1458 })
1459 .collect::<Vec<_>>()
1460 .join(", ");
1461 format!(
1462 "tensor k axis option `{key}` does not match a margin index or name; tensor margins are [{margin_names}]"
1463 )
1464 })
1465}
1466
1467fn is_tensor_k_axis_option_key(key: &str) -> bool {
1468 key.strip_prefix("k_")
1469 .is_some_and(|suffix| !suffix.is_empty())
1470}
1471
1472/// Parse a per-margin basis dimension list (`k=<scalar>`, `k=[k0, k1, ...]`,
1473/// or axis aliases like `k_x=...` / `k_0=...`). A scalar is broadcast across
1474/// all axes; `None` returns the heuristic from the data column.
1475fn parse_tensor_k_list(
1476 options: &BTreeMap<String, String>,
1477 cols: &[usize],
1478 ds: &Dataset,
1479) -> Result<(Vec<usize>, bool), String> {
1480 let mut axis_values = vec![None; cols.len()];
1481 let mut saw_axis_alias = false;
1482 for (key, value) in options {
1483 let Some(axis) = tensor_k_axis_option_axis(key, cols, ds)? else {
1484 continue;
1485 };
1486 saw_axis_alias = true;
1487 if axis_values[axis].is_some() {
1488 return Err(format!("tensor k axis {axis} is specified more than once"));
1489 }
1490 let k: usize = value
1491 .parse()
1492 .map_err(|err| format!("invalid tensor k option `{key}={value}`: {err}"))?;
1493 axis_values[axis] = Some(k);
1494 }
1495
1496 let raw = options
1497 .get("k")
1498 .or_else(|| options.get("basis_dim"))
1499 .or_else(|| options.get("basis-dim"))
1500 .or_else(|| options.get("basisdim"));
1501 if saw_axis_alias {
1502 if raw.is_some() {
1503 return Err(
1504 "tensor k axis aliases cannot be combined with k= or basis_dim=".to_string(),
1505 );
1506 }
1507 if let Some(missing_axis) = axis_values.iter().position(Option::is_none) {
1508 let margin_name = cols
1509 .get(missing_axis)
1510 .and_then(|col| ds.headers.get(*col))
1511 .map(String::as_str)
1512 .unwrap_or("<unnamed>");
1513 return Err(format!(
1514 "tensor k axis aliases must specify every margin; missing axis {missing_axis} ({margin_name})"
1515 ));
1516 }
1517 return Ok((
1518 axis_values
1519 .into_iter()
1520 .map(|k| k.expect("missing axis values rejected above"))
1521 .collect(),
1522 false,
1523 ));
1524 }
1525 let Some(raw) = raw else {
1526 let inferred = heuristic_tensor_margin_knots(cols, ds);
1527 return Ok((inferred, true));
1528 };
1529 let entries = split_list_option(raw);
1530 if entries.len() == 1 {
1531 let k: usize = entries[0]
1532 .parse()
1533 .map_err(|err| format!("invalid tensor k '{}': {err}", entries[0]))?;
1534 return Ok((vec![k; cols.len()], false));
1535 }
1536 if entries.len() != cols.len() {
1537 return Err(format!(
1538 "tensor k list length {} must match smooth dimension {}",
1539 entries.len(),
1540 cols.len()
1541 ));
1542 }
1543 let mut out = Vec::with_capacity(entries.len());
1544 for entry in entries {
1545 let k: usize = entry
1546 .parse()
1547 .map_err(|err| format!("invalid tensor k '{entry}': {err}"))?;
1548 out.push(k);
1549 }
1550 Ok((out, false))
1551}
1552
1553/// Parse the `identifiability=` option for tensor-product smooths. Mirrors the
1554/// vocabulary of the Matern/Duchon parsers so the formula DSL is consistent.
1555///
1556/// `kind` selects the default identifiability when no explicit
1557/// `identifiability=` option is supplied: `te(...)` ([`SmoothKind::Te`]) keeps
1558/// the full-tensor sum-to-zero default, while `ti(...)` ([`SmoothKind::Ti`])
1559/// defaults to per-margin sum-to-zero so the marginal main effects are excluded
1560/// (the mgcv tensor-interaction semantics). An explicit option always wins.
1561fn parse_tensor_identifiability(
1562 options: &BTreeMap<String, String>,
1563 kind: SmoothKind,
1564) -> Result<TensorBSplineIdentifiability, String> {
1565 let Some(raw) = options.get("identifiability").map(String::as_str) else {
1566 return Ok(match kind {
1567 SmoothKind::Ti => TensorBSplineIdentifiability::MarginalSumToZero,
1568 _ => TensorBSplineIdentifiability::default(),
1569 });
1570 };
1571 match raw.trim().to_ascii_lowercase().as_str() {
1572 "none" => Ok(TensorBSplineIdentifiability::None),
1573 "sum_tozero" | "sum-to-zero" | "center_sum_tozero" | "center-sum-to-zero" | "centered"
1574 | "sumtozero" => Ok(TensorBSplineIdentifiability::SumToZero),
1575 "marginal_sum_tozero" | "marginal-sum-to-zero" | "marginal_sumtozero"
1576 | "marginalsumtozero" | "interaction" => {
1577 Ok(TensorBSplineIdentifiability::MarginalSumToZero)
1578 }
1579 other => Err(TermBuilderError::unsupported_feature(format!(
1580 "invalid tensor identifiability '{other}'; expected one of: none, sum_tozero, marginal_sum_tozero"
1581 ))
1582 .to_string()),
1583 }
1584}
1585
1586fn bspline_boundary_declares_periodic_axis(options: &BTreeMap<String, String>) -> bool {
1587 options
1588 .get("boundary")
1589 .or_else(|| options.get("bc"))
1590 .map(|raw| {
1591 parse_option_list(raw)
1592 .into_iter()
1593 .any(|value| matches!(value.as_str(), "periodic" | "cyclic" | "cc"))
1594 })
1595 .unwrap_or(false)
1596}
1597
1598/// Canonical-name lookup for the `bs=`/`type=` smooth selector.
1599///
1600/// User-facing names — including mgcv-compatible spellings whose semantics
1601/// match an existing gamfit smooth exactly — collapse to the engine-internal
1602/// canonical names used by the dispatch in [`build_smooth_basis`]. Adding a
1603/// new exactly-equivalent alias is a one-line entry here; the match arms
1604/// below remain the single dispatch site.
1605///
1606/// Aliases listed here MUST be true semantic equivalents of the canonical
1607/// target, not approximations. mgcv names whose semantics differ from any
1608/// gamfit smooth (e.g. `bs="ts"` shrinkage thin-plate, `bs="ad"` adaptive)
1609/// are intentionally NOT mapped here — they should reach the unsupported-type
1610/// path so users get a real diagnostic instead of a silent semantic
1611/// substitution. mgcv's `bs="cr"`/`"cs"` (cubic regression and its shrinkage
1612/// twin) are handled directly in the [`build_smooth_basis`] dispatch — they
1613/// are not aliased here because the `cr`/`cs` distinction controls a default
1614/// (`double_penalty`) that the canonical-name layer cannot see.
1615///
1616/// Unrecognised inputs pass through unchanged so the dispatch can produce its
1617/// usual "unsupported smooth type" error, preserving the existing diagnostic
1618/// surface for genuine typos.
1619pub(crate) fn canonicalize_smooth_type(raw: &str) -> &str {
1620 match raw {
1621 // Thin-plate spline. mgcv `bs="tp"` is the default thin-plate
1622 // regression spline — exact semantic equivalent of gamfit's `"tps"`.
1623 "tp" => "tps",
1624 // Gaussian process / Matérn. mgcv `bs="gp"` defaults to a Matérn
1625 // covariance kernel with REML smoothing parameter selection, which
1626 // matches gamfit's `"matern"` exactly (same kernel-Gram identity,
1627 // same REML route).
1628 "gp" => "matern",
1629 // Constant-curvature (M_κ) geodesic-kernel smooth (#944). All aliases
1630 // collapse to one canonical type so `bs="curv"`/`bs="mkappa"` cannot
1631 // diverge from `curv(...)`.
1632 "curv" | "constant_curvature" | "mkappa" => "curvature",
1633 // Measure-jet spline: multiscale local-jet-residual energy of the
1634 // empirical measure. No mgcv equivalent (mgcv has no measure-learned
1635 // geometry smooth), so no mgcv alias is mapped.
1636 "mjs" | "measure_jet" | "web" => "measurejet",
1637 other => other,
1638 }
1639}
1640
1641/// Is `margin_bs` a per-margin basis name that the tensor builder realizes as a
1642/// penalized 1-D B-spline margin?
1643///
1644/// gam's tensor product is built from penalized B-spline marginals. mgcv's
1645/// thin-plate (`tp`/`tps`), P-spline (`ps`), B-spline (`bs`), cubic-regression
1646/// (`cr`/`cs`), and cyclic (`cc`/`cp`/`cyclic`) marginals are all penalized
1647/// splines spanning the same per-axis smoothing space, so a B-spline margin
1648/// reproduces the same tensor smoothing class. Margin kinds with fundamentally
1649/// different structure (adaptive, random-effect, sphere) are NOT accepted as
1650/// tensor margins.
1651pub(crate) fn tensor_margin_bs_is_supported(margin_bs: &str) -> bool {
1652 matches!(
1653 canonicalize_smooth_type(margin_bs),
1654 "tps" | "ps" | "bs" | "bspline" | "cr" | "cs" | "cc" | "cp" | "cyclic"
1655 )
1656}
1657
1658/// Does the smooth request a periodic/cyclic axis via its options?
1659///
1660/// Mirrors the boundary-condition reading used by the periodic-aware dispatch
1661/// branches. Factored out so the type resolver and `build_smooth_basis` agree
1662/// on a single notion of "periodic requested".
1663pub(crate) fn smooth_options_declare_periodic(options: &BTreeMap<String, String>) -> bool {
1664 options.contains_key("periodic")
1665 || options.contains_key("cyclic")
1666 || options
1667 .get("boundary")
1668 .or_else(|| options.get("bc"))
1669 .map(|boundary| {
1670 boundary.to_ascii_lowercase().contains("periodic")
1671 || boundary.to_ascii_lowercase().contains("cyclic")
1672 })
1673 .unwrap_or(false)
1674}
1675
1676/// Resolve the canonical engine-internal smooth-type name for a term.
1677///
1678/// Reads the user-facing `type=`/`bs=` selector and collapses mgcv-compatible
1679/// aliases (`tp`→`tps`, `gp`→`matern`) via [`canonicalize_smooth_type`], or
1680/// derives the default from the smooth kind/arity when no selector is given.
1681/// This is the single source of truth for the dispatch in
1682/// [`build_smooth_basis`]; other call sites (e.g. predictor-specific basis
1683/// policy) use it so the classification never drifts from the dispatch.
1684/// Is the raw `bs=`/`type=` selector a vector literal (`c('tp','tp')`,
1685/// `['tp','tp']`, `(tp, tp)`) rather than a scalar smooth-type name?
1686///
1687/// mgcv's tensor smooths take a *per-margin* basis vector
1688/// (`te(x1, x2, bs=c('tp','tp'))`). Such a value is not a scalar canonical
1689/// type and must not be fed through [`canonicalize_smooth_type`] — it has to be
1690/// recognized as a tensor request and split into per-margin types. A scalar
1691/// selector (`bs="tp"`) is left untouched.
1692pub(crate) fn bs_selector_is_vector(raw: &str) -> bool {
1693 let trimmed = raw.trim();
1694 let bracketed = (trimmed.starts_with('[') && trimmed.ends_with(']'))
1695 || (trimmed.starts_with("c(") || trimmed.starts_with("C(")) && trimmed.ends_with(')')
1696 || (trimmed.starts_with('(') && trimmed.ends_with(')'));
1697 bracketed && !parse_option_list(trimmed).is_empty()
1698}
1699
1700pub fn resolve_smooth_type_name(
1701 kind: SmoothKind,
1702 n_cols: usize,
1703 options: &BTreeMap<String, String>,
1704) -> String {
1705 let selector = options.get("type").or_else(|| options.get("bs"));
1706 // A per-margin basis vector is a tensor request, never a scalar type. Route
1707 // it to the tensor builder, which reads the per-margin types out of the
1708 // same `bs=` option. (A vector on a non-tensor smooth is ill-formed and
1709 // falls through to the scalar path below so the existing diagnostic fires.)
1710 if let Some(raw) = selector
1711 && bs_selector_is_vector(raw)
1712 && matches!(kind, SmoothKind::Te | SmoothKind::Ti | SmoothKind::T2)
1713 {
1714 return "tensor".to_string();
1715 }
1716 selector
1717 .map(|s| canonicalize_smooth_type(&s.to_ascii_lowercase()).to_string())
1718 .unwrap_or_else(|| match kind {
1719 SmoothKind::Te | SmoothKind::Ti | SmoothKind::T2 => "tensor".to_string(),
1720 SmoothKind::S if n_cols == 1 => "bspline".to_string(),
1721 // Mixed periodic Euclidean radial kernels are not separable on the
1722 // cylinder. Use a tensor product with a cyclic margin so s(theta,h)
1723 // honors seam continuity while preserving the formula-level s(...).
1724 SmoothKind::S if smooth_options_declare_periodic(options) => "tensor".to_string(),
1725 SmoothKind::S => "tps".to_string(),
1726 })
1727}
1728
1729/// Does this canonical smooth type size its basis through the generous spatial
1730/// center heuristic ([`crate::basis::default_num_centers`])?
1731///
1732/// Only the radial spatial bases (thin-plate, Matérn/GP, Duchon) route their
1733/// default basis dimension through `plan_spatial_basis(.., Default, ..)`. The
1734/// B-spline, cyclic, tensor, and factor-smooth bases use their own modest
1735/// knot-based defaults, so they are unaffected by — and must not be perturbed
1736/// by — secondary-predictor basis-parsimony adjustments (#501).
1737pub fn smooth_type_uses_spatial_center_heuristic(canonical_type: &str) -> bool {
1738 matches!(canonical_type, "tps" | "matern" | "duchon")
1739}
1740
1741pub fn build_smooth_basis(
1742 kind: SmoothKind,
1743 vars: &[String],
1744 cols: &[usize],
1745 options: &BTreeMap<String, String>,
1746 ds: &Dataset,
1747 inference_notes: &mut Vec<String>,
1748 policy: &ResourcePolicy,
1749 smooth_coordinate_count: usize,
1750) -> Result<SmoothBasisSpec, String> {
1751 // Fail fast on degenerate input: a smooth whose (non-categorical) coordinate
1752 // columns collapse to a SINGLE distinct point can only ever fit the response
1753 // mean — its design matrix is rank-1. For a UNIVARIATE smooth this is exactly
1754 // "the one column is constant": `smooth(x)`/`matern(x)` on constant `x` would
1755 // otherwise silently fit the mean of `y` with no visible cue (Duchon already
1756 // errors loudly via the basis layer; this makes the diagnosis explicit and
1757 // uniform). For a MULTIVARIATE smooth (tensor, sphere, tps, ...) a single
1758 // constant coordinate is NOT degenerate — the basis still varies along the
1759 // other coordinate(s) and the penalty absorbs the rank-deficient direction
1760 // (e.g. a constant-longitude meridian arc on the sphere is a well-posed 1-D
1761 // slice of S²). Such a term is degenerate only when EVERY coordinate is
1762 // constant at once, i.e. the joint input is a single point. Test the JOINT
1763 // cardinality, not each column independently, so the loud diagnosis still
1764 // fires for the genuinely rank-1 case without rejecting well-posed
1765 // lower-dimensional slices.
1766 let coord_cols: Vec<(&String, usize)> = vars
1767 .iter()
1768 .zip(cols.iter().copied())
1769 .filter(|(_, col)| !matches!(ds.column_kinds.get(*col), Some(ColumnKindTag::Categorical)))
1770 .collect();
1771 if !coord_cols.is_empty() {
1772 let views: Vec<ArrayView1<'_, f64>> = coord_cols
1773 .iter()
1774 .map(|(_, col)| ds.values.column(*col))
1775 .collect();
1776 let n_rows = views[0].len();
1777 let mut distinct_points = std::collections::HashSet::<Vec<u64>>::new();
1778 for r in 0..n_rows {
1779 let key: Vec<u64> = views
1780 .iter()
1781 .map(|v| {
1782 let x = v[r];
1783 let norm = if x == 0.0 { 0.0 } else { x };
1784 norm.to_bits()
1785 })
1786 .collect();
1787 distinct_points.insert(key);
1788 if distinct_points.len() > 1 {
1789 break;
1790 }
1791 }
1792 if distinct_points.len() <= 1 {
1793 return Err(TermBuilderError::degenerate_data(if coord_cols.len() == 1 {
1794 let var = coord_cols[0].0;
1795 format!(
1796 "smooth term over '{var}' has only one unique value in the training data \
1797 — a smooth on a constant column is degenerate and would only fit the response mean. \
1798 Remove `{var}` from the smooth, drop the term, or check the data."
1799 )
1800 } else {
1801 let names = coord_cols
1802 .iter()
1803 .map(|(v, _)| v.as_str())
1804 .collect::<Vec<_>>()
1805 .join(", ");
1806 format!(
1807 "smooth term over ({names}) has only one unique joint coordinate in the training \
1808 data — every coordinate is constant, so the smooth is degenerate and would only \
1809 fit the response mean. Drop the term or check the data."
1810 )
1811 })
1812 .to_string());
1813 }
1814 }
1815 if let Some(by_name) = options.get("by").cloned() {
1816 let by_col = options
1817 .get("__by_col")
1818 .and_then(|raw| raw.parse::<usize>().ok())
1819 .or_else(|| vars.iter().position(|v| v == &by_name).map(|idx| cols[idx]))
1820 .ok_or_else(|| format!("unknown by= column '{by_name}'"))?;
1821 let mut inner_options = options.clone();
1822 inner_options.remove("by");
1823 inner_options.remove("__by_col");
1824 inner_options.remove("id");
1825 let inner = build_smooth_basis(
1826 kind,
1827 vars,
1828 cols,
1829 &inner_options,
1830 ds,
1831 inference_notes,
1832 policy,
1833 smooth_coordinate_count,
1834 )?;
1835 let by_kind = match ds.column_kinds.get(by_col).copied() {
1836 Some(ColumnKindTag::Categorical) => ByVarKind::Factor {
1837 feature_col: by_col,
1838 ordered: option_bool(options, "ordered").unwrap_or(false),
1839 frozen_levels: None,
1840 },
1841 Some(ColumnKindTag::Continuous | ColumnKindTag::Binary) => ByVarKind::Numeric {
1842 feature_col: by_col,
1843 },
1844 None => {
1845 return Err(format!(
1846 "internal column-kind lookup failed for by='{by_name}'"
1847 ));
1848 }
1849 };
1850 return Ok(SmoothBasisSpec::BySmooth {
1851 smooth: Box::new(inner),
1852 by_kind,
1853 });
1854 }
1855
1856 let smooth_double_penalty = option_bool(options, "double_penalty").unwrap_or(true);
1857 let type_opt = resolve_smooth_type_name(kind, cols.len(), options);
1858
1859 if matches!(type_opt.as_str(), "fs" | "sz" | "re") {
1860 validate_known_options(
1861 type_opt.as_str(),
1862 options,
1863 &[
1864 "type",
1865 "bs",
1866 "k",
1867 "basis_dim",
1868 "basis-dim",
1869 "basisdim",
1870 "knots",
1871 "knot_placement",
1872 "knot-placement",
1873 "knotplacement",
1874 "degree",
1875 "penalty_order",
1876 "m",
1877 "double_penalty",
1878 "ordered",
1879 ],
1880 )?;
1881 if cols.len() != 2 {
1882 return Err(format!(
1883 "{} factor-smooth currently expects exactly two variables (one numeric, one categorical)",
1884 type_opt
1885 ));
1886 }
1887 let kinds = cols
1888 .iter()
1889 .map(|&c| ds.column_kinds.get(c).copied())
1890 .collect::<Vec<_>>();
1891 let (cont_idx, group_idx) = if type_opt == "re" {
1892 // mgcv random-slope examples are often s(g, x, bs="re").
1893 match (kinds[0], kinds[1]) {
1894 (Some(ColumnKindTag::Categorical), _) => (1usize, 0usize),
1895 (_, Some(ColumnKindTag::Categorical)) => (0usize, 1usize),
1896 _ => (1usize, 0usize),
1897 }
1898 } else {
1899 match (kinds[0], kinds[1]) {
1900 (_, Some(ColumnKindTag::Categorical)) => (0usize, 1usize),
1901 (Some(ColumnKindTag::Categorical), _) => (1usize, 0usize),
1902 _ => {
1903 return Err(format!(
1904 "{} factor-smooth requires one categorical factor variable",
1905 type_opt
1906 ));
1907 }
1908 }
1909 };
1910 let c = cols[cont_idx];
1911 let (minv, maxv) = col_minmax(ds.values.column(c))?;
1912 let degree = if type_opt == "re" {
1913 1
1914 } else {
1915 option_usize(options, "degree").unwrap_or(DEFAULT_BSPLINE_DEGREE)
1916 };
1917 // For a factor smooth every group's curve is fit from THAT group's rows
1918 // alone, so the marginal's flexibility must respect the least-resolved
1919 // group, not the pooled column. The pooled heuristic can hand the marginal
1920 // a basis that saturates (or exceeds) a small group's sample — e.g. the
1921 // sleepstudy panel has 8 training days per subject, and a default cubic
1922 // basis of 8 functions interpolates each subject's 8 points, leaving no
1923 // room for the wiggliness penalty to collapse the curve toward the
1924 // per-subject line. The factor smooth then fits within-group noise and
1925 // extrapolates badly (held-out forecast worse than the population mean).
1926 //
1927 // Cap the marginal basis below the minimum per-group covariate resolution
1928 // so the penalty always retains residual degrees of freedom to shrink each
1929 // group's curvature toward its linear null space (the random-slope
1930 // estimand). This small-group cap composes with a separate upper bound at
1931 // mgcv's factor-smooth default k=10 (FACTOR_SMOOTH_DEFAULT_BASIS_DIM,
1932 // applied below), so even ample-data groups get the modest SHARED marginal
1933 // a factor smooth wants rather than the full pooled basis. The explicit
1934 // `re` random-effect form takes neither cap: it is a raw linear `[1, x]`
1935 // random effect (0 internal knots), handled in the branch above.
1936 let pooled_internal = heuristic_knots_for_column(ds.values.column(c));
1937 let default_internal = if type_opt == "re" {
1938 // `bs="re"` is a PARAMETRIC random effect, not a smooth of the
1939 // covariate: `s(x, g, bs="re")` is the mgcv random intercept+slope
1940 // `(1 + x | g)`, i.e. a per-group line `[1, x]`, penalized by an iid
1941 // ridge. A degree-1 marginal with ZERO internal knots spans exactly
1942 // that linear space (2 coefficients per group). Using the pooled
1943 // knot heuristic here instead turned the marginal into a
1944 // piecewise-linear B-spline (e.g. 6 functions/group on sleepstudy),
1945 // i.e. a *smooth* with kinks rather than a random slope — many extra
1946 // collinear-across-levels coefficients that ill-condition the joint
1947 // Newton/REML solve (minutes-long fits, and a singular block when
1948 // combined with a separate random intercept `s(g, bs="re")`). The
1949 // raw linear basis is both the correct `re` semantics and fast.
1950 0
1951 } else {
1952 let min_group_resolution =
1953 min_per_group_unique_count(ds.values.column(c), ds.values.column(cols[group_idx]));
1954 // Per-group basis dim = degree + 1 + internal. Hold it well below the
1955 // smallest group's resolution (leave at least two residual points per
1956 // group) so the smooth cannot interpolate that group and the
1957 // wiggliness penalty retains the room to collapse each curve toward
1958 // its linear null space. Never drop below `degree + 2`, which keeps
1959 // exactly the linear span plus a single curvature direction — the
1960 // minimal smoother that can still bend if the data demand it.
1961 let basis_cap = min_group_resolution.saturating_sub(2).max(degree + 2);
1962 let internal_cap = basis_cap.saturating_sub(degree + 1);
1963 let capped = pooled_internal.min(internal_cap.max(1));
1964 // A factor smooth (`fs` AND `sz`) shares ONE marginal across ALL
1965 // levels, each level's curve fit from that group's rows alone. The
1966 // pooled knot heuristic (driven by the full column's sample) hands it
1967 // a much richer basis than the shared signal needs — ~24
1968 // functions/group on the gam#903 factor-smooth-recovery fixtures — so
1969 // REML has the capacity to fit within-group noise and over-fits the
1970 // shared shape (fs: edf 58 vs mgcv's k=10/edf 39; sz: gam 0.068 vs
1971 // mgcv 0.046 truth RMSE), losing the truth-recovery head-to-head with
1972 // the mature tool. mgcv's factor-smooth default `k=10` embodies the
1973 // right convention: a modest shared marginal. Cap the marginal there
1974 // (basis ≈ degree+1+internal ≈ 10) for both flavours when the
1975 // small-group cap above is not already tighter, so REML is not handed
1976 // noise-fitting capacity it does not need. An explicit `k`/`basis_dim`
1977 // overrides this (parse_ps_internal_knots); `re` is the raw linear
1978 // effect handled above.
1979 let fs_default_internal = FACTOR_SMOOTH_DEFAULT_BASIS_DIM
1980 .saturating_sub(degree + 1)
1981 .max(1);
1982 capped.min(fs_default_internal)
1983 };
1984 let (n_knots, _, effective_degree) =
1985 parse_ps_internal_knots(options, degree, default_internal)?;
1986 let penalty_order = option_usize(options, "penalty_order")
1987 .unwrap_or(if effective_degree > 1 { 2 } else { 1 })
1988 .min(effective_degree);
1989 // All factor-smooth flavours (`fs`, `sz`, `re`) place their per-level
1990 // marginal on the SAME penalized B-spline (P-spline) basis. The flavours
1991 // differ ONLY in their penalty/constraint structure (handled below) —
1992 // sz: zero-sum deviation blocks with the per-level null space left
1993 // unpenalized; fs: random-effect double penalty; re: identity ridge.
1994 //
1995 // `sz` USED to route its default-degree marginal to a NATURAL cubic
1996 // regression spline (`cr`), on the belief that mgcv's `bs="sz"` does the
1997 // same and that cr recovers smooth signals more efficiently than the
1998 // (then uncapped) B-spline margin (#1074). That introduced a consistency
1999 // failure (#1605): the `cr` basis enforces the natural boundary
2000 // conditions f''(x_1)=f''(x_k)=0 and extrapolates linearly past the end
2001 // knots, so it CANNOT represent a per-group deviation curve with non-zero
2002 // curvature at the data boundary. Phase-shifted deviation shapes
2003 // (f''(0) = -(2π)² sin(φ) ≠ 0) are then biased toward "free linear +
2004 // anchored wiggle", under-shooting the amplitude — a bias that does NOT
2005 // vanish as n→∞ (n-independent: a genuine consistency failure, not
2006 // finite-sample shrinkage). The earlier #700/#1074 sz fixtures used
2007 // d_g ∝ sin(2πx), whose f'' happens to vanish at x=0 and x=1, so they
2008 // accidentally satisfied the natural BC and never exposed the gap; the
2009 // `fs` sibling, on this very B-spline marginal, recovers the SAME
2010 // phase-shifted data to the noise floor.
2011 //
2012 // The penalized B-spline marginal makes no boundary assumption, so it
2013 // represents arbitrary deviation shapes, and — with the
2014 // FACTOR_SMOOTH_DEFAULT_BASIS_DIM cap above already removing the
2015 // noise-fitting capacity that originally motivated leaving B-splines —
2016 // it recovers the BC-satisfying #700/#1074 signals just as well. Sharing
2017 // one marginal basis across all flavours also lets the B-spline degree/
2018 // knot degradation handle low-cardinality covariates uniformly (what
2019 // `fs` already does), so the `sz`-only cr data-support cap (#1541/#1542)
2020 // — and the asymmetry where only the cr-marginal `sz` spelling hard-
2021 // failed a 3-level ordinal — is no longer needed.
2022 let marginal_knotspec = resolve_nonperiodic_bspline_knotspec(
2023 options,
2024 ds.values.column(c),
2025 (minv, maxv),
2026 effective_degree,
2027 n_knots,
2028 )?;
2029 let marginal = BSplineBasisSpec {
2030 degree: effective_degree,
2031 penalty_order,
2032 knotspec: marginal_knotspec,
2033 // mgcv's `bs="fs"` is a random-effect-style smooth: EVERY per-level
2034 // coefficient, including the marginal null space, is penalized so
2035 // unobserved groups can be predicted — so `fs` keeps the null-space
2036 // (double) penalty. mgcv's `bs="sz"` is a pure across-level
2037 // *deviation* smooth that, under the default `select=FALSE`, leaves
2038 // the per-level null space UNPENALIZED; carrying the double penalty
2039 // there shrinks the genuine deviation signal and over-smooths the
2040 // recovered curves relative to mgcv (gam#700). `re` carries its own
2041 // identity ridge below and ignores this flag. Honour an explicit
2042 // user `double_penalty=` either way.
2043 double_penalty: option_bool(options, "double_penalty")
2044 .unwrap_or(type_opt.as_str() != "sz"),
2045 identifiability: BSplineIdentifiability::None,
2046 boundary_conditions: Default::default(),
2047 boundary: OneDimensionalBoundary::Open,
2048 };
2049 let flavour = match type_opt.as_str() {
2050 "fs" => FactorSmoothFlavour::Fs {
2051 m_null_penalty_orders: vec![
2052 option_usize(options, "m").unwrap_or(DEFAULT_PENALTY_ORDER),
2053 ],
2054 },
2055 "sz" => FactorSmoothFlavour::Sz,
2056 "re" => FactorSmoothFlavour::Re,
2057 // Outer `matches!` already restricts to fs/sz/re.
2058 other => {
2059 return Err(format!(
2060 "internal: factor-smooth flavour dispatch reached unexpected type `{}`",
2061 other
2062 ));
2063 }
2064 };
2065 return Ok(SmoothBasisSpec::FactorSmooth {
2066 spec: FactorSmoothSpec {
2067 continuous_cols: vec![c],
2068 group_col: cols[group_idx],
2069 marginal,
2070 flavour,
2071 group_frozen_levels: None,
2072 frozen_global_orthogonality: None,
2073 },
2074 });
2075 }
2076
2077 match type_opt.as_str() {
2078 "cyclic" | "cc" | "cp" | "cyclic-ps" => {
2079 validate_known_options(
2080 "cyclic",
2081 options,
2082 &[
2083 "type",
2084 "bs",
2085 "by",
2086 "k",
2087 "basis_dim",
2088 "basis-dim",
2089 "basisdim",
2090 "degree",
2091 "penalty_order",
2092 "period",
2093 "periods",
2094 "period_start",
2095 "period_end",
2096 "start",
2097 "end",
2098 "origin",
2099 "origins",
2100 "period_origin",
2101 "period-origin",
2102 "domain_origin",
2103 "double_penalty",
2104 "id",
2105 "__by_col",
2106 "identifiability",
2107 ],
2108 )?;
2109 if cols.len() != 1 {
2110 return Err(format!(
2111 "periodic smooth expects one variable, got {}",
2112 cols.len()
2113 ));
2114 }
2115 let c = cols[0];
2116 let (minv, maxv) = col_minmax(ds.values.column(c))?;
2117 let degree = option_usize(options, "degree").unwrap_or(DEFAULT_BSPLINE_DEGREE);
2118 let mut default_internal = heuristic_knots_for_column(ds.values.column(c));
2119 if ds.values.nrows() <= 32 && smooth_coordinate_count >= 5 {
2120 default_internal = default_internal.min(1);
2121 }
2122 // A periodic cubic spline has no free endpoint behaviour to spend
2123 // degrees of freedom on: the wrap constraint removes the ordinary
2124 // boundary wiggle, and the cyclic second-difference penalty leaves
2125 // only the constant direction (handled by the smooth
2126 // identifiability constraint). An over-rich default would give
2127 // small binomial/continuation-ratio fits a large penalized nuisance
2128 // space whose REML/LAML optimum is driven by finite-sample Bernoulli
2129 // noise rather than the low-frequency periodic signal. Cap the
2130 // cyclic default in the mgcv `bs="cc"` spirit: a modest basis unless
2131 // the caller explicitly requests `k=...`; high-frequency periodic
2132 // structure remains available through that explicit contract. Since
2133 // gam#1680 lowered the open-spline univariate default to ≈12
2134 // functions this cap and the open-spline default coincide, so it now
2135 // acts as an explicit floor/guard that keeps the cyclic default lean
2136 // even if the open-spline heuristic is later widened.
2137 let cyclic_default_basis_cap = CYCLIC_DEFAULT_BASIS_DIM.max(degree + 1);
2138 let default_basis = (default_internal + degree + 1).min(cyclic_default_basis_cap);
2139 let num_basis = option_usize_any(options, &["k", "basis_dim", "basis-dim", "basisdim"])
2140 .unwrap_or(default_basis);
2141 if num_basis < degree + 1 {
2142 return Err(format!(
2143 "periodic smooth: k={} too small for degree {}; expected k >= {}",
2144 num_basis,
2145 degree,
2146 degree + 1
2147 ));
2148 }
2149 // The cyclic arm is periodic on its single axis by construction, so
2150 // resolve the period exactly the way the `s()`/`ps` arm does: honour
2151 // `period=`/`periods=` first (with `origin=` setting the domain
2152 // start), and fall back to the `period_start`/`period_end` endpoint
2153 // form only when `period=` is absent. Previously this arm jumped
2154 // straight to `parse_periodic_domain_1d`, so a `period=<v>`
2155 // declaration was silently dropped and the smooth wrapped at the
2156 // data range (#816). All three helpers route through
2157 // `parse_numeric_expr`, so `period=2*pi` and `period_end=2*pi` parse
2158 // identically (#815).
2159 let periodic_axes = [true];
2160 let periods = parse_periods(options, &periodic_axes)?;
2161 let origins = parse_period_origins(options, &periodic_axes)?;
2162 // Distinguish a *cyclic basis selector* (`bs='cc'`/`cp'`/`cyclic`,
2163 // this whole arm) from a generic B-spline forced periodic by a
2164 // `periodic=`/`boundary=` flag (the `ps`/`bspline` arm). Only the
2165 // latter carries the sample-dependent off-by-ε seam that #1771's
2166 // guard in `parse_periodic_domain_1d` requires an explicit period
2167 // to avoid. A bare `s(x, bs='cc')` opts INTO mgcv's `bs="cc"`
2168 // semantics — the wrap IS the observed data range — exactly like
2169 // the tensor cc-margin fallback (`te(x, z, bs=c('cc','cc'))`). The
2170 // cyclic arm was left routing through the now-strict helper when
2171 // #1771 tightened it, so a bare cyclic smooth hard-errored with
2172 // "periodic B-spline smooth requires an explicit period" even
2173 // though its period is well-defined. Honor `period=`/`periods=`
2174 // first, then the half-open `period_start`/`period_end` endpoint
2175 // form, and only otherwise wrap at the observed `[min, max]` span.
2176 let has_endpoint_decl = ["period_start", "start", "period_end", "end"]
2177 .iter()
2178 .any(|key| options.contains_key(*key));
2179 let (domain_start, period) = if let Some(p) = periods[0] {
2180 (origins[0].unwrap_or(minv), p)
2181 } else if has_endpoint_decl {
2182 parse_periodic_domain_1d(options, minv, maxv)?
2183 } else {
2184 let span = maxv - minv;
2185 if !(span.is_finite() && span > 0.0) {
2186 return Err(format!(
2187 "cyclic smooth requires a positive observed data range to derive \
2188 its period, got [{minv}, {maxv}]"
2189 ));
2190 }
2191 (origins[0].unwrap_or(minv), span)
2192 };
2193 Ok(SmoothBasisSpec::BSpline1D {
2194 feature_col: c,
2195 spec: BSplineBasisSpec {
2196 degree,
2197 penalty_order: option_usize(options, "penalty_order")
2198 .unwrap_or(DEFAULT_PENALTY_ORDER),
2199 knotspec: BSplineKnotSpec::PeriodicUniform {
2200 data_range: (domain_start, domain_start + period),
2201 num_basis,
2202 },
2203 double_penalty: smooth_double_penalty,
2204 identifiability: BSplineIdentifiability::default(),
2205 boundary_conditions: Default::default(),
2206 boundary: OneDimensionalBoundary::Cyclic {
2207 start: domain_start,
2208 end: domain_start + period,
2209 },
2210 },
2211 })
2212 }
2213 "bspline" | "ps" | "p-spline" | "cr" | "cs" => {
2214 // mgcv's `bs="cr"` (cubic regression spline) and `bs="cs"` (its
2215 // shrinkage twin) are penalized cubic-regression smooths that span
2216 // the same per-axis function space as gamfit's `bspline` (cubic
2217 // B-spline, second-derivative penalty). Route both through the
2218 // 1-D B-spline arm; the only semantic difference is whether the
2219 // null space is shrunk: `cr` is the no-shrinkage form (mgcv's
2220 // default) and `cs` is the shrinkage form (mgcv's `cs`/gamfit's
2221 // double_penalty). Without this route, a stand-alone
2222 // `s(x, bs='cr')` (which is otherwise a routine 1-D smooth in
2223 // mgcv-compatible formulae) reached the dispatch's default arm
2224 // and aborted the whole fit with `unsupported smooth type 'cr'`,
2225 // even though the same name was already recognized as a tensor
2226 // margin (`tensor_margin_bs_is_supported`).
2227 let validation_name = match type_opt.as_str() {
2228 "cr" => "cr",
2229 "cs" => "cs",
2230 _ => "bspline",
2231 };
2232 validate_known_options(
2233 validation_name,
2234 options,
2235 &[
2236 "type",
2237 "bs",
2238 "by",
2239 "k",
2240 "basis_dim",
2241 "basis-dim",
2242 "basisdim",
2243 "knots",
2244 "knot_placement",
2245 "knot-placement",
2246 "knotplacement",
2247 "degree",
2248 "penalty_order",
2249 "boundary",
2250 "bc",
2251 "boundary_conditions",
2252 "bc_left",
2253 "bc_right",
2254 "left_bc",
2255 "right_bc",
2256 "start_bc",
2257 "end_bc",
2258 "side",
2259 "anchor",
2260 "anchor_value",
2261 "value",
2262 "anchor_left",
2263 "left_anchor",
2264 "anchor_right",
2265 "right_anchor",
2266 "periodic",
2267 "period",
2268 "periods",
2269 "period_start",
2270 "period_end",
2271 "origin",
2272 "double_penalty",
2273 "by",
2274 "id",
2275 "__by_col",
2276 "identifiability",
2277 "by",
2278 ],
2279 )?;
2280 if cols.len() != 1 {
2281 return Err(TermBuilderError::incompatible_config(format!(
2282 "bspline smooth expects one variable, got {}",
2283 cols.len()
2284 ))
2285 .to_string());
2286 }
2287 let c = cols[0];
2288 let (minv, maxv) = col_minmax(ds.values.column(c))?;
2289 let degree = option_usize(options, "degree").unwrap_or(DEFAULT_BSPLINE_DEGREE);
2290 let default_internal = heuristic_knots_for_column(ds.values.column(c));
2291 let (mut n_knots, inferred, effective_degree) =
2292 parse_ps_internal_knots(options, degree, default_internal)?;
2293 let periodic_axes = parse_periodic_axes(options, 1).map_err(|e| e.to_string())?;
2294 // Periodic margins still need enough basis functions to wrap, so
2295 // surface the per-axis degree reduction as a config error when the
2296 // user explicitly asked for a periodic-but-too-small basis. The
2297 // non-periodic path silently degrades degree to match mgcv.
2298 if periodic_axes[0] && effective_degree != degree {
2299 return Err(TermBuilderError::invalid_option(format!(
2300 "periodic smooth: k={} too small for degree {}; expected k >= {}",
2301 effective_degree + 1,
2302 degree,
2303 degree + 1
2304 ))
2305 .to_string());
2306 }
2307 if inferred && ds.values.nrows() <= 32 && smooth_coordinate_count >= 5 {
2308 n_knots = n_knots.min(1);
2309 }
2310 if inferred {
2311 let unique = unique_count_column(ds.values.column(c));
2312 let ceiling = ((unique as f64).cbrt() as usize).max(20);
2313 inference_notes.push(format!(
2314 "Automatically set {} internal knots for smooth '{}' from {} unique values (rule: clamp(unique/4, 4..max(20, cbrt(unique))) = clamp(unique/4, 4..{})). Override with knots=... or k=....",
2315 n_knots,
2316 vars.join(","),
2317 unique,
2318 ceiling,
2319 ));
2320 }
2321 let boundary_conditions =
2322 if periodic_axes[0] && bspline_boundary_declares_periodic_axis(options) {
2323 BSplineBoundaryConditions::default()
2324 } else {
2325 parse_bspline_boundary_conditions(options).map_err(|e| e.to_string())?
2326 };
2327 let periods = parse_periods(options, &periodic_axes).map_err(|e| e.to_string())?;
2328 let origins =
2329 parse_period_origins(options, &periodic_axes).map_err(|e| e.to_string())?;
2330 let (knotspec, boundary) = if periodic_axes[0] {
2331 if !boundary_conditions.is_free() {
2332 return Err(TermBuilderError::incompatible_config(
2333 "periodic B-splines cannot also declare endpoint boundary conditions",
2334 )
2335 .to_string());
2336 }
2337 {
2338 let (domain_start, p_value) = if periods[0].is_some() {
2339 (origins[0].unwrap_or(minv), periods[0].unwrap())
2340 } else {
2341 parse_periodic_domain_1d(options, minv, maxv).map_err(|e| e.to_string())?
2342 };
2343 let domain_end = domain_start + p_value;
2344 (
2345 BSplineKnotSpec::PeriodicUniform {
2346 data_range: (domain_start, domain_end),
2347 num_basis: n_knots + effective_degree + 1,
2348 },
2349 OneDimensionalBoundary::Cyclic {
2350 start: domain_start,
2351 end: domain_end,
2352 },
2353 )
2354 }
2355 } else if type_opt == "cr" || type_opt == "cs" {
2356 // mgcv `bs="cr"`/`"cs"`: a natural cubic regression spline whose
2357 // basis is indexed by `k` values at quantile-placed knots (#1074),
2358 // NOT a B-spline knot vector. Match gam's `k=` convention by
2359 // requesting the same total basis size the B-spline arm would
2360 // produce (`n_knots` internal + degree + 1), floored at the cr
2361 // minimum of 3 knots. `cr` vs `cs` (shrinkage) is carried by the
2362 // `double_penalty` flag resolved below, which the cr builder reads.
2363 //
2364 // Cap that request to the covariate's data support (#1541): a cr
2365 // basis cannot place more value-knots than there are distinct
2366 // covariate values, so an unclamped `k` on a low-cardinality
2367 // predictor (binary indicator, 3-level ordinal, small count) used
2368 // to hard-fail in `select_cr_knots` instead of reducing like mgcv
2369 // and gam's tensor path. Below the cr minimum (a binary covariate)
2370 // degrade to the B-spline marginal the default `s(x, k=..)` basis
2371 // already fits on the same data — never a hard error.
2372 let k_cr = (n_knots + effective_degree + 1).max(CR_MIN_KNOTS);
2373 let knotspec = match capped_cr_marginal_knotspec(
2374 ds.values.column(c),
2375 k_cr,
2376 &vars.join(","),
2377 inference_notes,
2378 )? {
2379 Some(cr_knotspec) => cr_knotspec,
2380 None => resolve_nonperiodic_bspline_knotspec(
2381 options,
2382 ds.values.column(c),
2383 (minv, maxv),
2384 effective_degree,
2385 n_knots,
2386 )?,
2387 };
2388 (knotspec, parse_cyclic_boundary(options, minv, maxv)?)
2389 } else {
2390 (
2391 resolve_nonperiodic_bspline_knotspec(
2392 options,
2393 ds.values.column(c),
2394 (minv, maxv),
2395 effective_degree,
2396 n_knots,
2397 )?,
2398 parse_cyclic_boundary(options, minv, maxv)?,
2399 )
2400 };
2401 // mgcv `bs="cr"` does not shrink the linear null space; only `cs`
2402 // (and the gamfit-flavoured `bspline`/`ps`) do. Honour an explicit
2403 // `double_penalty=` either way.
2404 let double_penalty = if type_opt == "cr" {
2405 option_bool(options, "double_penalty").unwrap_or(false)
2406 } else {
2407 smooth_double_penalty
2408 };
2409 // Clamp the marginal difference penalty to `<= effective_degree`
2410 // so it stays well-defined when the per-axis degree was reduced
2411 // (mirrors the tensor margin path: `create_difference_penalty_matrix`
2412 // requires order < num_basis_functions).
2413 let penalty_order = option_usize(options, "penalty_order")
2414 .unwrap_or(DEFAULT_PENALTY_ORDER)
2415 .min(effective_degree);
2416 Ok(SmoothBasisSpec::BSpline1D {
2417 feature_col: c,
2418 spec: BSplineBasisSpec {
2419 degree: effective_degree,
2420 penalty_order,
2421 knotspec,
2422 double_penalty,
2423 identifiability: BSplineIdentifiability::default(),
2424 boundary,
2425 boundary_conditions,
2426 },
2427 })
2428 }
2429 "tps" | "thinplate" | "thin-plate" => {
2430 validate_known_options(
2431 "thinplate",
2432 options,
2433 &[
2434 SECONDARY_CENTER_CAP_OPTION,
2435 "type",
2436 "bs",
2437 "by",
2438 "length_scale",
2439 "centers",
2440 "k",
2441 "basis_dim",
2442 "basis-dim",
2443 "basisdim",
2444 "knots",
2445 "include_intercept",
2446 "double_penalty",
2447 "by",
2448 "id",
2449 "__by_col",
2450 "identifiability",
2451 "by",
2452 "periodic",
2453 "cyclic",
2454 "period",
2455 "period_start",
2456 "period_end",
2457 "scale_dims",
2458 ],
2459 )?;
2460 let plan = plan_spatial_basis(
2461 ds.values.nrows(),
2462 cols.len(),
2463 CenterCountRequest::Default,
2464 DuchonNullspaceOrder::Linear,
2465 option_bool(options, "scale_dims").unwrap_or(false),
2466 policy,
2467 )
2468 .map_err(|e| e.to_string())?;
2469 // #1074: the mgcv-sized basis cap (`k = 10·3^(d-1)`) that used to live
2470 // here was DELETED. It masked the real defect — the n-scaling default
2471 // over-sizes a thin-plate field, producing a weakly-identified
2472 // two-penalty ρ-surface the outer optimizer stalls on (row-order
2473 // dependent, #1378), and surplus columns REML can't penalize away on
2474 // weak-signal fits. Capping the basis hid that stall instead of fixing
2475 // it. The default now uses the generic spatial center heuristic; the
2476 // root fix (a well-identified ρ-surface / optimizer that doesn't stall)
2477 // is tracked separately. Explicit `k`/`centers` still take full effect.
2478 let default_centers = plan.centers;
2479 let centers = parse_countwith_basis_alias(
2480 options,
2481 "centers",
2482 cap_default_spatial_centers(options, default_centers),
2483 )?;
2484 let center_strategy = if has_explicit_countwith_basis_alias(options, "centers") {
2485 spatial_center_strategy_for_dimension(centers, cols.len())
2486 } else {
2487 auto_spatial_center_strategy(centers, cols.len())
2488 };
2489 Ok(SmoothBasisSpec::ThinPlate {
2490 feature_cols: cols.to_vec(),
2491 spec: ThinPlateBasisSpec {
2492 center_strategy,
2493 periodic: parse_periodic_axes_option(options, cols.len())?,
2494 // Sentinel: leave at 0.0 when the user didn't pass an
2495 // explicit length_scale so `auto_init_length_scale_in_place`
2496 // can replace it with a data-derived initialization. The
2497 // old hard-coded 1.0 was the documented basin (see
2498 // smooth.rs `auto_init_length_scale_in_place`) that the
2499 // spatial optimizer could not escape, leaving TPS terms
2500 // initialized off the data scale.
2501 length_scale: option_f64(options, "length_scale").unwrap_or(0.0),
2502 double_penalty: smooth_double_penalty,
2503 identifiability: parse_spatial_identifiability(options)
2504 .map_err(|e| e.to_string())?,
2505 radial_reparam: None,
2506 },
2507 input_scales: None,
2508 })
2509 }
2510 "sphere" | "s2" | "sos" => {
2511 validate_known_options(
2512 "sphere",
2513 options,
2514 &[
2515 "type",
2516 "bs",
2517 "by",
2518 "centers",
2519 "k",
2520 "basis_dim",
2521 "basis-dim",
2522 "basisdim",
2523 "knots",
2524 "penalty_order",
2525 "m",
2526 "double_penalty",
2527 "id",
2528 "__by_col",
2529 "kernel",
2530 "method",
2531 "radians",
2532 "units",
2533 "degree",
2534 "l",
2535 "max_degree",
2536 "max-degree",
2537 ],
2538 )?;
2539 if cols.len() != 2 {
2540 return Err(format!(
2541 "sphere smooth expects exactly two variables (lat, lon), got {}",
2542 cols.len()
2543 ));
2544 }
2545 let radians = option_bool(options, "radians").unwrap_or_else(|| {
2546 options
2547 .get("units")
2548 .map(|u| u.eq_ignore_ascii_case("radian") || u.eq_ignore_ascii_case("radians"))
2549 .unwrap_or(false)
2550 });
2551 // An explicit `degree`/`l`/`max_degree` names a spherical-harmonic
2552 // truncation, so with no explicit kernel/method it selects the
2553 // Harmonic construction (the Wahba kernel ignores `degree` and would
2554 // silently emit a 1-column kernel design). An explicit kernel/method
2555 // still wins.
2556 let degree_requested = options.contains_key("degree")
2557 || options.contains_key("l")
2558 || options.contains_key("max_degree")
2559 || options.contains_key("max-degree");
2560 let kernel = options
2561 .get("kernel")
2562 .or_else(|| options.get("method"))
2563 .map(|raw| strip_quotes(raw).trim().to_ascii_lowercase())
2564 .unwrap_or_else(|| {
2565 if degree_requested {
2566 "harmonic".to_string()
2567 } else {
2568 "sobolev".to_string()
2569 }
2570 });
2571 let (method, wahba_kernel) = match kernel.as_str() {
2572 "sobolev" | "wahba" | "wahba_sobolev" | "wahba-sobolev" => {
2573 (SphereMethod::Wahba, SphereWahbaKernel::Sobolev)
2574 }
2575 "pseudo" | "mgcv" | "sos" | "wahba_pseudo" | "wahba-pseudo" => {
2576 (SphereMethod::Wahba, SphereWahbaKernel::Pseudo)
2577 }
2578 "harmonic" | "spherical_harmonic" | "spherical-harmonic" => {
2579 (SphereMethod::Harmonic, SphereWahbaKernel::Sobolev)
2580 }
2581 other => {
2582 return Err(format!(
2583 "unsupported sphere kernel '{other}'; expected sobolev, pseudo, or harmonic"
2584 ));
2585 }
2586 };
2587 let max_degree = if matches!(method, SphereMethod::Harmonic) {
2588 let degree =
2589 option_usize_any(options, &["degree", "l", "max_degree", "max-degree"])
2590 .or_else(|| option_usize(options, "centers"))
2591 .or_else(|| {
2592 option_usize_any(options, &["k", "basis_dim", "basis-dim", "basisdim"])
2593 .and_then(|k| (1..=128).find(|&l| l * (l + 2) >= k))
2594 })
2595 .unwrap_or_else(|| default_spherical_harmonic_degree(ds.values.nrows()));
2596 if degree == 0 {
2597 return Err("sphere smooth requires degree/max_degree >= 1".to_string());
2598 }
2599 if degree > 32 {
2600 return Err(format!(
2601 "sphere smooth max_degree={} is too large for the dense harmonic engine (limit 32)",
2602 degree
2603 ));
2604 }
2605 Some(degree)
2606 } else {
2607 None
2608 };
2609 let penalty_order = option_usize(options, "penalty_order")
2610 .or_else(|| option_usize(options, "m"))
2611 .unwrap_or(DEFAULT_PENALTY_ORDER);
2612 let center_strategy = if matches!(method, SphereMethod::Wahba) {
2613 let mut centers = parse_countwith_basis_alias(
2614 options,
2615 "centers",
2616 default_num_centers(ds.values.nrows(), cols.len()),
2617 )?;
2618 if penalty_order >= 4 {
2619 centers = centers.max(30);
2620 }
2621 CenterStrategy::FarthestPoint {
2622 num_centers: centers,
2623 }
2624 } else {
2625 CenterStrategy::FarthestPoint { num_centers: 0 }
2626 };
2627 Ok(SmoothBasisSpec::Sphere {
2628 feature_cols: cols.to_vec(),
2629 spec: SphericalSplineBasisSpec {
2630 center_strategy,
2631 penalty_order,
2632 double_penalty: smooth_double_penalty,
2633 radians,
2634 method,
2635 max_degree,
2636 wahba_kernel,
2637 identifiability: SphericalSplineIdentifiability::CenterSumToZero,
2638 },
2639 })
2640 }
2641 "curvature" => {
2642 // Constant-curvature (M_κ) geodesic-kernel smooth (#944): the
2643 // κ-generic sibling of the intrinsic S² smooth above. The feature
2644 // columns are κ-stereographic chart coordinates; `kappa=` is the
2645 // fixed sectional curvature (default 0 = flat), and the geometry
2646 // comes from `geometry::constant_curvature::ConstantCurvature`.
2647 validate_known_options(
2648 "curvature",
2649 options,
2650 &[
2651 "type",
2652 "bs",
2653 "by",
2654 "centers",
2655 "k",
2656 "basis_dim",
2657 "basis-dim",
2658 "basisdim",
2659 "knots",
2660 "kappa",
2661 "length_scale",
2662 "double_penalty",
2663 "id",
2664 "__by_col",
2665 ],
2666 )?;
2667 let kappa = option_f64(options, "kappa").unwrap_or(0.0);
2668 if !kappa.is_finite() {
2669 return Err("curvature smooth requires a finite kappa".to_string());
2670 }
2671 let length_scale = option_f64(options, "length_scale").unwrap_or(0.0);
2672 if !length_scale.is_finite() || length_scale < 0.0 {
2673 return Err(format!(
2674 "curvature smooth length_scale must be positive (or omitted for auto); got {length_scale}"
2675 ));
2676 }
2677 let centers = parse_countwith_basis_alias(
2678 options,
2679 "centers",
2680 default_num_centers(ds.values.nrows(), cols.len()),
2681 )?;
2682 if centers < 2 {
2683 return Err("curvature smooth requires at least 2 centers".to_string());
2684 }
2685 Ok(SmoothBasisSpec::ConstantCurvature {
2686 feature_cols: cols.to_vec(),
2687 spec: ConstantCurvatureBasisSpec {
2688 center_strategy: CenterStrategy::FarthestPoint {
2689 num_centers: centers,
2690 },
2691 kappa,
2692 // 0.0 sentinel = κ-independent auto initialization in the
2693 // basis builder (median chart center spacing, doubled).
2694 length_scale,
2695 // Curvature smooth defaults to NO double-penalty ridge
2696 // (#1464): the curvature-blind ridge `I` absorbs the data fit
2697 // independently of κ and rails the fitted curvature to the
2698 // +chart bound (hyperbolic truth recovered as spherical). The
2699 // RKHS Gram penalty is already full-rank PD, so the ridge adds
2700 // no stability. Honour an EXPLICIT `double_penalty=` only.
2701 double_penalty: option_bool(options, "double_penalty").unwrap_or(false),
2702 identifiability: ConstantCurvatureIdentifiability::CenterSumToZero,
2703 },
2704 })
2705 }
2706 "measurejet" => {
2707 // Measure-jet spline: multiscale local-jet-residual energy of the
2708 // empirical measure. The feature columns are ambient coordinates
2709 // of data concentrated near an unknown low-dimensional set; the
2710 // geometry (centers, masses, scale band) is read off the measure
2711 // at build time — magic by default, every option optional.
2712 validate_known_options(
2713 "measurejet",
2714 options,
2715 &[
2716 "type",
2717 "bs",
2718 "by",
2719 "centers",
2720 "k",
2721 "basis_dim",
2722 "basis-dim",
2723 "basisdim",
2724 "knots",
2725 "s",
2726 "alpha",
2727 "tau",
2728 "scales",
2729 "length_scale",
2730 "double_penalty",
2731 "multiscale",
2732 "learn_length_scale",
2733 "id",
2734 "__by_col",
2735 ],
2736 )?;
2737 let order_s = option_f64(options, "s").unwrap_or(0.0);
2738 // 0.0 = auto sentinel; explicit values must sit inside the
2739 // admissible order interval of the affine-jet (r = 2) energy.
2740 if !(order_s.is_finite() && (order_s == 0.0 || (order_s > 0.0 && order_s < 2.0))) {
2741 return Err(format!(
2742 "measurejet smooth s must lie in (0, 2) (or be omitted for auto); got {order_s}"
2743 ));
2744 }
2745 // Default to the spec Default (α = 1, density-WEIGHTED Hessian
2746 // energy — the module-header default). The density-free α = 3/2
2747 // (q^{−2}) over-smooths low-intrinsic-dimension manifolds where the
2748 // local mass q is tiny and varies along the stratum (#1116:
2749 // 13×-worse-than-matérn on a 1-D curve in 3-D); α = 1's q^{−1} is
2750 // gentler and robust across intrinsic dimensions. An explicit
2751 // `alpha=` still overrides for full-dimensional density-free use.
2752 let alpha =
2753 option_f64(options, "alpha").unwrap_or(MeasureJetBasisSpec::default().alpha);
2754 if !alpha.is_finite() {
2755 return Err("measurejet smooth requires a finite alpha".to_string());
2756 }
2757 let tau0 = option_f64(options, "tau").unwrap_or(1e-3);
2758 if !(tau0.is_finite() && tau0 >= 0.0) {
2759 return Err(format!(
2760 "measurejet smooth tau must be finite and nonnegative; got {tau0}"
2761 ));
2762 }
2763 let num_scales = option_usize(options, "scales").unwrap_or(0);
2764 let length_scale = option_f64(options, "length_scale").unwrap_or(0.0);
2765 if !length_scale.is_finite() || length_scale < 0.0 {
2766 return Err(format!(
2767 "measurejet smooth length_scale must be positive (or omitted for auto); got {length_scale}"
2768 ));
2769 }
2770 let centers = parse_countwith_basis_alias(
2771 options,
2772 "centers",
2773 default_num_centers(ds.values.nrows(), cols.len()),
2774 )?;
2775 if centers < 3 {
2776 return Err("measurejet smooth requires at least 3 centers".to_string());
2777 }
2778 // Multiscale (per-scale spectral split + (α, lnτ) ψ dials + the
2779 // affine-preserving ridge) is an explicit opt-in (#1116): default
2780 // single-scale at any center count, the Duchon/Matérn footprint.
2781 let multiscale = option_bool(options, "multiscale").unwrap_or(false);
2782 // REML-learning the representer range ℓ is an explicit opt-in.
2783 // The stable default freezes ℓ at the auto/user value; the
2784 // design-moving coordinate is expensive and can overfit low-signal
2785 // surfaces when enabled implicitly.
2786 let learn_length_scale = option_bool(options, "learn_length_scale").unwrap_or(false);
2787 Ok(SmoothBasisSpec::MeasureJet {
2788 feature_cols: cols.to_vec(),
2789 spec: MeasureJetBasisSpec {
2790 center_strategy: CenterStrategy::FarthestPoint {
2791 num_centers: centers,
2792 },
2793 order_s,
2794 alpha,
2795 tau0,
2796 num_scales,
2797 // 0.0 sentinel = auto initialization in the basis builder
2798 // (median nearest-center spacing).
2799 length_scale,
2800 double_penalty: smooth_double_penalty,
2801 learn_length_scale,
2802 multiscale,
2803 identifiability: MeasureJetIdentifiability::CenterSumToZero,
2804 frozen_quadrature: None,
2805 },
2806 input_scales: None,
2807 })
2808 }
2809 "matern" => {
2810 // Catch typos like `lengt_scale=` / `nyu=` / `centerz=` before
2811 // they get silently ignored and the user wonders why their
2812 // option had no effect. The matern() term accepts exactly
2813 // these options.
2814 validate_known_options(
2815 "matern",
2816 options,
2817 &[
2818 SECONDARY_CENTER_CAP_OPTION,
2819 "type",
2820 "bs",
2821 "by",
2822 "nu",
2823 "length_scale",
2824 "centers",
2825 "k",
2826 "basis_dim",
2827 "basis-dim",
2828 "basisdim",
2829 "knots",
2830 "include_intercept",
2831 "double_penalty",
2832 "by",
2833 "id",
2834 "__by_col",
2835 "identifiability",
2836 "by",
2837 "periodic",
2838 "cyclic",
2839 "period",
2840 "period_start",
2841 "period_end",
2842 "scale_dims",
2843 ],
2844 )?;
2845 let plan = plan_spatial_basis(
2846 ds.values.nrows(),
2847 cols.len(),
2848 CenterCountRequest::Default,
2849 DuchonNullspaceOrder::Zero,
2850 option_bool(options, "scale_dims").unwrap_or(false),
2851 policy,
2852 )
2853 .map_err(|e| e.to_string())?;
2854 // #1867: spline-equivalent floor so a 1-D radial basis is not
2855 // dimensioned coarser than the competing `s(x)` on identical data.
2856 let univariate_floor = if cols.len() == 1 {
2857 heuristic_knots_for_column(ds.values.column(cols[0]))
2858 .saturating_add(DEFAULT_BSPLINE_DEGREE + 1)
2859 } else {
2860 0
2861 };
2862 let centers = parse_countwith_basis_alias(
2863 options,
2864 "centers",
2865 cap_default_spatial_centers(
2866 options,
2867 default_matern_center_count(
2868 ds.values.nrows(),
2869 cols.len(),
2870 plan.centers,
2871 univariate_floor,
2872 ),
2873 ),
2874 )?;
2875 let center_strategy = if has_explicit_countwith_basis_alias(options, "centers") {
2876 spatial_center_strategy_for_dimension(centers, cols.len())
2877 } else {
2878 auto_spatial_center_strategy(centers, cols.len())
2879 };
2880 let nu = parse_matern_nu(options.get("nu").map(String::as_str).unwrap_or("5/2"))?;
2881 // The exponential (ν = 1/2) Matérn kernel has a singular Laplacian
2882 // at zero in d ≥ 2, so the operator-collocation penalty machinery
2883 // hits a non-invertible matrix during fit. Surface the cause
2884 // up-front instead of letting the user see the generic
2885 // "Matrix conditioning issue detected" wrapper from PIRLS.
2886 if matches!(nu, MaternNu::Half) && cols.len() >= 2 {
2887 return Err(TermBuilderError::unsupported_feature(format!(
2888 "matern() with nu=1/2 is not supported for d>=2 (got {} covariates): \
2889 the exponential kernel's Laplacian is singular at center collisions, \
2890 which makes the operator-collocation penalty non-invertible. \
2891 Choose nu>=3/2 (e.g. nu=3/2 or the default nu=5/2) for multi-dimensional smooths.",
2892 cols.len()
2893 ))
2894 .to_string());
2895 }
2896 let aniso_log_scales = if option_bool(options, "scale_dims").unwrap_or(false) {
2897 Some(vec![0.0; cols.len()])
2898 } else {
2899 None
2900 };
2901 Ok(SmoothBasisSpec::Matern {
2902 feature_cols: cols.to_vec(),
2903 spec: MaternBasisSpec {
2904 center_strategy,
2905 periodic: parse_periodic_axes_option(options, cols.len())?,
2906 // Sentinel: leave at 0.0 when the user didn't pass an
2907 // explicit length_scale so the planner's
2908 // `auto_init_length_scale_in_place` can replace it with the
2909 // SAME data-derived wiggly-side initialization the thin-plate
2910 // path uses (`max_range / sqrt(n)`), then let the κ-optimizer
2911 // refine from there.
2912 //
2913 // gam#1629: the previous `default_matern_length_scale` seeded
2914 // the FULL data diameter — the maximally over-smoothed corner.
2915 // Because that value is non-zero, the `0.0`-gated auto-init was
2916 // a no-op for Matérn, so the κ-optimizer started in the flat
2917 // over-smoothed basin and parked there, leaving high-frequency
2918 // 2-D surfaces unresolved (truth-RMSE ~6× worse than
2919 // thin-plate/tensor on identical data, and insensitive to `k`).
2920 // Routing Matérn through the same `0.0` sentinel as thin-plate
2921 // (see the ThinPlate branch above) starts REML in the resolving
2922 // regime it can actually escape from.
2923 length_scale: option_f64(options, "length_scale").unwrap_or(0.0),
2924 nu,
2925 include_intercept: option_bool(options, "include_intercept").unwrap_or(false),
2926 double_penalty: smooth_double_penalty,
2927 identifiability: parse_matern_identifiability(options)
2928 .map_err(|e| e.to_string())?,
2929 aniso_log_scales,
2930 // Cold build: let the bootstrap-κ spectral test decide whether
2931 // the double-penalty nullspace shrinkage survives; the freeze
2932 // step then pins that decision into the FrozenTransform so the
2933 // κ-optimizer's rebuilds keep the count invariant (gam#787/#860).
2934 nullspace_shrinkage_survived: None,
2935 },
2936 input_scales: None,
2937 })
2938 }
2939 "duchon" | "ds" => {
2940 validate_known_options(
2941 "duchon",
2942 options,
2943 &[
2944 SECONDARY_CENTER_CAP_OPTION,
2945 "type",
2946 "bs",
2947 "by",
2948 "length_scale",
2949 "centers",
2950 "k",
2951 "basis_dim",
2952 "basis-dim",
2953 "basisdim",
2954 "knots",
2955 "power",
2956 "p",
2957 "nullspace_order",
2958 "order",
2959 "identifiability",
2960 "by",
2961 "periodic",
2962 "cyclic",
2963 "period",
2964 "period_start",
2965 "period_end",
2966 "scale_dims",
2967 "double_penalty",
2968 "by",
2969 "id",
2970 "__by_col",
2971 ],
2972 )?;
2973 if options.contains_key("double_penalty") {
2974 return Err(TermBuilderError::incompatible_config(format!(
2975 "Duchon smooth '{}' does not support double_penalty; the Duchon smoother already ships its native reproducing-norm penalty plus a null-space shrinkage ridge.",
2976 vars.join(", ")
2977 ))
2978 .to_string());
2979 }
2980 let requested_nullspace_order = parse_duchon_order(options)?;
2981 let length_scale = option_f64_strict(options, "length_scale")?;
2982 // Resolve `(nullspace_order, power)`. The default (magic) path is a
2983 // structural amplitude/slope/curvature smoother: an affine (`Linear`)
2984 // polynomial nullspace and spectral power `s = (d - 1)/2`, giving the
2985 // cubic kernel `r^3` in 1D. There is no nullspace-order escalation —
2986 // the structural cubic smoother is well-defined for every dimension.
2987 //
2988 // Explicit `power=...` honors the user's value verbatim against their
2989 // requested nullspace order; the kernel validator emits a precise
2990 // diagnostic for any inadmissible combination. In the scale-free
2991 // (non-hybrid) regime fractional powers are admitted and threaded as
2992 // `f64`. The hybrid Duchon-Matérn kernel (`length_scale=Some`) is
2993 // restricted to integer powers.
2994 let (nullspace_order, power) = match parse_duchon_power_policy(options)? {
2995 DuchonPowerPolicy::Explicit(req_power) => {
2996 if length_scale.is_some() && req_power.fract() != 0.0 {
2997 return Err(TermBuilderError::incompatible_config(format!(
2998 "hybrid Duchon-Matern smooth '{}' (length_scale=...) requires an integer power, got power={}; \
2999 drop length_scale to use the scale-free structural kernel with a fractional power.",
3000 vars.join(", "),
3001 req_power,
3002 ))
3003 .to_string());
3004 }
3005 (requested_nullspace_order, req_power)
3006 }
3007 DuchonPowerPolicy::CubicStructuralDefault => {
3008 // Magic cubic rule (REQUEST-LAYER default): no explicit power ⇒
3009 // affine null space + fractional spectral power s = (d-1)/2, i.e.
3010 // the Duchon kernel φ(r)=r³ in every dimension. An EXPLICIT
3011 // `power=0` is handled above and is honored as the s=0 Duchon
3012 // kernel (r²·log r ≡ the thin-plate kernel in even d) — the magic
3013 // default lives here, not in the basis builder.
3014 match length_scale {
3015 None => crate::basis::duchon_cubic_default(cols.len()),
3016 Some(_) => {
3017 // The hybrid Matérn-blended kernel (`length_scale=Some`)
3018 // requires an INTEGER spectral power `s` (the partial-
3019 // fraction split `1/(ρ^{2p}(κ²+ρ²)^s)` is only defined for
3020 // integer `s`). The fractional cubic default `s=(d-1)/2` is
3021 // a half-integer for even `d`, and the basis builder's
3022 // `power_as_usize` maps a NON-integer to `0` (not its
3023 // floor) — so for even `d ≥ 4` the realized kernel has
3024 // `2(p+s) = 2p = 4 ≤ d`, which is non-finite at the origin
3025 // and crashes the fit (historically a non-finite
3026 // eigendecomposition; now a fit-time validation error).
3027 //
3028 // Rather than emit the fractional cubic and let it truncate
3029 // into an inadmissible kernel, resolve the SMALLEST
3030 // admissible integer `(nullspace, s)` at the requested
3031 // nullspace order. The formula default is the same
3032 // native-Gram Duchon smoother as the scale-free path, so
3033 // there is no collocation-operator floor to honor here.
3034 // Users that opt into operator penalties get the stricter
3035 // gate at basis-build time from the requested operators.
3036 let max_op = crate::basis::duchon_max_active_operator_derivative_order(
3037 &DuchonOperatorPenaltySpec::all_disabled(),
3038 );
3039 let (ns, s) = crate::basis::resolve_duchon_orders(
3040 cols.len(),
3041 requested_nullspace_order,
3042 max_op,
3043 length_scale,
3044 );
3045 (ns, s as f64)
3046 }
3047 }
3048 }
3049 };
3050 let plan = plan_spatial_basis(
3051 ds.values.nrows(),
3052 cols.len(),
3053 CenterCountRequest::Default,
3054 nullspace_order,
3055 option_bool(options, "scale_dims").unwrap_or(false),
3056 policy,
3057 )
3058 .map_err(|e| e.to_string())?;
3059 let centers_explicit = has_explicit_countwith_basis_alias(options, "centers");
3060 let polynomial_cols = match nullspace_order {
3061 DuchonNullspaceOrder::Zero => 1,
3062 DuchonNullspaceOrder::Linear => cols.len() + 1,
3063 DuchonNullspaceOrder::Degree(degree) => {
3064 crate::basis::duchon_nullspace_dimension(cols.len(), degree)
3065 }
3066 };
3067 // #1867: spline-equivalent floor so a 1-D radial basis is not
3068 // dimensioned coarser than the competing `s(x)` on identical data.
3069 let univariate_floor = if cols.len() == 1 {
3070 heuristic_knots_for_column(ds.values.column(cols[0]))
3071 .saturating_add(DEFAULT_BSPLINE_DEGREE + 1)
3072 } else {
3073 0
3074 };
3075 let default_centers = default_duchon_center_count(
3076 ds.values.nrows(),
3077 cols.len(),
3078 plan.centers,
3079 polynomial_cols,
3080 univariate_floor,
3081 );
3082 let requested_centers = parse_countwith_basis_alias(
3083 options,
3084 "centers",
3085 cap_default_spatial_centers(options, default_centers),
3086 )?;
3087 if requested_centers <= polynomial_cols {
3088 return Err(TermBuilderError::incompatible_config(format!(
3089 "Duchon smooth '{}' requested basis dimension {} but order={:?} in {}D needs {} polynomial null-space columns; choose centers/k > {}",
3090 vars.join(", "),
3091 requested_centers,
3092 nullspace_order,
3093 cols.len(),
3094 polynomial_cols,
3095 polynomial_cols,
3096 ))
3097 .to_string());
3098 }
3099 let mut centers = requested_centers;
3100 if !centers_explicit && ds.values.nrows() <= 32 && smooth_coordinate_count >= 5 {
3101 centers = centers.max(polynomial_cols + 4);
3102 }
3103 let center_strategy = if centers_explicit {
3104 spatial_center_strategy_for_dimension(centers, cols.len())
3105 } else {
3106 auto_spatial_center_strategy(centers, cols.len())
3107 };
3108 let aniso_log_scales = if option_bool(options, "scale_dims").unwrap_or(false) {
3109 Some(vec![0.0; cols.len()])
3110 } else {
3111 None
3112 };
3113 // Formula-level `duchon(...)` is the native Duchon reproducing-norm
3114 // smoother: the always-on Primary Gram plus the polynomial trend
3115 // ridge. Do not silently add collocated mass/tension penalties here.
3116 // They add extra REML hyperparameters and an O(k)-support quadrature
3117 // build to the default 2-D path, making `duchon(x, z)` materially
3118 // slower than the equivalent thin-plate fit without a principled
3119 // accuracy gain (gam#1718). Lower-order Hilbert-scale penalties remain
3120 // available to callers that construct an explicit DuchonBasisSpec.
3121 let operator_penalties = DuchonOperatorPenaltySpec::all_disabled();
3122 // For a 1-D periodic Duchon with no EXPLICIT period, anchor the wrap
3123 // to the covariate DATA range rather than letting the basis builder
3124 // derive it from the (k-subsampled) center span. The center span is a
3125 // strict subset of the data and undershoots the true period, seaming
3126 // the curve (f(0) ≠ f(2π)); the data range is the caller's actual
3127 // domain. Honors any explicit `period=` (parse_periodic_axes_option
3128 // already threaded it) and leaves multi-D / non-periodic untouched.
3129 let mut periodic = parse_periodic_axes_option(options, cols.len())?;
3130 if cols.len() == 1
3131 && let Some(axes) = periodic.as_mut()
3132 && axes.len() == 1
3133 && axes[0].is_none()
3134 {
3135 let (minv, maxv) = col_minmax(ds.values.column(cols[0]))?;
3136 if maxv > minv {
3137 axes[0] = Some(maxv - minv);
3138 }
3139 }
3140 Ok(SmoothBasisSpec::Duchon {
3141 feature_cols: cols.to_vec(),
3142 spec: DuchonBasisSpec {
3143 center_strategy,
3144 periodic,
3145 length_scale,
3146 power,
3147 nullspace_order,
3148 identifiability: parse_spatial_identifiability(options)
3149 .map_err(|e| e.to_string())?,
3150 aniso_log_scales,
3151 operator_penalties,
3152 boundary: if cols.len() == 1 {
3153 let c = cols[0];
3154 let (minv, maxv) = col_minmax(ds.values.column(c))?;
3155 parse_cyclic_boundary(options, minv, maxv)?
3156 } else {
3157 OneDimensionalBoundary::Open
3158 },
3159 radial_reparam: None,
3160 },
3161 input_scales: None,
3162 })
3163 }
3164 "tensor" | "te" | "ti" | "t2" => {
3165 validate_known_options(
3166 "tensor",
3167 options,
3168 &[
3169 "type",
3170 "bs",
3171 "by",
3172 "k",
3173 "basis_dim",
3174 "basis-dim",
3175 "basisdim",
3176 "knot_placement",
3177 "knot-placement",
3178 "knotplacement",
3179 "degree",
3180 "penalty_order",
3181 "double_penalty",
3182 "periodic",
3183 "cyclic",
3184 "period",
3185 "periods",
3186 "period_start",
3187 "period_end",
3188 "origin",
3189 "origins",
3190 "period_origin",
3191 "period-origin",
3192 "domain_origin",
3193 "boundary",
3194 "bc",
3195 "identifiability",
3196 "id",
3197 "__by_col",
3198 ],
3199 )?;
3200 if cols.len() < 2 {
3201 return Err(TermBuilderError::incompatible_config(format!(
3202 "tensor smooth expects at least 2 variables, got {}",
3203 cols.len()
3204 ))
3205 .to_string());
3206 }
3207 let dim = cols.len();
3208
3209 // Tensor-product contract (#1082). `te(x1, x2, ...)` ALWAYS builds a
3210 // genuine anisotropic tensor product of per-margin bases (the arm
3211 // below), exactly as mgcv's `te()` does — one smoothing parameter per
3212 // margin, a marginal-Kronecker-sum penalty, and the bilinear null
3213 // space left unpenalized under the default `select = FALSE`. A margin
3214 // vector `bs=c('tp','tp')` requests a thin-plate FUNCTION SPACE per
3215 // axis; the tensor realizes each axis as a 1-D penalized B-spline
3216 // margin spanning that same per-axis space (tp/ps/cr/bs/cc all share
3217 // it). We deliberately do NOT silently swap the requested tensor for a
3218 // single multi-D ISOTROPIC thin-plate radial smooth (`s(x,y,bs='tp')`):
3219 // that is a different model — one isotropic smoothing parameter, no
3220 // per-margin anisotropy — and substituting it while the user wrote a
3221 // tensor formula is dishonest. A user who genuinely wants the isotropic
3222 // radial smooth asks for it directly with `s(x1, x2, bs='tp')`.
3223 // Per-margin basis vector (`bs=c('tp','tp')` / `bs=['ps','cr']`):
3224 // validate each requested margin is a penalized-spline basis that
3225 // the tensor product realizes as a 1-D B-spline margin. mgcv's
3226 // `tp`/`ps`/`cr`/`bs`/`cc` margins are all penalized splines over
3227 // the same per-axis function space, so a B-spline margin recovers
3228 // the same tensor smoothing space; genuinely different margin kinds
3229 // (e.g. adaptive `ad`, random `re`) are rejected loudly rather than
3230 // silently substituted.
3231 if let Some(raw) = options.get("bs").or_else(|| options.get("type"))
3232 && bs_selector_is_vector(raw)
3233 {
3234 let per_margin = parse_option_list(raw);
3235 if per_margin.len() != dim {
3236 return Err(TermBuilderError::invalid_option(format!(
3237 "tensor smooth per-margin bs vector has {} entries but the smooth has {} margins",
3238 per_margin.len(),
3239 dim
3240 ))
3241 .to_string());
3242 }
3243 for (axis, margin_bs) in per_margin.iter().enumerate() {
3244 if !tensor_margin_bs_is_supported(margin_bs) {
3245 return Err(TermBuilderError::unsupported_feature(format!(
3246 "tensor smooth margin {axis} basis '{margin_bs}' is not a supported penalized-spline margin; \
3247 tensor margins accept tp/tps/ps/bs/cr/cc"
3248 ))
3249 .to_string());
3250 }
3251 }
3252 }
3253 let periodic_axes = parse_tensor_periodic_axes(options, dim)?;
3254 validate_tensor_boundary_tokens(options, dim)?;
3255 let periods_opt = parse_periods(options, &periodic_axes)?;
3256 let origins_opt = parse_period_origins(options, &periodic_axes)?;
3257 let degree = option_usize(options, "degree").unwrap_or(DEFAULT_BSPLINE_DEGREE);
3258 let penalty_order =
3259 option_usize(options, "penalty_order").unwrap_or(if degree > 1 { 2 } else { 1 });
3260 let (mut k_list, k_inferred) = parse_tensor_k_list(options, cols, ds)?;
3261 if ds.values.nrows() <= 32 && smooth_coordinate_count >= 5 {
3262 for k in &mut k_list {
3263 *k = (*k).min(degree + 2);
3264 }
3265 }
3266 if k_inferred {
3267 inference_notes.push(format!(
3268 "Automatically set per-margin basis sizes {:?} for tensor smooth '{}' \
3269 (dimension-aware tensor budget: total ∏k kept near the mgcv-te default \
3270 and within the data support, distributed geometrically across margins and \
3271 capped per margin by each column's resolution). \
3272 Override with k=<int> or k=[k0,k1,...].",
3273 k_list,
3274 vars.join(",")
3275 ));
3276 }
3277 // Per-axis requested marginal basis family. mgcv's `te()`/`ti()`
3278 // default marginal basis is the cubic regression spline (`cr`), and
3279 // the te_3d quality gap (#1074) is precisely the marginal-basis
3280 // resolution at small `k`: a `cr` margin places k value-knots at
3281 // data quantiles (finer interior resolution under natural boundary
3282 // constraints) where the cubic B-spline margin has only
3283 // `k-degree-1` interior knots. Resolve each axis to either an
3284 // explicit per-margin `bs` (vector `bs=c('cr','ps')`), a single
3285 // scalar `bs`, or the unset default — and route
3286 // `cr`/`cs`/unset/`tp`/`tps` margins through the natural cubic
3287 // regression builder (`NaturalCubicRegression` knotspec), keeping
3288 // explicit `ps`/`bs`/`bspline` on the B-spline margin.
3289 let per_axis_bs: Vec<Option<String>> =
3290 match options.get("bs").or_else(|| options.get("type")) {
3291 Some(raw) if bs_selector_is_vector(raw) => {
3292 let list = parse_option_list(raw);
3293 (0..dim).map(|a| list.get(a).cloned()).collect()
3294 }
3295 Some(raw) => {
3296 let scalar = raw
3297 .trim()
3298 .trim_matches('"')
3299 .trim_matches('\'')
3300 .to_ascii_lowercase();
3301 vec![Some(scalar); dim]
3302 }
3303 None => vec![None; dim],
3304 };
3305 // A margin is realized as a natural cubic regression spline when it
3306 // is the (unset) mgcv default, an explicit `cr`/`cs`, or a
3307 // `tp`/`tps` (same per-axis penalized-spline space). Explicit
3308 // B-spline-family margins (`ps`/`bs`/`bspline`/`p-spline`) keep the
3309 // open B-spline margin.
3310 let margin_wants_cr = |bs: &Option<String>| -> bool {
3311 matches!(
3312 bs.as_deref(),
3313 None | Some("cr") | Some("cs") | Some("tp") | Some("tps")
3314 )
3315 };
3316 let requested_knot_placement = parse_knot_placement(options)?;
3317 let mut margins: Vec<BSplineBasisSpec> = Vec::with_capacity(dim);
3318 let mut emitted_periods: Vec<Option<f64>> = Vec::with_capacity(dim);
3319 for axis in 0..dim {
3320 let c = cols[axis];
3321 let (data_min, data_max) = col_minmax(ds.values.column(c))?;
3322 // mgcv reduces a tensor margin's basis dimension to what its data
3323 // can support: a cr or B-spline margin cannot place more value
3324 // knots / basis functions than there are DISTINCT covariate
3325 // values on that axis. Without this cap an explicit `k` on a
3326 // low-cardinality margin — e.g. the binary `badh ∈ {0,1}` in
3327 // `te(age, badh, k=5)` — hard-failed in `select_cr_knots` ("cubic
3328 // regression spline with k=5 requires at least 5 distinct values,
3329 // got 2") instead of degrading to the 2-function (linear) margin
3330 // mgcv builds there. The auto-`k` path already caps per margin via
3331 // `heuristic_tensor_margin_knots`; mirror that for explicit `k`.
3332 // The cap propagates correctly: every per-axis quantity below
3333 // (effective degree, knot set, penalty order) is derived from
3334 // `k_axis`, and the marginal basis size is read from the resulting
3335 // knot spec — never from `k_list`. Floor at 2 so a margin still
3336 // carries at least a linear basis (tensor margins require k >= 2).
3337 let k_requested = k_list[axis];
3338 let n_distinct_axis = unique_count_column(ds.values.column(c));
3339 let k_axis = k_requested.min(n_distinct_axis).max(2);
3340 if k_axis < k_requested {
3341 log::info!(
3342 "tensor smooth: margin axis {axis} requested k={k_requested}, but the \
3343 covariate has only {n_distinct_axis} distinct value(s); reducing this \
3344 margin to k={k_axis} (mgcv-style data-support cap on the per-axis basis)."
3345 );
3346 }
3347 // Per-axis effective spline degree. The B-spline basis with `k`
3348 // functions is well-defined for any `degree <= k - 1`; mgcv's
3349 // `te(...)` exploits this so a binary tensor margin
3350 // (`k=2` → linear basis) or a ternary margin (`k=3` → quadratic)
3351 // can coexist with a smoother continuous margin under one
3352 // shared `degree=` request. We mirror that: if the caller
3353 // explicitly asks for `k < degree + 1`, drop the degree on
3354 // THAT axis only to the largest feasible spline, and track the
3355 // penalty order so the marginal difference penalty stays
3356 // well-defined (`order < num_basis_functions` is required by
3357 // `create_difference_penalty_matrix`). Apply the same
3358 // per-margin degree shrinkage to periodic tensor margins too:
3359 // a cyclic marginal basis with k=3 cannot be cubic, but it is
3360 // still a valid lower-degree cyclic margin with dimension k,
3361 // matching mgcv's small-k tensor-margin behavior.
3362 if k_axis < 2 {
3363 return Err(TermBuilderError::invalid_option(format!(
3364 "tensor smooth: k[{axis}]={k_axis} too small; tensor margins require k >= 2"
3365 ))
3366 .to_string());
3367 }
3368 let effective_degree = degree.min(k_axis - 1).max(1);
3369 let effective_penalty_order = penalty_order.min(effective_degree);
3370 // A `cc`/`cp`/`cyclic` per-margin basis declares periodicity
3371 // without necessarily supplying a `period=`: mgcv's `bs="cc"`
3372 // wraps at the covariate's observed data range. Mirror the 1-D
3373 // cyclic fallback (`parse_periodic_domain_1d`) here so a bare
3374 // `te(x, z, bs=c('cc','cc'))` wraps each margin on its own
3375 // [min, max] span instead of hard-erroring (#1752).
3376 let margin_is_cc = matches!(
3377 canonicalize_smooth_type(per_axis_bs[axis].as_deref().unwrap_or("")),
3378 "cc" | "cp" | "cyclic"
3379 );
3380 let (knotspec, boundary, axis_period) = if periodic_axes[axis] {
3381 // A `cc`/`cp`/`cyclic` per-margin basis declares periodicity
3382 // without necessarily supplying a `period=`; in that case wrap
3383 // at the covariate's observed [min, max] span, mirroring the
3384 // 1-D cyclic fallback (`parse_periodic_domain_1d`) so a bare
3385 // `te(x, z, bs=c('cc','cc'))` wraps each margin on its own
3386 // range instead of hard-erroring (#1752). An axis made
3387 // periodic by an explicit `periodic=`/`boundary=` selector
3388 // (not a cyclic margin basis) still requires an explicit
3389 // `period=`: a data-derived period there is a sample-dependent
3390 // off-by-ε seam and is not inferred.
3391 let (domain_start, period_value) = match periods_opt[axis] {
3392 Some(period_value) => {
3393 if !period_value.is_finite() || period_value <= 0.0 {
3394 return Err(format!(
3395 "tensor smooth axis {axis}: period must be a positive finite value, got {period_value}"
3396 ));
3397 }
3398 (origins_opt[axis].unwrap_or(data_min), period_value)
3399 }
3400 None if margin_is_cc => {
3401 let span = data_max - data_min;
3402 if !span.is_finite() || span <= 0.0 {
3403 return Err(format!(
3404 "tensor smooth axis {axis}: cyclic margin requires a positive \
3405 observed data range to derive its period, got [{data_min}, {data_max}]"
3406 ));
3407 }
3408 (origins_opt[axis].unwrap_or(data_min), span)
3409 }
3410 None => {
3411 return Err(format!(
3412 "tensor smooth axis {axis} is periodic but requires an explicit \
3413 period: pass period=<value> (scalar) or period=[..., <value>, ...]. \
3414 Deriving the period from the observed data range is sample-dependent \
3415 (off-by-ε seam), so it is not inferred."
3416 ));
3417 }
3418 };
3419 let domain_end = domain_start + period_value;
3420 (
3421 BSplineKnotSpec::PeriodicUniform {
3422 data_range: (domain_start, domain_end),
3423 num_basis: k_axis,
3424 },
3425 OneDimensionalBoundary::Cyclic {
3426 start: domain_start,
3427 end: domain_end,
3428 },
3429 Some(period_value),
3430 )
3431 } else if margin_wants_cr(&per_axis_bs[axis])
3432 && requested_knot_placement != crate::basis::BSplineKnotPlacement::Quantile
3433 && k_axis >= 3
3434 {
3435 // mgcv `te()`/`ti()` default cr margin: place exactly
3436 // `k_axis` Lancaster–Salkauskas value-knots at data
3437 // quantiles. The cr basis dimension equals the knot count,
3438 // so this reproduces the requested per-margin `k` directly.
3439 // A natural cubic regression spline needs at least 3 knots
3440 // (one interior); a `k_axis < 3` margin (e.g. a binary
3441 // tensor axis requesting a linear margin) falls through to
3442 // the B-spline branch below, exactly as before #1074 — mgcv
3443 // likewise does not build a `cr` margin below k=3. An
3444 // explicit `knot_placement=quantile` also falls through:
3445 // that option selects the generated B-spline knot strategy
3446 // represented by `Automatic { Quantile }`, whereas the cr
3447 // margin has already materialized its quantile value-knots.
3448 let cr_knots = crate::basis::select_cr_knots(ds.values.column(c), k_axis)
3449 .map_err(|e| e.to_string())?;
3450 (
3451 BSplineKnotSpec::NaturalCubicRegression { knots: cr_knots },
3452 OneDimensionalBoundary::Open,
3453 None,
3454 )
3455 } else {
3456 // `num_internal_knots = k - degree - 1` reproduces the
3457 // requested basis size exactly when degree was reduced for
3458 // a low-cardinality margin; keep the legacy `.max(1)`
3459 // floor on the un-reduced path so the existing knot
3460 // geometry is unchanged whenever the user already passed
3461 // k >= degree + 1.
3462 let num_internal_knots = if effective_degree < degree {
3463 k_axis.saturating_sub(effective_degree + 1)
3464 } else {
3465 k_axis.saturating_sub(degree + 1).max(1)
3466 };
3467 let knotspec = match requested_knot_placement {
3468 crate::basis::BSplineKnotPlacement::Uniform => BSplineKnotSpec::Generate {
3469 data_range: (data_min, data_max),
3470 num_internal_knots,
3471 },
3472 crate::basis::BSplineKnotPlacement::Quantile => {
3473 crate::basis::auto_knot_vector_1d_quantile(
3474 ds.values.column(c),
3475 num_internal_knots,
3476 effective_degree,
3477 )
3478 .map_err(|e| e.to_string())?;
3479 BSplineKnotSpec::Automatic {
3480 num_internal_knots: Some(num_internal_knots),
3481 placement: crate::basis::BSplineKnotPlacement::Quantile,
3482 }
3483 }
3484 };
3485 (knotspec, OneDimensionalBoundary::Open, None)
3486 };
3487 // A `cr` margin fixes cubic regression geometry; the cr builder
3488 // reads only the knot set + `double_penalty`. Enable null-space
3489 // shrinkage for an explicit `cs` margin. B-spline margins keep
3490 // the resolved effective degree / penalty order with no extra
3491 // null-space penalty (mgcv `select = FALSE` tensor default).
3492 let is_cr_margin =
3493 matches!(knotspec, BSplineKnotSpec::NaturalCubicRegression { .. });
3494 let margin_double_penalty =
3495 is_cr_margin && matches!(per_axis_bs[axis].as_deref(), Some("cs"));
3496 margins.push(BSplineBasisSpec {
3497 degree: effective_degree,
3498 penalty_order: effective_penalty_order,
3499 knotspec,
3500 double_penalty: margin_double_penalty,
3501 identifiability: BSplineIdentifiability::None,
3502 boundary,
3503 boundary_conditions: BSplineBoundaryConditions::default(),
3504 });
3505 emitted_periods.push(axis_period);
3506 }
3507 // #1593: canonicalize the margin order so a tensor smooth is invariant
3508 // to the typed order of its covariates. `te(x, z)` and `te(z, x)` span
3509 // the IDENTICAL tensor-product space under the identical per-margin
3510 // penalty family, but the design is the Khatri–Rao product
3511 // `B_first ⊙ B_second`, so the typed order permutes the design columns
3512 // (and the per-margin penalty blocks `S_first⊗I`, `I⊗S_second`). That
3513 // permutation is a pure relabelling in exact arithmetic — REML is
3514 // invariant to it — yet it reorders the penalized normal-equation / REML
3515 // eigen/Cholesky linear algebra, and the resulting sub-ULP differences
3516 // route the outer λ optimizer to a different terminal point in te's flat
3517 // REML valley (the over-smoothed margin rails to the ρ bound while the
3518 // other lands on a materially different λ̂). So the shipped surface
3519 // drifted ~2–6 % of range with a cosmetic swap of the covariate order
3520 // (the #1378 row-permutation / #1456 rotation flat-valley gauge family).
3521 // Sorting the margins by their source feature-column index makes the same
3522 // physical model build the identical problem regardless of typed order,
3523 // so the fit — and every prediction rebuilt from the resolved spec — is
3524 // genuinely order-invariant. `ti`/`t2` share this arm and become exactly
3525 // invariant too (they were already ~1e-5 by centring each margin
3526 // separately; canonicalization makes the swap bit-identical).
3527 let canon_cols: Vec<usize> = {
3528 let mut perm: Vec<usize> = (0..dim).collect();
3529 perm.sort_by_key(|&a| cols[a]);
3530 if perm.iter().enumerate().any(|(i, &a)| i != a) {
3531 margins = perm.iter().map(|&a| margins[a].clone()).collect();
3532 emitted_periods = perm.iter().map(|&a| emitted_periods[a]).collect();
3533 }
3534 perm.iter().map(|&a| cols[a]).collect()
3535 };
3536 let any_periodic = emitted_periods.iter().any(|p| p.is_some());
3537 let periods_vec = if any_periodic {
3538 emitted_periods
3539 } else {
3540 Vec::new()
3541 };
3542 // Tensor smooths (`te`/`ti`/`t2`) must match mgcv's DEFAULT
3543 // `select = FALSE`: the joint null space of the per-margin
3544 // penalties — the bilinear, low-order interaction directions that
3545 // no marginal roughness operator can see — is left UNPENALIZED.
3546 // mgcv only adds a null-space shrinkage penalty there under the
3547 // opt-in `select = TRUE` (which gam exposes as `double_penalty`).
3548 //
3549 // The general smooth default (`smooth_double_penalty`, true) is
3550 // calibrated for 1-D `s()` terms; carrying it into tensors silently
3551 // shrinks the genuinely-present bilinear interaction signal, so
3552 // REML places positive weight on the extra ridge and systematically
3553 // OVER-SMOOTHS the recovered surface relative to mgcv's plain
3554 // `te`/`ti` (gam#700/#701/#702/#703). Default tensors to no extra
3555 // null-space penalty; an explicit user `double_penalty=`/`select=`
3556 // still wins.
3557 let tensor_double_penalty = option_bool(options, "double_penalty").unwrap_or(false);
3558 Ok(SmoothBasisSpec::TensorBSpline {
3559 feature_cols: canon_cols,
3560 spec: TensorBSplineSpec {
3561 marginalspecs: margins,
3562 periods: periods_vec,
3563 double_penalty: tensor_double_penalty,
3564 identifiability: parse_tensor_identifiability(options, kind)?,
3565 // `t2` selects mgcv's separable (Wood, Scheipl & Faraway
3566 // 2013) decomposition. It can arrive either as the `t2(...)`
3567 // function form (`SmoothKind::T2`) or as a `type="t2"` /
3568 // `bs="t2"` option on an `s(...)`/`te(...)` term, in which
3569 // case `kind` is *not* `T2` but the resolved type string is
3570 // "t2". Keying only off `kind` silently aliased the option
3571 // form to `te`'s Kronecker-sum penalty (gam#1185); key off
3572 // the resolved type string as well so both routes build the
3573 // separable penalty.
3574 penalty_decomposition: if matches!(kind, SmoothKind::T2)
3575 || type_opt.as_str() == "t2"
3576 {
3577 TensorBSplinePenaltyDecomposition::Separable
3578 } else {
3579 TensorBSplinePenaltyDecomposition::MarginalKroneckerSum
3580 },
3581 },
3582 })
3583 }
3584 "pca" => {
3585 validate_known_options(
3586 "pca",
3587 options,
3588 &[
3589 "type",
3590 "bs",
3591 "by",
3592 "k",
3593 "basis_dim",
3594 "basis-dim",
3595 "basisdim",
3596 "lazy_path",
3597 "path",
3598 "pca_basis_path",
3599 "chunk_size",
3600 "smooth_penalty",
3601 "centered",
3602 "double_penalty",
3603 "id",
3604 "__by_col",
3605 ],
3606 )?;
3607 let path = options
3608 .get("lazy_path")
3609 .or_else(|| options.get("pca_basis_path"))
3610 .or_else(|| options.get("path"))
3611 .map(|raw| PathBuf::from(strip_quotes(raw)));
3612 let Some(path) = path else {
3613 return Err(TermBuilderError::incompatible_config(
3614 "pca smooth requires lazy_path=... on the formula path",
3615 )
3616 .to_string());
3617 };
3618 let k = option_usize_any(options, &["k", "basis_dim", "basis-dim", "basisdim"])
3619 .unwrap_or(0);
3620 let chunk_size = option_usize(options, "chunk_size").unwrap_or(DEFAULT_PCA_CHUNK_SIZE);
3621 Ok(SmoothBasisSpec::Pca {
3622 feature_cols: cols.to_vec(),
3623 basis_matrix: Array2::<f64>::zeros((cols.len(), k)),
3624 centered: option_bool(options, "centered").unwrap_or(true),
3625 smooth_penalty: option_f64(options, "smooth_penalty").unwrap_or(1.0),
3626 center_mean: None,
3627 pca_basis_path: Some(path),
3628 chunk_size,
3629 })
3630 }
3631 other => Err(TermBuilderError::unsupported_feature(format!(
3632 "unsupported smooth type '{other}'"
3633 ))
3634 .to_string()),
3635 }
3636}
3637
3638/// Initialise per-axis anisotropic log-scales on eligible spatial smooth specs.
3639pub fn enable_scale_dimensions(spec: &mut TermCollectionSpec) {
3640 for smooth in spec.smooth_terms.iter_mut() {
3641 // A multi-axis thin-plate term cannot carry per-axis anisotropy on its
3642 // single curvature penalty, so `scale_dimensions` was historically a
3643 // silent no-op for `bs="tp"` (gam#1676). Rewrite it to the
3644 // mathematically-equivalent anisotropic s=0 Duchon spline first; the
3645 // Duchon arm below then sees an already-seeded `aniso_log_scales` and
3646 // leaves it untouched.
3647 promote_thin_plate_for_scale_dimensions(&mut smooth.basis);
3648 match &mut smooth.basis {
3649 SmoothBasisSpec::Matern {
3650 feature_cols,
3651 spec: matern,
3652 ..
3653 } => {
3654 if matern.aniso_log_scales.is_none() {
3655 let d = feature_cols.len();
3656 matern.aniso_log_scales = Some(vec![0.0; d]);
3657 }
3658 }
3659 SmoothBasisSpec::Duchon {
3660 feature_cols,
3661 spec: duchon,
3662 ..
3663 } => {
3664 if duchon.aniso_log_scales.is_none() {
3665 let d = feature_cols.len();
3666 duchon.aniso_log_scales = Some(vec![0.0; d]);
3667 }
3668 }
3669 _ => {}
3670 }
3671 }
3672}
3673
3674/// Rewrite a multi-axis thin-plate term into the mathematically-equivalent
3675/// anisotropic s=0 Duchon spline so that `scale_dimensions` genuinely engages
3676/// (gam#1676).
3677///
3678/// ## Why a rewrite rather than a new field on the TPS builder
3679///
3680/// A canonical thin-plate regression spline carries a *single* curvature
3681/// penalty — the exact `∫|Dᵐ f|²` reproducing-kernel Gram. That penalty has no
3682/// per-axis structure to make one direction more or less relevant than another,
3683/// so per-axis anisotropy (`scale_dimensions`) cannot be expressed on it. The
3684/// flag was therefore a silent no-op for `bs="tp"` while it engaged for
3685/// `duchon()`/`matern()`.
3686///
3687/// The thin-plate kernel `r^{2m−d}` (the `r²·log r` log-case in even `d`) is
3688/// *exactly* the s=0 Duchon kernel (`DuchonBasisSpec::power = 0`,
3689/// `length_scale = None`) at the matching polynomial null-space order
3690/// `m = thin_plate_penalty_order(d)`. The Duchon polyharmonic family already
3691/// carries the per-axis tension ARD that `scale_dimensions` requests: its
3692/// isotropic first-order roughness penalty `Σ‖∇f‖²` splits into `d` directional
3693/// penalties `Σ(∂f/∂x_a)²`, each with its own REML `λ_a`
3694/// (`duchon_operator_penalty_candidates`). So the well-posed *anisotropic
3695/// thin-plate spline is the anisotropic s=0 Duchon spline*. Rewriting to that
3696/// representation reuses the battle-tested Duchon anisotropy / ψ-derivative /
3697/// freeze / predict machinery instead of duplicating it onto the TPS metadata
3698/// path, and keeps the polyharmonic family internally consistent. The codebase
3699/// already promotes infeasible-`k` TPS to Duchon for the same reason (the
3700/// canonical TPS single curvature penalty cannot deliver a requested
3701/// capability); per-axis anisotropy is another such capability.
3702///
3703/// This fires *only* when the user opts into `scale_dimensions`; the default
3704/// thin-plate path (`scale_dimensions` off) is left bit-for-bit unchanged.
3705/// A 1-D thin-plate term is left untouched — anisotropy is meaningless on a
3706/// single axis (its `Σ η = 0` contrast vector is empty), exactly as for a 1-D
3707/// Matérn/Duchon term.
3708fn promote_thin_plate_for_scale_dimensions(basis: &mut SmoothBasisSpec) {
3709 let SmoothBasisSpec::ThinPlate {
3710 feature_cols,
3711 spec,
3712 input_scales,
3713 } = &*basis
3714 else {
3715 return;
3716 };
3717 let d = feature_cols.len();
3718 if d <= 1 {
3719 return;
3720 }
3721 // m = thin_plate_penalty_order(d) is the TPS penalty order; the Duchon
3722 // null-space order naming is `Zero → m=1`, `Linear → m=2`,
3723 // `Degree(g) → m=g+1`, so the s=0 Duchon kernel exponent
3724 // `2(p+s) − d = 2m − d` reproduces the TPS kernel exactly.
3725 let m = thin_plate_penalty_order(d);
3726 let nullspace_order = match m {
3727 0 | 1 => DuchonNullspaceOrder::Zero,
3728 2 => DuchonNullspaceOrder::Linear,
3729 _ => DuchonNullspaceOrder::Degree(m - 1),
3730 };
3731 let duchon_spec = DuchonBasisSpec {
3732 center_strategy: spec.center_strategy.clone(),
3733 periodic: spec.periodic.clone(),
3734 // Pure, scale-free Duchon — the thin-plate kernel has no length scale
3735 // (a global TPS kernel scale is non-identifiable once REML learns the
3736 // smoothing penalty: gam#718/#721/#731/#732). The per-axis relevance
3737 // the user asked for is carried by the tension-ARD `λ_a`, not a κ axis.
3738 length_scale: None,
3739 // s = 0 ⇒ thin-plate kernel `r^{2m−d}`.
3740 power: 0.0,
3741 nullspace_order,
3742 identifiability: spec.identifiability.clone(),
3743 // All-zero geometry seed sentinel: `auto_seed_aniso_contrasts` resolves
3744 // it from the (standardized) knot cloud, and the per-axis tension split
3745 // engages on `aniso.is_some()`.
3746 aniso_log_scales: Some(vec![0.0; d]),
3747 operator_penalties: DuchonOperatorPenaltySpec::default(),
3748 boundary: OneDimensionalBoundary::Open,
3749 radial_reparam: None,
3750 };
3751 let feature_cols = feature_cols.clone();
3752 let input_scales = input_scales.clone();
3753 // All borrows of `*basis` (the `&*basis` destructure above) end with the
3754 // clones on the two preceding lines, so the reassignment is sound.
3755 *basis = SmoothBasisSpec::Duchon {
3756 feature_cols,
3757 spec: duchon_spec,
3758 input_scales,
3759 };
3760}
3761
3762// ---------------------------------------------------------------------------
3763// Data-aware helpers
3764// ---------------------------------------------------------------------------
3765
3766pub fn spatial_center_strategy_for_dimension(num_centers: usize, d: usize) -> CenterStrategy {
3767 if d <= 3 {
3768 // In low-dimensional spatial smooths, an explicit `k` is a resolution
3769 // request rather than a request for marginal quantile-midpoint centers.
3770 // Use deterministic maximin geometry so Matérn/GP and Duchon REML see a
3771 // well-resolved native kernel block with small fill distance instead of
3772 // compensating for holes or endpoint under-resolution by over-smoothing
3773 // low-noise signals (#504).
3774 CenterStrategy::FarthestPoint { num_centers }
3775 } else {
3776 default_spatial_center_strategy(num_centers, d)
3777 }
3778}
3779
3780pub fn col_minmax(col: ArrayView1<'_, f64>) -> Result<(f64, f64), String> {
3781 let min = col.iter().fold(f64::INFINITY, |a, &b| a.min(b));
3782 let max = col.iter().fold(f64::NEG_INFINITY, |a, &b| a.max(b));
3783 if !min.is_finite() || !max.is_finite() {
3784 return Err(TermBuilderError::degenerate_data(
3785 "non-finite data encountered while inferring knot range",
3786 )
3787 .to_string());
3788 }
3789 if (max - min).abs() < 1e-12 {
3790 Ok((min, min + 1e-6))
3791 } else {
3792 Ok((min, max))
3793 }
3794}
3795
3796pub fn unique_count_column(col: ArrayView1<'_, f64>) -> usize {
3797 use std::collections::HashSet;
3798 let mut set = HashSet::<u64>::with_capacity(col.len());
3799 for &v in col {
3800 let norm = if v == 0.0 { 0.0 } else { v };
3801 set.insert(norm.to_bits());
3802 }
3803 set.len().max(1)
3804}
3805
3806/// Minimum knot count for a natural cubic regression spline: `select_cr_knots`
3807/// places one value-knot per basis function and needs at least an interior knot,
3808/// so the sparsest representable cr basis is `{const, linear, curvature}` at
3809/// three knots. Below this a cr spline is not constructible and the caller must
3810/// degrade to the linear B-spline marginal.
3811pub(crate) const CR_MIN_KNOTS: usize = 3;
3812
3813/// Build a cubic-regression marginal knot spec capped to the covariate's data
3814/// support, mgcv-style.
3815///
3816/// A `cr`/`cs`/`sz` marginal places exactly one basis function per value-knot,
3817/// so `select_cr_knots` cannot place more knots than the covariate has DISTINCT
3818/// values — it `bail`s with "cubic regression spline with k=N requires at least
3819/// N distinct values" otherwise. An unclamped `k` on an ordinary low-cardinality
3820/// covariate (a binary indicator, a 3-level ordinal/Likert score, a small count)
3821/// therefore hard-failed the whole fit instead of reducing the basis the way
3822/// mgcv — and gam's own tensor-margin path (996f829d7, `term_builder.rs:2986` /
3823/// the `k_axis >= 3` cr gate at `:3047`) — do. This is the univariate / factor-
3824/// smooth sibling of that tensor cap (#1541, #1542).
3825///
3826/// Returns:
3827/// - `Some(NaturalCubicRegression { .. })` with `k = min(k_requested, n_distinct)`
3828/// value-knots when the data supports a cr spline (`n_distinct >= CR_MIN_KNOTS`).
3829/// A cr basis of exactly `n_distinct` knots is full-rank for the data — it can
3830/// represent any per-distinct-value structure (e.g. 3 arbitrary group means on
3831/// a ternary covariate) — so the cap never costs recoverable signal.
3832/// - `None` when `n_distinct < CR_MIN_KNOTS` (a binary covariate): too few
3833/// distinct values for ANY cr spline, so the caller degrades to the linear
3834/// B-spline marginal — exactly what the default `s(x, k=..)` basis already
3835/// builds on the same data, and what the tensor path's `< 3` branch builds.
3836///
3837/// `inference_notes` records any reduction so the user sees that `k` was capped
3838/// (mgcv emits a warning in the same situation).
3839fn capped_cr_marginal_knotspec(
3840 col: ArrayView1<'_, f64>,
3841 k_cr_requested: usize,
3842 label: &str,
3843 inference_notes: &mut Vec<String>,
3844) -> Result<Option<BSplineKnotSpec>, String> {
3845 let n_distinct = unique_count_column(col);
3846 let k_cr = k_cr_requested.min(n_distinct);
3847 if k_cr < CR_MIN_KNOTS {
3848 inference_notes.push(format!(
3849 "Smooth '{label}': cubic-regression ('cr'/'cs'/'sz') basis requested k={k_cr_requested}, \
3850 but the covariate has only {n_distinct} distinct value(s) — too few to support a cubic \
3851 regression spline (needs >= {CR_MIN_KNOTS} distinct values). Degraded to the linear \
3852 B-spline marginal the default basis builds on the same data."
3853 ));
3854 return Ok(None);
3855 }
3856 if k_cr < k_cr_requested {
3857 inference_notes.push(format!(
3858 "Smooth '{label}': cubic-regression ('cr'/'cs'/'sz') basis reduced from k={k_cr_requested} \
3859 to k={k_cr} to match the covariate's {n_distinct} distinct value(s) (mgcv-style \
3860 data-support cap; a cr basis cannot place more value-knots than the data has)."
3861 ));
3862 }
3863 let cr_knots = crate::basis::select_cr_knots(col, k_cr).map_err(|e| e.to_string())?;
3864 Ok(Some(BSplineKnotSpec::NaturalCubicRegression {
3865 knots: cr_knots,
3866 }))
3867}
3868
3869/// Smallest number of distinct covariate values seen within any single group
3870/// of `group_col`. For a factor smooth this is the resolution that bounds the
3871/// marginal basis: a group with `m` distinct covariate values can only inform
3872/// `m` basis coefficients, so a marginal richer than that interpolates the
3873/// group instead of estimating a penalized trend. Bits are compared exactly so
3874/// integer-valued covariates (days, dose levels) collapse to their true count.
3875fn min_per_group_unique_count(
3876 feature_col: ArrayView1<'_, f64>,
3877 group_col: ArrayView1<'_, f64>,
3878) -> usize {
3879 use std::collections::{HashMap, HashSet};
3880 let mut per_group: HashMap<u64, HashSet<u64>> = HashMap::new();
3881 for (xi, gi) in feature_col.iter().zip(group_col.iter()) {
3882 let xnorm = if *xi == 0.0 { 0.0 } else { *xi };
3883 let gnorm = if *gi == 0.0 { 0.0 } else { *gi };
3884 per_group
3885 .entry(gnorm.to_bits())
3886 .or_default()
3887 .insert(xnorm.to_bits());
3888 }
3889 per_group
3890 .values()
3891 .map(|s| s.len())
3892 .min()
3893 .unwrap_or(1)
3894 .max(1)
3895}
3896
3897/// Default internal-knot count for an *additive* univariate smooth, derived
3898/// from the column's unique-value count.
3899///
3900/// The basis dimension is `internal_knots + degree + 1`, so the cap below maps
3901/// to a default cubic basis of ~12 functions — deliberately close to mgcv's
3902/// univariate default (`k = 10`). A penalized smooth controls its wiggliness
3903/// through the *penalty*, not the basis size: REML/LAML shrinks a too-rich
3904/// basis toward the null, but it cannot do so cleanly when the basis is so
3905/// over-sized that the design becomes weakly identified. Growing the basis with
3906/// `n` (the old `n^(1/3)`-ceilinged `unique/4` rule, which pinned to 20 internal
3907/// knots ⇒ a 24-function basis for any column with ≥80 unique values) therefore
3908/// *hurts* recovery on finite, weak-signal fits: a 4-smooth additive model on
3909/// n=120 asks for ~92 coefficients, the outer optimizer stalls on the resulting
3910/// flat two-penalty (range + null-space) REML surface, and the truth leaks into
3911/// surplus columns the penalty can't shrink away (gam#1680; the same defect was
3912/// documented for thin-plate fields in gam#1074). A k-sweep on the #1680 design
3913/// confirms a basis of ~10–15 recovers truth at RMSE ≈ 0.12 while the old
3914/// 24-function default lands at ≈ 0.39 (~3× worse) — *whether or not* the
3915/// covariates are collinear, so this is basis over-richness, not collinearity.
3916///
3917/// The cap is flat in `n`: a user who genuinely needs a wigglier fit raises `k`
3918/// explicitly (mgcv's contract — opt *in* to more flexibility), and the SPEC
3919/// requires the default to allow recovering the null rather than forcing the
3920/// user to opt out of overfitting. The 4-knot floor stays put because we still
3921/// need enough basis functions to fit a non-trivial smooth at all, and the
3922/// `unique/4` growth below the cap keeps small/sparse columns (n ≤ 32, where
3923/// `unique/4 ≤ 8`) on exactly their previous knot count.
3924pub fn heuristic_knots_for_column(col: ArrayView1<'_, f64>) -> usize {
3925 /// Default cubic basis ≈ `MAX_DEFAULT_INTERNAL_KNOTS + degree + 1` = 12
3926 /// functions, matching mgcv's lean univariate default.
3927 const MAX_DEFAULT_INTERNAL_KNOTS: usize = 8;
3928 let unique = unique_count_column(col);
3929 (unique / 4).clamp(4, MAX_DEFAULT_INTERNAL_KNOTS)
3930}
3931
3932/// Per-margin basis sizes for a tensor-product smooth (`te`/`ti`/`t2`).
3933///
3934/// The 1-D heuristic [`heuristic_knots_for_column`] is calibrated for an
3935/// *additive* margin: a well-resolved column asks for the lean univariate
3936/// default (≈12 basis functions, the mgcv-like cap of 8 internal knots; see
3937/// gam#1680), which is sensible for a single `s(x)` term.
3938/// A tensor product, however, multiplies the per-margin sizes:
3939/// `p = ∏_d k_d`. Reusing the 1-D rule per margin makes `p` explode with the
3940/// tensor dimension — a 3-D `te(x,y,z)` at the 1-D ceiling of 12/margin is
3941/// `12³ ≈ 1728` columns, and every REML evaluation pays an O(p³) dense
3942/// penalty reparameterization (the full-tensor sum-to-zero constraint is not
3943/// Kronecker-factorable), turning model selection over tensor candidates into
3944/// a multi-minute single-threaded stall (gam#813). It also requests far more
3945/// coefficients than the data can identify whenever `p ≫ n`.
3946///
3947/// mgcv's `te(...)` uses a small per-margin default (`k = 5`, i.e. `5^d`).
3948/// We match that spirit while staying data-adaptive: budget the *total* tensor
3949/// column count `p_target` and distribute it geometrically across the margins
3950/// so `∏ k_d ≈ p_target`, never asking a margin for more functions than its
3951/// own unique values (and the data set) can support.
3952fn heuristic_tensor_margin_knots(cols: &[usize], ds: &Dataset) -> Vec<usize> {
3953 let d = cols.len().max(1);
3954 let degree = DEFAULT_BSPLINE_DEGREE;
3955 let min_k = degree + 2; // smallest margin that carries a difference penalty
3956 let n = ds.values.nrows();
3957
3958 // Per-margin 1-D ceiling: never request more basis functions than the
3959 // margin's own resolution (unique values) supports. This caps each axis
3960 // independently before the joint budget is applied.
3961 let per_margin_cap: Vec<usize> = cols
3962 .iter()
3963 .map(|&c| heuristic_knots_for_column(ds.values.column(c)).max(min_k))
3964 .collect();
3965
3966 // Total-basis budget. A tensor with ∏k ≫ n coefficients is rank-deficient
3967 // and pure REML cost; cap the product at a generous fraction of n while
3968 // honoring mgcv's small default for the common small-d case. The budget
3969 // grows with n but the geometric split below keeps each margin modest.
3970 // d=2 → up to ~7²=49 (mgcv-`te`-like), d=3 → ~5³=125, larger d shrinks
3971 // per-margin further so the product never blows past the data support.
3972 let mgcv_like_per_margin = match d {
3973 2 => 7usize,
3974 3 => 5usize,
3975 _ => 4usize,
3976 };
3977 let mgcv_like_total = (mgcv_like_per_margin as f64).powi(d as i32);
3978 let data_budget = (n as f64) * 0.8;
3979 let p_target = mgcv_like_total
3980 .max(min_k.pow(d as u32) as f64)
3981 .min(data_budget);
3982
3983 // Geometric per-margin target so ∏k ≈ p_target, then clamp each margin to
3984 // its own 1-D resolution cap and the difference-penalty floor.
3985 let geo_per_margin = p_target.powf(1.0 / d as f64).round() as usize;
3986 let unclamped: Vec<usize> = per_margin_cap
3987 .iter()
3988 .map(|&cap| geo_per_margin.clamp(min_k, cap))
3989 .collect();
3990
3991 // The per-margin clamps can pull some axes below `geo_per_margin` (a
3992 // low-resolution column), leaving headroom in the joint budget. Redistribute
3993 // that headroom to the margins that can still grow, so the realized ∏k stays
3994 // close to p_target instead of systematically under-shooting it.
3995 let mut k_list = unclamped;
3996 loop {
3997 let product: f64 = k_list.iter().map(|&k| k as f64).product();
3998 if product >= p_target {
3999 break;
4000 }
4001 // Grow the axis with the most remaining headroom (cap − current),
4002 // breaking ties toward the largest cap. Stop when none can grow.
4003 let Some(idx) = k_list
4004 .iter()
4005 .zip(per_margin_cap.iter())
4006 .enumerate()
4007 .filter(|&(_, (k, cap))| k < cap)
4008 .max_by_key(|&(_, (k, cap))| (cap - k, *cap))
4009 .map(|(i, _)| i)
4010 else {
4011 break;
4012 };
4013 k_list[idx] += 1;
4014 }
4015 k_list
4016}
4017
4018pub fn heuristic_centers(n: usize, d: usize) -> usize {
4019 default_num_centers(n, d)
4020}
4021
4022// ---------------------------------------------------------------------------
4023// Smooth option parsers
4024// ---------------------------------------------------------------------------
4025
4026fn parse_endpoint_side(
4027 value: &str,
4028 context: &str,
4029) -> Result<BSplineEndpointBoundaryCondition, String> {
4030 match value.trim().to_ascii_lowercase().as_str() {
4031 "" | "none" | "open" | "unconstrained" | "free" => {
4032 Ok(BSplineEndpointBoundaryCondition::Free)
4033 }
4034 "clamped" | "clamp" | "zero_derivative" | "zero-derivative" => {
4035 Ok(BSplineEndpointBoundaryCondition::Clamped)
4036 }
4037 "anchored" | "anchor" | "zero" | "zero_value" | "zero-value" => {
4038 Ok(BSplineEndpointBoundaryCondition::Anchored { value: 0.0 })
4039 }
4040 other => Err(format!(
4041 "unsupported {context} boundary condition '{other}'; expected free, clamped, or anchored"
4042 )),
4043 }
4044}
4045
4046fn boundary_anchor_value(
4047 options: &BTreeMap<String, String>,
4048 side: &str,
4049 fallback: Option<f64>,
4050) -> Option<f64> {
4051 [
4052 format!("anchor_{side}"),
4053 format!("{side}_anchor"),
4054 format!("anchor-value-{side}"),
4055 ]
4056 .iter()
4057 .find_map(|key| option_f64(options, key))
4058 .or(fallback)
4059}
4060
4061fn apply_anchor_value(
4062 cond: BSplineEndpointBoundaryCondition,
4063 value: Option<f64>,
4064) -> BSplineEndpointBoundaryCondition {
4065 match cond {
4066 BSplineEndpointBoundaryCondition::Anchored { .. } => {
4067 BSplineEndpointBoundaryCondition::Anchored {
4068 value: value.unwrap_or(0.0),
4069 }
4070 }
4071 other => other,
4072 }
4073}
4074
4075fn parse_bspline_boundary_conditions(
4076 options: &BTreeMap<String, String>,
4077) -> Result<BSplineBoundaryConditions, String> {
4078 let fallback_anchor = option_f64(options, "anchor")
4079 .or_else(|| option_f64(options, "anchor_value"))
4080 .or_else(|| option_f64(options, "value"));
4081 let global_boundary_conditions = options
4082 .get("boundary_conditions")
4083 .or_else(|| options.get("bc"));
4084 let mut boundary_conditions = BSplineBoundaryConditions::default();
4085
4086 if let Some(raw_boundary_conditions) = global_boundary_conditions {
4087 let cond = parse_endpoint_side(raw_boundary_conditions, "boundary_conditions")?;
4088 let side = options
4089 .get("side")
4090 .map(|s| s.trim().to_ascii_lowercase())
4091 .unwrap_or_else(|| "both".to_string());
4092 match side.as_str() {
4093 "both" | "all" | "endpoints" => {
4094 boundary_conditions.left = cond;
4095 boundary_conditions.right = cond;
4096 }
4097 "left" | "start" | "lower" => boundary_conditions.left = cond,
4098 "right" | "end" | "upper" => boundary_conditions.right = cond,
4099 other => {
4100 return Err(format!(
4101 "unsupported B-spline boundary side '{other}'; expected left, right, or both"
4102 ));
4103 }
4104 }
4105 }
4106
4107 if let Some(raw) = options
4108 .get("bc_left")
4109 .or_else(|| options.get("left_bc"))
4110 .or_else(|| options.get("bc_start"))
4111 .or_else(|| options.get("start_bc"))
4112 {
4113 boundary_conditions.left = parse_endpoint_side(raw, "left endpoint")?;
4114 }
4115 if let Some(raw) = options
4116 .get("bc_right")
4117 .or_else(|| options.get("right_bc"))
4118 .or_else(|| options.get("bc_end"))
4119 .or_else(|| options.get("end_bc"))
4120 {
4121 boundary_conditions.right = parse_endpoint_side(raw, "right endpoint")?;
4122 }
4123
4124 boundary_conditions.left = apply_anchor_value(
4125 boundary_conditions.left,
4126 boundary_anchor_value(options, "left", fallback_anchor),
4127 );
4128 boundary_conditions.right = apply_anchor_value(
4129 boundary_conditions.right,
4130 boundary_anchor_value(options, "right", fallback_anchor),
4131 );
4132
4133 // Non-zero anchors require an affine offset term that the current basis
4134 // builder does not synthesize (see `build_bspline_basis_1d` in
4135 // src/terms/basis.rs). Surface the rejection at parse time with the side
4136 // and value in the diagnostic, instead of letting the value-only error
4137 // emerge deep inside the basis builder where the user has no context
4138 // about which anchor key (`anchor`, `left_anchor`, `right_anchor`, …)
4139 // routed into which endpoint.
4140 reject_nonzero_anchor("left", boundary_conditions.left)?;
4141 reject_nonzero_anchor("right", boundary_conditions.right)?;
4142
4143 Ok(boundary_conditions)
4144}
4145
4146fn reject_nonzero_anchor(side: &str, cond: BSplineEndpointBoundaryCondition) -> Result<(), String> {
4147 if let BSplineEndpointBoundaryCondition::Anchored { value } = cond {
4148 if value.abs() > 1e-12 {
4149 return Err(format!(
4150 "non-zero {side} anchor {value} requires an affine offset term that is not yet supported; only anchored value 0 is accepted at parse time"
4151 ));
4152 }
4153 }
4154 Ok(())
4155}
4156
4157/// Resolve the requested internal-knot count and effective spline degree for
4158/// a 1-D penalized B-spline smooth. This mirrors the tensor-margin per-axis
4159/// degree-reduction policy: a 1-D B-spline basis with `k` functions
4160/// is well-defined for any `degree <= k - 1`, so an explicit
4161/// `s(x, bs="ps", k=3)` with default `degree=3` is interpreted as the
4162/// largest representable spline (`effective_degree = k - 1 = 2`, quadratic)
4163/// rather than rejected. The `penalty_order` carried by the caller must be
4164/// clamped to `<= effective_degree` so the marginal difference penalty
4165/// stays well-defined; the returned `effective_degree` makes that explicit.
4166///
4167/// Mirrors the tensor margin treatment in the `te(...)` builder so a
4168/// standalone smooth, a factor smooth, and a tensor margin all interpret
4169/// "small k" the same way.
4170fn parse_ps_internal_knots(
4171 options: &BTreeMap<String, String>,
4172 degree: usize,
4173 default_internal_knots: usize,
4174) -> Result<(usize, bool, usize), String> {
4175 const MIN_EXPRESSIVE_INTERNAL_KNOTS: usize = 2;
4176 // Strict variants: reject `k=-1`, `k=1.5`, `knots=-2` etc. with a
4177 // focused error instead of silently dropping the value and using the
4178 // default. Lenient `option_usize` / `option_usize_any` silently swallow
4179 // unparseable values, which leaves the user thinking they configured
4180 // something when they did not.
4181 // A list-valued `knots=[...]` carries explicit internal positions, not a
4182 // count; it is consumed by `parse_explicit_internal_knots`. Treat it as
4183 // "count not specified" here so the strict integer parse does not reject
4184 // the bracketed value (the Provided path ignores the returned count).
4185 let knots_internal = if knots_option_is_list(options) {
4186 None
4187 } else {
4188 option_usize_strict(options, "knots")?
4189 };
4190 let basis_dim = option_usize_any_strict(options, &["k", "basis_dim", "basis-dim", "basisdim"])?;
4191 if knots_internal.is_some() && basis_dim.is_some() {
4192 return Err(TermBuilderError::incompatible_config(
4193 "ps/bspline smooth: specify either knots=<internal_knots> or k=<basis_dim> (not both)",
4194 )
4195 .to_string());
4196 }
4197 if let Some(k) = basis_dim {
4198 if k < 2 {
4199 return Err(TermBuilderError::invalid_option(format!(
4200 "ps/bspline smooth: k={} too small; B-spline basis requires k >= 2",
4201 k
4202 ))
4203 .to_string());
4204 }
4205 // `degree <= k - 1` is required for the B-spline basis to be
4206 // well-defined; reduce on this axis only when the user asked for
4207 // a smaller k than the cubic default supports. This matches mgcv's
4208 // behaviour (e.g. `s(x, bs="ps", k=3)` becomes a quadratic basis)
4209 // and the per-axis reduction the tensor builder already does.
4210 let effective_degree = degree.min(k - 1).max(1);
4211 let num_internal_knots = if effective_degree < degree {
4212 // Reproduce the requested basis size exactly when degree was
4213 // reduced for a low-cardinality axis: num_basis = k.
4214 k.saturating_sub(effective_degree + 1)
4215 } else {
4216 (k - degree - 1).max(MIN_EXPRESSIVE_INTERNAL_KNOTS)
4217 };
4218 Ok((num_internal_knots, false, effective_degree))
4219 } else {
4220 Ok((
4221 knots_internal.unwrap_or(default_internal_knots),
4222 knots_internal.is_none(),
4223 degree,
4224 ))
4225 }
4226}
4227
4228/// True when the `knots` option value is a *list* literal (`[...]`, `c(...)`,
4229/// or `(...)`) rather than a scalar count. mgcv's `knots=` accepts both: a
4230/// single integer is an internal-knot count, while a vector is explicit
4231/// internal knot positions. We disambiguate purely on the wrapper syntax so a
4232/// bare `knots=5` keeps its historical count meaning.
4233fn knots_option_is_list(options: &BTreeMap<String, String>) -> bool {
4234 options
4235 .get("knots")
4236 .map(|raw| {
4237 let t = raw.trim();
4238 t.starts_with('[') || t.starts_with("c(") || t.starts_with("C(") || t.starts_with('(')
4239 })
4240 .unwrap_or(false)
4241}
4242
4243/// Parse `knots=[k0, k1, ...]` (or `c(...)` / `(...)`) into explicit internal
4244/// knot positions. Returns `Ok(None)` when `knots` is absent or a scalar count
4245/// (handled by [`parse_ps_internal_knots`]); `Ok(Some(positions))` when it is a
4246/// non-empty numeric list; and an error for an empty or unparseable list.
4247fn parse_explicit_internal_knots(
4248 options: &BTreeMap<String, String>,
4249) -> Result<Option<Vec<f64>>, String> {
4250 if !knots_option_is_list(options) {
4251 return Ok(None);
4252 }
4253 let raw = options
4254 .get("knots")
4255 .expect("knots_option_is_list implies the key is present");
4256 let tokens = split_list_option(raw);
4257 if tokens.is_empty() {
4258 return Err(TermBuilderError::invalid_option(format!(
4259 "knots={raw} is an empty list; supply at least one internal knot position \
4260 (e.g. knots=[0.2, 0.5, 0.8]) or a scalar count (e.g. knots=8)"
4261 ))
4262 .to_string());
4263 }
4264 let mut positions = Vec::with_capacity(tokens.len());
4265 for tok in &tokens {
4266 let value = parse_numeric_expr(tok).map_err(|err| {
4267 TermBuilderError::invalid_option(format!(
4268 "knots list entry '{tok}' is not a numeric position: {err}"
4269 ))
4270 .to_string()
4271 })?;
4272 positions.push(value);
4273 }
4274 Ok(Some(positions))
4275}
4276
4277/// Resolve the `knot_placement=` option for an automatically generated knot
4278/// vector. Accepts `"uniform"` (the default, equal spacing on the data range)
4279/// and `"quantile"` (interior knots at empirical data quantiles, better for
4280/// skewed covariates). Unknown values are rejected so typos do not silently
4281/// fall back to uniform.
4282fn parse_knot_placement(
4283 options: &BTreeMap<String, String>,
4284) -> Result<crate::basis::BSplineKnotPlacement, String> {
4285 use crate::basis::BSplineKnotPlacement;
4286 match options
4287 .get("knot_placement")
4288 .or_else(|| options.get("knot-placement"))
4289 .or_else(|| options.get("knotplacement"))
4290 {
4291 None => Ok(BSplineKnotPlacement::Uniform),
4292 Some(raw) => match raw
4293 .trim()
4294 .trim_matches('"')
4295 .trim_matches('\'')
4296 .to_ascii_lowercase()
4297 .as_str()
4298 {
4299 "uniform" | "even" | "equal" => Ok(BSplineKnotPlacement::Uniform),
4300 "quantile" | "quantiles" | "data" | "empirical" => Ok(BSplineKnotPlacement::Quantile),
4301 other => Err(TermBuilderError::invalid_option(format!(
4302 "knot_placement={other} is not recognised; expected \"uniform\" or \"quantile\""
4303 ))
4304 .to_string()),
4305 },
4306 }
4307}
4308
4309/// Build the non-periodic 1D B-spline knot spec for the `ps`/`bspline` and
4310/// factor-smooth marginal paths, honoring (in priority order):
4311/// 1. `knots=[...]` explicit internal positions → [`BSplineKnotSpec::Provided`]
4312/// 2. `knot_placement="quantile"` → [`BSplineKnotSpec::Automatic`]
4313/// 3. uniform generation → [`BSplineKnotSpec::Generate`]
4314///
4315/// `data` is the covariate column (used to clamp explicit positions to the
4316/// observed range and to drive quantile placement); `n_knots` is the resolved
4317/// internal-knot count from [`parse_ps_internal_knots`] used for the automatic
4318/// strategies.
4319fn resolve_nonperiodic_bspline_knotspec(
4320 options: &BTreeMap<String, String>,
4321 data: ArrayView1<'_, f64>,
4322 data_range: (f64, f64),
4323 degree: usize,
4324 n_knots: usize,
4325) -> Result<BSplineKnotSpec, String> {
4326 use crate::basis::{BSplineKnotPlacement, clamped_knot_vector_from_internal_positions};
4327 if let Some(positions) = parse_explicit_internal_knots(options)? {
4328 if option_usize_any_strict(options, &["k", "basis_dim", "basis-dim", "basisdim"])?.is_some()
4329 {
4330 return Err(TermBuilderError::incompatible_config(
4331 "ps/bspline smooth: specify either explicit knots=[...] positions or \
4332 k=<basis_dim> (not both); the basis size is fixed by the knot vector",
4333 )
4334 .to_string());
4335 }
4336 let knots = clamped_knot_vector_from_internal_positions(data_range, &positions, degree)
4337 .map_err(|e| e.to_string())?;
4338 return Ok(BSplineKnotSpec::Provided(knots));
4339 }
4340 match parse_knot_placement(options)? {
4341 BSplineKnotPlacement::Uniform => Ok(BSplineKnotSpec::Generate {
4342 data_range,
4343 num_internal_knots: n_knots,
4344 }),
4345 BSplineKnotPlacement::Quantile => {
4346 // Validate the column up-front so an unfittable request surfaces a
4347 // user-correctable error at parse time rather than deep in basis
4348 // construction. The same data drives the eventual quantile knots.
4349 crate::basis::auto_knot_vector_1d_quantile(data, n_knots, degree)
4350 .map_err(|e| e.to_string())?;
4351 Ok(BSplineKnotSpec::Automatic {
4352 num_internal_knots: Some(n_knots),
4353 placement: BSplineKnotPlacement::Quantile,
4354 })
4355 }
4356 }
4357}
4358
4359/// Reject unknown option keys with a focused error that names the term and
4360/// the offending key, plus suggests near-matches from the known-key list.
4361/// Without this, typos like `lengt_scale=0.1` or `nyu=5/2` are silently
4362/// dropped, the term uses the default, and the user has no idea why their
4363/// option had no effect.
4364pub fn validate_known_options(
4365 term_name: &str,
4366 options: &BTreeMap<String, String>,
4367 known: &[&str],
4368) -> Result<(), String> {
4369 let known_set: std::collections::BTreeSet<&&str> = known.iter().collect();
4370 for key in options.keys() {
4371 if !known_set.contains(&key.as_str()) {
4372 if term_name == "tensor" && is_tensor_k_axis_option_key(key) {
4373 continue;
4374 }
4375 // Suggest near-matches (substring or shared prefix ≥ 3).
4376 let key_l = key.to_ascii_lowercase();
4377 let mut suggestions: Vec<&str> = known
4378 .iter()
4379 .filter(|k| {
4380 let kl = k.to_ascii_lowercase();
4381 kl.contains(&key_l) || key_l.contains(&kl) || {
4382 let n = kl
4383 .chars()
4384 .zip(key_l.chars())
4385 .take_while(|(a, b)| a == b)
4386 .count();
4387 n >= 3
4388 }
4389 })
4390 .copied()
4391 .collect();
4392 suggestions.sort_unstable();
4393 suggestions.dedup();
4394 let hint = if suggestions.is_empty() {
4395 String::new()
4396 } else {
4397 format!(" — did you mean one of [{}]?", suggestions.join(", "))
4398 };
4399 return Err(TermBuilderError::invalid_option(format!(
4400 "{term_name}() does not accept option `{key}`{hint}. Valid options: [{}]",
4401 {
4402 let mut sorted = known.to_vec();
4403 sorted.sort_unstable();
4404 sorted.join(", ")
4405 }
4406 ))
4407 .to_string());
4408 }
4409 }
4410 Ok(())
4411}
4412
4413/// Private (engine-injected) option that caps the *default* spatial center
4414/// count for a secondary (distributional) predictor's smooth — see
4415/// `solver::fit_orchestration::apply_secondary_predictor_basis_parsimony` and #501.
4416///
4417/// It is deliberately NOT one of the user-facing count aliases recognised by
4418/// [`has_explicit_countwith_basis_alias`], so it never flips the spatial basis
4419/// onto the explicit (hard) center-placement strategy: the cap lowers the
4420/// *default* count while the `Auto` strategy is retained, so the count is still
4421/// softly reduced when the data can't support it.
4422pub const SECONDARY_CENTER_CAP_OPTION: &str = "__secondary_center_cap";
4423
4424/// Apply the secondary-predictor center cap to a *default* spatial center
4425/// count. A no-op when the cap option is absent (the common case) or when the
4426/// user supplied an explicit count (then `default_count` is ignored downstream
4427/// by [`parse_countwith_basis_alias`] anyway).
4428pub(crate) fn cap_default_spatial_centers(
4429 options: &BTreeMap<String, String>,
4430 default_count: usize,
4431) -> usize {
4432 match option_usize(options, SECONDARY_CENTER_CAP_OPTION) {
4433 Some(cap) => default_count.min(cap),
4434 None => default_count,
4435 }
4436}
4437
4438fn default_matern_center_count(
4439 n: usize,
4440 d: usize,
4441 planned_count: usize,
4442 univariate_floor: usize,
4443) -> usize {
4444 // #1074: the mgcv-sized basis cap (`k = 10·3^(d-1)`) was DELETED here too — it
4445 // masked the same over-sizing/under-penalization defect by shrinking the basis
4446 // rather than fixing the optimizer. The default now uses the generic n-scaling
4447 // plan. A small-n floor against a numerically-fragile two-column kernel block
4448 // is a legitimate degenerate guard and is kept. Explicit `k`/`centers` still
4449 // take full effect upstream.
4450 let low_n_floor = (d + 4).min(n);
4451 // #1867: at small n the generic conditioning cap (`n / COND_N_DIVISOR`) in
4452 // `default_num_centers` starves a 1-D radial basis BELOW the resolution the
4453 // univariate B-spline `s(x)` is handed on the SAME data (e.g. 7 vs 11 basis
4454 // functions at n=30), so `matern(x)`/`duchon(x)` over-smooth sparse
4455 // oscillations that `s(x)` recovers cleanly. Smoothness is set by the REML
4456 // penalty λ, not by the raw center count (see `default_num_centers`), so a
4457 // radial smooth competing with `s(x)` must not be dimensioned coarser than
4458 // it. `univariate_floor` carries that spline-equivalent resolution for a 1-D
4459 // smooth (0 for d>1, where there is no direct univariate analogue) and is
4460 // bounded by n. Explicit `k`/`centers` still override upstream.
4461 planned_count
4462 .max(low_n_floor)
4463 .max(univariate_floor.min(n))
4464 .max(1)
4465}
4466
4467fn default_duchon_center_count(
4468 n: usize,
4469 d: usize,
4470 planned_count: usize,
4471 polynomial_cols: usize,
4472 univariate_floor: usize,
4473) -> usize {
4474 // Duchon fits pay a larger setup cost than Matérn/TPS because the
4475 // constrained radial block is rotated through its center Gram and several
4476 // operator-collocation penalties. The old generic spatial default handed a
4477 // 2-D Gaussian Duchon at n≈500 more than one hundred centers, so cold fits
4478 // spent most of their time in dense O(k³) eigensolves even though the REML
4479 // smoother uses a low-rank basis. mgcv's Duchon spline default is the
4480 // thin-plate-style `k = 10 * 3^(d - 1)` (30 in 2-D); use that as the
4481 // implicit low-rank cap while preserving the user's explicit `centers=`/`k=`
4482 // request above. The polynomial null space must still fit, so tiny
4483 // high-order bases are raised to the smallest admissible count.
4484 let mgcv_default = 10usize.saturating_mul(3usize.saturating_pow(d.saturating_sub(1) as u32));
4485 let low_n_floor = (polynomial_cols + 1).min(n).max(1);
4486 // #1867: at small n the generic conditioning cap (`n / COND_N_DIVISOR`) in
4487 // `default_num_centers` starves `planned_count` below the univariate spline
4488 // resolution the competing `s(x)` gets on the SAME data, so `duchon(x)`
4489 // over-smooths sparse oscillations. `univariate_floor` (0 for d>1) carries
4490 // that spline-equivalent basis dimension and floors the 1-D default,
4491 // bounded by n; smoothness is set by the REML penalty, not the raw count.
4492 // Explicit `k`/`centers` still override upstream.
4493 planned_count
4494 .min(mgcv_default)
4495 .max(low_n_floor)
4496 .max(univariate_floor.min(n))
4497}
4498
4499pub fn parse_countwith_basis_alias(
4500 options: &BTreeMap<String, String>,
4501 primarykey: &str,
4502 default_count: usize,
4503) -> Result<usize, String> {
4504 // Strict: reject unparseable values (e.g. `centers=many`, `centers=-1`,
4505 // `centers=1.5`) instead of silently dropping them and falling through
4506 // to the default. Without this the user gets the auto-inferred count
4507 // silently and never realizes their explicit option was ignored.
4508 let primary = option_usize_strict(options, primarykey)?;
4509 let basis_dim = option_usize_any_strict(
4510 options,
4511 &["k", "basis_dim", "basis-dim", "basisdim", "knots"],
4512 )?;
4513 if primary.is_some() && basis_dim.is_some() {
4514 return Err(TermBuilderError::incompatible_config(format!(
4515 "specify either {}=<count> or k=<basis_dim> (not both)",
4516 primarykey
4517 ))
4518 .to_string());
4519 }
4520 Ok(primary.or(basis_dim).unwrap_or(default_count))
4521}
4522
4523pub fn has_explicit_countwith_basis_alias(
4524 options: &BTreeMap<String, String>,
4525 primarykey: &str,
4526) -> bool {
4527 options.contains_key(primarykey)
4528 || ["k", "basis_dim", "basis-dim", "basisdim", "knots"]
4529 .iter()
4530 .any(|alias| options.contains_key(*alias))
4531}
4532
4533pub fn parse_cyclic_boundary(
4534 options: &BTreeMap<String, String>,
4535 minv: f64,
4536 maxv: f64,
4537) -> Result<OneDimensionalBoundary, String> {
4538 let cyclic = option_bool(options, "cyclic")
4539 .or_else(|| option_bool(options, "periodic"))
4540 .unwrap_or(false);
4541 if !cyclic {
4542 return Ok(OneDimensionalBoundary::Open);
4543 }
4544 let start = match option_numeric_expr(options, "period_start")? {
4545 Some(v) => v,
4546 None => option_numeric_expr(options, "start")?.unwrap_or(minv),
4547 };
4548 let end = match option_numeric_expr(options, "period_end")? {
4549 Some(v) => v,
4550 None => option_numeric_expr(options, "end")?.unwrap_or(maxv),
4551 };
4552 if end <= start {
4553 return Err(format!(
4554 "cyclic smooth requires period_end/end ({end}) > period_start/start ({start})"
4555 ));
4556 }
4557 Ok(OneDimensionalBoundary::Cyclic { start, end })
4558}
4559
4560/// Parse the periodic-uniform domain for a one-dimensional cyclic smooth.
4561///
4562/// Returns the `(domain_start, period)` pair derived from
4563/// `period_start` / `start`, `period_end` / `end`, falling back to the
4564/// data range `[minv, maxv)` when neither bound is provided. The period
4565/// must be strictly positive.
4566pub fn parse_periodic_domain_1d(
4567 options: &BTreeMap<String, String>,
4568 minv: f64,
4569 maxv: f64,
4570) -> Result<(f64, f64), String> {
4571 let start_opt = match option_numeric_expr(options, "period_start")? {
4572 Some(v) => Some(v),
4573 None => option_numeric_expr(options, "start")?,
4574 };
4575 let end_opt = match option_numeric_expr(options, "period_end")? {
4576 Some(v) => Some(v),
4577 None => option_numeric_expr(options, "end")?,
4578 };
4579 // Reject the pure data-range fallback. A B-spline periodic smooth that takes
4580 // its wrap from the observed [min, max] is sample-dependent and silently
4581 // wrong: uniform draws on a true period of 2π land on [ε, 2π−ε], so using
4582 // (max−min) as the period seams the curve with an off-by-ε discontinuity and
4583 // the fit drifts with the sample. (Unlike the radial closed-lattice Duchon
4584 // path, whose centers DO tile a full period, so its span-derive is exact —
4585 // see `parse_periodic_axes_option`.) Require the caller to name the period
4586 // explicitly via `period=`/`period_end`. The end is only defaulted to `maxv`
4587 // when a `period_start`/`start` was given (a half-open declaration); a bare
4588 // periodic smooth with neither bound is an error.
4589 if end_opt.is_none() && start_opt.is_none() {
4590 return Err(
4591 "periodic B-spline smooth requires an explicit period: pass period=<value> \
4592 (e.g. period=2*pi) or period_start=/period_end=. Deriving the period from the \
4593 observed data range is sample-dependent and produces an off-by-ε seam, so it is \
4594 not inferred."
4595 .to_string(),
4596 );
4597 }
4598 let start = start_opt.unwrap_or(minv);
4599 let end = end_opt.unwrap_or(maxv);
4600 if !(start.is_finite() && end.is_finite()) {
4601 return Err(format!(
4602 "periodic smooth domain requires finite endpoints, got ({start}, {end})"
4603 ));
4604 }
4605 if end <= start {
4606 return Err(format!(
4607 "periodic smooth requires period_end/end ({end}) > period_start/start ({start})"
4608 ));
4609 }
4610 Ok((start, end - start))
4611}
4612
4613fn parse_matern_nu(raw: &str) -> Result<MaternNu, String> {
4614 let trimmed = raw.trim();
4615 let lowered = trimmed.to_ascii_lowercase();
4616 match lowered.as_str() {
4617 "1/2" | "0.5" | "half" => return Ok(MaternNu::Half),
4618 "3/2" | "1.5" => return Ok(MaternNu::ThreeHalves),
4619 "5/2" | "2.5" => return Ok(MaternNu::FiveHalves),
4620 "7/2" | "3.5" => return Ok(MaternNu::SevenHalves),
4621 "9/2" | "4.5" => return Ok(MaternNu::NineHalves),
4622 _ => {}
4623 }
4624
4625 let value = if let Some((num, den)) = trimmed.split_once('/') {
4626 let num = num
4627 .trim()
4628 .parse::<f64>()
4629 .map_err(|err| format!("{}: {err}", unsupported_matern_nu_message(raw)))?;
4630 let den = den
4631 .trim()
4632 .parse::<f64>()
4633 .map_err(|err| format!("{}: {err}", unsupported_matern_nu_message(raw)))?;
4634 if den == 0.0 || !num.is_finite() || !den.is_finite() {
4635 return Err(unsupported_matern_nu_message(raw));
4636 }
4637 num / den
4638 } else {
4639 trimmed
4640 .parse::<f64>()
4641 .map_err(|err| format!("{}: {err}", unsupported_matern_nu_message(raw)))?
4642 };
4643
4644 const TOL: f64 = 1e-12;
4645 if (value - 0.5).abs() <= TOL {
4646 Ok(MaternNu::Half)
4647 } else if (value - 1.5).abs() <= TOL {
4648 Ok(MaternNu::ThreeHalves)
4649 } else if (value - 2.5).abs() <= TOL {
4650 Ok(MaternNu::FiveHalves)
4651 } else if (value - 3.5).abs() <= TOL {
4652 Ok(MaternNu::SevenHalves)
4653 } else if (value - 4.5).abs() <= TOL {
4654 Ok(MaternNu::NineHalves)
4655 } else {
4656 Err(unsupported_matern_nu_message(raw))
4657 }
4658}
4659
4660fn unsupported_matern_nu_message(raw: &str) -> String {
4661 TermBuilderError::unsupported_feature(format!(
4662 "unsupported Matern nu '{raw}'; supported half-integer values are 1/2, 3/2, 5/2, 7/2, and 9/2"
4663 ))
4664 .to_string()
4665}
4666
4667#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
4668pub enum DuchonPowerPolicy {
4669 Explicit(f64),
4670 /// No explicit `power=` given: defer to the cubic structural default, which
4671 /// the builder resolves dimension-aware as `s = (d − 1)/2` (so `φ(r) = r³`
4672 /// in every dimension). There is no triple-operator minimum any more.
4673 CubicStructuralDefault,
4674}
4675
4676pub fn parse_duchon_power_policy(
4677 options: &BTreeMap<String, String>,
4678) -> Result<DuchonPowerPolicy, String> {
4679 if let Some(raw_nu) = options.get("nu") {
4680 return Err(TermBuilderError::incompatible_config(format!(
4681 "Duchon smooths use power=<number>, not nu='{}'. Use power=1.5, power=2, etc.",
4682 raw_nu
4683 ))
4684 .to_string());
4685 }
4686 match options.get("power") {
4687 Some(raw) => {
4688 let value = raw.parse::<f64>().map_err(|err| {
4689 TermBuilderError::invalid_option(format!(
4690 "invalid Duchon power '{}'; expected a non-negative number such as power=1.5 or power=2: {}",
4691 raw, err
4692 ))
4693 .to_string()
4694 })?;
4695 if !value.is_finite() || value < 0.0 {
4696 return Err(TermBuilderError::invalid_option(format!(
4697 "invalid Duchon power '{}'; expected a finite non-negative number such as power=1.5 or power=2",
4698 raw
4699 ))
4700 .to_string());
4701 }
4702 Ok(DuchonPowerPolicy::Explicit(value))
4703 }
4704 None => Ok(DuchonPowerPolicy::CubicStructuralDefault),
4705 }
4706}
4707
4708pub fn parse_duchon_power(options: &BTreeMap<String, String>) -> Result<f64, String> {
4709 match parse_duchon_power_policy(options)? {
4710 DuchonPowerPolicy::Explicit(power) => Ok(power),
4711 // Context-free placeholder: the bare option parser has no column count,
4712 // so it cannot compute the dimension-aware cubic power `s = (d − 1)/2`.
4713 // The dimension-aware resolution happens later in `build_smooth_basis`;
4714 // this 1.5 is only a stand-in for callers that need a concrete number
4715 // without data context (e.g. round-trip parser tests).
4716 DuchonPowerPolicy::CubicStructuralDefault => Ok(1.5),
4717 }
4718}
4719
4720pub fn parse_duchon_order(
4721 options: &BTreeMap<String, String>,
4722) -> Result<DuchonNullspaceOrder, String> {
4723 match options.get("order") {
4724 // Structural cubic Duchon is affine-by-default: an unspecified order is
4725 // the `Linear` (constant + linear) null space, matching the magic
4726 // default. An explicit `order=0` still selects the constant-only space.
4727 None => Ok(DuchonNullspaceOrder::Linear),
4728 Some(raw) => match raw.parse::<usize>() {
4729 Ok(0) => Ok(DuchonNullspaceOrder::Zero),
4730 Ok(1) => Ok(DuchonNullspaceOrder::Linear),
4731 Ok(other) => Ok(DuchonNullspaceOrder::Degree(other)),
4732 Err(_) => Err(TermBuilderError::invalid_option(format!(
4733 "invalid Duchon order '{}'; expected a non-negative integer such as order=0, order=1, or order=2",
4734 raw
4735 ))
4736 .to_string()),
4737 },
4738 }
4739}
4740
4741fn parse_matern_identifiability(
4742 options: &BTreeMap<String, String>,
4743) -> Result<MaternIdentifiability, TermBuilderError> {
4744 let Some(raw) = options.get("identifiability").map(String::as_str) else {
4745 return Ok(MaternIdentifiability::default());
4746 };
4747 match raw.trim().to_ascii_lowercase().as_str() {
4748 "none" => Ok(MaternIdentifiability::None),
4749 "sum_tozero" | "sum-to-zero" | "center_sum_tozero" | "center-sum-to-zero" | "centered" => {
4750 Ok(MaternIdentifiability::CenterSumToZero)
4751 }
4752 "linear" | "center_linear_orthogonal" | "center-linear-orthogonal" => {
4753 Ok(MaternIdentifiability::CenterLinearOrthogonal)
4754 }
4755 other => Err(TermBuilderError::unsupported_feature(format!(
4756 "invalid Matérn identifiability '{other}'; expected one of: none, sum_tozero, linear"
4757 ))),
4758 }
4759}
4760
4761fn parse_spatial_identifiability(
4762 options: &BTreeMap<String, String>,
4763) -> Result<SpatialIdentifiability, TermBuilderError> {
4764 let Some(raw) = options.get("identifiability").map(String::as_str) else {
4765 return Ok(SpatialIdentifiability::default());
4766 };
4767 match raw.trim().to_ascii_lowercase().as_str() {
4768 "none" => Ok(SpatialIdentifiability::None),
4769 "orthogonal"
4770 | "orthogonal_to_parametric"
4771 | "orthogonal-to-parametric"
4772 | "parametric_orthogonal" => Ok(SpatialIdentifiability::OrthogonalToParametric),
4773 "frozen" => Err(TermBuilderError::unsupported_feature(
4774 "spatial identifiability 'frozen' is internal-only; use none or orthogonal_to_parametric",
4775 )),
4776 other => Err(TermBuilderError::unsupported_feature(format!(
4777 "invalid spatial identifiability '{other}'; expected one of: none, orthogonal_to_parametric"
4778 ))),
4779 }
4780}
4781
4782#[cfg(test)]
4783mod tests {
4784 use super::*;
4785 use crate::basis::OperatorPenaltySpec;
4786 use crate::inference::formula_dsl::parse_formula;
4787 use gam_data::{DataSchema, SchemaColumn};
4788 use ndarray::{Array1, Array2};
4789 use std::collections::BTreeMap;
4790
4791 /// #1867 regression: on sparse 1-D data the generic conditioning cap in
4792 /// [`default_num_centers`] (`n / COND_N_DIVISOR`) starves a radial
4793 /// (matérn/duchon) basis BELOW the resolution the univariate B-spline
4794 /// `s(x)` is handed on the SAME data — 7 vs 11 basis functions at n=30 —
4795 /// so `matern(x)`/`duchon(x)` over-smooth oscillations that `s(x)`
4796 /// recovers. The spline-equivalent floor threaded into the radial default
4797 /// count must restore that resolution. Without the floor (the `0` argument,
4798 /// i.e. the pre-fix behaviour) the radial default stays starved.
4799 #[test]
4800 fn radial_1d_default_not_starved_below_univariate_spline_resolution_1867() {
4801 let n = 30usize;
4802 let d = 1usize;
4803 // Raw radial default, starved by the n/COND_N_DIVISOR conditioning cap.
4804 let planned = default_num_centers(n, d);
4805 assert!(
4806 planned < 11,
4807 "precondition: conditioning cap starves the raw radial default (got {planned})"
4808 );
4809 // A well-resolved 1-D column of `n` distinct values asks for the
4810 // univariate spline basis dimension the competing `s(x)` gets.
4811 let col: Array1<f64> = Array1::from_iter((0..n).map(|i| i as f64 / (n as f64 - 1.0)));
4812 let univariate_floor =
4813 heuristic_knots_for_column(col.view()).saturating_add(DEFAULT_BSPLINE_DEGREE + 1);
4814 assert_eq!(univariate_floor, 11, "univariate spline resolution at n=30");
4815
4816 // BEFORE (no floor): radial defaults inherit the starved count.
4817 assert_eq!(default_matern_center_count(n, d, planned, 0), planned);
4818 assert!(default_duchon_center_count(n, d, planned, 2, 0) <= planned);
4819
4820 // AFTER (spline-equivalent floor): radial defaults are lifted to at
4821 // least the univariate spline resolution, so they are not dimensioned
4822 // coarser than `s(x)` on identical data.
4823 assert!(
4824 default_matern_center_count(n, d, planned, univariate_floor) >= univariate_floor,
4825 "matern 1-D default must not be starved below the spline resolution"
4826 );
4827 assert!(
4828 default_duchon_center_count(n, d, planned, 2, univariate_floor) >= univariate_floor,
4829 "duchon 1-D default must not be starved below the spline resolution"
4830 );
4831
4832 // The floor is scoped to 1-D: a multivariate smooth passes 0 and keeps
4833 // the generic n-scaling plan unchanged.
4834 assert_eq!(default_matern_center_count(200, 2, 40, 0), 40);
4835 }
4836
4837 fn continuous_dataset(headers: &[&str], rows: Vec<Vec<f64>>) -> Dataset {
4838 let nrows = rows.len();
4839 let ncols = headers.len();
4840 let values = Array2::from_shape_vec(
4841 (nrows, ncols),
4842 rows.into_iter().flat_map(|row| row.into_iter()).collect(),
4843 )
4844 .expect("rectangular test data");
4845 Dataset {
4846 headers: headers.iter().map(|name| name.to_string()).collect(),
4847 values,
4848 schema: DataSchema {
4849 columns: headers
4850 .iter()
4851 .map(|name| SchemaColumn {
4852 name: name.to_string(),
4853 kind: ColumnKindTag::Continuous,
4854 levels: vec![],
4855 })
4856 .collect(),
4857 },
4858 column_kinds: vec![ColumnKindTag::Continuous; ncols],
4859 }
4860 }
4861
4862 fn factor_dataset() -> Dataset {
4863 let rows = (0..24)
4864 .map(|i| {
4865 let x = i as f64 / 23.0;
4866 let g = (i % 2) as f64;
4867 vec![x + g, x, g]
4868 })
4869 .collect::<Vec<_>>();
4870 Dataset {
4871 headers: vec!["y".into(), "x".into(), "g".into()],
4872 values: Array2::from_shape_vec(
4873 (rows.len(), 3),
4874 rows.into_iter().flat_map(|row| row.into_iter()).collect(),
4875 )
4876 .expect("rectangular factor test data"),
4877 schema: DataSchema {
4878 columns: vec![
4879 SchemaColumn {
4880 name: "y".into(),
4881 kind: ColumnKindTag::Continuous,
4882 levels: vec![],
4883 },
4884 SchemaColumn {
4885 name: "x".into(),
4886 kind: ColumnKindTag::Continuous,
4887 levels: vec![],
4888 },
4889 SchemaColumn {
4890 name: "g".into(),
4891 kind: ColumnKindTag::Categorical,
4892 levels: vec!["a".into(), "b".into()],
4893 },
4894 ],
4895 },
4896 column_kinds: vec![
4897 ColumnKindTag::Continuous,
4898 ColumnKindTag::Continuous,
4899 ColumnKindTag::Categorical,
4900 ],
4901 }
4902 }
4903
4904 /// #1378: the DEFAULT univariate `s(x, bs="tp")` must build a *modest*
4905 /// mgcv-sized basis, not the n-scaled spatial heuristic. The oversized
4906 /// default basis left the two-penalty REML ρ-surface with a flat valley
4907 /// whose optimizer landing point depended on row order, breaking
4908 /// row-permutation invariance. Pin the default 1-D center count so a
4909 /// regression that reinstates the n-scaled default trips here, fast, with
4910 /// no fit/optimizer in the loop.
4911 #[test]
4912 fn default_univariate_thinplate_basis_dim_is_modest() {
4913 // n = 300 (the #1378 scenario): the n-scaled spatial heuristic would
4914 // request ~75 centers here. The modest default must stay near k = 10.
4915 let n = 300usize;
4916 let rows: Vec<Vec<f64>> = (0..n)
4917 .map(|i| {
4918 let x = -3.0 + 6.0 * (i as f64) / ((n - 1) as f64);
4919 vec![x.sin(), x]
4920 })
4921 .collect();
4922 let ds = continuous_dataset(&["y", "x"], rows);
4923
4924 let mut options = BTreeMap::new();
4925 options.insert("bs".to_string(), "tp".to_string());
4926
4927 let mut notes = Vec::new();
4928 let basis = build_smooth_basis(
4929 SmoothKind::S,
4930 &["x".to_string()],
4931 &[1],
4932 &options,
4933 &ds,
4934 &mut notes,
4935 &ResourcePolicy::default_library(),
4936 1,
4937 )
4938 .expect("build default univariate tp smooth");
4939
4940 let centers = match &basis {
4941 SmoothBasisSpec::ThinPlate { spec, .. } => match &spec.center_strategy {
4942 CenterStrategy::Auto(inner) => match inner.as_ref() {
4943 CenterStrategy::FarthestPoint { num_centers }
4944 | CenterStrategy::EqualMass { num_centers }
4945 | CenterStrategy::EqualMassCovarRepresentative { num_centers }
4946 | CenterStrategy::KMeans { num_centers, .. } => *num_centers,
4947 other => panic!("unexpected auto inner center strategy: {other:?}"),
4948 },
4949 CenterStrategy::FarthestPoint { num_centers }
4950 | CenterStrategy::EqualMass { num_centers }
4951 | CenterStrategy::EqualMassCovarRepresentative { num_centers }
4952 | CenterStrategy::KMeans { num_centers, .. } => *num_centers,
4953 other => panic!("unexpected center strategy: {other:?}"),
4954 },
4955 other => panic!("expected ThinPlate basis, got {other:?}"),
4956 };
4957
4958 // #1074: the mgcv-sized basis-dim ceiling assertion was removed with the
4959 // cap it tested. The default tp basis is now n-scaled; we only assert it
4960 // still builds a usable basis.
4961 assert!(
4962 centers >= 1,
4963 "default univariate tp must still build a usable basis (centers={centers})",
4964 );
4965 }
4966
4967 /// gam#1629: a default 2-D `matern(x1, x2)` (no explicit `length_scale`)
4968 /// must leave the length-scale at the `0.0` auto sentinel — NOT the full
4969 /// data diameter — so the planner's `auto_init_length_scale_in_place` seeds
4970 /// it on the wiggly/resolving side (`max_range / sqrt(n)`), the same regime
4971 /// thin-plate uses. The previous `default_matern_length_scale` returned the
4972 /// full diameter, which is non-zero, so the `0.0`-gated auto-init was a
4973 /// no-op and the κ-optimizer started in the over-smoothed corner and parked
4974 /// there (truth-RMSE ~6× worse than thin-plate/tensor on identical
4975 /// high-frequency 2-D surfaces, insensitive to `k`). This pins the corrected
4976 /// seed geometry without a fit/optimizer in the loop.
4977 #[test]
4978 fn default_matern_2d_seeds_resolving_length_scale_not_overscaled_diameter() {
4979 // A fine multi-frequency 2-D grid (the #1629 reproduction shape): the
4980 // data diameter is O(1.4) in each axis; the resolving seed must be far
4981 // smaller than the diameter so high-frequency structure stays reachable.
4982 let side = 24usize; // n = 576
4983 let mut rows: Vec<Vec<f64>> = Vec::with_capacity(side * side);
4984 for i in 0..side {
4985 for j in 0..side {
4986 let x1 = i as f64 / (side - 1) as f64; // [0, 1]
4987 let x2 = j as f64 / (side - 1) as f64; // [0, 1]
4988 let y = (6.0 * x1).sin() * (6.0 * x2).cos();
4989 rows.push(vec![y, x1, x2]);
4990 }
4991 }
4992 let n = rows.len();
4993 let ds = continuous_dataset(&["y", "x1", "x2"], rows);
4994
4995 let mut options = BTreeMap::new();
4996 options.insert("bs".to_string(), "gp".to_string()); // gp ⇒ Matérn
4997 let mut notes = Vec::new();
4998 let mut basis = build_smooth_basis(
4999 SmoothKind::S,
5000 &["x1".to_string(), "x2".to_string()],
5001 &[1, 2],
5002 &options,
5003 &ds,
5004 &mut notes,
5005 &ResourcePolicy::default_library(),
5006 1,
5007 )
5008 .expect("build default 2-D matern smooth");
5009
5010 // (1) The builder must emit the auto sentinel, not a baked-in diameter.
5011 let (feature_cols, seeded_length_scale) = match &basis {
5012 SmoothBasisSpec::Matern {
5013 feature_cols, spec, ..
5014 } => (feature_cols.clone(), spec.length_scale),
5015 other => panic!("expected Matern basis, got {other:?}"),
5016 };
5017 assert_eq!(
5018 seeded_length_scale, 0.0,
5019 "default matern() must leave length_scale at the 0.0 auto sentinel \
5020 (got {seeded_length_scale}); a non-zero diameter default re-enters the \
5021 over-smoothed basin and disables the planner's wiggly-side auto-init",
5022 );
5023
5024 // (2) After the shared auto-init runs, the realized length-scale must
5025 // land in the resolving regime: `max_range / sqrt(n)`, far below the
5026 // data diameter. This is the seed the κ-optimizer starts REML from.
5027 crate::smooth::auto_init_length_scale_in_basis(ds.values.view(), &mut basis);
5028 let realized = match &basis {
5029 SmoothBasisSpec::Matern { spec, .. } => spec.length_scale,
5030 other => panic!("expected Matern basis after auto-init, got {other:?}"),
5031 };
5032 let expected = crate::smooth::auto_initial_length_scale(ds.values.view(), &feature_cols);
5033 assert!(
5034 (realized - expected).abs() <= 1e-12,
5035 "auto-init must seed the wiggly-side length scale max_range/sqrt(n) \
5036 (expected {expected}, got {realized})",
5037 );
5038
5039 // Sanity: the resolving seed is well below the per-axis range (≈1.0).
5040 // Before the fix the seed was the full diameter (≈√2 ≈ 1.414); the
5041 // resolving seed here is ≈ 1.0 / sqrt(576) ≈ 0.042, ~30× smaller.
5042 let max_range = 1.0_f64; // each axis spans [0, 1]
5043 assert!(
5044 realized < max_range / 4.0,
5045 "matern seed length_scale {realized} must be in the resolving regime, \
5046 not the over-smoothed diameter corner (n={n}, max_range≈{max_range})",
5047 );
5048 }
5049
5050 /// gam#1778: `matern(..., periodic=true)` and `thinplate(..., periodic=true)`
5051 /// must be ACCEPTED. The squash-merge that wired periodic support into the
5052 /// matern/thinplate basis specs forgot to add the periodic option keys to
5053 /// those two builders' `validate_known_options` whitelists (only `duchon`
5054 /// got both), so `periodic=`/`period=`/`cyclic=`/`period_start=`/`period_end=`
5055 /// were rejected as unknown options even though the spec/builder consume them.
5056 /// Before the whitelist fix this returned an "unknown option" error.
5057 #[test]
5058 fn matern_and_thinplate_accept_periodic_option() {
5059 let n = 200usize;
5060 let rows: Vec<Vec<f64>> = (0..n)
5061 .map(|i| {
5062 let x = -3.0 + 6.0 * (i as f64) / ((n - 1) as f64);
5063 vec![x.sin(), x]
5064 })
5065 .collect();
5066 let ds = continuous_dataset(&["y", "x"], rows);
5067
5068 // matern() with periodic=true must build without an unknown-option error.
5069 let mut matern_opts = BTreeMap::new();
5070 matern_opts.insert("bs".to_string(), "gp".to_string()); // gp ⇒ Matérn
5071 matern_opts.insert("periodic".to_string(), "true".to_string());
5072 let mut notes = Vec::new();
5073 let matern_basis = build_smooth_basis(
5074 SmoothKind::S,
5075 &["x".to_string()],
5076 &[1],
5077 &matern_opts,
5078 &ds,
5079 &mut notes,
5080 &ResourcePolicy::default_library(),
5081 1,
5082 )
5083 .expect("matern(x, periodic=true) must be accepted");
5084 match &matern_basis {
5085 SmoothBasisSpec::Matern { spec, .. } => assert!(
5086 spec.periodic.is_some(),
5087 "periodic=true must thread a Some(periodic) into the matern spec",
5088 ),
5089 other => panic!("expected Matern basis, got {other:?}"),
5090 }
5091
5092 // thinplate()/tps() with periodic=true must likewise be accepted.
5093 let mut tps_opts = BTreeMap::new();
5094 tps_opts.insert("bs".to_string(), "tp".to_string());
5095 tps_opts.insert("periodic".to_string(), "true".to_string());
5096 let mut notes = Vec::new();
5097 let tps_basis = build_smooth_basis(
5098 SmoothKind::S,
5099 &["x".to_string()],
5100 &[1],
5101 &tps_opts,
5102 &ds,
5103 &mut notes,
5104 &ResourcePolicy::default_library(),
5105 1,
5106 )
5107 .expect("thinplate(x, periodic=true) must be accepted");
5108 match &tps_basis {
5109 SmoothBasisSpec::ThinPlate { spec, .. } => assert!(
5110 spec.periodic.is_some(),
5111 "periodic=true must thread a Some(periodic) into the thinplate spec",
5112 ),
5113 other => panic!("expected ThinPlate basis, got {other:?}"),
5114 }
5115 }
5116
5117 /// Regression: an explicit scalar `periodic=false` on a radial spatial smooth
5118 /// must build a NON-periodic basis. The scalar-boolean shortcut used to emit
5119 /// `Some(vec![None; dim])`, which the 1-D radial builders route on via
5120 /// `spec.periodic.is_some()` (and the Duchon arm even back-fills the data
5121 /// range into a lone `None`), so `periodic=false` silently produced a
5122 /// *periodic* smooth — the opposite of what was asked. The spec's `periodic`
5123 /// field must be `None` for every radial base (matern / thinplate / duchon),
5124 /// matching the bracketed `[false]` form.
5125 #[test]
5126 fn scalar_periodic_false_builds_non_periodic_radial_smooth() {
5127 let n = 200usize;
5128 let rows: Vec<Vec<f64>> = (0..n)
5129 .map(|i| {
5130 let x = -3.0 + 6.0 * (i as f64) / ((n - 1) as f64);
5131 vec![x.sin(), x]
5132 })
5133 .collect();
5134 let ds = continuous_dataset(&["y", "x"], rows);
5135
5136 let build = |bs: &str| -> SmoothBasisSpec {
5137 let mut opts = BTreeMap::new();
5138 opts.insert("bs".to_string(), bs.to_string());
5139 opts.insert("periodic".to_string(), "false".to_string());
5140 let mut notes = Vec::new();
5141 build_smooth_basis(
5142 SmoothKind::S,
5143 &["x".to_string()],
5144 &[1],
5145 &opts,
5146 &ds,
5147 &mut notes,
5148 &ResourcePolicy::default_library(),
5149 1,
5150 )
5151 .unwrap_or_else(|e| panic!("s(x, bs={bs}, periodic=false) must be accepted: {e}"))
5152 };
5153
5154 match &build("gp") {
5155 SmoothBasisSpec::Matern { spec, .. } => assert!(
5156 spec.periodic.is_none(),
5157 "periodic=false must leave the matern spec non-periodic, got {:?}",
5158 spec.periodic
5159 ),
5160 other => panic!("expected Matern basis, got {other:?}"),
5161 }
5162 match &build("tp") {
5163 SmoothBasisSpec::ThinPlate { spec, .. } => assert!(
5164 spec.periodic.is_none(),
5165 "periodic=false must leave the thinplate spec non-periodic, got {:?}",
5166 spec.periodic
5167 ),
5168 other => panic!("expected ThinPlate basis, got {other:?}"),
5169 }
5170 match &build("duchon") {
5171 SmoothBasisSpec::Duchon { spec, .. } => assert!(
5172 spec.periodic.is_none(),
5173 "periodic=false must leave the duchon spec non-periodic (no data-range \
5174 back-fill), got {:?}",
5175 spec.periodic
5176 ),
5177 other => panic!("expected Duchon basis, got {other:?}"),
5178 }
5179 }
5180
5181 fn inferred_tensor_basis_product(ds: &Dataset) -> usize {
5182 let parsed = parse_formula("y ~ te(theta, h)").expect("parse tensor formula");
5183 let col_map = ds.column_map();
5184 let mut notes = Vec::new();
5185 let terms = build_termspec(
5186 &parsed.terms,
5187 ds,
5188 &col_map,
5189 &mut notes,
5190 &ResourcePolicy::default_library(),
5191 )
5192 .expect("build tensor termspec");
5193 let SmoothBasisSpec::TensorBSpline { spec, .. } = &terms.smooth_terms[0].basis else {
5194 panic!("expected tensor smooth");
5195 };
5196 spec.marginalspecs
5197 .iter()
5198 .map(|marginal| match marginal.knotspec {
5199 BSplineKnotSpec::Generate {
5200 num_internal_knots, ..
5201 } => num_internal_knots + marginal.degree + 1,
5202 BSplineKnotSpec::PeriodicUniform { num_basis, .. } => num_basis,
5203 BSplineKnotSpec::Automatic {
5204 num_internal_knots: Some(num_internal_knots),
5205 ..
5206 } => num_internal_knots + marginal.degree + 1,
5207 BSplineKnotSpec::Automatic {
5208 num_internal_knots: None,
5209 ..
5210 } => panic!("test helper cannot infer automatic knot count"),
5211 BSplineKnotSpec::Provided(ref knots) => {
5212 knots.len().saturating_sub(marginal.degree + 1)
5213 }
5214 // cr basis dimension equals the knot count (no degree offset).
5215 BSplineKnotSpec::NaturalCubicRegression { ref knots } => knots.len(),
5216 })
5217 .product()
5218 }
5219
5220 fn tensor_margin_basis_sizes(ds: &Dataset, formula: &str) -> Vec<usize> {
5221 let parsed = parse_formula(formula).expect("parse tensor formula");
5222 let col_map = ds.column_map();
5223 let mut notes = Vec::new();
5224 let terms = build_termspec(
5225 &parsed.terms,
5226 ds,
5227 &col_map,
5228 &mut notes,
5229 &ResourcePolicy::default_library(),
5230 )
5231 .expect("build tensor termspec");
5232 let SmoothBasisSpec::TensorBSpline { spec, .. } = &terms.smooth_terms[0].basis else {
5233 panic!("expected tensor smooth");
5234 };
5235 spec.marginalspecs
5236 .iter()
5237 .map(|marginal| match marginal.knotspec {
5238 BSplineKnotSpec::Generate {
5239 num_internal_knots, ..
5240 } => num_internal_knots + marginal.degree + 1,
5241 BSplineKnotSpec::PeriodicUniform { num_basis, .. } => num_basis,
5242 BSplineKnotSpec::Automatic {
5243 num_internal_knots: Some(num_internal_knots),
5244 ..
5245 } => num_internal_knots + marginal.degree + 1,
5246 BSplineKnotSpec::Automatic {
5247 num_internal_knots: None,
5248 ..
5249 } => panic!("test helper cannot infer automatic knot count"),
5250 BSplineKnotSpec::Provided(ref knots) => {
5251 knots.len().saturating_sub(marginal.degree + 1)
5252 }
5253 // cr basis dimension equals the knot count (no degree offset).
5254 BSplineKnotSpec::NaturalCubicRegression { ref knots } => knots.len(),
5255 })
5256 .collect()
5257 }
5258
5259 #[test]
5260 fn validate_known_options_lists_valid_option_names_for_unknown_parameter() {
5261 let mut options = BTreeMap::new();
5262 options.insert("lengt_scale".to_string(), "0.25".to_string());
5263 let err = validate_known_options(
5264 "matern",
5265 &options,
5266 &["type", "bs", "length_scale", "centers", "k", "nu"],
5267 )
5268 .expect_err("unknown smooth option should be rejected");
5269 assert!(
5270 err.contains("matern() does not accept option `lengt_scale`"),
5271 "error should name the invalid option, got: {err}"
5272 );
5273 assert!(
5274 err.contains("did you mean one of [length_scale]"),
5275 "error should suggest the closest valid option, got: {err}"
5276 );
5277 assert!(
5278 err.contains("Valid options: ["),
5279 "error should list valid option names, got: {err}"
5280 );
5281 }
5282
5283 #[test]
5284 fn tensor_k_accepts_square_bracket_per_margin_list() {
5285 let ds = continuous_dataset(
5286 &["y", "x", "z"],
5287 (0..40)
5288 .map(|i| {
5289 let x = i as f64 / 39.0;
5290 let z = ((i * 7) % 40) as f64 / 39.0;
5291 vec![x.sin() + z.cos(), x, z]
5292 })
5293 .collect(),
5294 );
5295
5296 assert_eq!(
5297 tensor_margin_basis_sizes(&ds, "y ~ te(x, z, k=[5, 6])"),
5298 vec![5, 6],
5299 "square-bracket k lists should materialize the requested per-margin values"
5300 );
5301 }
5302
5303 /// #1776 / #1752: a bare doubly-cyclic tensor `te(x, z, bs=c('cc','cc'))`
5304 /// with NO explicit `period=` must build — each cyclic margin wraps on its
5305 /// own observed `[min, max]` data span (mirroring mgcv's `bs="cc"` and the
5306 /// 1-D cyclic fallback), instead of hard-erroring "periodic but requires an
5307 /// explicit period". The periodic-radial refactor (c8c3192fa) replaced that
5308 /// fallback with an unconditional `period=`-required error and orphaned the
5309 /// `margin_is_cc` binding that drives it (the #1776 dead-binding `-D
5310 /// warnings` build break). This pins the restored data-range derivation so a
5311 /// regression that drops the `None if margin_is_cc` branch trips here, fast,
5312 /// with no fit/optimizer in the loop.
5313 #[test]
5314 fn bare_doubly_cyclic_tensor_derives_period_from_data_range_1776() {
5315 let ds = continuous_dataset(
5316 &["y", "x", "z"],
5317 (0..40)
5318 .map(|i| {
5319 let x = i as f64 / 39.0;
5320 let z = ((i * 7) % 40) as f64 / 39.0;
5321 vec![x.sin() + z.cos(), x, z]
5322 })
5323 .collect(),
5324 );
5325
5326 let parsed = parse_formula("y ~ te(x, z, bs=c('cc','cc'))")
5327 .expect("parse doubly-cyclic tensor formula");
5328 let col_map = ds.column_map();
5329 let mut notes = Vec::new();
5330 // Must NOT hard-error: the bare cyclic margins derive their period from
5331 // the observed data range (the restored #1752 fallback).
5332 let terms = build_termspec(
5333 &parsed.terms,
5334 &ds,
5335 &col_map,
5336 &mut notes,
5337 &ResourcePolicy::default_library(),
5338 )
5339 .expect(
5340 "bare cc-cc tensor must build via the data-range period fallback (#1776/#1752), \
5341 not hard-error on a missing explicit period",
5342 );
5343 let SmoothBasisSpec::TensorBSpline { spec, .. } = &terms.smooth_terms[0].basis else {
5344 panic!("expected tensor smooth");
5345 };
5346 assert_eq!(
5347 spec.marginalspecs.len(),
5348 2,
5349 "te(x, z) builds exactly two tensor margins"
5350 );
5351 for (axis, marginal) in spec.marginalspecs.iter().enumerate() {
5352 assert!(
5353 matches!(marginal.knotspec, BSplineKnotSpec::PeriodicUniform { .. }),
5354 "cyclic margin {axis} must build a periodic (wrapped) knotspec from the \
5355 data range, got {:?}",
5356 marginal.knotspec
5357 );
5358 }
5359 }
5360
5361 #[test]
5362 fn parse_cylinder_periodic_options_match_requested_forms() {
5363 let mut opts = BTreeMap::new();
5364 opts.insert("periodic".to_string(), "[0]".to_string());
5365 opts.insert("period".to_string(), "[2*pi, None]".to_string());
5366 let axes = parse_periodic_axes(&opts, 2).expect("axes");
5367 let periods = parse_periods(&opts, &axes).expect("periods");
5368 assert_eq!(axes, vec![true, false]);
5369 assert!((periods[0].unwrap() - 2.0 * std::f64::consts::PI).abs() < 1e-12);
5370 assert_eq!(periods[1], None);
5371
5372 let mut boundary_opts = BTreeMap::new();
5373 boundary_opts.insert(
5374 "boundary".to_string(),
5375 "['periodic', 'natural']".to_string(),
5376 );
5377 boundary_opts.insert("period".to_string(), "[2*pi, None]".to_string());
5378 let boundary_axes = parse_periodic_axes(&boundary_opts, 2).expect("boundary axes");
5379 let boundary_periods =
5380 parse_periods(&boundary_opts, &boundary_axes).expect("boundary periods");
5381 assert_eq!(boundary_axes, vec![true, false]);
5382 assert!((boundary_periods[0].unwrap() - 2.0 * std::f64::consts::PI).abs() < 1e-12);
5383 assert_eq!(boundary_periods[1], None);
5384
5385 let mut unicode_opts = BTreeMap::new();
5386 unicode_opts.insert("periodic".to_string(), "[0,1]".to_string());
5387 unicode_opts.insert("period".to_string(), "[2π, τ]".to_string());
5388 let unicode_axes = parse_periodic_axes(&unicode_opts, 2).expect("unicode axes");
5389 let unicode_periods = parse_periods(&unicode_opts, &unicode_axes).expect("unicode periods");
5390 assert_eq!(unicode_axes, vec![true, true]);
5391 assert!((unicode_periods[0].unwrap() - 2.0 * std::f64::consts::PI).abs() < 1e-12);
5392 assert!((unicode_periods[1].unwrap() - std::f64::consts::TAU).abs() < 1e-12);
5393 }
5394
5395 /// The tensor boundary-token guard must ACCEPT `clamped`/`open` (the
5396 /// B-spline-clamped, non-periodic margin spelling) alongside the periodic
5397 /// selectors and the other inert non-periodic markers, and still REJECT a
5398 /// genuine endpoint constraint like `anchored`. This locks the #415 /
5399 /// cylinder fix (`te(theta, z, boundary=['periodic','clamped'])`, mgcv
5400 /// `te(bs=c("cc","ps"))`) in the fast unit lane — the end-to-end cylinder
5401 /// recovery test is R-gated (`run_r` + mgcv), so without this the guard
5402 /// regressing back to rejecting `clamped` would slip through CPU CI.
5403 #[test]
5404 fn tensor_boundary_tokens_accept_clamped_open_reject_anchored() {
5405 fn boundary(raw: &str, dim: usize) -> Result<(), String> {
5406 let mut opts = BTreeMap::new();
5407 opts.insert("boundary".to_string(), raw.to_string());
5408 validate_tensor_boundary_tokens(&opts, dim)
5409 }
5410
5411 // Mixed periodic + clamped (the cylinder) and its bare/case/quote
5412 // variants are all accepted.
5413 for raw in [
5414 "['periodic', 'clamped']",
5415 "['periodic', 'open']",
5416 "['cc', 'clamped']",
5417 "['clamped', 'natural']",
5418 "[Periodic, CLAMPED]",
5419 "c('cc', 'clamped')", // mgcv-style c(...) vector form round-trips
5420 ] {
5421 assert!(
5422 boundary(raw, 2).is_ok(),
5423 "boundary={raw:?} must be accepted (clamped/open/inert non-periodic markers)"
5424 );
5425 }
5426
5427 // `bc=` is an accepted alias for `boundary=`.
5428 let mut bc_opts = BTreeMap::new();
5429 bc_opts.insert("bc".to_string(), "['periodic', 'clamped']".to_string());
5430 assert!(validate_tensor_boundary_tokens(&bc_opts, 2).is_ok());
5431
5432 // A genuine endpoint constraint has no ordinary-margin meaning on a
5433 // tensor and must still be surfaced as a clean unsupported-feature error
5434 // rather than silently dropped.
5435 let err = boundary("['periodic', 'anchored']", 2)
5436 .expect_err("anchored endpoint constraint must be rejected on a tensor margin");
5437 assert!(
5438 err.contains("anchored") && err.contains("not supported"),
5439 "rejection must name the offending token and be an unsupported-feature error: {err}"
5440 );
5441
5442 // Absent boundary/bc is a no-op success.
5443 assert!(validate_tensor_boundary_tokens(&BTreeMap::new(), 2).is_ok());
5444 }
5445
5446 #[test]
5447 fn parse_single_axis_periodic_zero_as_axis_not_false() {
5448 let mut opts = BTreeMap::new();
5449 opts.insert("periodic".to_string(), "[0]".to_string());
5450 opts.insert("period".to_string(), "2*pi".to_string());
5451 opts.insert("origin".to_string(), "0".to_string());
5452 let axes = parse_periodic_axes(&opts, 1).expect("axes");
5453 let periods = parse_periods(&opts, &axes).expect("periods");
5454 let origins = parse_period_origins(&opts, &axes).expect("origins");
5455 assert_eq!(axes, vec![true]);
5456 assert!((periods[0].unwrap() - 2.0 * std::f64::consts::PI).abs() < 1e-12);
5457 assert_eq!(origins[0], Some(0.0));
5458 }
5459
5460 #[test]
5461 fn one_dimensional_bspline_accepts_boundary_periodic() {
5462 let ds = continuous_dataset(
5463 &["y", "theta"],
5464 (0..16)
5465 .map(|i| {
5466 let theta = std::f64::consts::TAU * i as f64 / 16.0;
5467 vec![theta.sin(), theta]
5468 })
5469 .collect(),
5470 );
5471 let parsed = parse_formula("y ~ s(theta, boundary=periodic, period=2*pi, origin=0, k=8)")
5472 .expect("parse");
5473 let col_map = ds.column_map();
5474 let mut notes = Vec::new();
5475 let terms = build_termspec(
5476 &parsed.terms,
5477 &ds,
5478 &col_map,
5479 &mut notes,
5480 &gam_runtime::resource::ResourcePolicy::default_library(),
5481 )
5482 .expect("periodic boundary should build");
5483 let SmoothBasisSpec::BSpline1D { spec, .. } = &terms.smooth_terms[0].basis else {
5484 panic!("expected 1D B-spline");
5485 };
5486 assert!(matches!(
5487 &spec.knotspec,
5488 BSplineKnotSpec::PeriodicUniform {
5489 data_range,
5490 num_basis: 8
5491 } if *data_range == (0.0, std::f64::consts::TAU)
5492 ));
5493 }
5494
5495 #[test]
5496 fn univariate_smooth_accepts_mgcv_cubic_regression_aliases() {
5497 let ds = continuous_dataset(
5498 &["y", "x"],
5499 (0..32)
5500 .map(|i| {
5501 let x = i as f64 / 31.0;
5502 vec![x * x, x]
5503 })
5504 .collect(),
5505 );
5506 let col_map = ds.column_map();
5507
5508 for (selector, expect_double_penalty) in [("cr", false), ("cs", true)] {
5509 let formula = format!("y ~ s(x, bs='{selector}')");
5510 let parsed = parse_formula(&formula).expect("parse cr/cs smooth");
5511 let mut notes = Vec::new();
5512 let terms = build_termspec(
5513 &parsed.terms,
5514 &ds,
5515 &col_map,
5516 &mut notes,
5517 &gam_runtime::resource::ResourcePolicy::default_library(),
5518 )
5519 .unwrap_or_else(|err| panic!("bs='{selector}' must build a 1-D smooth, got: {err:?}"));
5520 let SmoothBasisSpec::BSpline1D { spec, .. } = &terms.smooth_terms[0].basis else {
5521 panic!(
5522 "bs='{selector}' must lower to a BSpline1D; got {:?}",
5523 terms.smooth_terms[0].basis
5524 );
5525 };
5526 assert_eq!(
5527 spec.double_penalty, expect_double_penalty,
5528 "bs='{selector}' must default double_penalty to mgcv's convention \
5529 (cr=no-shrinkage, cs=shrinkage); got double_penalty={}",
5530 spec.double_penalty
5531 );
5532 }
5533 }
5534
5535 #[test]
5536 fn univariate_ps_small_k_degree_reduces_through_build(/* gam#1130 */) {
5537 // mgcv accepts `s(x, bs="ps", k=3)` (and the default cubic-regression
5538 // `s(x, k=3)`) by silently reducing the cubic basis to a quadratic.
5539 // The univariate ps/bspline build path used to reject this with
5540 // "k too small for degree 3"; it must now lower to a degree-2 basis
5541 // with zero internal knots (num_basis = k = 3), matching the te(...)
5542 // margin behaviour fixed in b75f55a91. Verified across the ps alias
5543 // and the default (cr) selector that both route through
5544 // parse_ps_internal_knots.
5545 let ds = continuous_dataset(
5546 &["y", "x"],
5547 (0..32)
5548 .map(|i| {
5549 let x = i as f64 / 31.0;
5550 vec![x * x, x]
5551 })
5552 .collect(),
5553 );
5554 let col_map = ds.column_map();
5555
5556 for formula in ["y ~ s(x, bs='ps', k=3)", "y ~ s(x, k=3)"] {
5557 let parsed = parse_formula(formula).expect("parse small-k ps/cr smooth");
5558 let mut notes = Vec::new();
5559 let terms = build_termspec(
5560 &parsed.terms,
5561 &ds,
5562 &col_map,
5563 &mut notes,
5564 &gam_runtime::resource::ResourcePolicy::default_library(),
5565 )
5566 .unwrap_or_else(|err| {
5567 panic!("`{formula}` must degree-reduce, not error; got: {err:?}")
5568 });
5569 let SmoothBasisSpec::BSpline1D { spec, .. } = &terms.smooth_terms[0].basis else {
5570 panic!(
5571 "`{formula}` must lower to a BSpline1D; got {:?}",
5572 terms.smooth_terms[0].basis
5573 );
5574 };
5575 assert_eq!(
5576 spec.degree, 2,
5577 "`{formula}` must drop the cubic default to a quadratic basis"
5578 );
5579 let num_internal = match &spec.knotspec {
5580 BSplineKnotSpec::Generate {
5581 num_internal_knots, ..
5582 } => *num_internal_knots,
5583 BSplineKnotSpec::Automatic {
5584 num_internal_knots: Some(n),
5585 ..
5586 } => *n,
5587 other => panic!("`{formula}` unexpected knotspec: {other:?}"),
5588 };
5589 assert_eq!(
5590 num_internal, 0,
5591 "`{formula}` must have zero internal knots (num_basis = k = 3)"
5592 );
5593 // Resulting basis dimension is num_internal + degree + 1 = 3 = k.
5594 assert!(
5595 spec.penalty_order >= 1 && spec.penalty_order <= spec.degree,
5596 "`{formula}` penalty_order {} must satisfy 1 <= order <= degree={}",
5597 spec.penalty_order,
5598 spec.degree
5599 );
5600 }
5601 }
5602
5603 #[test]
5604 fn formula_shape_constraint_round_trips_and_rejects_bogus() {
5605 let ds = continuous_dataset(
5606 &["y", "x"],
5607 (0..32)
5608 .map(|i| {
5609 let x = i as f64 / 31.0;
5610 vec![x * x, x]
5611 })
5612 .collect(),
5613 );
5614 let col_map = ds.column_map();
5615
5616 let parsed =
5617 parse_formula("y ~ s(x, shape=monotone_increasing)").expect("parse monotone smooth");
5618 let mut notes = Vec::new();
5619 let terms = build_termspec(
5620 &parsed.terms,
5621 &ds,
5622 &col_map,
5623 &mut notes,
5624 &gam_runtime::resource::ResourcePolicy::default_library(),
5625 )
5626 .expect("monotone smooth should build");
5627 assert_eq!(
5628 terms.smooth_terms[0].shape,
5629 ShapeConstraint::MonotoneIncreasing
5630 );
5631
5632 let parsed_bad = parse_formula("y ~ s(x, shape=bogus)").expect("parse bogus shape");
5633 let mut notes_bad = Vec::new();
5634 let err = build_termspec(
5635 &parsed_bad.terms,
5636 &ds,
5637 &col_map,
5638 &mut notes_bad,
5639 &gam_runtime::resource::ResourcePolicy::default_library(),
5640 )
5641 .expect_err("bogus shape must error");
5642 assert!(
5643 format!("{err:?}").contains("unknown shape constraint"),
5644 "got: {err:?}"
5645 );
5646 }
5647
5648 #[test]
5649 fn default_sphere_smooth_uses_spherical_farthest_point_centers() {
5650 let ds = continuous_dataset(
5651 &["y", "lat", "lon"],
5652 (0..24)
5653 .map(|i| {
5654 let t = i as f64 / 24.0;
5655 let lat = -60.0 + 120.0 * t;
5656 let lon = -180.0 + 360.0 * ((7 * i) % 24) as f64 / 24.0;
5657 vec![lat.to_radians().sin(), lat, lon]
5658 })
5659 .collect(),
5660 );
5661 let parsed = parse_formula("y ~ sphere(lat, lon)").expect("parse");
5662 let col_map = ds.column_map();
5663 let mut notes = Vec::new();
5664 let terms = build_termspec(
5665 &parsed.terms,
5666 &ds,
5667 &col_map,
5668 &mut notes,
5669 &gam_runtime::resource::ResourcePolicy::default_library(),
5670 )
5671 .expect("build sphere termspec");
5672 let SmoothBasisSpec::Sphere { spec, .. } = &terms.smooth_terms[0].basis else {
5673 panic!("expected sphere term");
5674 };
5675 assert!(matches!(
5676 spec.center_strategy,
5677 CenterStrategy::FarthestPoint { .. }
5678 ));
5679 }
5680
5681 #[test]
5682 fn one_dimensional_duchon_defaults_to_scale_free_length_scale() {
5683 let ds = continuous_dataset(
5684 &["y", "x"],
5685 (0..32)
5686 .map(|i| {
5687 let x = i as f64 / 31.0;
5688 vec![(std::f64::consts::TAU * x).sin(), x]
5689 })
5690 .collect(),
5691 );
5692 let parsed = parse_formula("y ~ duchon(x)").expect("parse");
5693 let col_map = ds.column_map();
5694 let mut notes = Vec::new();
5695 let terms = build_termspec(
5696 &parsed.terms,
5697 &ds,
5698 &col_map,
5699 &mut notes,
5700 &gam_runtime::resource::ResourcePolicy::default_library(),
5701 )
5702 .expect("build default duchon termspec");
5703 let SmoothBasisSpec::Duchon { spec, .. } = &terms.smooth_terms[0].basis else {
5704 panic!("expected Duchon term");
5705 };
5706 assert_eq!(spec.length_scale, None);
5707 }
5708
5709 #[test]
5710 fn formula_duchon_default_does_not_enable_collocation_operators() {
5711 let ds = continuous_dataset(
5712 &["y", "x", "z"],
5713 (0..40)
5714 .map(|i| {
5715 let x = (i as f64 / 39.0).fract();
5716 let z = ((7 * i) as f64 / 39.0).fract();
5717 vec![x + z, x, z]
5718 })
5719 .collect(),
5720 );
5721 let parsed = parse_formula("y ~ duchon(x, z)").expect("parse");
5722 let col_map = ds.column_map();
5723 let mut notes = Vec::new();
5724 let terms = build_termspec(
5725 &parsed.terms,
5726 &ds,
5727 &col_map,
5728 &mut notes,
5729 &gam_runtime::resource::ResourcePolicy::default_library(),
5730 )
5731 .expect("build default 2D duchon termspec");
5732 let SmoothBasisSpec::Duchon { spec, .. } = &terms.smooth_terms[0].basis else {
5733 panic!("expected Duchon term");
5734 };
5735 assert!(matches!(
5736 spec.operator_penalties.mass,
5737 OperatorPenaltySpec::Disabled
5738 ));
5739 assert!(matches!(
5740 spec.operator_penalties.tension,
5741 OperatorPenaltySpec::Disabled
5742 ));
5743 assert!(matches!(
5744 spec.operator_penalties.stiffness,
5745 OperatorPenaltySpec::Disabled
5746 ));
5747 }
5748
5749 #[test]
5750 fn one_dimensional_duchon_length_scale_opts_into_hybrid_mode() {
5751 let ds = continuous_dataset(
5752 &["y", "x"],
5753 (0..32)
5754 .map(|i| {
5755 let x = i as f64 / 31.0;
5756 vec![(std::f64::consts::TAU * x).sin(), x]
5757 })
5758 .collect(),
5759 );
5760 let parsed = parse_formula("y ~ duchon(x, length_scale=0.25)").expect("parse");
5761 let col_map = ds.column_map();
5762 let mut notes = Vec::new();
5763 let terms = build_termspec(
5764 &parsed.terms,
5765 &ds,
5766 &col_map,
5767 &mut notes,
5768 &gam_runtime::resource::ResourcePolicy::default_library(),
5769 )
5770 .expect("build hybrid duchon termspec");
5771 let SmoothBasisSpec::Duchon { spec, .. } = &terms.smooth_terms[0].basis else {
5772 panic!("expected Duchon term");
5773 };
5774 assert_eq!(spec.length_scale, Some(0.25));
5775 }
5776
5777 #[test]
5778 fn multidimensional_duchon_default_uses_low_rank_mgcv_sized_basis() {
5779 let ds = continuous_dataset(
5780 &["y", "x1", "x2"],
5781 (0..500)
5782 .map(|i| {
5783 let x1 = 2.0 * (i as f64 / 499.0) - 1.0;
5784 let x2 = (((37 * i) % 500) as f64 / 499.0) * 2.0 - 1.0;
5785 vec![(2.0 * x1).sin() + (1.5 * x2).cos(), x1, x2]
5786 })
5787 .collect(),
5788 );
5789 let parsed = parse_formula("y ~ duchon(x1, x2)").expect("parse");
5790 let col_map = ds.column_map();
5791 let mut notes = Vec::new();
5792 let terms = build_termspec(
5793 &parsed.terms,
5794 &ds,
5795 &col_map,
5796 &mut notes,
5797 &gam_runtime::resource::ResourcePolicy::default_library(),
5798 )
5799 .expect("build default 2D duchon termspec");
5800 let SmoothBasisSpec::Duchon { spec, .. } = &terms.smooth_terms[0].basis else {
5801 panic!("expected Duchon term");
5802 };
5803 let CenterStrategy::Auto(inner) = &spec.center_strategy else {
5804 panic!("expected auto center strategy");
5805 };
5806 assert!(matches!(
5807 inner.as_ref(),
5808 CenterStrategy::FarthestPoint { num_centers: 30 }
5809 ));
5810 }
5811
5812 #[test]
5813 fn parse_matern_nu_accepts_equivalent_half_integer_forms() {
5814 let cases = [
5815 ("1/2", MaternNu::Half),
5816 (" 1 / 2 ", MaternNu::Half),
5817 (".5", MaternNu::Half),
5818 ("0.50", MaternNu::Half),
5819 ("half", MaternNu::Half),
5820 ("3 / 2", MaternNu::ThreeHalves),
5821 ("1.50", MaternNu::ThreeHalves),
5822 ("5 / 2", MaternNu::FiveHalves),
5823 ("2.500000000000", MaternNu::FiveHalves),
5824 ("7 / 2", MaternNu::SevenHalves),
5825 ("3.50", MaternNu::SevenHalves),
5826 ("9 / 2", MaternNu::NineHalves),
5827 ("4.50", MaternNu::NineHalves),
5828 ];
5829 for (raw, expected) in cases {
5830 let parsed = parse_matern_nu(raw).expect(raw);
5831 assert!(
5832 matches!(
5833 (parsed, expected),
5834 (MaternNu::Half, MaternNu::Half)
5835 | (MaternNu::ThreeHalves, MaternNu::ThreeHalves)
5836 | (MaternNu::FiveHalves, MaternNu::FiveHalves)
5837 | (MaternNu::SevenHalves, MaternNu::SevenHalves)
5838 | (MaternNu::NineHalves, MaternNu::NineHalves)
5839 ),
5840 "parsed {raw:?} as {parsed:?}, expected {expected:?}"
5841 );
5842 }
5843 }
5844
5845 #[test]
5846 fn parse_matern_nu_rejects_unsupported_or_invalid_values() {
5847 for raw in ["1", "2", "11/2", "1/0", "nan", "fast"] {
5848 let err = parse_matern_nu(raw).expect_err(raw);
5849 assert!(
5850 err.contains("supported half-integer values"),
5851 "unexpected error for {raw:?}: {err}"
5852 );
5853 }
5854 }
5855
5856 #[test]
5857 fn parse_ps_k_promotes_underexpressive_cubic_basis() {
5858 let mut opts = BTreeMap::new();
5859 opts.insert("k".to_string(), "4".to_string());
5860 let (internal, inferred, eff_degree) = parse_ps_internal_knots(&opts, 3, 20).expect("k=4");
5861 assert_eq!(internal, 2);
5862 assert_eq!(eff_degree, 3);
5863 assert!(!inferred);
5864
5865 opts.insert("k".to_string(), "6".to_string());
5866 let (internal, inferred, eff_degree) = parse_ps_internal_knots(&opts, 3, 20).expect("k=6");
5867 assert_eq!(internal, 2);
5868 assert_eq!(eff_degree, 3);
5869 assert!(!inferred);
5870
5871 opts.insert("k".to_string(), "10".to_string());
5872 let (internal, inferred, eff_degree) = parse_ps_internal_knots(&opts, 3, 20).expect("k=10");
5873 assert_eq!(internal, 6);
5874 assert_eq!(eff_degree, 3);
5875 assert!(!inferred);
5876 }
5877
5878 #[test]
5879 fn parse_ps_internal_knots_drops_degree_for_small_k() {
5880 // mgcv's `s(x, bs="ps", k=3)` with the default cubic basis silently
5881 // reduces to a quadratic (`degree=2`) marginal. `k=3, degree=3`
5882 // should yield a quadratic basis with zero internal knots
5883 // (`num_basis = k = 3`).
5884 let mut opts = BTreeMap::new();
5885 opts.insert("k".to_string(), "3".to_string());
5886 let (internal, inferred, eff_degree) = parse_ps_internal_knots(&opts, 3, 20).expect("k=3");
5887 assert_eq!(eff_degree, 2);
5888 assert_eq!(internal, 0);
5889 assert!(!inferred);
5890
5891 // `k=2` reduces to a linear (`degree=1`) marginal — the smallest
5892 // non-trivial spline basis.
5893 opts.insert("k".to_string(), "2".to_string());
5894 let (internal, inferred, eff_degree) = parse_ps_internal_knots(&opts, 3, 20).expect("k=2");
5895 assert_eq!(eff_degree, 1);
5896 assert_eq!(internal, 0);
5897 assert!(!inferred);
5898
5899 // The under-2 case is structurally under-specified and rejected even
5900 // by the degree-reducing variant: no B-spline basis has fewer than
5901 // two functions.
5902 opts.insert("k".to_string(), "1".to_string());
5903 let err = parse_ps_internal_knots(&opts, 3, 20)
5904 .expect_err("k=1 is below the irreducible spline floor");
5905 assert!(err.contains("requires k >= 2"), "unexpected error: {err}");
5906
5907 // When the user already passed `k >= degree+1`, the helper must
5908 // preserve the existing knot geometry exactly.
5909 opts.insert("k".to_string(), "4".to_string());
5910 let (internal, inferred, eff_degree) = parse_ps_internal_knots(&opts, 3, 20).expect("k=4");
5911 assert_eq!(eff_degree, 3);
5912 assert_eq!(internal, 2);
5913 assert!(!inferred);
5914 }
5915
5916 #[test]
5917 fn factor_smooth_marginal_degree_reduces_for_small_k() {
5918 let ds = factor_dataset();
5919 let col_map = ds.column_map();
5920
5921 for (k, expected_degree) in [(3usize, 2usize), (2usize, 1usize)] {
5922 let parsed =
5923 parse_formula(&format!("y ~ s(x, g, bs=fs, k={k})")).expect("parse factor smooth");
5924 let mut notes = Vec::new();
5925 let terms = build_termspec(
5926 &parsed.terms,
5927 &ds,
5928 &col_map,
5929 &mut notes,
5930 &gam_runtime::resource::ResourcePolicy::default_library(),
5931 )
5932 .unwrap_or_else(|err| panic!("fs k={k} should degree-reduce, got: {err:?}"));
5933 let SmoothBasisSpec::FactorSmooth { spec } = &terms.smooth_terms[0].basis else {
5934 panic!(
5935 "expected factor smooth, got {:?}",
5936 terms.smooth_terms[0].basis
5937 );
5938 };
5939 assert_eq!(spec.marginal.degree, expected_degree);
5940 assert!(
5941 spec.marginal.penalty_order <= spec.marginal.degree,
5942 "penalty_order {} must be clamped to degree {}",
5943 spec.marginal.penalty_order,
5944 spec.marginal.degree
5945 );
5946 let basis_size = match spec.marginal.knotspec {
5947 BSplineKnotSpec::Generate {
5948 num_internal_knots, ..
5949 } => num_internal_knots + spec.marginal.degree + 1,
5950 BSplineKnotSpec::Automatic {
5951 num_internal_knots: Some(num_internal_knots),
5952 ..
5953 } => num_internal_knots + spec.marginal.degree + 1,
5954 ref other => panic!("unexpected factor-smooth knotspec: {other:?}"),
5955 };
5956 assert_eq!(basis_size, k);
5957 }
5958 }
5959
5960 /// Build a dataset with a ternary continuous covariate `x ∈ {0,1,2}` and a
5961 /// 2-level categorical group `g`, for the low-cardinality cr-cap tests.
5962 fn ternary_factor_dataset() -> Dataset {
5963 let rows = (0..120)
5964 .map(|i| {
5965 let x = (i % 3) as f64;
5966 let g = (i % 2) as f64;
5967 vec![x + g, x, g]
5968 })
5969 .collect::<Vec<_>>();
5970 Dataset {
5971 headers: vec!["y".into(), "x".into(), "g".into()],
5972 values: Array2::from_shape_vec(
5973 (rows.len(), 3),
5974 rows.into_iter().flat_map(|row| row.into_iter()).collect(),
5975 )
5976 .expect("rectangular ternary factor test data"),
5977 schema: DataSchema {
5978 columns: vec![
5979 SchemaColumn {
5980 name: "y".into(),
5981 kind: ColumnKindTag::Continuous,
5982 levels: vec![],
5983 },
5984 SchemaColumn {
5985 name: "x".into(),
5986 kind: ColumnKindTag::Continuous,
5987 levels: vec![],
5988 },
5989 SchemaColumn {
5990 name: "g".into(),
5991 kind: ColumnKindTag::Categorical,
5992 levels: vec!["a".into(), "b".into()],
5993 },
5994 ],
5995 },
5996 column_kinds: vec![
5997 ColumnKindTag::Continuous,
5998 ColumnKindTag::Continuous,
5999 ColumnKindTag::Categorical,
6000 ],
6001 }
6002 }
6003
6004 #[test]
6005 fn univariate_cr_smooth_caps_knots_to_data_support() {
6006 // #1541: `s(x, bs=cr, k=10)` on a ternary covariate (3 distinct values)
6007 // must NOT hard-fail in cr-knot selection ("cubic regression spline with
6008 // k=10 requires at least 10 distinct values, got 3"). The cr basis is
6009 // capped to the data support — exactly 3 value-knots at {0,1,2} — which
6010 // is full-rank for the data, so it can still represent any 3 group means.
6011 let ds = continuous_dataset(
6012 &["y", "x"],
6013 (0..90)
6014 .map(|i| vec![(i % 3) as f64, (i % 3) as f64])
6015 .collect(),
6016 );
6017 let col_map = ds.column_map();
6018 let parsed = parse_formula("y ~ s(x, bs=cr, k=10)").expect("parse cr smooth");
6019 let mut notes = Vec::new();
6020 let terms = build_termspec(
6021 &parsed.terms,
6022 &ds,
6023 &col_map,
6024 &mut notes,
6025 &gam_runtime::resource::ResourcePolicy::default_library(),
6026 )
6027 .expect("cr k=10 must cap to data support instead of erroring");
6028 let SmoothBasisSpec::BSpline1D { spec, .. } = &terms.smooth_terms[0].basis else {
6029 panic!("expected BSpline1D for s(x, bs=cr)");
6030 };
6031 let BSplineKnotSpec::NaturalCubicRegression { knots } = &spec.knotspec else {
6032 panic!("expected cr knotspec, got {:?}", spec.knotspec);
6033 };
6034 // Capped to exactly the 3 distinct covariate values.
6035 assert_eq!(knots.len(), 3, "cr basis not capped to 3 distinct values");
6036 assert_eq!(knots.as_slice().unwrap(), &[0.0, 1.0, 2.0]);
6037 // The reduction is surfaced to the user (mgcv warns in the same case).
6038 assert!(
6039 notes.iter().any(|n| n.contains("data-support cap")),
6040 "cap not reported in inference notes: {notes:?}"
6041 );
6042 }
6043
6044 #[test]
6045 fn univariate_cr_smooth_binary_covariate_degrades_to_bspline() {
6046 // #1541: a BINARY covariate has too few distinct values (2) for ANY cr
6047 // spline (needs >= 3 distinct). `s(x, bs=cr)` must degrade to a B-spline
6048 // marginal — the default basis the same data already fits — NOT hard-fail.
6049 let ds = continuous_dataset(
6050 &["y", "x"],
6051 (0..80)
6052 .map(|i| vec![(i % 2) as f64, (i % 2) as f64])
6053 .collect(),
6054 );
6055 let col_map = ds.column_map();
6056 let parsed = parse_formula("y ~ s(x, bs=cr, k=10)").expect("parse cr smooth");
6057 let mut notes = Vec::new();
6058 let terms = build_termspec(
6059 &parsed.terms,
6060 &ds,
6061 &col_map,
6062 &mut notes,
6063 &gam_runtime::resource::ResourcePolicy::default_library(),
6064 )
6065 .expect("binary cr must degrade to B-spline instead of erroring");
6066 let SmoothBasisSpec::BSpline1D { spec, .. } = &terms.smooth_terms[0].basis else {
6067 panic!("expected BSpline1D for s(x, bs=cr)");
6068 };
6069 assert!(
6070 !matches!(
6071 spec.knotspec,
6072 BSplineKnotSpec::NaturalCubicRegression { .. }
6073 ),
6074 "binary covariate must NOT build a cr basis, got {:?}",
6075 spec.knotspec
6076 );
6077 assert!(
6078 notes
6079 .iter()
6080 .any(|n| n.contains("Degraded to the linear B-spline")),
6081 "degradation not reported in inference notes: {notes:?}"
6082 );
6083 }
6084
6085 #[test]
6086 fn sz_factor_smooth_low_cardinality_uses_bspline_marginal() {
6087 // #1605: the `sz` factor-smooth marginal is the SAME penalized B-spline
6088 // the `fs` sibling uses — NOT a natural cubic regression (`cr`) marginal,
6089 // whose hard natural boundary conditions f''=0 bias curved deviations
6090 // (a consistency failure). #1542 (the reason this test exists) is
6091 // subsumed: with a B-spline marginal a low-cardinality covariate no
6092 // longer needs a special cr data-support cap and can never hard-fail the
6093 // way the old cr-marginal `sz` spelling did — the build just succeeds,
6094 // exactly as `fs` already does on the identical data.
6095 let ds = ternary_factor_dataset();
6096 let col_map = ds.column_map();
6097 let parsed = parse_formula("y ~ s(x, g, bs=sz, k=10)").expect("parse sz factor smooth");
6098 let mut notes = Vec::new();
6099 let terms = build_termspec(
6100 &parsed.terms,
6101 &ds,
6102 &col_map,
6103 &mut notes,
6104 &gam_runtime::resource::ResourcePolicy::default_library(),
6105 )
6106 .expect("sz on a ternary covariate must build (B-spline marginal), not hard-fail");
6107 let SmoothBasisSpec::FactorSmooth { spec } = &terms.smooth_terms[0].basis else {
6108 panic!("expected FactorSmooth for s(x, g, bs=sz)");
6109 };
6110 assert!(
6111 !matches!(
6112 spec.marginal.knotspec,
6113 BSplineKnotSpec::NaturalCubicRegression { .. }
6114 ),
6115 "sz marginal must be a B-spline (curvature-capable), not the \
6116 natural-BC cr basis; got {:?}",
6117 spec.marginal.knotspec
6118 );
6119 }
6120
6121 /// A dataset with a genuinely continuous covariate `x` (many distinct
6122 /// values) and a `L`-level grouping factor `g`, suitable for building a
6123 /// real factor-smooth marginal with a non-trivial {const, linear} null
6124 /// space. `y` is unused by the structural penalty checks below.
6125 fn continuous_x_factor_dataset(n: usize, n_groups: usize) -> Dataset {
6126 let rows = (0..n)
6127 .map(|i| {
6128 let x = i as f64 / (n as f64 - 1.0);
6129 let g = (i % n_groups) as f64;
6130 vec![x + g, x, g]
6131 })
6132 .collect::<Vec<_>>();
6133 let levels: Vec<String> = (0..n_groups).map(|k| format!("g{k}")).collect();
6134 Dataset {
6135 headers: vec!["y".into(), "x".into(), "g".into()],
6136 values: Array2::from_shape_vec(
6137 (rows.len(), 3),
6138 rows.into_iter().flat_map(|row| row.into_iter()).collect(),
6139 )
6140 .expect("rectangular continuous-x factor data"),
6141 schema: DataSchema {
6142 columns: vec![
6143 SchemaColumn {
6144 name: "y".into(),
6145 kind: ColumnKindTag::Continuous,
6146 levels: vec![],
6147 },
6148 SchemaColumn {
6149 name: "x".into(),
6150 kind: ColumnKindTag::Continuous,
6151 levels: vec![],
6152 },
6153 SchemaColumn {
6154 name: "g".into(),
6155 kind: ColumnKindTag::Categorical,
6156 levels,
6157 },
6158 ],
6159 },
6160 column_kinds: vec![
6161 ColumnKindTag::Continuous,
6162 ColumnKindTag::Continuous,
6163 ColumnKindTag::Categorical,
6164 ],
6165 }
6166 }
6167
6168 fn factor_smooth_spec_for(formula: &str, ds: &Dataset) -> FactorSmoothSpec {
6169 let col_map = ds.column_map();
6170 let parsed = parse_formula(formula).expect("parse factor smooth formula");
6171 let mut notes = Vec::new();
6172 let terms = build_termspec(
6173 &parsed.terms,
6174 ds,
6175 &col_map,
6176 &mut notes,
6177 &gam_runtime::resource::ResourcePolicy::default_library(),
6178 )
6179 .expect("build factor smooth term");
6180 let SmoothBasisSpec::FactorSmooth { spec } = &terms.smooth_terms[0].basis else {
6181 panic!("expected FactorSmooth basis for `{formula}`");
6182 };
6183 spec.clone()
6184 }
6185
6186 /// #1605: the sum-to-zero factor smooth `s(x, g, bs="sz")` under-fit data
6187 /// drawn from its own model class because its deviation blocks carried ONLY
6188 /// the marginal wiggliness penalty — the {const, linear} null space of every
6189 /// deviation curve was left completely unpenalized, so the single combined
6190 /// wiggliness λ could not separate per-group intercept/slope variance from
6191 /// curvature variance and REML parked it over-smoothed (same defect class as
6192 /// the closed #700, more severe). mgcv's `bs="fs"` sibling avoids the gap by
6193 /// adding a SEPARATE per-null-dimension ridge (one λ each), the
6194 /// double-penalty `I_L ⊗ S_j` structure. The fix gives `sz` the same
6195 /// null-space-ridge structure, mapped into the zero-sum CONTRAST space so the
6196 /// constraint (and `sz`'s distinctness from `fs`) is preserved.
6197 ///
6198 /// This pins the structural defect: after the fix the `sz` deviation build
6199 /// must carry MORE than just its wiggliness penalty(s) — exactly one extra
6200 /// null-space-ridge penalty per marginal null direction, matching the count
6201 /// that `fs` carries — while keeping the narrower `(L-1)·p` zero-sum design
6202 /// (NOT the `L·p` full-rank `fs` design). Before the fix `sz` carried only
6203 /// the wiggliness penalties and this fails.
6204 #[test]
6205 fn sz_factor_smooth_carries_null_space_ridge_like_fs() {
6206 let ds = continuous_x_factor_dataset(180, 4);
6207 let mut workspace = crate::basis::BasisWorkspace::new();
6208
6209 let sz_spec = factor_smooth_spec_for("y ~ s(x, g, bs=sz, k=8)", &ds);
6210 let sz_built = crate::smooth::build_factor_smooth(
6211 ds.values.view(),
6212 &sz_spec,
6213 "sz_term",
6214 &mut workspace,
6215 )
6216 .expect("build sz factor smooth");
6217
6218 let fs_spec = factor_smooth_spec_for("y ~ s(x, g, bs=fs, k=8)", &ds);
6219 let fs_built = crate::smooth::build_factor_smooth(
6220 ds.values.view(),
6221 &fs_spec,
6222 "fs_term",
6223 &mut workspace,
6224 )
6225 .expect("build fs factor smooth");
6226
6227 // Penalty structure (#1074 + #1605). `fs` is the exchangeable
6228 // random-effect smooth: all `L` level blocks share ONE wiggliness λ per
6229 // marginal penalty, plus one rank-1 null-space ridge per marginal null
6230 // direction (the #1605 double penalty). `sz` is the sum-to-zero factor
6231 // smooth and mgcv's `smooth.construct.sz` emits ONE penalty matrix PER
6232 // LEVEL — `L` independent curvature smoothing parameters — so REML can
6233 // shrink a low-amplitude group's deviation hard while leaving a busy
6234 // group nearly unpenalized. We mirror that: the single marginal
6235 // wiggliness penalty is split into its `L` independent zero-sum-contrast
6236 // summands (`L-1` free per-group blocks `(e_k e_kᵀ)⊗S` + the reference
6237 // coupling block `(11ᵀ)⊗S`), each carrying its own λ, and the null-space
6238 // ridges stay POOLED (the per-group intercept/slope shrinkage mgcv pools
6239 // under one variance even for `sz`).
6240 //
6241 // So with `nw` marginal wiggliness penalties and `nn` marginal null
6242 // directions: fs has `nw + nn` penalties; sz has `L·nw + nn`. sz must
6243 // therefore carry strictly MORE penalties than fs (the per-group split),
6244 // and the surplus must be exactly `(L-1)·nw`.
6245 let n_levels = sz_spec
6246 .group_frozen_levels
6247 .as_ref()
6248 .map(|l| l.len())
6249 .unwrap_or(4);
6250 assert!(n_levels >= 3, "test needs >=3 groups, got {n_levels}");
6251
6252 // fs = nw + nn ⇒ nn = fs_penalties - nw. The marginal has nw==1
6253 // wiggliness penalty (a single difference/curvature operator), so the
6254 // per-group split adds exactly (L-1)·nw = (L-1) extra penalties on top of
6255 // fs's count.
6256 let nw = 1usize; // one marginal wiggliness penalty for the B-spline marginal
6257 let expected_sz = fs_built.penalties.len() + (n_levels - 1) * nw;
6258 assert_eq!(
6259 sz_built.penalties.len(),
6260 expected_sz,
6261 "sz must split its wiggliness penalty per level (#1074): expected \
6262 fs_count {} + (L-1)·nw {} = {}, but sz had {}",
6263 fs_built.penalties.len(),
6264 (n_levels - 1) * nw,
6265 expected_sz,
6266 sz_built.penalties.len(),
6267 );
6268 assert!(
6269 sz_built.penalties.len() > fs_built.penalties.len(),
6270 "sz must carry strictly more penalties than fs after the per-group \
6271 split (sz={}, fs={})",
6272 sz_built.penalties.len(),
6273 fs_built.penalties.len(),
6274 );
6275
6276 // The null-space ridges must still be present (the #1605 property that
6277 // keeps the deviation curvature un-over-smoothed). After removing the `L`
6278 // per-group wiggliness blocks, the remainder are the pooled null ridges,
6279 // and there must be at least one (a B-spline marginal has a non-empty
6280 // {const, linear} null space).
6281 let n_wiggliness = n_levels * nw; // L per-group blocks
6282 assert!(
6283 sz_built.penalties.len() > n_wiggliness,
6284 "sz deviation block carries no null-space ridge (penalties={}, \
6285 wiggliness blocks={}); the null space is unpenalized and REML \
6286 over-smooths the deviations",
6287 sz_built.penalties.len(),
6288 n_wiggliness,
6289 );
6290
6291 // The zero-sum constraint must be preserved: the sz design must stay the
6292 // NARROWER `(L-1)·p` contrast design, strictly narrower than the fs
6293 // full-rank `L·p` design. This guards against "fixing" sz by making it
6294 // identical to fs (which would break identifiability / sum-to-zero).
6295 assert!(
6296 sz_built.dim < fs_built.dim,
6297 "sz design width {} must be strictly less than fs width {} \
6298 (zero-sum contrast drops one level block)",
6299 sz_built.dim,
6300 fs_built.dim,
6301 );
6302
6303 // Every penalty/metadata vector must stay parallel (length invariant the
6304 // downstream REML assembly relies on).
6305 assert_eq!(sz_built.penalties.len(), sz_built.nullspaces.len());
6306 assert_eq!(sz_built.penalties.len(), sz_built.penaltyinfo.len());
6307 assert_eq!(sz_built.penalties.len(), sz_built.null_eigenvectors.len());
6308 }
6309
6310 /// #1457: `y ~ s(x, by=g) + g` with a BARE categorical `g` must NOT lower to
6311 /// two `g` design blocks. The bare `+ g` is auto-promoted to a single
6312 /// penalized random-effect block owning the factor's full level offsets; the
6313 /// `by=` branch must then recognize that owner and skip adding its own
6314 /// unpenalized treatment-coded main effect. Before the fix the dedup guard
6315 /// recognized only explicit `group(g)` (a `ParsedTerm::RandomEffect`), so the
6316 /// auto-promoted bare-`+ g` block slipped past and a spurious second `g`
6317 /// block (plus an extra smoothing parameter) was added. Assert exactly ONE
6318 /// `g` random/categorical block, and that adding the bare `+ g` introduces no
6319 /// extra `g` blocks beyond `y ~ s(x, by=g)` alone.
6320 fn factor_dataset_l3() -> Dataset {
6321 // `g` is categorical with THREE levels (encoded 0.0/1.0/2.0).
6322 let rows = (0..30)
6323 .map(|i| {
6324 let x = i as f64 / 29.0;
6325 let g = (i % 3) as f64;
6326 vec![x + g, x, g]
6327 })
6328 .collect::<Vec<_>>();
6329 Dataset {
6330 headers: vec!["y".into(), "x".into(), "g".into()],
6331 values: Array2::from_shape_vec(
6332 (rows.len(), 3),
6333 rows.into_iter().flat_map(|row| row.into_iter()).collect(),
6334 )
6335 .expect("rectangular L=3 factor test data"),
6336 schema: DataSchema {
6337 columns: vec![
6338 SchemaColumn {
6339 name: "y".into(),
6340 kind: ColumnKindTag::Continuous,
6341 levels: vec![],
6342 },
6343 SchemaColumn {
6344 name: "x".into(),
6345 kind: ColumnKindTag::Continuous,
6346 levels: vec![],
6347 },
6348 SchemaColumn {
6349 name: "g".into(),
6350 kind: ColumnKindTag::Categorical,
6351 levels: vec!["a".into(), "b".into(), "c".into()],
6352 },
6353 ],
6354 },
6355 column_kinds: vec![
6356 ColumnKindTag::Continuous,
6357 ColumnKindTag::Continuous,
6358 ColumnKindTag::Categorical,
6359 ],
6360 }
6361 }
6362
6363 #[test]
6364 fn factor_by_smooth_plus_bare_categorical_does_not_duplicate_factor_block() {
6365 let ds = factor_dataset_l3();
6366 let col_map = ds.column_map();
6367
6368 let g_blocks = |formula: &str| -> usize {
6369 let parsed = parse_formula(formula).expect("parse by-smooth formula");
6370 let mut notes = Vec::new();
6371 let terms = build_termspec(
6372 &parsed.terms,
6373 &ds,
6374 &col_map,
6375 &mut notes,
6376 &ResourcePolicy::default_library(),
6377 )
6378 .unwrap_or_else(|err| panic!("`{formula}` must build, got: {err:?}"));
6379 terms
6380 .random_effect_terms
6381 .iter()
6382 .filter(|rt| rt.name == "g")
6383 .count()
6384 };
6385
6386 // Baseline: the standalone factor-by smooth carries exactly ONE `g`
6387 // block (the unpenalized treatment-coded factor main effect added by the
6388 // `by=` branch).
6389 let by_only = g_blocks("y ~ s(x, by=g, k=10)");
6390 assert_eq!(
6391 by_only, 1,
6392 "`y ~ s(x, by=g)` must produce exactly one `g` design block"
6393 );
6394
6395 // The bug: adding a bare `+ g` (auto-promoted to a penalized random
6396 // block owning the same level offsets) must NOT introduce a second `g`
6397 // block. Before the fix this was 2.
6398 let by_plus_bare = g_blocks("y ~ s(x, by=g, k=10) + g");
6399 assert_eq!(
6400 by_plus_bare, 1,
6401 "`y ~ s(x, by=g) + g` must collapse to ONE `g` block (#1457): the bare \
6402 `+ g` already owns the factor's level offsets, so the `by=` branch \
6403 must not add a second, treatment-coded main effect"
6404 );
6405
6406 // The bare `+ g` adds no spurious extra `g` block versus the baseline.
6407 assert_eq!(
6408 by_plus_bare, by_only,
6409 "the bare `+ g` collision must add zero extra `g` blocks (#1457)"
6410 );
6411 }
6412
6413 #[test]
6414 fn parse_tensor_periods_and_origins_aliases() {
6415 let mut opts = BTreeMap::new();
6416 opts.insert(
6417 "boundary".to_string(),
6418 "['periodic', 'periodic']".to_string(),
6419 );
6420 opts.insert("periods".to_string(), "[7, 24]".to_string());
6421 opts.insert("origins".to_string(), "[0, -12]".to_string());
6422 let axes = parse_periodic_axes(&opts, 2).expect("axes");
6423 let periods = parse_periods(&opts, &axes).expect("periods");
6424 let origins = parse_period_origins(&opts, &axes).expect("origins");
6425 assert_eq!(axes, vec![true, true]);
6426 assert_eq!(periods, vec![Some(7.0), Some(24.0)]);
6427 assert_eq!(origins, vec![Some(0.0), Some(-12.0)]);
6428 }
6429
6430 #[test]
6431 fn tensor_smooth_honors_per_margin_k_list() {
6432 let ds = continuous_dataset(
6433 &["y", "theta", "h"],
6434 (0..20)
6435 .map(|i| {
6436 let theta = std::f64::consts::TAU * i as f64 / 20.0;
6437 let h = -1.0 + 2.0 * (i % 5) as f64 / 4.0;
6438 vec![theta.cos() + h, theta, h]
6439 })
6440 .collect(),
6441 );
6442 let parsed = parse_formula(
6443 "y ~ te(theta, h, periodic=[0], period=[2*pi, None], origin=[0, None], k=[9,5])",
6444 )
6445 .expect("parse tensor formula");
6446 let col_map = ds.column_map();
6447 let mut notes = Vec::new();
6448 let terms = build_termspec(
6449 &parsed.terms,
6450 &ds,
6451 &col_map,
6452 &mut notes,
6453 &gam_runtime::resource::ResourcePolicy::default_library(),
6454 )
6455 .expect("build tensor terms");
6456 let SmoothBasisSpec::TensorBSpline { spec, .. } = &terms.smooth_terms[0].basis else {
6457 panic!("expected tensor B-spline");
6458 };
6459 let dims = spec
6460 .marginalspecs
6461 .iter()
6462 .map(|m| match m.knotspec {
6463 BSplineKnotSpec::PeriodicUniform { num_basis, .. } => num_basis,
6464 BSplineKnotSpec::Generate {
6465 num_internal_knots, ..
6466 } => num_internal_knots + m.degree + 1,
6467 // The mgcv-default `cr` margin (#1074) reports its basis size as
6468 // the number of value-knots placed.
6469 BSplineKnotSpec::NaturalCubicRegression { ref knots } => knots.len(),
6470 _ => panic!("unexpected tensor marginal knotspec"),
6471 })
6472 .collect::<Vec<_>>();
6473 assert_eq!(dims, vec![9, 5]);
6474 }
6475
6476 #[test]
6477 fn tensor_smooth_honors_per_margin_k_axis_aliases() {
6478 let ds = continuous_dataset(
6479 &["resp", "x", "y"],
6480 (0..12)
6481 .map(|i| {
6482 let t = i as f64 / 11.0;
6483 vec![t, t, 1.0 - t]
6484 })
6485 .collect(),
6486 );
6487 assert_eq!(
6488 tensor_margin_basis_sizes(&ds, "resp ~ te(x, y, k_x=9, k_y=5)"),
6489 vec![9, 5],
6490 "k_<margin> aliases should materialize requested per-margin values"
6491 );
6492 }
6493
6494 #[test]
6495 fn tensor_smooth_low_cardinality_axis_falls_back_to_lower_degree_basis() {
6496 // mgcv-style: `te(x, b, k=c(5, 2))` with a BINARY second margin (only
6497 // values {0, 1}) is a legitimate request — the binary axis can hold at
6498 // most a 2-function linear basis. We must NOT reject k=2 with a
6499 // "k too small for degree 3" config error; instead, drop the spline
6500 // degree on the binary axis to k_axis - 1 (here 1, linear) while
6501 // keeping the continuous margin at the requested degree=3, k=5.
6502 let ds = continuous_dataset(
6503 &["y", "x", "b"],
6504 (0..40)
6505 .map(|i| {
6506 let x = i as f64 / 39.0;
6507 let b = (i % 2) as f64;
6508 vec![x.sin() + 0.5 * b, x, b]
6509 })
6510 .collect(),
6511 );
6512 let parsed = parse_formula("y ~ te(x, b, k=[5, 2])").expect("parse tensor with k=[5,2]");
6513 let col_map = ds.column_map();
6514 let mut notes = Vec::new();
6515 let terms = build_termspec(
6516 &parsed.terms,
6517 &ds,
6518 &col_map,
6519 &mut notes,
6520 &gam_runtime::resource::ResourcePolicy::default_library(),
6521 )
6522 .expect("build tensor with binary margin");
6523 let SmoothBasisSpec::TensorBSpline { spec, .. } = &terms.smooth_terms[0].basis else {
6524 panic!("expected tensor B-spline for te(x, b)");
6525 };
6526 // Continuous margin keeps requested degree=3 and k=5; binary margin
6527 // drops to degree=1 (linear) so the requested k=2 yields exactly two
6528 // basis functions before tensor-product identifiability is applied.
6529 let continuous = &spec.marginalspecs[0];
6530 let binary = &spec.marginalspecs[1];
6531 assert_eq!(continuous.degree, 3);
6532 assert_eq!(binary.degree, 1);
6533 assert!(
6534 binary.penalty_order >= 1 && binary.penalty_order <= binary.degree,
6535 "binary margin penalty_order {} must satisfy 1 <= order <= degree={}",
6536 binary.penalty_order,
6537 binary.degree
6538 );
6539 let basis_size = |m: &BSplineBasisSpec| match m.knotspec {
6540 BSplineKnotSpec::PeriodicUniform { num_basis, .. } => num_basis,
6541 BSplineKnotSpec::Generate {
6542 num_internal_knots, ..
6543 } => num_internal_knots + m.degree + 1,
6544 BSplineKnotSpec::Automatic {
6545 num_internal_knots: Some(n),
6546 ..
6547 } => n + m.degree + 1,
6548 // The mgcv-default `cr` margin (#1074) reports its basis size as the
6549 // number of value-knots placed.
6550 BSplineKnotSpec::NaturalCubicRegression { ref knots } => knots.len(),
6551 _ => panic!("unexpected tensor marginal knotspec"),
6552 };
6553 assert_eq!(basis_size(continuous), 5);
6554 assert_eq!(basis_size(binary), 2);
6555 }
6556
6557 #[test]
6558 fn tensor_smooth_uniform_k_is_capped_to_a_low_cardinality_margins_distinct_values() {
6559 // Regression: a SINGLE `k=5` applied to every axis of `te(x, b, k=5)`
6560 // with a BINARY second margin (`b ∈ {0, 1}`) must build a valid tensor,
6561 // NOT hard-fail in cr-knot selection ("cubic regression spline with k=5
6562 // requires at least 5 distinct values, got 2"). mgcv caps a margin's
6563 // basis to its data support; the binary axis becomes the 2-function
6564 // (linear) margin, while the continuous axis keeps the requested k=5.
6565 // This is the `te(age, badh, k=5)` real-data case that previously errored.
6566 let ds = continuous_dataset(
6567 &["y", "x", "b"],
6568 (0..40)
6569 .map(|i| {
6570 let x = i as f64 / 39.0;
6571 let b = (i % 2) as f64;
6572 vec![x.sin() + 0.5 * b, x, b]
6573 })
6574 .collect(),
6575 );
6576 let parsed = parse_formula("y ~ te(x, b, k=5)").expect("parse tensor with uniform k=5");
6577 let col_map = ds.column_map();
6578 let mut notes = Vec::new();
6579 let terms = build_termspec(
6580 &parsed.terms,
6581 &ds,
6582 &col_map,
6583 &mut notes,
6584 &gam_runtime::resource::ResourcePolicy::default_library(),
6585 )
6586 .expect("uniform k=5 must auto-cap the binary margin instead of erroring");
6587 let SmoothBasisSpec::TensorBSpline { spec, .. } = &terms.smooth_terms[0].basis else {
6588 panic!("expected tensor B-spline for te(x, b)");
6589 };
6590 let basis_size = |m: &BSplineBasisSpec| match &m.knotspec {
6591 BSplineKnotSpec::PeriodicUniform { num_basis, .. } => *num_basis,
6592 BSplineKnotSpec::Generate {
6593 num_internal_knots, ..
6594 } => num_internal_knots + m.degree + 1,
6595 BSplineKnotSpec::Automatic {
6596 num_internal_knots: Some(n),
6597 ..
6598 } => n + m.degree + 1,
6599 BSplineKnotSpec::NaturalCubicRegression { knots } => knots.len(),
6600 other => panic!("unexpected tensor marginal knotspec: {other:?}"),
6601 };
6602 let binary = &spec.marginalspecs[1];
6603 // Binary margin is reduced to the 2-function linear basis its data
6604 // supports (k capped from 5 to 2, degree dropped to 1).
6605 assert_eq!(basis_size(binary), 2);
6606 assert_eq!(binary.degree, 1);
6607 // The continuous margin is unaffected by the cap (40 distinct values).
6608 assert_eq!(basis_size(&spec.marginalspecs[0]), 5);
6609 }
6610
6611 #[test]
6612 fn tensor_all_tp_margins_with_per_margin_k_routes_to_bspline_tensor() {
6613 // `te(x1, x2, bs=c('tp','tp'), k=c(5,5))` is mgcv's per-margin tp tensor
6614 // with per-margin basis sizes — a tensor product of two 1-D bases, each
6615 // of dimension 5. The list-valued `k=c(5,5)` is honored by
6616 // `parse_tensor_k_list`, producing one penalized B-spline margin per axis
6617 // (each spanning the requested per-axis thin-plate function space). This
6618 // is the same anisotropic-tensor routing the scalar/no-`k` case takes —
6619 // a `te()` request is ALWAYS a tensor product, never a silent isotropic
6620 // thin-plate substitution.
6621 let ds = continuous_dataset(
6622 &["y", "x1", "x2"],
6623 (0..32)
6624 .map(|i| {
6625 let t = i as f64 / 31.0;
6626 vec![t.sin(), t, 1.0 - t]
6627 })
6628 .collect(),
6629 );
6630 let parsed =
6631 parse_formula("y ~ te(x1, x2, bs=c('tp','tp'), k=c(5,5))").expect("parse tensor");
6632 let col_map = ds.column_map();
6633 let mut notes = Vec::new();
6634 let terms = build_termspec(
6635 &parsed.terms,
6636 &ds,
6637 &col_map,
6638 &mut notes,
6639 &gam_runtime::resource::ResourcePolicy::default_library(),
6640 )
6641 .expect("build tensor terms with per-margin k");
6642 let SmoothBasisSpec::TensorBSpline { spec, .. } = &terms.smooth_terms[0].basis else {
6643 panic!(
6644 "expected B-spline tensor when k=c(5,5) is supplied with bs=c('tp','tp'), got {:?}",
6645 terms.smooth_terms[0].basis
6646 );
6647 };
6648 // Since #1074 a `tp` tensor margin (k >= 3) is realized as a
6649 // Lancaster–Salkauskas natural cubic-regression margin (cr basis
6650 // dimension == knot count), not an open `Generate` B-spline. It is
6651 // still a `TensorBSpline` spec with one penalized 1-D margin per axis,
6652 // so the routing assertion above still holds; only the per-margin
6653 // knotspec variant changed. The earlier `_ => panic!` arm pinned the
6654 // pre-#1074 `Generate`-only representation and is stale. Decode every
6655 // margin variant to its basis dimension (mirroring the
6656 // `tensor_margin_basis_sizes` helper).
6657 let dims = spec
6658 .marginalspecs
6659 .iter()
6660 .map(|m| match m.knotspec {
6661 BSplineKnotSpec::Generate {
6662 num_internal_knots, ..
6663 } => num_internal_knots + m.degree + 1,
6664 BSplineKnotSpec::Automatic {
6665 num_internal_knots: Some(num_internal_knots),
6666 ..
6667 } => num_internal_knots + m.degree + 1,
6668 BSplineKnotSpec::PeriodicUniform { num_basis, .. } => num_basis,
6669 BSplineKnotSpec::Provided(ref knots) => knots.len().saturating_sub(m.degree + 1),
6670 BSplineKnotSpec::NaturalCubicRegression { ref knots } => knots.len(),
6671 BSplineKnotSpec::Automatic {
6672 num_internal_knots: None,
6673 ..
6674 } => panic!("test cannot infer automatic knot count"),
6675 })
6676 .collect::<Vec<_>>();
6677 assert_eq!(dims, vec![5, 5]);
6678 }
6679
6680 #[test]
6681 fn tensor_all_tp_margins_without_per_margin_k_builds_anisotropic_tensor() {
6682 // `te(x1, x2, bs=c('tp','tp'))` is a tensor-product request and must
6683 // build a genuine anisotropic tensor product (one smoothing parameter
6684 // per margin), NOT a silently-substituted multi-D isotropic thin-plate
6685 // radial smooth — that would be a different model (`s(x1,x2,bs='tp')`).
6686 // The routing is now consistent whether or not `k` is list-valued: a tp
6687 // margin vector always realizes each axis as a 1-D penalized B-spline
6688 // margin spanning the same per-axis thin-plate function space (#1082).
6689 let ds = continuous_dataset(
6690 &["y", "x1", "x2"],
6691 (0..32)
6692 .map(|i| {
6693 let t = i as f64 / 31.0;
6694 vec![t.sin(), t, 1.0 - t]
6695 })
6696 .collect(),
6697 );
6698 let parsed = parse_formula("y ~ te(x1, x2, bs=c('tp','tp'))").expect("parse tensor");
6699 let col_map = ds.column_map();
6700 let mut notes = Vec::new();
6701 let terms = build_termspec(
6702 &parsed.terms,
6703 &ds,
6704 &col_map,
6705 &mut notes,
6706 &gam_runtime::resource::ResourcePolicy::default_library(),
6707 )
6708 .expect("build tensor terms without per-margin k");
6709 let SmoothBasisSpec::TensorBSpline { spec, .. } = &terms.smooth_terms[0].basis else {
6710 panic!(
6711 "te(...,bs=c('tp','tp')) must route to an anisotropic tensor product, not a \
6712 silent isotropic thin-plate substitution; got {:?}",
6713 terms.smooth_terms[0].basis
6714 );
6715 };
6716 assert_eq!(
6717 spec.marginalspecs.len(),
6718 2,
6719 "tp tensor must carry one penalized B-spline margin per axis"
6720 );
6721 }
6722
6723 #[test]
6724 fn explicit_basis_sizes_are_not_small_n_clamped() {
6725 let ds = continuous_dataset(
6726 &["y", "x1", "x2", "x3", "x4", "x5"],
6727 (0..12)
6728 .map(|i| {
6729 let x = i as f64 / 11.0;
6730 vec![x.sin(), x, x * x, x + 0.1, 1.0 - x, (2.0 * x).sin()]
6731 })
6732 .collect(),
6733 );
6734 let parsed = parse_formula("y ~ s(x1, k=10) + s(x2) + s(x3) + s(x4) + s(x5)")
6735 .expect("parse multi-smooth formula");
6736 let col_map = ds.column_map();
6737 let mut notes = Vec::new();
6738 let terms = build_termspec(
6739 &parsed.terms,
6740 &ds,
6741 &col_map,
6742 &mut notes,
6743 &gam_runtime::resource::ResourcePolicy::default_library(),
6744 )
6745 .expect("build multi-smooth terms");
6746 let SmoothBasisSpec::BSpline1D { spec, .. } = &terms.smooth_terms[0].basis else {
6747 panic!("expected first smooth to be B-spline");
6748 };
6749 assert!(matches!(
6750 &spec.knotspec,
6751 BSplineKnotSpec::Generate {
6752 num_internal_knots: 6,
6753 ..
6754 }
6755 ));
6756 }
6757
6758 #[test]
6759 fn explicit_duchon_centers_are_not_small_n_bumped() {
6760 let ds = continuous_dataset(
6761 &["y", "x1", "x2", "x3", "x4", "x5"],
6762 (0..12)
6763 .map(|i| {
6764 let x = i as f64 / 11.0;
6765 vec![x.sin(), x, x * x, x + 0.1, 1.0 - x, (2.0 * x).sin()]
6766 })
6767 .collect(),
6768 );
6769 // Pure 1D Duchon at default options resolves the nullspace to Linear
6770 // (2s < d forces escalation), giving 2 polynomial nullspace columns;
6771 // the well-posedness gate requires num_centers > polynomial_cols, so
6772 // 3 is the smallest valid count. It is still well below the small-N
6773 // bump target of polynomial_cols + 4 = 6, so this exercises the
6774 // "explicit value is honored" path the test name advertises.
6775 let parsed = parse_formula("y ~ duchon(x1, centers=3) + s(x2) + s(x3) + s(x4) + s(x5)")
6776 .expect("parse multi-smooth formula");
6777 let col_map = ds.column_map();
6778 let mut notes = Vec::new();
6779 let terms = build_termspec(
6780 &parsed.terms,
6781 &ds,
6782 &col_map,
6783 &mut notes,
6784 &gam_runtime::resource::ResourcePolicy::default_library(),
6785 )
6786 .expect("build multi-smooth terms");
6787 let SmoothBasisSpec::Duchon { spec, .. } = &terms.smooth_terms[0].basis else {
6788 panic!("expected first smooth to be Duchon");
6789 };
6790 assert!(matches!(
6791 spec.center_strategy,
6792 CenterStrategy::FarthestPoint { num_centers: 3 }
6793 ));
6794 }
6795
6796 #[test]
6797 fn inferred_tensor_basis_cap_uses_coordinate_support_not_duplicate_rows() {
6798 let mut unique_rows = Vec::new();
6799 for i in 0..50 {
6800 let theta = i as f64 / 50.0;
6801 for j in 0..16 {
6802 let h = -1.0 + 2.0 * (j as f64) / 15.0;
6803 let y = theta.cos() + h;
6804 unique_rows.push(vec![y, theta, h]);
6805 }
6806 }
6807 let mut repeated_rows = Vec::new();
6808 for _ in 0..12 {
6809 repeated_rows.extend(unique_rows.iter().cloned());
6810 }
6811
6812 let unique = continuous_dataset(&["y", "theta", "h"], unique_rows);
6813 let repeated = continuous_dataset(&["y", "theta", "h"], repeated_rows);
6814
6815 let unique_basis = inferred_tensor_basis_product(&unique);
6816 let repeated_basis = inferred_tensor_basis_product(&repeated);
6817
6818 assert_eq!(
6819 unique_basis, repeated_basis,
6820 "duplicating existing tensor coordinates must not inflate inferred basis width"
6821 );
6822 }
6823
6824 #[test]
6825 fn inferred_three_dim_tensor_basis_stays_bounded_for_reml_selection() {
6826 // Regression for gam#813: the inferred per-margin k must be
6827 // dimension-aware so the 3-D tensor width p = ∏ k_d does not explode.
6828 // With the old 1-D-per-margin rule a 3-D `te` defaulted to 7³=343 at
6829 // small n and 20³=8000 at larger n, making the (non-Kronecker-factorable)
6830 // full-tensor sum-to-zero penalty's O(p³) REML reparameterization a
6831 // multi-minute stall. The dimension-aware budget keeps the product near
6832 // mgcv's te default (≈5³=125) regardless of n.
6833 let make = |n: usize| -> usize {
6834 let mut rows = Vec::with_capacity(n);
6835 for i in 0..n {
6836 let f = i as f64 / n as f64;
6837 rows.push(vec![f.sin(), f, (2.0 * f).cos(), (3.0 * f) % 1.0]);
6838 }
6839 let ds = continuous_dataset(&["y", "x1", "x2", "x3"], rows);
6840 let parsed = parse_formula("y ~ te(x1, x2, x3)").expect("parse 3-D tensor");
6841 let col_map = ds.column_map();
6842 let mut notes = Vec::new();
6843 let terms = build_termspec(
6844 &parsed.terms,
6845 &ds,
6846 &col_map,
6847 &mut notes,
6848 &ResourcePolicy::default_library(),
6849 )
6850 .expect("build 3-D tensor termspec");
6851 let SmoothBasisSpec::TensorBSpline { spec, .. } = &terms.smooth_terms[0].basis else {
6852 panic!("expected tensor smooth");
6853 };
6854 spec.marginalspecs
6855 .iter()
6856 .map(|m| match m.knotspec {
6857 BSplineKnotSpec::Generate {
6858 num_internal_knots, ..
6859 } => num_internal_knots + m.degree + 1,
6860 BSplineKnotSpec::Automatic {
6861 num_internal_knots: Some(num_internal_knots),
6862 ..
6863 } => num_internal_knots + m.degree + 1,
6864 // The mgcv-default `cr` margin (#1074) reports its basis size
6865 // as the number of value-knots placed.
6866 BSplineKnotSpec::NaturalCubicRegression { ref knots } => knots.len(),
6867 _ => panic!("unexpected tensor margin knotspec"),
6868 })
6869 .product()
6870 };
6871
6872 // n=30 (the issue's data): was 7³=343, must now be modest.
6873 assert!(
6874 make(60) <= 216,
6875 "3-D te at small n must stay near the mgcv te default, got {}",
6876 make(60)
6877 );
6878 // Larger n must NOT grow the product toward n³ (was 20³=8000).
6879 assert!(
6880 make(2000) <= 216,
6881 "3-D te at large n must not blow ∏k toward the data size, got {}",
6882 make(2000)
6883 );
6884 }
6885
6886 #[test]
6887 fn parse_bspline_boundary_conditions_and_side_selector() {
6888 // Non-zero anchors are rejected at parse time; the diagnostic must
6889 // name the side and value, which doubles as a check that the
6890 // `side=left` filter routes the global `anchor=` value to the
6891 // left endpoint (not the right).
6892 let mut opts = BTreeMap::new();
6893 opts.insert("boundary_conditions".to_string(), "anchored".to_string());
6894 opts.insert("side".to_string(), "left".to_string());
6895 opts.insert("anchor".to_string(), "2.5".to_string());
6896 let err = parse_bspline_boundary_conditions(&opts)
6897 .expect_err("non-zero left anchor must be rejected")
6898 .to_string();
6899 assert!(
6900 err.contains("left") && err.contains("2.5"),
6901 "rejection should name the affected side and value: {err}"
6902 );
6903
6904 // Side-specific aliases (`start_bc`/`end_bc`) plus the side-specific
6905 // anchor key (`right_anchor`) must funnel the value onto the right
6906 // endpoint — verified through the rejection diagnostic.
6907 let mut opts = BTreeMap::new();
6908 opts.insert("start_bc".to_string(), "clamped".to_string());
6909 opts.insert("end_bc".to_string(), "zero".to_string());
6910 opts.insert("right_anchor".to_string(), "-1.0".to_string());
6911 let err = parse_bspline_boundary_conditions(&opts)
6912 .expect_err("non-zero right anchor must be rejected")
6913 .to_string();
6914 assert!(
6915 err.contains("right") && err.contains("-1"),
6916 "rejection should name the affected side and value: {err}"
6917 );
6918
6919 // With anchors at zero the basis builder accepts the configuration,
6920 // so the same alias plumbing yields a clean `Anchored { value: 0.0 }`
6921 // on the right and `Clamped` on the left.
6922 let mut opts = BTreeMap::new();
6923 opts.insert("start_bc".to_string(), "clamped".to_string());
6924 opts.insert("end_bc".to_string(), "zero".to_string());
6925 let parsed = parse_bspline_boundary_conditions(&opts).expect("boundary conditions");
6926 assert!(matches!(
6927 parsed.left,
6928 BSplineEndpointBoundaryCondition::Clamped
6929 ));
6930 assert!(matches!(
6931 parsed.right,
6932 BSplineEndpointBoundaryCondition::Anchored { value } if value.abs() < 1e-12
6933 ));
6934 }
6935
6936 #[test]
6937 fn categorical_by_numeric_interaction_expands_treatment_coded_cells() {
6938 // `y ~ x:g` is an INTERACTION-ONLY numeric-by-factor model: there is no
6939 // `x` main effect, so the marginal parent that would identify a dropped
6940 // reference level is ABSENT. The expansion must therefore be marginality-
6941 // aware (gam#1158) and DUMMY-code `g` — keep ALL levels — yielding the
6942 // "common intercept, separate slopes" design (one x-slope column per
6943 // group). Treatment-coding here (dropping the reference level) would pin
6944 // the reference group's slope to zero, a rank-deficient fit; that wrong
6945 // behaviour is what this test now guards against. (The treatment-coded
6946 // path is exercised when the `x` parent is present — see
6947 // `categorical_by_numeric_interaction_keeps_treatment_coding_with_parent`.)
6948 let ds = factor_dataset();
6949 // `g` is categorical with two levels (encoded 0.0 → "a", 1.0 → "b").
6950 let parsed = parse_formula("y ~ x:g").expect("parse `y ~ x:g`");
6951 let col_map = ds.column_map();
6952 let mut notes = Vec::new();
6953 let terms = build_termspec(
6954 &parsed.terms,
6955 &ds,
6956 &col_map,
6957 &mut notes,
6958 &ResourcePolicy::default_library(),
6959 )
6960 .expect("factor-aware `x:g` interaction must build, not error");
6961
6962 assert_eq!(
6963 terms.linear_terms.len(),
6964 2,
6965 "interaction-only `x:g` keeps ALL factor levels (full dummy coding): one slope column per group"
6966 );
6967
6968 let x_col = *col_map.get("x").expect("x column");
6969 let g_col = *col_map.get("g").expect("g column");
6970
6971 // Both level gates must appear exactly once across the two cell columns,
6972 // and each cell carries `x` as a product factor (not a raw column for g).
6973 let mut seen_bits = std::collections::HashSet::new();
6974 for term in &terms.linear_terms {
6975 assert!(
6976 term.is_interaction(),
6977 "the categorical-by-numeric cell is a Wilkinson-Rogers interaction"
6978 );
6979 assert_eq!(term.feature_cols, vec![x_col]);
6980 assert_eq!(term.categorical_levels.len(), 1);
6981 let (gate_col, gate_bits) = term.categorical_levels[0];
6982 assert_eq!(gate_col, g_col);
6983 assert!(seen_bits.insert(gate_bits), "each level appears once");
6984
6985 // Realize and check it equals `1[g == gate_bits] * x` row by row.
6986 let column = term
6987 .realized_design_column(ds.values.view())
6988 .expect("realize cell column");
6989 let n = ds.values.nrows();
6990 assert_eq!(column.len(), n);
6991 for row in 0..n {
6992 let x = ds.values[[row, x_col]];
6993 let g = ds.values[[row, g_col]];
6994 let expected = if g.to_bits() == gate_bits { x } else { 0.0 };
6995 assert!(
6996 (column[row] - expected).abs() < 1e-12,
6997 "row {row}: g={g}, x={x}, expected {expected}, got {}",
6998 column[row]
6999 );
7000 }
7001 }
7002 // Both the reference level "a" (0.0) and the non-reference "b" (1.0) are
7003 // kept — the reference level is NOT dropped in the interaction-only form.
7004 assert!(seen_bits.contains(&0.0_f64.to_bits()));
7005 assert!(seen_bits.contains(&1.0_f64.to_bits()));
7006 }
7007
7008 #[test]
7009 fn categorical_by_numeric_interaction_keeps_treatment_coding_with_parent() {
7010 // With the `x` main effect PRESENT (`y ~ x + x:g`), the marginal parent
7011 // that identifies a dropped reference level exists, so `x:g` keeps its
7012 // historical treatment coding: the reference level "a" is dropped and
7013 // only the non-reference slope-deviation column for "b" is emitted. This
7014 // guards that the marginality-aware fix (gam#1158) does NOT regress the
7015 // parent-present form, which must stay column-space-identical to mgcv's
7016 // `x + x:g`.
7017 let ds = factor_dataset();
7018 let parsed = parse_formula("y ~ x + x:g").expect("parse `y ~ x + x:g`");
7019 let col_map = ds.column_map();
7020 let mut notes = Vec::new();
7021 let terms = build_termspec(
7022 &parsed.terms,
7023 &ds,
7024 &col_map,
7025 &mut notes,
7026 &ResourcePolicy::default_library(),
7027 )
7028 .expect("`x + x:g` must build");
7029
7030 // One main-effect `x` column plus one treatment-coded interaction cell.
7031 let x_col = *col_map.get("x").expect("x column");
7032 let g_col = *col_map.get("g").expect("g column");
7033 let interaction_cells: Vec<_> = terms
7034 .linear_terms
7035 .iter()
7036 .filter(|t| t.is_interaction())
7037 .collect();
7038 assert_eq!(
7039 interaction_cells.len(),
7040 1,
7041 "with `x` present, `x:g` is treatment-coded → one cell (reference dropped)"
7042 );
7043 let term = interaction_cells[0];
7044 assert_eq!(term.feature_cols, vec![x_col]);
7045 assert_eq!(term.categorical_levels.len(), 1);
7046 let (gate_col, gate_bits) = term.categorical_levels[0];
7047 assert_eq!(gate_col, g_col);
7048 // The dropped reference is "a" (0.0); the kept gate is "b" (1.0).
7049 assert_eq!(gate_bits, 1.0_f64.to_bits());
7050 }
7051
7052 #[test]
7053 fn categorical_by_categorical_interaction_expands_full_cross_cells() {
7054 // `y ~ f:g` is an INTERACTION-ONLY factor-by-factor model: neither `f`
7055 // nor `g` appears as a main effect, so neither marginal parent is
7056 // present and BOTH factors must be dummy-coded (gam#1159). The correct
7057 // design is the SATURATED cell-means model: the full cross of ALL levels
7058 // (3 * 2 = 6 cells) minus ONE reference cell (the lexicographically-first
7059 // level of every factor, here f0:g0) absorbed by the intercept — rank
7060 // 6-1 = 5 cell columns + intercept, column-space-identical to `f*g`.
7061 // Treatment-coding both factors (the old behaviour) kept only
7062 // (3-1)*(2-1) = 2 cells and collapsed the rest onto the intercept, a
7063 // rank-deficient fit; that is the bug this test now guards against.
7064 let n = 30usize;
7065 let mut rows = Vec::with_capacity(n);
7066 for i in 0..n {
7067 let y = (i as f64).sin();
7068 let f = (i % 3) as f64; // 3 levels: 0,1,2
7069 let g = (i % 2) as f64; // 2 levels: 0,1
7070 rows.push(vec![y, f, g]);
7071 }
7072 let values = Array2::from_shape_vec(
7073 (n, 3),
7074 rows.into_iter().flat_map(|row| row.into_iter()).collect(),
7075 )
7076 .expect("rectangular cross-factor data");
7077 let ds = Dataset {
7078 headers: vec!["y".into(), "f".into(), "g".into()],
7079 values,
7080 schema: DataSchema {
7081 columns: vec![
7082 SchemaColumn {
7083 name: "y".into(),
7084 kind: ColumnKindTag::Continuous,
7085 levels: vec![],
7086 },
7087 SchemaColumn {
7088 name: "f".into(),
7089 kind: ColumnKindTag::Categorical,
7090 levels: vec!["f0".into(), "f1".into(), "f2".into()],
7091 },
7092 SchemaColumn {
7093 name: "g".into(),
7094 kind: ColumnKindTag::Categorical,
7095 levels: vec!["g0".into(), "g1".into()],
7096 },
7097 ],
7098 },
7099 column_kinds: vec![
7100 ColumnKindTag::Continuous,
7101 ColumnKindTag::Categorical,
7102 ColumnKindTag::Categorical,
7103 ],
7104 };
7105
7106 let parsed = parse_formula("y ~ f:g").expect("parse `y ~ f:g`");
7107 let col_map = ds.column_map();
7108 let mut notes = Vec::new();
7109 let terms = build_termspec(
7110 &parsed.terms,
7111 &ds,
7112 &col_map,
7113 &mut notes,
7114 &ResourcePolicy::default_library(),
7115 )
7116 .expect("factor-by-factor `f:g` interaction must build, not error");
7117
7118 assert_eq!(
7119 terms.linear_terms.len(),
7120 5,
7121 "saturated 3*2 = 6 cross cells minus one reference cell (f0:g0) = 5"
7122 );
7123
7124 let f_col = *col_map.get("f").expect("f column");
7125 let g_col = *col_map.get("g").expect("g column");
7126 // The dropped reference cell pairs each factor's lexicographically-first
7127 // level: f0 (0.0) and g0 (0.0). It must NOT appear among the emitted
7128 // cells; every OTHER cross cell must.
7129 let f0 = 0.0_f64.to_bits();
7130 let g0 = 0.0_f64.to_bits();
7131 let mut emitted = std::collections::HashSet::new();
7132 for term in &terms.linear_terms {
7133 // No numeric operand: the realized column is a pure cell indicator.
7134 assert!(term.feature_cols.is_empty());
7135 assert_eq!(term.categorical_levels.len(), 2);
7136 let mut gates = std::collections::HashMap::new();
7137 for &(col, bits) in &term.categorical_levels {
7138 gates.insert(col, bits);
7139 }
7140 let f_bits = *gates.get(&f_col).expect("f gate present");
7141 let g_bits = *gates.get(&g_col).expect("g gate present");
7142 // The reference cell f0:g0 must have been dropped.
7143 assert!(
7144 !(f_bits == f0 && g_bits == g0),
7145 "the reference cell f0:g0 must be absorbed by the intercept, not emitted"
7146 );
7147 emitted.insert((f_bits, g_bits));
7148
7149 let column = term
7150 .realized_design_column(ds.values.view())
7151 .expect("realize cross cell");
7152 for row in 0..n {
7153 let f = ds.values[[row, f_col]];
7154 let g = ds.values[[row, g_col]];
7155 let expected = if f.to_bits() == f_bits && g.to_bits() == g_bits {
7156 1.0
7157 } else {
7158 0.0
7159 };
7160 assert!(
7161 (column[row] - expected).abs() < 1e-12,
7162 "row {row}: expected {expected}, got {}",
7163 column[row]
7164 );
7165 }
7166 assert!(
7167 column.iter().any(|&v| v == 1.0),
7168 "each cross cell must be observed in the data"
7169 );
7170 }
7171 // Every non-reference cross cell is present exactly once: all 6 cells
7172 // except f0:g0.
7173 let f_levels = [0.0_f64.to_bits(), 1.0_f64.to_bits(), 2.0_f64.to_bits()];
7174 let g_levels = [0.0_f64.to_bits(), 1.0_f64.to_bits()];
7175 for &fb in &f_levels {
7176 for &gb in &g_levels {
7177 if fb == f0 && gb == g0 {
7178 continue;
7179 }
7180 assert!(
7181 emitted.contains(&(fb, gb)),
7182 "saturated cross cell must be present"
7183 );
7184 }
7185 }
7186 }
7187}