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