Skip to main content

gam_terms/
term_builder.rs

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