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