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