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, DuchonSpectralBasis, MaternBasisSpec,
17    MaternIdentifiability, MaternLengthScale, MaternNu, MeasureJetBasisSpec,
18    MeasureJetIdentifiability, OneDimensionalBoundary, SpatialIdentifiability, SphereMethod,
19    SphereWahbaKernel, SphericalSplineBasisSpec, SphericalSplineIdentifiability,
20    ThinPlateBasisSpec, auto_spatial_center_strategy, count_unique_coordinate_rows,
21    default_num_centers, default_spatial_center_strategy, default_spherical_harmonic_degree,
22    plan_spatial_basis, select_r_uniform_subsample_centers, thin_plate_penalty_order,
23};
24use crate::inference::formula_dsl::{
25    ParsedTerm, SmoothKind, option_bool, option_f64, option_f64_strict, option_usize,
26    option_usize_any, option_usize_any_strict, option_usize_strict, parsed_term_column_names,
27    strip_quotes,
28};
29use crate::smooth::{
30    BySmoothKind, ByVarKind, ByVariableSpec, FactorSmoothFlavour, FactorSmoothSpec,
31    LinearCoefficientGeometry, LinearTermSpec, RandomEffectTermSpec, ShapeConstraint,
32    SmoothBasisSpec, SmoothTermSpec, TensorBSplineIdentifiability,
33    TensorBSplinePenaltyDecomposition, TensorBSplineSpec, TermCollectionSpec,
34};
35use gam_data::{ColumnKindTag, DataError, EncodedDataset as Dataset};
36use gam_problem::types::ColIdx;
37use gam_runtime::resource::ResourcePolicy;
38
39/// Default B-spline degree when a smooth's `degree=` option is absent. Cubic
40/// (degree 3) is the standard GAM convention: C² continuity with a low knot
41/// count.
42const DEFAULT_BSPLINE_DEGREE: usize = 3;
43
44/// Default difference-penalty order when a smooth's `penalty_order=` (alias
45/// `m=`) option is absent. Second-order (curvature) is the standard P-spline
46/// convention.
47const DEFAULT_PENALTY_ORDER: usize = 2;
48
49/// Admissible `lmax=` for the truncated Wahba sphere kernels, matching the
50/// documented range on [`SphereWahbaKernel::SobolevTruncated`]. The lower end
51/// keeps at least a few degrees of resolution; the upper end is the bound the
52/// device kernel bakes in as a compile-time `#define`.
53const SPHERE_TRUNCATION_LMAX_RANGE: std::ops::RangeInclusive<usize> = 5..=200;
54
55/// Default basis dimension for one-dimensional cyclic cubic P-splines.
56///
57/// Periodic smooths spend no coefficients on free endpoints, so they should not
58/// inherit the larger open B-spline knot ceiling by default.  This is still only
59/// a default: callers can request a richer periodic space with `k=`.
60const CYCLIC_DEFAULT_BASIS_DIM: usize = 12;
61
62/// Default shared-marginal basis dimension for `bs="fs"`/`bs="sz"` factor smooths,
63/// matching mgcv's factor-smooth default `k=10`. A factor smooth shares one
64/// marginal across all levels; a modest basis recovers the shared signal without
65/// over-fitting each group's within-group noise (gam#903). Overridden by an
66/// explicit `k`/`basis_dim`.
67const FACTOR_SMOOTH_DEFAULT_BASIS_DIM: usize = 10;
68
69/// Default row-chunk size for the out-of-core PCA-basis smooth when the
70/// `chunk_size=` option is absent. Streams the design in row blocks to bound
71/// peak memory independent of the dataset row count.
72const DEFAULT_PCA_CHUNK_SIZE: usize = 4096;
73
74// ---------------------------------------------------------------------------
75// Typed errors
76// ---------------------------------------------------------------------------
77
78/// Typed errors emitted by term-builder helpers. `Display` reproduces the exact
79/// pre-refactor `format!(...)` text byte-for-byte, so callers that string-match
80/// on the message (tests, log assertions) keep working unchanged. Public-API
81/// functions still return `Result<_, String>` and use `.to_string()` shims at
82/// their boundary to stay compatible with callers in protected modules.
83#[derive(Clone, Debug)]
84pub enum TermBuilderError {
85    /// Column-resolution / column-kind lookup failures whose context is purely
86    /// internal (column-kind table out-of-sync, alias map missing an entry,
87    /// etc.). User-facing "this formula references a column that doesn't
88    /// exist" diagnostics use the dedicated `ColumnNotFound` variant so the
89    /// FFI boundary can lift the structured payload into a Python
90    /// `ColumnNotFoundError` without parsing prose.
91    MissingColumn { reason: String },
92    /// A formula referenced a column that is not present in the input data.
93    /// Mirrors `DataError::ColumnNotFound` field-for-field so the conversion
94    /// across module boundaries is a pure data move (no re-derivation, no
95    /// string re-parsing). Public callers see byte-identical `Display`
96    /// output to the legacy `missing_column_message` text.
97    ColumnNotFound {
98        name: String,
99        role: Option<String>,
100        available: Vec<String>,
101        similar: Vec<String>,
102        tsv_hint: bool,
103    },
104    /// User-specified configuration is internally inconsistent (e.g. too few
105    /// variables for a smooth type, conflicting size options, requested basis
106    /// dimension below the polynomial nullspace).
107    IncompatibleConfig { reason: String },
108    /// Option parsing failure: malformed numeric expression, unknown option
109    /// key, out-of-range integer, list-length mismatch, etc.
110    InvalidOption { reason: String },
111    /// User requested a feature that is intentionally not supported (unknown
112    /// smooth type / method / kernel / identifiability, non-zero anchor,
113    /// internal-only token, etc.).
114    UnsupportedFeature { reason: String },
115    /// Input data is degenerate for the requested term (constant column,
116    /// non-finite categorical entries, ...).
117    DegenerateData { reason: String },
118    /// Term-collection-stage formula error — a node that the caller was
119    /// supposed to resolve upstream reached the builder.
120    MalformedFormula { reason: String },
121}
122
123impl std::fmt::Display for TermBuilderError {
124    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125        match self {
126            TermBuilderError::MissingColumn { reason }
127            | TermBuilderError::IncompatibleConfig { reason }
128            | TermBuilderError::InvalidOption { reason }
129            | TermBuilderError::UnsupportedFeature { reason }
130            | TermBuilderError::DegenerateData { reason }
131            | TermBuilderError::MalformedFormula { reason } => f.write_str(reason),
132            // Delegate to the canonical `DataError::ColumnNotFound` formatter
133            // so a single source of truth defines the human text. The
134            // intermediate `DataError` constructed here owns its strings only
135            // for the duration of the Display call — no allocation cost
136            // beyond the original payload that this variant already holds.
137            TermBuilderError::ColumnNotFound {
138                name,
139                role,
140                available,
141                similar,
142                tsv_hint,
143            } => {
144                let canonical = DataError::ColumnNotFound {
145                    name: name.clone(),
146                    role: role.clone(),
147                    available: available.clone(),
148                    similar: similar.clone(),
149                    tsv_hint: *tsv_hint,
150                };
151                std::fmt::Display::fmt(&canonical, f)
152            }
153        }
154    }
155}
156
157impl From<TermBuilderError> for String {
158    fn from(err: TermBuilderError) -> String {
159        err.to_string()
160    }
161}
162
163/// Catchall lift for the term-builder's internal `Result<_, String>` helpers
164/// (numeric expression parsing, option lookup, boundary-condition parsing,
165/// ...) that flow into `build_termspec` via `?`. Maps to
166/// `IncompatibleConfig`, which is the most appropriate generic bucket for
167/// option/config-style failures — leaf sites that emit structured payloads
168/// (`From<DataError>` for column-not-found) bypass this fallback.
169impl From<String> for TermBuilderError {
170    fn from(reason: String) -> Self {
171        Self::IncompatibleConfig { reason }
172    }
173}
174
175/// Typed lift from data-layer errors. `DataError::ColumnNotFound` becomes
176/// `TermBuilderError::ColumnNotFound` field-for-field — no stringification,
177/// no information loss — so the FFI boundary downstream can dispatch on
178/// the typed variant. Other `DataError` variants degrade into
179/// `MissingColumn` since they describe column-resolution-time failures
180/// without a dedicated structured destination.
181impl From<DataError> for TermBuilderError {
182    fn from(err: DataError) -> Self {
183        match err {
184            DataError::ColumnNotFound {
185                name,
186                role,
187                available,
188                similar,
189                tsv_hint,
190            } => Self::ColumnNotFound {
191                name,
192                role,
193                available,
194                similar,
195                tsv_hint,
196            },
197            DataError::SchemaMismatch { reason }
198            | DataError::ParseError { reason }
199            | DataError::EncodingFailure { reason }
200            | DataError::EmptyInput { reason }
201            | DataError::InvalidValue { reason } => Self::MissingColumn { reason },
202            DataError::DegenerateColumn { column, problem } => Self::DegenerateData {
203                reason: format!("column '{column}' {problem}"),
204            },
205        }
206    }
207}
208
209// Constructor helpers — keep error-site code compact and consistent.
210impl TermBuilderError {
211    #[inline]
212    fn missing_column(reason: impl Into<String>) -> Self {
213        TermBuilderError::MissingColumn {
214            reason: reason.into(),
215        }
216    }
217    #[inline]
218    fn incompatible_config(reason: impl Into<String>) -> Self {
219        TermBuilderError::IncompatibleConfig {
220            reason: reason.into(),
221        }
222    }
223    #[inline]
224    fn invalid_option(reason: impl Into<String>) -> Self {
225        TermBuilderError::InvalidOption {
226            reason: reason.into(),
227        }
228    }
229    #[inline]
230    fn unsupported_feature(reason: impl Into<String>) -> Self {
231        TermBuilderError::UnsupportedFeature {
232            reason: reason.into(),
233        }
234    }
235    #[inline]
236    fn degenerate_data(reason: impl Into<String>) -> Self {
237        TermBuilderError::DegenerateData {
238            reason: reason.into(),
239        }
240    }
241    #[inline]
242    fn malformed_formula(reason: impl Into<String>) -> Self {
243        TermBuilderError::MalformedFormula {
244            reason: reason.into(),
245        }
246    }
247}
248
249// ---------------------------------------------------------------------------
250// Column resolution
251// ---------------------------------------------------------------------------
252
253/// Resolve a bare column name to its index, returning a typed
254/// `DataError::ColumnNotFound` on miss so the FFI boundary can surface a
255/// structured `gamfit.ColumnNotFoundError(column=…, available=…)` rather
256/// than rely on string-classification of human prose. Internal callers that
257/// still flow `Result<_, String>` get byte-identical text via
258/// `From<DataError> for String`.
259pub fn resolve_col(col_map: &HashMap<String, usize>, name: &str) -> Result<usize, DataError> {
260    col_map
261        .get(name)
262        .copied()
263        .ok_or_else(|| DataError::column_not_found(col_map, name, None))
264}
265
266/// Like `resolve_col` but tags the missing-column payload with a role label
267/// (`"response"`, `"entry"`, `"exit"`, `"event"`, `"z"`, `"id"`, …) so the
268/// boundary-side Python exception can disambiguate which formula slot held
269/// the bad reference.
270pub fn resolve_role_col(
271    col_map: &HashMap<String, usize>,
272    name: &str,
273    role: &str,
274) -> Result<usize, DataError> {
275    col_map
276        .get(name)
277        .copied()
278        .ok_or_else(|| DataError::column_not_found(col_map, name, Some(role)))
279}
280
281fn encoded_levels_for_column(ds: &Dataset, col: ColIdx) -> Vec<(u64, String)> {
282    let mut seen = BTreeSet::<u64>::new();
283    for value in ds.values.column(col.get()) {
284        if value.is_finite() {
285            seen.insert(gam_data::canonical_level_bits(*value));
286        }
287    }
288    let schema_levels = ds
289        .schema
290        .columns
291        .get(col.get())
292        .map(|column| column.levels.as_slice())
293        .unwrap_or(&[]);
294    seen.into_iter()
295        .enumerate()
296        .map(|(idx, bits)| {
297            let fallback = format!("level{}", idx + 1);
298            let label = schema_levels.get(idx).cloned().unwrap_or(fallback);
299            (bits, label)
300        })
301        .collect()
302}
303
304/// Internal option key carrying the row count that n-scaling BASIS DEFAULTS
305/// (radial center counts, spatial plans) must size from. A factor-by smooth
306/// expands into per-level blocks that each see ONLY their level's rows, so
307/// sizing the default from the pooled row count over-provisions every level —
308/// measured on the #1561 by-group location-scale fixture: `s(x, bs='tp',
309/// by=group)` at n=200 (100/group) got ~50 centers PER LEVEL, an
310/// ill-conditioned 100-column mean block whose truth-recovery floor (0.111)
311/// no λ could beat, while the same smooth sized for the level's own 100 rows
312/// recovers to ~0.036. Explicit user `centers=`/`k=` bypass the default and
313/// are unaffected. Stripped at the top of [`build_smooth_basis`] like
314/// `__by_col`, so per-kind option allow-lists never see it.
315const DEFAULT_SIZING_ROWS_OPTION: &str = "__default_sizing_rows";
316
317/// The smallest per-level row count of a categorical by-column: the effective
318/// sample size each by-level smooth block actually fits. `None` when the
319/// column has no finite rows (callers fall back to the pooled count).
320fn min_categorical_by_level_rows(ds: &Dataset, by_col: usize) -> Option<usize> {
321    let mut counts: BTreeMap<u64, usize> = BTreeMap::new();
322    for value in ds.values.column(by_col) {
323        if value.is_finite() {
324            *counts
325                .entry(gam_data::canonical_level_bits(*value))
326                .or_insert(0) += 1;
327        }
328    }
329    counts.values().copied().min()
330}
331
332/// Insert [`DEFAULT_SIZING_ROWS_OPTION`] into `inner_options` when the by
333/// column is categorical (numeric-by smooths keep one shared block over all
334/// rows, so pooled sizing stays correct there).
335fn inject_by_level_sizing_rows(
336    inner_options: &mut BTreeMap<String, String>,
337    ds: &Dataset,
338    by_col: usize,
339) {
340    if matches!(
341        ds.column_kinds.get(by_col).copied(),
342        Some(ColumnKindTag::Categorical)
343    ) && let Some(min_rows) = min_categorical_by_level_rows(ds, by_col)
344    {
345        inner_options.insert(DEFAULT_SIZING_ROWS_OPTION.to_string(), min_rows.to_string());
346    }
347}
348
349pub fn column_map_with_alias(
350    col_map: &HashMap<String, usize>,
351    alias: &str,
352    target_column: &str,
353) -> HashMap<String, usize> {
354    let mut aliased = col_map.clone();
355    if let Some(idx) = col_map.get(target_column).copied() {
356        aliased.entry(alias.to_string()).or_insert(idx);
357    }
358    aliased
359}
360
361/// The canonical marginal-slope alias: `z` in a formula binds to the column
362/// named by `z_column`.
363pub const MARGINAL_SLOPE_Z_ALIAS: &str = "z";
364
365/// Whether writing `z` in a formula would resolve to `z_column` for this frame.
366///
367/// `column_map_with_alias` inserts with `or_insert`, so a frame that carries its
368/// own real `z` column keeps it and the alias is inert — writing `z` there means
369/// that column, which is legitimate. The alias is live only when `z_column`
370/// exists and `z` does not, and only then does `z` silently denote the score.
371pub fn marginal_slope_z_alias_is_live(col_map: &HashMap<String, usize>, z_column: &str) -> bool {
372    col_map.contains_key(z_column) && !col_map.contains_key(MARGINAL_SLOPE_Z_ALIAS)
373}
374
375// ---------------------------------------------------------------------------
376// ParsedTerm[] + Dataset → TermCollectionSpec
377// ---------------------------------------------------------------------------
378
379pub fn build_termspec(
380    terms: &[ParsedTerm],
381    ds: &Dataset,
382    col_map: &HashMap<String, usize>,
383    inference_notes: &mut Vec<String>,
384    policy: &ResourcePolicy,
385) -> Result<TermCollectionSpec, TermBuilderError> {
386    // Generic ingestion deliberately preserves missing cells because it runs
387    // before a formula exists. This is the first layer that knows the complete
388    // set of columns consumed by term construction (including `by=` and nested
389    // slope surfaces), so completeness is enforced here and nowhere wider.
390    let mut consumed_columns = BTreeSet::new();
391    parsed_term_column_names(terms, &mut consumed_columns);
392    for name in consumed_columns {
393        let column = resolve_col(col_map, &name)?;
394        if let Some(row) = ds
395            .values
396            .column(column)
397            .iter()
398            .position(|value| !value.is_finite())
399        {
400            return Err(TermBuilderError::degenerate_data(format!(
401                "model term column '{name}' contains a non-finite value at row {}",
402                row + 1
403            )));
404        }
405    }
406
407    let mut linear_terms = Vec::<LinearTermSpec>::new();
408    let mut random_terms = Vec::<RandomEffectTermSpec>::new();
409    let mut smooth_terms = Vec::<SmoothTermSpec>::new();
410    let smooth_coordinate_count = terms
411        .iter()
412        .map(|term| match term {
413            ParsedTerm::Smooth { vars, .. } => vars.len(),
414            _ => 0,
415        })
416        .sum::<usize>();
417
418    for t in terms {
419        match t {
420            ParsedTerm::Linear {
421                name,
422                explicit,
423                double_penalty,
424                coefficient_min,
425                coefficient_max,
426            } => {
427                let col = resolve_col(col_map, name)?;
428                let auto_kind = ds.column_kinds.get(col).copied().ok_or_else(|| {
429                    TermBuilderError::missing_column(format!(
430                        "internal column-kind lookup failed for '{name}'"
431                    ))
432                    .to_string()
433                })?;
434                if *explicit {
435                    linear_terms.push(LinearTermSpec {
436                        name: name.clone(),
437                        feature_col: col,
438                        feature_cols: vec![col],
439                        categorical_levels: vec![],
440                        // Parametric terms are unpenalized/MLE by default.
441                        // `double_penalty=true` is an explicit shrinkage choice
442                        // carried by the parsed term.
443                        double_penalty: *double_penalty,
444                        coefficient_geometry: LinearCoefficientGeometry::Unconstrained,
445                        coefficient_min: *coefficient_min,
446                        coefficient_max: *coefficient_max,
447                        frozen_function_mass: None,
448                    });
449                } else {
450                    match auto_kind {
451                        ColumnKindTag::Continuous | ColumnKindTag::Binary => {
452                            linear_terms.push(LinearTermSpec {
453                                name: name.clone(),
454                                feature_col: col,
455                                feature_cols: vec![col],
456                                categorical_levels: vec![],
457                                // Preserve the parser's explicit opt-in. Bare
458                                // numeric terms arrive as `false`.
459                                double_penalty: *double_penalty,
460                                coefficient_geometry: LinearCoefficientGeometry::Unconstrained,
461                                coefficient_min: *coefficient_min,
462                                coefficient_max: *coefficient_max,
463                                frozen_function_mass: None,
464                            });
465                        }
466                        ColumnKindTag::Categorical => {
467                            if coefficient_min.is_some() || coefficient_max.is_some() {
468                                return Err(TermBuilderError::incompatible_config(format!(
469                                    "coefficient constraints are not supported for categorical auto-random-effect term '{name}'; use group({name}) or an unconstrained numeric term"
470                                )));
471                            }
472                            random_terms.push(RandomEffectTermSpec {
473                                name: name.clone(),
474                                feature_col: col,
475                                drop_first_level: false,
476                                penalized: true,
477                                frozen_levels: None,
478                                // A BARE categorical main effect (`+ g`) is a FIXED
479                                // parametric factor. Although it is auto-promoted to
480                                // a penalized random block above, an *unseen* level
481                                // at predict must raise a schema mismatch rather than
482                                // be mapped to the factor's centering point (#2102).
483                                lenient_unseen: false,
484                            });
485                        }
486                    }
487                }
488            }
489            ParsedTerm::BoundedLinear {
490                name,
491                min,
492                max,
493                prior,
494                double_penalty,
495            } => {
496                let col = resolve_col(col_map, name)?;
497                let auto_kind = ds.column_kinds.get(col).copied().ok_or_else(|| {
498                    TermBuilderError::missing_column(format!(
499                        "internal column-kind lookup failed for '{name}'"
500                    ))
501                    .to_string()
502                })?;
503                if !matches!(auto_kind, ColumnKindTag::Continuous | ColumnKindTag::Binary) {
504                    return Err(TermBuilderError::incompatible_config(format!(
505                        "bounded() currently supports only numeric columns, got categorical '{name}'"
506                    )));
507                }
508                linear_terms.push(LinearTermSpec {
509                    name: name.clone(),
510                    feature_col: col,
511                    feature_cols: vec![col],
512                    categorical_levels: vec![],
513                    double_penalty: *double_penalty,
514                    coefficient_geometry: LinearCoefficientGeometry::Bounded {
515                        min: *min,
516                        max: *max,
517                        prior: prior.clone(),
518                    },
519                    coefficient_min: None,
520                    coefficient_max: None,
521                    frozen_function_mass: None,
522                });
523            }
524            ParsedTerm::RandomEffect {
525                name,
526                lenient_unseen,
527            } => {
528                let col = resolve_col(col_map, name)?;
529                random_terms.push(RandomEffectTermSpec {
530                    name: name.clone(),
531                    feature_col: col,
532                    drop_first_level: false,
533                    penalized: true,
534                    frozen_levels: None,
535                    // Unseen-level policy is fixed by the wrapper the user wrote
536                    // (`formula_dsl`): a genuine random effect
537                    // (`group(g)`/`re(g)`/`s(g, bs="re")`) shrinks a held-out
538                    // group to the population mean and so tolerates unseen
539                    // levels; a fixed `factor(g)`, like a bare `+ g` categorical
540                    // main effect, must reject an unseen level rather than
541                    // collapse onto the centering point (#2137/#2102).
542                    lenient_unseen: *lenient_unseen,
543                });
544            }
545            ParsedTerm::Smooth {
546                label,
547                vars,
548                kind,
549                options,
550            } => {
551                let smooth_vars = vars.clone();
552                let by_name = options.get("by").cloned();
553                // `bs="sz"` (sum-to-zero), like `bs="fs"`/`bs="re"`, is a
554                // factor-smooth family handled natively by `build_smooth_basis`'s
555                // fs/sz/re path: it detects the categorical factor among the
556                // variables and emits a `SmoothBasisSpec::FactorSmooth { Sz }`
557                // with the correct single-penalty marginal and modest default
558                // basis. Route sz straight through `build_smooth_basis` rather
559                // than intercepting it into a legacy `FactorSumToZero` envelope
560                // here (which left `sz(fac, x)` mis-typed as `FactorSumToZero`
561                // instead of the expected `FactorSmooth { Sz }`).
562                let cols = smooth_vars
563                    .iter()
564                    .map(|v| resolve_col(col_map, v))
565                    .collect::<Result<Vec<_>, _>>()?;
566                let mut inner_options = options.clone();
567                inner_options.remove("by");
568                // `ordered=` is consumed here (ByVarKind::Factor routing) and
569                // must not propagate to the inner basis builder, which has no
570                // allow-list entry for it and would reject it as an unknown option.
571                inner_options.remove("ordered");
572                // Pop the shape constraint before `build_smooth_basis` runs so
573                // it never reaches the per-kind `validate_known_options`
574                // allow-lists (the constraint is a property of the smooth term,
575                // not of any one basis kind). Basis-incompatible requests still
576                // fail loudly downstream via `shape_supports_basis`.
577                let shape = match inner_options.remove("shape") {
578                    None => ShapeConstraint::None,
579                    Some(raw) => crate::smooth::parse_shape_constraint(&raw)
580                        .map_err(TermBuilderError::invalid_option)?,
581                };
582                // A categorical by= expands into per-level blocks below; size
583                // the inner basis's n-scaling defaults from the smallest
584                // level's rows, not the pooled count (see
585                // `DEFAULT_SIZING_ROWS_OPTION`).
586                if let Some(by_name) = by_name.as_deref() {
587                    let by_col = resolve_col(col_map, by_name)?;
588                    inject_by_level_sizing_rows(&mut inner_options, ds, by_col);
589                }
590                let inner_basis = build_smooth_basis(
591                    *kind,
592                    &smooth_vars,
593                    &cols,
594                    &inner_options,
595                    ds,
596                    inference_notes,
597                    policy,
598                    smooth_coordinate_count,
599                )?;
600                // `bs="sz"` deliberately stays typed as `SmoothBasisSpec::FactorSmooth
601                // { Sz }` (#1403, owner-confirmed in #1887): the `FactorSumToZero`
602                // envelope is the *legacy, mis-typed* representation. `build_factor_smooth`
603                // reuses the sum-to-zero construction internally as its single source of
604                // truth for the zero-sum geometry (term_specs.rs) while keeping the
605                // freeze-consistent `FactorSmooth` metadata shape shared by fs/sz/re, so
606                // there is no reason to re-wrap the spec into the legacy envelope here —
607                // doing so (#1981) mis-typed `sz(fac, x)` back to `FactorSumToZero` and
608                // broke the refit/predict freeze path's `(FactorSmooth, …)` metadata match.
609                if let Some(by_name) = by_name {
610                    let by_col = resolve_col(col_map, &by_name)?;
611                    match ds.column_kinds.get(by_col).copied().ok_or_else(|| {
612                        format!("internal column-kind lookup failed for by variable '{by_name}'")
613                    })? {
614                        ColumnKindTag::Categorical => {
615                            let levels = encoded_levels_for_column(ds, ColIdx::new(by_col));
616                            // A penalized random block for this factor already
617                            // owns its full level offsets when EITHER an explicit
618                            // `group(factor)` appears, OR a *bare* categorical
619                            // `+ factor` does — the latter is auto-promoted to a
620                            // penalized random-effect block (see the
621                            // `ParsedTerm::Linear` / `ColumnKindTag::Categorical`
622                            // arm above, `penalized: true`). Both representations
623                            // carry the same per-level offsets, so #1457: the
624                            // `by=` branch must NOT additionally add its own
625                            // unpenalized treatment-coded main effect, which would
626                            // double-represent the factor (two `g` design blocks +
627                            // a spurious extra smoothing parameter).
628                            let penalized_group_owner_present =
629                                terms.iter().any(|other| match other {
630                                    ParsedTerm::RandomEffect { name, .. } => name == &by_name,
631                                    ParsedTerm::Linear {
632                                        name,
633                                        explicit: false,
634                                        ..
635                                    } if name == &by_name => col_map
636                                        .get(name)
637                                        .and_then(|c| ds.column_kinds.get(*c).copied())
638                                        .map(|kind| matches!(kind, ColumnKindTag::Categorical))
639                                        .unwrap_or(false),
640                                    _ => false,
641                                });
642                            // Add an unpenalized treatment-coded fixed main
643                            // effect for a standalone factor-by smooth, unless
644                            // the same factor already has an explicit
645                            // `group(factor)` term OR a bare categorical `+
646                            // factor` that was auto-promoted to a penalized
647                            // random block (#1457).  In those mixed-model forms
648                            // the penalized random intercept is the coherent
649                            // owner of level offsets; adding a no-pooling fixed
650                            // factor effect would bypass random-effect
651                            // shrinkage and degrade BLUP-style predictions.
652                            if !random_terms.iter().any(|rt| rt.name == by_name)
653                                && !penalized_group_owner_present
654                            {
655                                random_terms.push(RandomEffectTermSpec {
656                                    name: by_name.clone(),
657                                    feature_col: by_col,
658                                    drop_first_level: true,
659                                    penalized: false,
660                                    frozen_levels: None,
661                                    // Unpenalized treatment-coded FIXED factor main
662                                    // effect for a factor-by smooth: an unseen level
663                                    // is out of contract and must raise, not center
664                                    // (#2102).
665                                    lenient_unseen: false,
666                                });
667                            }
668                            // Unordered factor-by smooths are independent
669                            // level-specific smooths. Preserve that
670                            // term-spec structure explicitly so later
671                            // hierarchy/identifiability passes can see the
672                            // per-level ownership rather than a generic
673                            // BySmooth envelope.
674                            for (level_bits, level_label) in levels {
675                                smooth_terms.push(SmoothTermSpec {
676                                    frozen_parametric_residualization: None,
677                                    name: format!("{label}:by={by_name}[{level_label}]"),
678                                    basis: SmoothBasisSpec::ByVariable {
679                                        inner: Box::new(inner_basis.clone()),
680                                        by_col,
681                                        kind: BySmoothKind::Level { level_bits },
682                                        by: ByVariableSpec::Level {
683                                            value_bits: level_bits,
684                                            label: level_label,
685                                        },
686                                    },
687                                    shape: shape.clone(),
688                                    joint_null_rotation: None,
689                                });
690                            }
691                        }
692                        ColumnKindTag::Binary | ColumnKindTag::Continuous => {
693                            let mut inner_basis = inner_basis;
694                            // A continuous by-variable makes a varying
695                            // coefficient `f(x)·z` whose constant direction is
696                            // `z` itself, not the intercept, so the inner
697                            // smooth's default centring would delete the
698                            // coefficient's average and force it into a
699                            // separate unpenalised `z` term. Keep the constant
700                            // in the penalised block; an explicit
701                            // `identifiability=` still wins. A binary
702                            // by-variable is a factor in disguise and keeps
703                            // the factor convention (`s(x, by=g) + g`).
704                            if matches!(ds.column_kinds.get(by_col), Some(ColumnKindTag::Continuous))
705                                && !options.contains_key("identifiability")
706                            {
707                                crate::smooth::keep_constant_in_numeric_by_smooth(&mut inner_basis);
708                            }
709                            smooth_terms.push(SmoothTermSpec {
710                                frozen_parametric_residualization: None,
711                                name: label.clone(),
712                                basis: SmoothBasisSpec::ByVariable {
713                                    inner: Box::new(inner_basis),
714                                    by_col,
715                                    kind: BySmoothKind::Numeric,
716                                    by: ByVariableSpec::Numeric,
717                                },
718                                shape,
719                                joint_null_rotation: None,
720                            });
721                        }
722                    }
723                } else {
724                    smooth_terms.push(SmoothTermSpec {
725                        frozen_parametric_residualization: None,
726                        name: label.clone(),
727                        basis: inner_basis,
728                        shape,
729                        joint_null_rotation: None,
730                    });
731                }
732            }
733            ParsedTerm::LinkWiggle { .. }
734            | ParsedTerm::TimeWiggle { .. }
735            | ParsedTerm::LinkConfig { .. }
736            | ParsedTerm::SurvivalConfig { .. } => {
737                // Consumed at formula level, not design terms.
738            }
739            ParsedTerm::SlopeSurface { .. } => {
740                return Err(TermBuilderError::malformed_formula(
741                    "slope(...) declarations must be resolved by the marginal-slope formula path before building a term spec",
742                ));
743            }
744            ParsedTerm::Interaction {
745                vars,
746                double_penalty,
747            } => {
748                // A linear `:` interaction realizes one design column equal to
749                // the elementwise product of its operands. Numeric (continuous/
750                // binary) operands multiply directly; a categorical operand is
751                // a factor, so the product is expanded factor-aware: one design
752                // column per surviving cell of the factor(s), each an indicator
753                // `1[factor == level]` gating the numeric product.
754                //
755                // Coding is MARGINALITY-AWARE (gam#1158, gam#1159). A categorical
756                // operand `g` is treatment-coded (its lexicographically first
757                // reference level dropped) ONLY when the lower-order term obtained
758                // by removing `g` from this interaction is also present in the
759                // model — that lower-order term is what makes the dropped level
760                // identifiable, exactly mgcv's marginality rule. When that parent
761                // is ABSENT (the interaction-only form), dropping the reference
762                // level instead pins a group to the reference fit (a rank-deficient
763                // design), so we keep ALL levels (full dummy coding) and rely on a
764                // single intercept cell-drop below for identifiability:
765                //   * `y ~ x:g` with no `x` main effect → "common intercept,
766                //     separate slopes": every group keeps its own x-slope.
767                //   * `y ~ g:h` with no `g`/`h` main effects → the saturated
768                //     cell-means model: full cross of all levels minus one
769                //     reference cell absorbed by the intercept.
770                // When the parents ARE present (`x + x:g`, or `g*h` = `g + h +
771                // g:h`), the historical treatment coding is preserved so those
772                // forms stay correct.
773                //
774                // A main effect for var V is a `Linear`/`BoundedLinear`/
775                // `RandomEffect` ParsedTerm whose referenced name is V (an
776                // auto-detected categorical `Linear` becomes a RandomEffect main
777                // effect; either spelling counts). We only treat such standalone
778                // main-effect terms as parents — not V appearing inside another
779                // interaction.
780                let main_effect_present = |target: &str| -> bool {
781                    terms.iter().any(|other| match other {
782                        ParsedTerm::Linear { name, .. }
783                        | ParsedTerm::BoundedLinear { name, .. }
784                        | ParsedTerm::RandomEffect { name, .. } => name == target,
785                        _ => false,
786                    })
787                };
788                // The lower-order parent of dropping operand `drop_var` from this
789                // interaction is present iff EVERY other operand is a main effect.
790                // For the two cases we care about (`x:g`, `g:h`) the interaction
791                // has two operands, so this reduces to "is the single remaining
792                // operand a main effect"; the general form handles any arity.
793                let parent_present = |drop_var: &str| -> bool {
794                    vars.iter()
795                        .filter(|v| v.as_str() != drop_var)
796                        .all(|v| main_effect_present(v))
797                };
798
799                let mut numeric_cols = Vec::<usize>::new();
800                // Per categorical operand: (var name, col, kept levels, was the
801                // reference level dropped / treatment-coded?).
802                let mut categorical_factors =
803                    Vec::<(String, usize, Vec<(u64, String)>, bool)>::new();
804                for var in vars {
805                    let col = resolve_col(col_map, var)?;
806                    let kind = ds.column_kinds.get(col).copied().ok_or_else(|| {
807                        TermBuilderError::missing_column(format!(
808                            "internal column-kind lookup failed for '{var}'"
809                        ))
810                        .to_string()
811                    })?;
812                    match kind {
813                        ColumnKindTag::Continuous | ColumnKindTag::Binary => numeric_cols.push(col),
814                        ColumnKindTag::Categorical => {
815                            let mut levels = encoded_levels_for_column(ds, ColIdx::new(col));
816                            // Treatment-code (drop the reference level) only when
817                            // the marginal parent that identifies it is present;
818                            // otherwise keep every level (full dummy coding).
819                            let treatment_coded = parent_present(var);
820                            if treatment_coded && levels.len() > 1 {
821                                levels.remove(0);
822                            }
823                            if levels.is_empty() {
824                                return Err(TermBuilderError::incompatible_config(format!(
825                                    "interaction `{}` references categorical column `{var}` with no usable levels",
826                                    vars.join(":")
827                                )));
828                            }
829                            categorical_factors.push((var.clone(), col, levels, treatment_coded));
830                        }
831                    }
832                }
833
834                let label = vars.join(":");
835
836                if categorical_factors.is_empty() {
837                    // Pure numeric `:` interaction — single product column,
838                    // identical to the historical behaviour.
839                    linear_terms.push(LinearTermSpec {
840                        name: label,
841                        feature_col: numeric_cols[0],
842                        feature_cols: numeric_cols,
843                        categorical_levels: vec![],
844                        // Interactions are recoverable as zero by default.
845                        double_penalty: *double_penalty,
846                        coefficient_geometry: LinearCoefficientGeometry::Unconstrained,
847                        coefficient_min: None,
848                        coefficient_max: None,
849                        frozen_function_mass: None,
850                    });
851                    inference_notes.push(format!(
852                        "wired linear interaction `{}` as product of numeric columns",
853                        vars.join(":")
854                    ));
855                } else {
856                    // Factor-aware expansion: cartesian product over the kept
857                    // levels of every categorical operand. Each cell yields one
858                    // column gating the numeric product (or, with no numeric
859                    // operand, a pure cell indicator).
860                    let mut cells: Vec<Vec<(usize, u64, String)>> = vec![Vec::new()];
861                    for (_var, col, levels, _treatment_coded) in &categorical_factors {
862                        let mut next = Vec::with_capacity(cells.len() * levels.len());
863                        for cell in &cells {
864                            for (bits, level_label) in levels {
865                                let mut extended = cell.clone();
866                                extended.push((*col, *bits, level_label.clone()));
867                                next.push(extended);
868                            }
869                        }
870                        cells = next;
871                    }
872
873                    // Intercept-identifiability cell drop. When the cells are PURE
874                    // INDICATORS (no numeric operand) and at least one factor was
875                    // dummy-coded (kept all its levels), the full set of cell
876                    // columns sums to the all-ones intercept and is rank-deficient
877                    // against it. Drop exactly ONE reference cell — the cell where
878                    // every factor sits at its reference (lexicographically first)
879                    // level — so the remaining saturated cells are identifiable
880                    // (rank n_g*n_h - 1 cells + intercept). With a numeric operand
881                    // the cells gate `x` and sum to `x`, not the intercept, so no
882                    // cell is dropped (the collinearity there is with the absent
883                    // `x` main effect, which is exactly why full coding is right).
884                    let any_dummy_coded = categorical_factors
885                        .iter()
886                        .any(|(_, _, _, treatment_coded)| !*treatment_coded);
887                    if numeric_cols.is_empty() && any_dummy_coded {
888                        // The reference cell pairs each factor's column with the
889                        // bits of its lexicographically-first (index 0) level.
890                        let reference_cell: Vec<(usize, u64)> = categorical_factors
891                            .iter()
892                            .map(|(_, col, _, _)| {
893                                let levels = encoded_levels_for_column(ds, ColIdx::new(*col));
894                                (*col, levels[0].0)
895                            })
896                            .collect();
897                        cells.retain(|cell| {
898                            !reference_cell.iter().all(|(rcol, rbits)| {
899                                cell.iter()
900                                    .any(|(col, bits, _)| col == rcol && bits == rbits)
901                            })
902                        });
903                    }
904
905                    let n_cells = cells.len();
906                    for cell in cells {
907                        let cell_suffix = cell
908                            .iter()
909                            .map(|(_, _, level_label)| level_label.as_str())
910                            .collect::<Vec<_>>()
911                            .join(":");
912                        let categorical_levels =
913                            cell.iter().map(|(col, bits, _)| (*col, *bits)).collect();
914                        // `feature_col` is required to point at a real column;
915                        // use the first numeric operand when present, otherwise
916                        // the first categorical column (its raw value is never
917                        // multiplied — `realized_design_column` starts from ones
918                        // and only gates by the level indicators).
919                        let feature_col = numeric_cols
920                            .first()
921                            .copied()
922                            .unwrap_or(categorical_factors[0].1);
923                        linear_terms.push(LinearTermSpec {
924                            name: format!("{label}:{cell_suffix}"),
925                            feature_col,
926                            feature_cols: numeric_cols.clone(),
927                            categorical_levels,
928                            double_penalty: *double_penalty,
929                            coefficient_geometry: LinearCoefficientGeometry::Unconstrained,
930                            coefficient_min: None,
931                            coefficient_max: None,
932                            frozen_function_mass: None,
933                        });
934                    }
935                    let all_treatment_coded = !any_dummy_coded;
936                    let coding = if all_treatment_coded {
937                        "treatment-coded"
938                    } else {
939                        "marginality-aware (full dummy / saturated)"
940                    };
941                    inference_notes.push(format!(
942                        "wired factor-aware linear interaction `{}` as {} {} cell column(s)",
943                        vars.join(":"),
944                        n_cells,
945                        coding
946                    ));
947                }
948            }
949        }
950    }
951
952    let spec = TermCollectionSpec {
953        linear_terms,
954        random_effect_terms: random_terms,
955        smooth_terms,
956    };
957    // Structural advisories — several 1-D spatial smooths where one surface was
958    // meant, a feature carried by both a smooth and a linear term, nested
959    // smooths under hierarchical ownership — change what a term MEANS, so they
960    // are inference notes: recorded here, once, for every front end and every
961    // model class that lowers a formula (the CLI prints them, Python raises
962    // them as `GamInferenceWarning`s, and the saved model carries them).
963    inference_notes.extend(crate::smooth::collect_smooth_structure_warnings(
964        &spec,
965        &ds.headers,
966        "model",
967    ));
968    Ok(spec)
969}
970
971fn split_list_option(raw: &str) -> Vec<String> {
972    let t = raw.trim();
973    // Accept the Python/JSON list form `[a, b]` AND mgcv's R-vector forms
974    // `c(a, b)` / `(a, b)` as bracketed wrappers around a comma-separated body.
975    // mgcv-style formulas pass per-margin numeric options as `k=c(5,5)` /
976    // `period=c(2*pi, pi)`; without R-vector peeling here those entries were
977    // split into `["c(5", "5)"]` and the downstream numeric parser then
978    // misreported the leading garbage as the invalid digit.
979    let inner = t
980        .strip_prefix('[')
981        .and_then(|u| u.strip_suffix(']'))
982        .or_else(|| {
983            t.strip_prefix("c(")
984                .or_else(|| t.strip_prefix("C("))
985                .or_else(|| t.strip_prefix('('))
986                .and_then(|u| u.strip_suffix(')'))
987        })
988        .unwrap_or(t);
989    inner
990        .split(',')
991        .map(|v| v.trim().to_string())
992        .filter(|v| !v.is_empty())
993        .collect()
994}
995
996fn parse_numeric_expr(raw: &str) -> Result<f64, String> {
997    let mut acc = 1.0f64;
998    let normalized = raw.replace(' ', "");
999    if normalized.eq_ignore_ascii_case("none") {
1000        return Err("None is not numeric".to_string());
1001    }
1002    for factor in normalized.split('*') {
1003        if factor.is_empty() {
1004            return Err(format!("invalid numeric expression '{raw}'"));
1005        }
1006        let value = if factor.eq_ignore_ascii_case("pi") || factor == "π" {
1007            std::f64::consts::PI
1008        } else if factor.eq_ignore_ascii_case("tau") || factor == "τ" {
1009            std::f64::consts::TAU
1010        } else if let Some(prefix) = factor
1011            .strip_suffix("pi")
1012            .or_else(|| factor.strip_suffix("π"))
1013        {
1014            let coefficient = if prefix.is_empty() {
1015                1.0
1016            } else {
1017                prefix
1018                    .parse::<f64>()
1019                    .map_err(|err| format!("invalid numeric expression '{raw}': {err}"))?
1020            };
1021            coefficient * std::f64::consts::PI
1022        } else if let Some(prefix) = factor
1023            .strip_suffix("tau")
1024            .or_else(|| factor.strip_suffix("τ"))
1025        {
1026            let coefficient = if prefix.is_empty() {
1027                1.0
1028            } else {
1029                prefix
1030                    .parse::<f64>()
1031                    .map_err(|err| format!("invalid numeric expression '{raw}': {err}"))?
1032            };
1033            coefficient * std::f64::consts::TAU
1034        } else {
1035            factor
1036                .parse::<f64>()
1037                .map_err(|err| format!("invalid numeric expression '{raw}': {err}"))?
1038        };
1039        acc *= value;
1040    }
1041    Ok(acc)
1042}
1043
1044/// Read an endpoint/period option as a numeric *expression* (`2*pi`, `tau`,
1045/// `0.5*tau`, `6.283185307179586`, ...) — the same grammar that `period=` and
1046/// `origin=` already accept via [`parse_numeric_expr`].
1047///
1048/// Returns `Ok(None)` when the key is absent, `Ok(Some(v))` when it parses, and
1049/// a hard `Err` when the key is *present but unparseable*. The crucial contrast
1050/// is with the lenient [`option_f64`], which collapses an unparseable value to
1051/// `None` and lets the caller silently substitute the data range — wrapping a
1052/// cyclic smooth at the wrong period with no diagnostic (the #815 failure mode).
1053fn option_numeric_expr(
1054    options: &BTreeMap<String, String>,
1055    key: &str,
1056) -> Result<Option<f64>, String> {
1057    match options.get(key) {
1058        None => Ok(None),
1059        Some(raw) => parse_numeric_expr(raw)
1060            .map(Some)
1061            .map_err(|err| format!("option `{key}={raw}` is not a valid numeric value: {err}")),
1062    }
1063}
1064
1065fn parse_periods_option(
1066    options: &BTreeMap<String, String>,
1067    dim: usize,
1068) -> Result<Option<Vec<Option<f64>>>, String> {
1069    let Some(raw) = options.get("period") else {
1070        return Ok(None);
1071    };
1072    let values = split_list_option(raw);
1073    let mut periods = vec![None; dim];
1074    if values.len() == 1 && dim == 1 {
1075        periods[0] = Some(parse_numeric_expr(&values[0])?);
1076    } else {
1077        if values.len() != dim {
1078            return Err(format!(
1079                "period list length {} must match smooth dimension {}",
1080                values.len(),
1081                dim
1082            ));
1083        }
1084        for (i, v) in values.iter().enumerate() {
1085            if v.eq_ignore_ascii_case("none") {
1086                continue;
1087            }
1088            periods[i] = Some(parse_numeric_expr(v)?);
1089        }
1090    }
1091    Ok(Some(periods))
1092}
1093
1094fn parse_periodic_axes_option(
1095    options: &BTreeMap<String, String>,
1096    dim: usize,
1097) -> Result<Option<Vec<Option<f64>>>, String> {
1098    // `cyclic=` is whitelisted as the alias of `periodic=` on every radial arm,
1099    // so read it here too; it was previously accepted and dropped (#2781).
1100    let Some(raw_axes) = options.get("periodic").or_else(|| options.get("cyclic")) else {
1101        // No periodicity FLAG — but a declared period is itself the declaration.
1102        // A period is not a property an aperiodic basis has, so an axis that
1103        // carries one is periodic, exactly as on the 1-D B-spline and tensor
1104        // paths (`axes_with_declared_period`). Before #2781 this early return
1105        // dropped `matern(x, z, period=[2*pi, None])` on the floor: the option
1106        // was validated by the arm's whitelist and then never read.
1107        let declared = parse_periods_option(options, dim)?;
1108        return Ok(match declared {
1109            Some(periods) if periods.iter().any(Option::is_some) => Some(periods),
1110            _ => None,
1111        });
1112    };
1113    let mut periods = parse_periods_option(options, dim)?.unwrap_or_else(|| vec![None; dim]);
1114    // Scalar boolean form (`periodic=true` / `false`, `yes` / `no`) applies to
1115    // every axis — the documented per-axis-flag broadcast (see the doc on
1116    // `parse_periodic_axes`, the tensor sibling that already accepts it). A
1117    // 1-D `duchon(x, periodic=true)` lands here: the cyclic *domain* is then
1118    // resolved from the data range by `parse_cyclic_boundary` (the 1-D builder
1119    // consults `boundary` first), so a finite explicit period is NOT required —
1120    // we only need to NOT mis-read "true" as an axis index (#1074). `false`
1121    // means no axis is periodic.
1122    let lowered = raw_axes.trim().to_ascii_lowercase();
1123    if matches!(lowered.as_str(), "true" | "yes" | "y") {
1124        return Ok(Some(periods));
1125    }
1126    // `false` means NO axis is periodic. Return `None` — NOT
1127    // `Some(vec![None; dim])` — because the radial 1-D consumer treats a
1128    // `Some([None])` as "periodicity requested, derive the wrap period from
1129    // the data range" (see the Duchon builder arm below, which back-fills
1130    // `axes[0] = data_span` for a lone `None`) and the 1-D builder routes on
1131    // `spec.periodic.is_some()`. Emitting `Some([None])` here therefore
1132    // silently produced a *periodic* smooth for an explicit `periodic=false`
1133    // — the exact regression this branch now avoids, matching the bracketed
1134    // `[false]` form handled by the per-axis boolean block below.
1135    if matches!(lowered.as_str(), "false" | "no" | "n") {
1136        return Ok(None);
1137    }
1138    let axes = split_list_option(raw_axes);
1139    if axes.is_empty() {
1140        return Ok(Some(periods));
1141    }
1142
1143    // Boolean forms `periodic=true` / `periodic=[true, false, ...]`, mirroring
1144    // `parse_tensor_periodic_axes`. The radial 1-D builders (`duchon`/`tps`/
1145    // `matern`) intentionally DERIVE the wrap period from the closed center
1146    // lattice when none is supplied (`prepare_periodic_duchon_centers_1d_with_period`,
1147    // gam#580: `None => span`), so a boolean-selected periodic axis legitimately
1148    // omits `period`. Without this branch, `duchon(x, periodic=true)`-style
1149    // radial formulas failed with the misleading "invalid periodic axis 'true'".
1150    let is_bool = |t: &str| {
1151        matches!(
1152            t.to_ascii_lowercase().as_str(),
1153            "true" | "yes" | "y" | "false" | "no" | "n"
1154        )
1155    };
1156    let is_truthy = |t: &str| matches!(t.to_ascii_lowercase().as_str(), "true" | "yes" | "y");
1157
1158    // Scalar boolean: `periodic=true` / `periodic=false`.
1159    if axes.len() == 1 && is_bool(&axes[0]) {
1160        if !is_truthy(&axes[0]) {
1161            // Non-periodic: return None so the 1-D builder (which routes on
1162            // `spec.periodic.is_some()`) does NOT take the periodic path.
1163            return Ok(None);
1164        }
1165        // Every axis periodic; honor any explicit per-axis period, else leave
1166        // `None` for the caller (formula arm) / builder to derive the span.
1167        return Ok(Some(periods));
1168    }
1169
1170    // Per-axis boolean list: `periodic=[true, false, ...]` (length must match dim).
1171    if axes.iter().all(|a| is_bool(a)) {
1172        if axes.len() != dim {
1173            return Err(format!(
1174                "periodic flag list length {} must match smooth dimension {dim}",
1175                axes.len()
1176            ));
1177        }
1178        if !axes.iter().any(|a| is_truthy(a)) {
1179            return Ok(None);
1180        }
1181        for (i, a) in axes.iter().enumerate() {
1182            if !is_truthy(a) {
1183                periods[i] = None;
1184            }
1185        }
1186        return Ok(Some(periods));
1187    }
1188
1189    // Index-list form: `periodic=[0, 2]`. Each listed axis must carry an
1190    // explicit finite period — an index gives no per-axis span-derive hint.
1191    for a in &axes {
1192        let axis = a
1193            .parse::<usize>()
1194            .map_err(|err| format!("invalid periodic axis '{a}': {err}"))?;
1195        if axis >= dim {
1196            return Err(format!(
1197                "periodic axis {axis} out of range for {dim}D smooth"
1198            ));
1199        }
1200        if periods[axis].is_none() {
1201            return Err(format!(
1202                "periodic axis {axis} requires period[{axis}] to be finite"
1203            ));
1204        }
1205    }
1206    // Axes not listed are non-periodic even if period list has a finite placeholder.
1207    let listed: std::collections::BTreeSet<usize> = axes
1208        .iter()
1209        .filter_map(|a| a.parse::<usize>().ok())
1210        .collect();
1211    for i in 0..dim {
1212        if !listed.contains(&i) {
1213            periods[i] = None;
1214        }
1215    }
1216    Ok(Some(periods))
1217}
1218
1219// ---------------------------------------------------------------------------
1220// Smooth basis spec construction
1221// ---------------------------------------------------------------------------
1222
1223fn parse_option_list(raw: &str) -> Vec<String> {
1224    let trimmed = raw.trim();
1225    // Accept both the Python/JSON list form `[a, b]` and mgcv's R vector form
1226    // `c(a, b)` (and a bare `(a, b)`) as the bracketed wrapper around a
1227    // comma-separated option list. mgcv writes per-margin options as
1228    // `bs=c('tp','tp')` / `m=c(2,2)`, so the `c(...)` form must round-trip
1229    // through the same splitter the `[...]` form uses.
1230    let inner = trimmed
1231        .strip_prefix('[')
1232        .and_then(|v| v.strip_suffix(']'))
1233        .or_else(|| {
1234            trimmed
1235                .strip_prefix("c(")
1236                .or_else(|| trimmed.strip_prefix("C("))
1237                .or_else(|| trimmed.strip_prefix('('))
1238                .and_then(|v| v.strip_suffix(')'))
1239        })
1240        .unwrap_or(trimmed);
1241    inner
1242        .split(',')
1243        .map(|v| {
1244            v.trim()
1245                .trim_matches('"')
1246                .trim_matches('\'')
1247                .to_ascii_lowercase()
1248        })
1249        .filter(|v| !v.is_empty())
1250        .collect()
1251}
1252
1253/// Axes for which the caller has explicitly declared a period.
1254///
1255/// A period is not a property an aperiodic basis has: declaring one *is* the
1256/// periodicity declaration, and `periodic=` / `bc='periodic'` is a second,
1257/// redundant spelling of the same fact for the axes it names. Before #2781 the
1258/// axis resolvers read only that second spelling, so `s(t, period=24)`,
1259/// `s(t, period_start=0, period_end=24)` and
1260/// `te(th, h, periods=[2*pi, None])` were each validated as a legal option and
1261/// then dropped on the floor — the caller asked for a cyclic smooth, got an
1262/// aperiodic one with a discontinuity at the seam, and was never told.
1263///
1264/// Only *unambiguous* declarations are read here: a per-axis list (which
1265/// includes the scalar form on a 1-D smooth, where the list has length one).
1266/// A bare scalar on a multi-margin tensor does not say which margin it belongs
1267/// to, so it is left to [`parse_periods`], which either broadcasts it onto a
1268/// lone axis already flagged periodic or refuses the length mismatch.
1269fn axes_with_declared_period(
1270    options: &BTreeMap<String, String>,
1271    dim: usize,
1272) -> Result<Vec<bool>, String> {
1273    let mut axes = vec![false; dim];
1274    if let Some(raw) = options.get("period").or_else(|| options.get("periods")) {
1275        let values = split_list_option(raw);
1276        if values.len() == dim {
1277            for (axis, value) in values.iter().enumerate() {
1278                if !value.trim().eq_ignore_ascii_case("none") {
1279                    axes[axis] = true;
1280                }
1281            }
1282        }
1283    }
1284    // The half-open endpoint spelling (`period_start=`/`period_end=`, aliases
1285    // `start=`/`end=`) declares the periodic DOMAIN of one axis, so it only has
1286    // a referent on a 1-D smooth. `parse_periodic_domain_1d` is what reads it.
1287    if dim == 1
1288        && PERIOD_ENDPOINT_OPTION_KEYS
1289            .iter()
1290            .any(|key| options.contains_key(*key))
1291    {
1292        axes[0] = true;
1293    }
1294    Ok(axes)
1295}
1296
1297/// Option keys that declare a periodic domain by its endpoints.
1298const PERIOD_ENDPOINT_OPTION_KEYS: [&str; 4] = ["period_start", "period_end", "start", "end"];
1299
1300/// Option keys that declare a period length.
1301const PERIOD_LENGTH_OPTION_KEYS: [&str; 2] = ["period", "periods"];
1302
1303/// Option keys that place the start of a periodic domain.
1304const PERIOD_ORIGIN_OPTION_KEYS: [&str; 5] = [
1305    "origin",
1306    "origins",
1307    "period_origin",
1308    "period-origin",
1309    "domain_origin",
1310];
1311
1312/// Refuse a period declaration that no axis of the smooth can consume.
1313///
1314/// After [`axes_with_declared_period`] has folded every unambiguous declaration
1315/// into the periodic-axis set, the only ways to still hold a period option that
1316/// nothing reads are (a) a bare scalar `period=` on a multi-margin tensor, which
1317/// does not name its margin, (b) an `origin=` with no period to be the origin
1318/// of, and (c) an explicit `periodic=false` that contradicts the declaration.
1319/// Each of those is refused here rather than discarded, which is the contract
1320/// `docs/formulas.md` already states for this family of options: "an unparseable
1321/// endpoint or an unknown option is rejected rather than silently dropped"
1322/// (#2781).
1323fn reject_unconsumable_period_declaration(
1324    term_name: &str,
1325    options: &BTreeMap<String, String>,
1326    periodic_axes: &[bool],
1327) -> Result<(), String> {
1328    if periodic_axes.iter().any(|periodic| *periodic) {
1329        return Ok(());
1330    }
1331    let dim = periodic_axes.len();
1332    if let Some(key) = PERIOD_LENGTH_OPTION_KEYS
1333        .iter()
1334        .find(|key| options.contains_key(**key))
1335    {
1336        let hint = if dim > 1 {
1337            format!(
1338                "a scalar `{key}=` does not say which of the {dim} margins wraps; write one entry \
1339                 per margin (e.g. {key}=[<value>, None]) or name the axis with periodic=<axis>"
1340            )
1341        } else {
1342            "declare it on a periodic axis or drop it".to_string()
1343        };
1344        return Err(TermBuilderError::invalid_option(format!(
1345            "{term_name}(): `{key}=` declares a period, but no axis of this smooth is periodic — {hint}"
1346        ))
1347        .to_string());
1348    }
1349    if let Some(key) = PERIOD_ORIGIN_OPTION_KEYS
1350        .iter()
1351        .find(|key| options.contains_key(**key))
1352    {
1353        return Err(TermBuilderError::invalid_option(format!(
1354            "{term_name}(): `{key}=` places the start of a periodic domain, but this smooth \
1355             declares no period; add period=<value> or drop it"
1356        ))
1357        .to_string());
1358    }
1359    if let Some(key) = PERIOD_ENDPOINT_OPTION_KEYS
1360        .iter()
1361        .find(|key| options.contains_key(**key))
1362    {
1363        return Err(TermBuilderError::invalid_option(format!(
1364            "{term_name}(): `{key}=` declares a periodic domain endpoint, but no axis of this \
1365             smooth is periodic; on a tensor smooth use periods=[...] with origins=[...], which \
1366             name their margin"
1367        ))
1368        .to_string());
1369    }
1370    Ok(())
1371}
1372
1373/// The radial (`thinplate` / `matern` / `duchon`) counterpart of
1374/// [`reject_unconsumable_period_declaration`] (#2781).
1375///
1376/// [`parse_periodic_axes_option`] returns the per-axis period vector these arms
1377/// actually consume, and an axis wraps only when that vector carries a finite
1378/// period for it — with the one exception that a ONE-dimensional radial smooth
1379/// derives its period from the closed center lattice, which tiles a full period
1380/// exactly (gam#580), unlike the sample-dependent data-range derive the B-spline
1381/// path refuses (#1771). Every other spelling used to be validated by the arm's
1382/// whitelist and then dropped: `matern(x, z, period=0.7)` and
1383/// `matern(x, z, periodic=true)` were each bit-identical to the plain aperiodic
1384/// fit, with no error and no warning.
1385///
1386/// Two refusals:
1387///
1388/// * a periodicity or period declaration that leaves no axis periodic — which
1389///   on a multi-dimensional radial smooth is exactly what `periodic=true` alone
1390///   does, since there is no per-axis span to derive from;
1391/// * `period_start=` / `period_end=` on a multi-dimensional radial smooth.
1392///   Those name ONE axis's domain and are read by `parse_cyclic_boundary`,
1393///   which these arms consult only when `d == 1`.
1394fn reject_unconsumable_radial_period_declaration(
1395    term_name: &str,
1396    options: &BTreeMap<String, String>,
1397    dim: usize,
1398    periodic: Option<&[Option<f64>]>,
1399    boundary_is_cyclic: bool,
1400) -> Result<(), String> {
1401    if dim > 1
1402        && let Some(key) = PERIOD_ENDPOINT_OPTION_KEYS
1403            .iter()
1404            .find(|key| options.contains_key(**key))
1405    {
1406        return Err(TermBuilderError::invalid_option(format!(
1407            "{term_name}(): `{key}=` names one axis's periodic domain and is only read on a \
1408             one-dimensional radial smooth; this one has {dim} covariates, so give the wrap as \
1409             period=[…] with one entry per axis"
1410        ))
1411        .to_string());
1412    }
1413    let any_axis_wraps = boundary_is_cyclic
1414        || periodic.is_some_and(|axes| {
1415            (dim == 1 && !axes.is_empty()) || axes.iter().any(Option::is_some)
1416        });
1417    if any_axis_wraps {
1418        return Ok(());
1419    }
1420    let declared = ["periodic", "cyclic"]
1421        .iter()
1422        .chain(PERIOD_LENGTH_OPTION_KEYS.iter())
1423        .chain(PERIOD_ENDPOINT_OPTION_KEYS.iter())
1424        .find(|key| options.contains_key(**key));
1425    let Some(key) = declared else {
1426        return Ok(());
1427    };
1428    // `periodic=false` is a denial, not a declaration: it legitimately leaves
1429    // every axis open.
1430    if matches!(*key, "periodic" | "cyclic")
1431        && options
1432            .get(*key)
1433            .map(|raw| raw.trim().to_ascii_lowercase())
1434            .is_some_and(|raw| matches!(raw.as_str(), "false" | "no" | "n"))
1435    {
1436        return Ok(());
1437    }
1438    Err(TermBuilderError::invalid_option(format!(
1439        "{term_name}(): `{key}=` declares periodicity, but no axis of this smooth ends up \
1440         periodic. A radial smooth derives its wrap from the center lattice only in one \
1441         dimension (this one has {dim}), so name the period per axis: \
1442         period=[<value>, None, …]"
1443    ))
1444    .to_string())
1445}
1446
1447fn parse_periodic_axes(
1448    options: &BTreeMap<String, String>,
1449    dim: usize,
1450) -> Result<Vec<bool>, String> {
1451    let mut axes = vec![false; dim];
1452    // `periodic=false` is an explicit denial, not merely the absence of a
1453    // declaration: it suppresses the `boundary=` spelling below, and it
1454    // CONTRADICTS a period declaration rather than silently outranking it.
1455    let mut explicitly_aperiodic = false;
1456    if let Some(raw) = options.get("periodic").or_else(|| options.get("cyclic")) {
1457        let lowered = raw.trim().to_ascii_lowercase();
1458        if matches!(lowered.as_str(), "true" | "yes" | "y") {
1459            axes.fill(true);
1460        } else if matches!(lowered.as_str(), "false" | "no" | "n") {
1461            explicitly_aperiodic = true;
1462        } else {
1463            for axis_raw in parse_option_list(raw) {
1464                let axis = axis_raw
1465                    .parse::<usize>()
1466                    .map_err(|err| format!("invalid periodic axis '{axis_raw}': {err}"))?;
1467                if axis >= dim {
1468                    return Err(format!(
1469                        "periodic axis {axis} out of range for {dim}D smooth"
1470                    ));
1471                }
1472                axes[axis] = true;
1473            }
1474        }
1475    }
1476    if !explicitly_aperiodic
1477        && let Some(raw) = options.get("boundary").or_else(|| options.get("bc"))
1478    {
1479        let boundary = parse_option_list(raw);
1480        if boundary.len() == dim {
1481            for (axis, value) in boundary.iter().enumerate() {
1482                if matches!(value.as_str(), "periodic" | "cyclic" | "cc") {
1483                    axes[axis] = true;
1484                }
1485            }
1486        } else if dim == 1
1487            && matches!(
1488                boundary.first().map(String::as_str),
1489                Some("periodic" | "cyclic" | "cc")
1490            )
1491        {
1492            axes[0] = true;
1493        }
1494    }
1495    fold_in_declared_periods(options, dim, &mut axes, explicitly_aperiodic)?;
1496    Ok(axes)
1497}
1498
1499/// Fold every unambiguous period declaration into `axes` (#2781), refusing a
1500/// declaration that an explicit `periodic=false` contradicts.
1501///
1502/// Shared by the 1-D and tensor axis resolvers so one rule — "a declared period
1503/// makes its axis periodic" — holds on both paths.
1504fn fold_in_declared_periods(
1505    options: &BTreeMap<String, String>,
1506    dim: usize,
1507    axes: &mut [bool],
1508    explicitly_aperiodic: bool,
1509) -> Result<(), String> {
1510    let declared = axes_with_declared_period(options, dim)?;
1511    if explicitly_aperiodic && declared.iter().any(|d| *d) {
1512        return Err(TermBuilderError::incompatible_config(
1513            "periodic=false denies the periodicity that the smooth's own period declaration \
1514             asserts; drop one of the two",
1515        )
1516        .to_string());
1517    }
1518    for (axis, declared_axis) in declared.into_iter().enumerate() {
1519        axes[axis] |= declared_axis;
1520    }
1521    Ok(())
1522}
1523
1524fn parse_optional_numeric_list(
1525    options: &BTreeMap<String, String>,
1526    keys: &[&str],
1527    dim: usize,
1528) -> Result<Vec<Option<f64>>, String> {
1529    let Some(raw) = keys.iter().find_map(|key| options.get(*key)) else {
1530        return Ok(vec![None; dim]);
1531    };
1532    let values = split_list_option(raw);
1533    let mut out = vec![None; dim];
1534    if values.len() == 1 && dim == 1 {
1535        if !values[0].eq_ignore_ascii_case("none") {
1536            out[0] = Some(parse_numeric_expr(&values[0])?);
1537        }
1538        return Ok(out);
1539    }
1540    if values.len() != dim {
1541        return Err(format!(
1542            "numeric option list length {} must match smooth dimension {}",
1543            values.len(),
1544            dim
1545        ));
1546    }
1547    for (i, value) in values.iter().enumerate() {
1548        if !value.eq_ignore_ascii_case("none") {
1549            out[i] = Some(parse_numeric_expr(value)?);
1550        }
1551    }
1552    Ok(out)
1553}
1554
1555fn parse_periods(
1556    options: &BTreeMap<String, String>,
1557    periodic_axes: &[bool],
1558) -> Result<Vec<Option<f64>>, String> {
1559    let dim = periodic_axes.len();
1560    // Broadcast a single-element `period=[v]` onto the lone periodic axis
1561    // of a multi-axis smooth (e.g. `te(th, h, bc=['periodic','natural'],
1562    // period=[2*pi])`): with only one periodic margin, the value can only
1563    // belong there.
1564    let lone_periodic_broadcast = options
1565        .get("period")
1566        .or_else(|| options.get("periods"))
1567        .and_then(|raw| {
1568            let values = split_list_option(raw);
1569            if values.len() != 1 || dim <= 1 {
1570                return None;
1571            }
1572            let mut iter = periodic_axes.iter().enumerate().filter(|(_, p)| **p);
1573            let first = iter.next()?;
1574            if iter.next().is_some() {
1575                return None;
1576            }
1577            Some((first.0, values.into_iter().next()?))
1578        });
1579    let periods = if let Some((axis, value)) = lone_periodic_broadcast {
1580        let mut out = vec![None; dim];
1581        if !value.eq_ignore_ascii_case("none") {
1582            out[axis] = Some(parse_numeric_expr(&value)?);
1583        }
1584        out
1585    } else {
1586        parse_optional_numeric_list(options, &["period", "periods"], dim)?
1587    };
1588    for (axis, (periodic, period)) in periodic_axes.iter().zip(periods.iter()).enumerate() {
1589        if *periodic
1590            && let Some(value) = period
1591            && (!value.is_finite() || *value <= 0.0)
1592        {
1593            return Err(format!(
1594                "period for periodic axis {axis} must be finite and positive, got {value}"
1595            ));
1596        }
1597    }
1598    Ok(periods)
1599}
1600
1601fn parse_period_origins(
1602    options: &BTreeMap<String, String>,
1603    periodic_axes: &[bool],
1604) -> Result<Vec<Option<f64>>, String> {
1605    parse_optional_numeric_list(
1606        options,
1607        &[
1608            "origin",
1609            "origins",
1610            "period_origin",
1611            "period-origin",
1612            "domain_origin",
1613        ],
1614        periodic_axes.len(),
1615    )
1616}
1617
1618/// Parse a per-axis periodic flag list for tensor smooths. Accepts three forms:
1619/// - `periodic=true` / `periodic=false` (scalar applied to every axis),
1620/// - `periodic=[true, false, ...]` (one flag per axis, length `dim`),
1621/// - `periodic=c(1, 1)` / `c(0, 0)` (a length-`dim` 0/1 mask, mgcv's
1622///   per-margin spelling — distinguished from an axis-index list by the
1623///   repeated 0/1 value), and
1624/// - `periodic=[0, 2, ...]` (axis indices that are periodic; others are not).
1625///
1626/// `boundary=[..., "periodic"/"cyclic"/"cc", ...]` may also flip individual
1627/// axes on; non-matching tokens leave the existing flag unchanged.
1628fn parse_tensor_periodic_axes(
1629    options: &BTreeMap<String, String>,
1630    dim: usize,
1631) -> Result<Vec<bool>, String> {
1632    let mut axes = vec![false; dim];
1633    if let Some(raw) = options.get("periodic").or_else(|| options.get("cyclic")) {
1634        let lowered = raw.trim().to_ascii_lowercase();
1635        match lowered.as_str() {
1636            "true" | "yes" | "y" => {
1637                axes.fill(true);
1638            }
1639            "false" | "no" | "n" => {
1640                // Already false; allow `boundary=` below to flip axes if set.
1641            }
1642            _ => {
1643                let entries = parse_option_list(raw);
1644                let all_bool = !entries.is_empty()
1645                    && entries.iter().all(|v| {
1646                        matches!(
1647                            v.as_str(),
1648                            "true" | "yes" | "y" | "false" | "no" | "n" | "none"
1649                        )
1650                    });
1651                // mgcv writes per-margin flag vectors as `periodic=c(1,1)` /
1652                // `periodic=c(0,0)` — a length-`dim` mask where each entry is a
1653                // 0/1 flag for THAT margin, not an axis index. A bare axis-index
1654                // list (`periodic=[0,1]`, `periodic=[0]`) lists DISTINCT margin
1655                // indices to turn on. The two collide only when the list is all
1656                // 0/1 of length `dim`; disambiguate by the repeated-value
1657                // signature `c(1,1)`/`c(0,0)` (a valid axis-index set never
1658                // repeats an index), which is the canonical mask spelling. This
1659                // is what makes the leading tensor margin honor its periodic flag
1660                // (#1751: `periodic=c(1,1)` previously parsed `1,1` as axis
1661                // indices, marking only axis 1 and dropping axis 0).
1662                let all_zero_one =
1663                    !entries.is_empty() && entries.iter().all(|v| v == "0" || v == "1");
1664                let has_repeat = {
1665                    let mut seen = std::collections::BTreeSet::new();
1666                    !entries.iter().all(|v| seen.insert(v.clone()))
1667                };
1668                let numeric_mask = all_zero_one && entries.len() == dim && has_repeat;
1669                if all_bool || numeric_mask {
1670                    if entries.len() != dim {
1671                        return Err(format!(
1672                            "periodic list length {} must match smooth dimension {}",
1673                            entries.len(),
1674                            dim
1675                        ));
1676                    }
1677                    for (i, v) in entries.iter().enumerate() {
1678                        axes[i] = matches!(v.as_str(), "true" | "yes" | "y" | "1");
1679                    }
1680                } else {
1681                    for axis_raw in entries {
1682                        let axis = axis_raw
1683                            .parse::<usize>()
1684                            .map_err(|err| format!("invalid periodic axis '{axis_raw}': {err}"))?;
1685                        if axis >= dim {
1686                            return Err(format!(
1687                                "periodic axis {axis} out of range for {dim}D smooth"
1688                            ));
1689                        }
1690                        axes[axis] = true;
1691                    }
1692                }
1693            }
1694        }
1695    }
1696    if let Some(raw) = options.get("boundary").or_else(|| options.get("bc")) {
1697        let boundary = parse_option_list(raw);
1698        // A scalar token applies to every margin; `validate_tensor_boundary_tokens`
1699        // has already refused any other length (#2782).
1700        if boundary.len() == 1 {
1701            if matches!(boundary[0].as_str(), "periodic" | "cyclic" | "cc") {
1702                axes.fill(true);
1703            }
1704        } else if boundary.len() == dim {
1705            for (axis, value) in boundary.iter().enumerate() {
1706                if matches!(value.as_str(), "periodic" | "cyclic" | "cc") {
1707                    axes[axis] = true;
1708                }
1709            }
1710        }
1711    }
1712    // A per-margin basis vector (`bs=c('cc','ps')` / `type=[...]`) declares each
1713    // margin's basis family, and a cyclic family (`cc`/`cp`/`cyclic`) makes THAT
1714    // margin periodic — exactly as the 1-D `s(x, bs='cc')` smooth wraps its lone
1715    // axis. Without this, the per-margin `cc` token was validated but discarded:
1716    // every `bs=c(...)` spelling collapsed to the same open B-spline tensor
1717    // (#1752). Only honor the vector form here; a scalar `bs='cc'` on a tensor is
1718    // ambiguous about which margins wrap, so it does not flip any axis on.
1719    if let Some(raw) = options.get("bs").or_else(|| options.get("type"))
1720        && bs_selector_is_vector(raw)
1721    {
1722        let per_margin = parse_option_list(raw);
1723        if per_margin.len() == dim {
1724            for (axis, margin_bs) in per_margin.iter().enumerate() {
1725                if matches!(canonicalize_smooth_type(margin_bs), "cc" | "cp" | "cyclic") {
1726                    axes[axis] = true;
1727                }
1728            }
1729        }
1730    }
1731    // A per-margin period list names its own margins, so it declares
1732    // periodicity just as `periodic=`/`bc=` do (#2781). Without this,
1733    // `te(th, h, periods=[2*pi, None])` was bit-identical to `te(th, h)`.
1734    let explicitly_aperiodic = options
1735        .get("periodic")
1736        .or_else(|| options.get("cyclic"))
1737        .is_some_and(|raw| {
1738            matches!(
1739                raw.trim().to_ascii_lowercase().as_str(),
1740                "false" | "no" | "n"
1741            )
1742        });
1743    fold_in_declared_periods(options, dim, &mut axes, explicitly_aperiodic)?;
1744    Ok(axes)
1745}
1746
1747/// Validate the per-margin `boundary=`/`bc=` tokens on a tensor-product smooth.
1748///
1749/// The tensor `boundary`/`bc` list selects, per margin, whether the margin
1750/// *wraps* (a `periodic`/`cyclic`/`cc` token, consumed by
1751/// [`parse_tensor_periodic_axes`]) or is an ordinary non-periodic margin. In the
1752/// tensor DSL a *non-periodic* margin is spelled `clamped` — in the B-spline
1753/// sense of a **clamped knot vector**, i.e. the standard open spline that is
1754/// free at its two ends and does not wrap (exactly how the callers document it:
1755/// "non-periodic / clamped … free at the two ends, no wrap"). It is therefore an
1756/// inert marker here, not a zero-derivative endpoint reparameterization: a
1757/// cylinder `te(theta, z, boundary=['periodic','clamped'], …)` is a cyclic θ
1758/// margin tensor-producted with an ordinary open z margin, the direct analog of
1759/// mgcv `te(bs=c("cc","ps"))` / `te(bs=c("cc","cr"))`.
1760///
1761/// The periodic selectors and the inert non-periodic markers
1762/// (`clamped`/`open`/`natural`/`free`/`none`/empty) are accepted; anything else
1763/// (e.g. a genuine `anchored` zero-value endpoint constraint, which has no
1764/// ordinary-margin meaning in a tensor) is surfaced as a clean
1765/// unsupported-feature error rather than silently dropped. Previously `clamped`
1766/// itself was rejected, so the cylinder/torus mixed-boundary tensors — the exact
1767/// construction the manifold quality suite builds — could not be fit at all.
1768fn validate_tensor_boundary_tokens(
1769    options: &BTreeMap<String, String>,
1770    dim: usize,
1771) -> Result<(), String> {
1772    let Some(raw) = options.get("boundary").or_else(|| options.get("bc")) else {
1773        return Ok(());
1774    };
1775    let entries = parse_option_list(raw);
1776    // A scalar token applies to every margin (the same broadcast `k=`, `bs=` and
1777    // `degree=` use); any other length names margins that do not exist. Both were
1778    // previously accepted and then dropped by the `len() == dim` guard in
1779    // `parse_tensor_periodic_axes`, so `te(x, z, bc='periodic')` silently built
1780    // an aperiodic tensor (#2782).
1781    if entries.len() != 1 && entries.len() != dim {
1782        return Err(TermBuilderError::invalid_option(format!(
1783            "tensor smooth bc/boundary={raw:?} has {} entries but the smooth has {dim} margins; \
1784             pass one token per margin or a single token for all of them",
1785            entries.len()
1786        ))
1787        .to_string());
1788    }
1789    for (axis, value) in entries.iter().enumerate() {
1790        let inert = matches!(
1791            value.trim().to_ascii_lowercase().as_str(),
1792            "clamped" | "open" | "natural" | "free" | "none" | "" | "periodic" | "cyclic" | "cc"
1793        );
1794        if !inert {
1795            return Err(TermBuilderError::unsupported_feature(format!(
1796                "tensor smooth margin {axis} boundary token '{value}' is not supported \
1797                 (got bc/boundary={raw:?} on a {dim}-D tensor); tensor margins accept the periodic \
1798                 selectors (periodic/cyclic/cc) or the non-periodic markers (clamped/open/natural/free). \
1799                 Apply anchored/zero-value endpoint constraints with a 1-D s(x, bc=...) term instead."
1800            ))
1801            .to_string());
1802        }
1803    }
1804    Ok(())
1805}
1806
1807fn tensor_k_axis_option_axis(
1808    key: &str,
1809    cols: &[usize],
1810    ds: &Dataset,
1811) -> Result<Option<usize>, String> {
1812    let Some(suffix) = key.strip_prefix("k_") else {
1813        return Ok(None);
1814    };
1815    if suffix.is_empty() {
1816        return Err("tensor k axis option must be named k_<axis> or k_<variable>".to_string());
1817    }
1818    if let Ok(axis) = suffix.parse::<usize>() {
1819        return if axis < cols.len() {
1820            Ok(Some(axis))
1821        } else {
1822            Err(format!(
1823                "tensor k axis option `{key}` references axis {axis}, but the smooth has {} margins",
1824                cols.len()
1825            ))
1826        };
1827    }
1828
1829    let mut matches = cols
1830        .iter()
1831        .enumerate()
1832        .filter(|(_, col)| ds.headers.get(**col).is_some_and(|name| name == suffix))
1833        .map(|(axis, _)| axis);
1834    let first = matches.next();
1835    if matches.next().is_some() {
1836        return Err(format!(
1837            "tensor k axis option `{key}` matches more than one margin named `{suffix}`"
1838        ));
1839    }
1840    first.map(Some).ok_or_else(|| {
1841        let margin_names = cols
1842            .iter()
1843            .enumerate()
1844            .map(|(axis, col)| {
1845                let name = ds
1846                    .headers
1847                    .get(*col)
1848                    .map(String::as_str)
1849                    .unwrap_or("<unnamed>");
1850                format!("{axis}:{name}")
1851            })
1852            .collect::<Vec<_>>()
1853            .join(", ");
1854        format!(
1855            "tensor k axis option `{key}` does not match a margin index or name; tensor margins are [{margin_names}]"
1856        )
1857    })
1858}
1859
1860fn is_tensor_k_axis_option_key(key: &str) -> bool {
1861    key.strip_prefix("k_")
1862        .is_some_and(|suffix| !suffix.is_empty())
1863}
1864
1865/// Parse a per-margin basis dimension list (`k=<scalar>`, `k=[k0, k1, ...]`,
1866/// or axis aliases like `k_x=...` / `k_0=...`). A scalar is broadcast across
1867/// all axes; `None` returns the heuristic from the data column.
1868fn parse_tensor_k_list(
1869    options: &BTreeMap<String, String>,
1870    cols: &[usize],
1871    ds: &Dataset,
1872) -> Result<(Vec<usize>, bool), String> {
1873    let mut axis_values = vec![None; cols.len()];
1874    let mut saw_axis_alias = false;
1875    for (key, value) in options {
1876        let Some(axis) = tensor_k_axis_option_axis(key, cols, ds)? else {
1877            continue;
1878        };
1879        saw_axis_alias = true;
1880        if axis_values[axis].is_some() {
1881            return Err(format!("tensor k axis {axis} is specified more than once"));
1882        }
1883        let k: usize = value
1884            .parse()
1885            .map_err(|err| format!("invalid tensor k option `{key}={value}`: {err}"))?;
1886        axis_values[axis] = Some(k);
1887    }
1888
1889    let raw = options
1890        .get("k")
1891        .or_else(|| options.get("basis_dim"))
1892        .or_else(|| options.get("basis-dim"))
1893        .or_else(|| options.get("basisdim"));
1894    if saw_axis_alias {
1895        if raw.is_some() {
1896            return Err(
1897                "tensor k axis aliases cannot be combined with k= or basis_dim=".to_string(),
1898            );
1899        }
1900        if let Some(missing_axis) = axis_values.iter().position(Option::is_none) {
1901            let margin_name = cols
1902                .get(missing_axis)
1903                .and_then(|col| ds.headers.get(*col))
1904                .map(String::as_str)
1905                .unwrap_or("<unnamed>");
1906            return Err(format!(
1907                "tensor k axis aliases must specify every margin; missing axis {missing_axis} ({margin_name})"
1908            ));
1909        }
1910        return Ok((
1911            axis_values
1912                .into_iter()
1913                .map(|k| k.expect("missing axis values rejected above"))
1914                .collect(),
1915            false,
1916        ));
1917    }
1918    let Some(raw) = raw else {
1919        let inferred = heuristic_tensor_margin_knots(cols, ds);
1920        return Ok((inferred, true));
1921    };
1922    let entries = split_list_option(raw);
1923    if entries.len() == 1 {
1924        let k: usize = entries[0]
1925            .parse()
1926            .map_err(|err| format!("invalid tensor k '{}': {err}", entries[0]))?;
1927        return Ok((vec![k; cols.len()], false));
1928    }
1929    if entries.len() != cols.len() {
1930        return Err(format!(
1931            "tensor k list length {} must match smooth dimension {}",
1932            entries.len(),
1933            cols.len()
1934        ));
1935    }
1936    let mut out = Vec::with_capacity(entries.len());
1937    for entry in entries {
1938        let k: usize = entry
1939            .parse()
1940            .map_err(|err| format!("invalid tensor k '{entry}': {err}"))?;
1941        out.push(k);
1942    }
1943    Ok((out, false))
1944}
1945
1946/// Parse the `identifiability=` option for tensor-product smooths. Mirrors the
1947/// vocabulary of the Matern/Duchon parsers so the formula DSL is consistent.
1948///
1949/// `kind` selects the default identifiability when no explicit
1950/// `identifiability=` option is supplied: `te(...)` ([`SmoothKind::Te`]) keeps
1951/// the full-tensor sum-to-zero default, while `ti(...)` ([`SmoothKind::Ti`])
1952/// defaults to per-margin sum-to-zero so the marginal main effects are excluded
1953/// (the mgcv tensor-interaction semantics). An explicit option always wins.
1954fn parse_tensor_identifiability(
1955    options: &BTreeMap<String, String>,
1956    kind: SmoothKind,
1957) -> Result<TensorBSplineIdentifiability, String> {
1958    let Some(raw) = options.get("identifiability").map(String::as_str) else {
1959        return Ok(match kind {
1960            SmoothKind::Ti => TensorBSplineIdentifiability::MarginalSumToZero,
1961            _ => TensorBSplineIdentifiability::default(),
1962        });
1963    };
1964    match raw.trim().to_ascii_lowercase().as_str() {
1965        "none" => Ok(TensorBSplineIdentifiability::None),
1966        "sum_tozero" | "sum-to-zero" | "center_sum_tozero" | "center-sum-to-zero" | "centered"
1967        | "sumtozero" => Ok(TensorBSplineIdentifiability::SumToZero),
1968        "marginal_sum_tozero" | "marginal-sum-to-zero" | "marginal_sumtozero"
1969        | "marginalsumtozero" | "interaction" => {
1970            Ok(TensorBSplineIdentifiability::MarginalSumToZero)
1971        }
1972        other => Err(TermBuilderError::unsupported_feature(format!(
1973            "invalid tensor identifiability '{other}'; expected one of: none, sum_tozero, marginal_sum_tozero"
1974        ))
1975        .to_string()),
1976    }
1977}
1978
1979/// Parse the `identifiability=` option for every 1-D B-spline family arm —
1980/// `s()` / `bs='ps'|'bspline'|'cr'|'cs'` and the cyclic `cc`/`cp`/`periodic`
1981/// selector.
1982///
1983/// Returns `Ok(None)` when the option is absent so each arm can keep applying
1984/// its own *structural* default: an anchored endpoint is already the model's
1985/// level gauge and therefore defaults to [`BSplineIdentifiability::None`],
1986/// while every other 1-D smooth defaults to sum-to-zero centering. An explicit
1987/// token always wins over the default, and an unrecognised one is refused
1988/// rather than silently discarded (#2783).
1989///
1990/// The vocabulary deliberately mirrors [`parse_tensor_identifiability`],
1991/// [`parse_matern_identifiability`] and [`parse_spatial_identifiability`] so a
1992/// token means the same thing on every smooth kind: `none` keeps the
1993/// unconstrained basis columns, the `sum_tozero` family centers, and `linear`
1994/// removes the constant *and* linear directions (the 1-D Greville-geometry
1995/// analogue of the Matérn `CenterLinearOrthogonal` policy).
1996///
1997/// [`BSplineIdentifiability::OrthogonalToDesignColumns`] and
1998/// [`BSplineIdentifiability::FrozenTransform`] are engine-internal: the first
1999/// needs a design-column block no formula can name, the second is minted by
2000/// design freezing at fit time. Both are refused with a message that says so,
2001/// exactly as `parse_spatial_identifiability` refuses `frozen`.
2002fn parse_bspline_identifiability(
2003    options: &BTreeMap<String, String>,
2004) -> Result<Option<BSplineIdentifiability>, String> {
2005    let Some(raw) = options.get("identifiability").map(String::as_str) else {
2006        return Ok(None);
2007    };
2008    match raw.trim().to_ascii_lowercase().as_str() {
2009        "none" => Ok(Some(BSplineIdentifiability::None)),
2010        "sum_tozero" | "sum-to-zero" | "center_sum_tozero" | "center-sum-to-zero" | "centered"
2011        | "sumtozero" => Ok(Some(BSplineIdentifiability::WeightedSumToZero {
2012            weights: None,
2013        })),
2014        "linear" | "remove_linear_trend" | "remove-linear-trend" | "removelineartrend"
2015        | "center_linear_orthogonal" | "center-linear-orthogonal" => {
2016            Ok(Some(BSplineIdentifiability::RemoveLinearTrend))
2017        }
2018        "frozen" | "frozen_transform" | "orthogonal" | "orthogonal_to_design_columns" => {
2019            Err(TermBuilderError::unsupported_feature(format!(
2020                "B-spline identifiability '{}' is internal-only (it is minted by design freezing \
2021                 or needs an explicit design-column block); use one of: none, sum_tozero, linear",
2022                raw.trim()
2023            ))
2024            .to_string())
2025        }
2026        other => Err(TermBuilderError::unsupported_feature(format!(
2027            "invalid B-spline identifiability '{other}'; expected one of: none, sum_tozero, linear"
2028        ))
2029        .to_string()),
2030    }
2031}
2032
2033/// The structural facts about a 1-D B-spline arm that decide which
2034/// identifiability policies are simultaneously satisfiable with the basis the
2035/// arm is about to build.
2036#[derive(Debug, Clone, Copy, Default)]
2037struct BSplineIdentifiabilityContext {
2038    /// An endpoint is pinned to a value, so the smooth already carries its own
2039    /// level gauge and the global intercept is suppressed.
2040    has_anchor: bool,
2041    /// The basis wraps, so no aperiodic (constant + linear) chart applies.
2042    periodic: bool,
2043    /// The basis is a natural cubic regression spline indexed by value-at-knot
2044    /// (`bs="cr"`/`"cs"`), which carries no B-spline knot/degree geometry for a
2045    /// Greville-abscissae chart to be built from.
2046    natural_cubic_regression: bool,
2047}
2048
2049/// Resolve the 1-D B-spline identifiability policy from the caller's
2050/// `identifiability=` token (if any) and the structural default the arm would
2051/// otherwise apply, refusing the combinations that are not simultaneously
2052/// satisfiable.
2053///
2054/// Three refusals, each of which would otherwise be a silent mis-fit rather
2055/// than a mere style violation:
2056///
2057/// * **anchored endpoint + a centering policy.** An anchored endpoint pins the
2058///   function's absolute level and suppresses the global intercept, so the
2059///   *fitted function* — not a centered deviation — obeys the pin. Layering
2060///   sum-to-zero on top demands the same function additionally have sample mean
2061///   zero, which excludes every non-zero-mean anchored curve from the model
2062///   space before REML is even evaluated (#1867, #2297). The implicit default
2063///   already resolves this by choosing `None`; an explicit request for the
2064///   incompatible policy is refused rather than quietly overridden.
2065/// * **periodic basis + `linear`.** [`BSplineIdentifiability::RemoveLinearTrend`]
2066///   builds its transform from the Greville abscissae of an *open* knot vector
2067///   and removes the constant and linear directions. A linear trend is not a
2068///   periodic function, so it is not in the span of a cyclic basis at all: the
2069///   constraint is ill-posed there, and the transform would in any case be
2070///   derived from the wrong knot geometry.
2071/// * **`bs="cr"`/`"cs"` + `linear`.** The natural cubic regression basis is
2072///   parameterized by function values at its knots, so a Greville-based linear
2073///   removal would be applied to the wrong coordinates.
2074///   [`crate::basis::build_cubic_regression_basis_1d`] refuses this too; doing
2075///   it here turns a fit-time basis error into a formula-time configuration
2076///   error naming the option the user actually wrote.
2077fn resolve_bspline_identifiability(
2078    options: &BTreeMap<String, String>,
2079    structural_default: BSplineIdentifiability,
2080    context: BSplineIdentifiabilityContext,
2081) -> Result<BSplineIdentifiability, String> {
2082    let Some(explicit) = parse_bspline_identifiability(options)? else {
2083        return Ok(structural_default);
2084    };
2085    if context.has_anchor && !matches!(explicit, BSplineIdentifiability::None) {
2086        return Err(TermBuilderError::incompatible_config(
2087            "an anchored endpoint already fixes the smooth's level (the global intercept is \
2088             suppressed), so it cannot also carry a centering identifiability constraint; \
2089             drop the anchor or use identifiability='none'",
2090        )
2091        .to_string());
2092    }
2093    if matches!(explicit, BSplineIdentifiability::RemoveLinearTrend) {
2094        if context.periodic {
2095            return Err(TermBuilderError::incompatible_config(
2096                "identifiability='linear' removes the constant and linear directions using \
2097                 open-knot Greville geometry, which a periodic basis does not span; use 'none' \
2098                 or 'sum_tozero' on a periodic smooth",
2099            )
2100            .to_string());
2101        }
2102        if context.natural_cubic_regression {
2103            return Err(TermBuilderError::incompatible_config(
2104                "identifiability='linear' needs B-spline knot/degree geometry, which the natural \
2105                 cubic regression basis (bs='cr'/'cs') does not carry; use 'none' or 'sum_tozero', \
2106                 or switch to bs='ps'",
2107            )
2108            .to_string());
2109        }
2110    }
2111    Ok(explicit)
2112}
2113
2114fn bspline_boundary_declares_periodic_axis(options: &BTreeMap<String, String>) -> bool {
2115    options
2116        .get("boundary")
2117        .or_else(|| options.get("bc"))
2118        .map(|raw| {
2119            parse_option_list(raw)
2120                .into_iter()
2121                .any(|value| matches!(value.as_str(), "periodic" | "cyclic" | "cc"))
2122        })
2123        .unwrap_or(false)
2124}
2125
2126/// Canonical-name lookup for the `bs=`/`type=` smooth selector.
2127///
2128/// User-facing names — including mgcv-compatible spellings whose semantics
2129/// match an existing gamfit smooth exactly — collapse to the engine-internal
2130/// canonical names used by the dispatch in [`build_smooth_basis`]. Adding a
2131/// new exactly-equivalent alias is a one-line entry here; the match arms
2132/// below remain the single dispatch site.
2133///
2134/// Aliases listed here MUST be true semantic equivalents of the canonical
2135/// target, not approximations. mgcv names whose semantics differ from any
2136/// gamfit smooth (e.g. `bs="ts"` shrinkage thin-plate, `bs="ad"` adaptive)
2137/// are intentionally NOT mapped here — they should reach the unsupported-type
2138/// path so users get a real diagnostic instead of a silent semantic
2139/// substitution. mgcv's `bs="cr"`/`"cs"` (cubic regression and its shrinkage
2140/// twin) are handled directly in the [`build_smooth_basis`] dispatch — they
2141/// are not aliased here because the `cr`/`cs` distinction controls a default
2142/// (`double_penalty`) that the canonical-name layer cannot see.
2143///
2144/// Unrecognised inputs pass through unchanged so the dispatch can produce its
2145/// usual "unsupported smooth type" error, preserving the existing diagnostic
2146/// surface for genuine typos.
2147pub(crate) fn canonicalize_smooth_type(raw: &str) -> &str {
2148    match raw {
2149        // Thin-plate spline. mgcv `bs="tp"` is the default thin-plate
2150        // regression spline — exact semantic equivalent of gamfit's `"tps"`.
2151        "tp" => "tps",
2152        // Gaussian process / Matérn. mgcv `bs="gp"` defaults to a Matérn
2153        // covariance kernel with REML smoothing parameter selection, which
2154        // matches gamfit's `"matern"` exactly (same kernel-Gram identity,
2155        // same REML route).
2156        "gp" => "matern",
2157        // Constant-curvature (M_κ) geodesic-kernel smooth (#944). All aliases
2158        // collapse to one canonical type so `bs="curv"`/`bs="mkappa"` cannot
2159        // diverge from `curv(...)`.
2160        "curv" | "constant_curvature" | "mkappa" => "curvature",
2161        // Measure-jet spline: multiscale local-jet-residual energy of the
2162        // empirical measure. No mgcv equivalent (mgcv has no measure-learned
2163        // geometry smooth), so no mgcv alias is mapped.
2164        "mjs" | "measure_jet" | "web" => "measurejet",
2165        other => other,
2166    }
2167}
2168
2169/// Is `margin_bs` a per-margin basis name that the tensor builder realizes as a
2170/// penalized 1-D B-spline margin?
2171///
2172/// gam's tensor product is built from penalized B-spline marginals. mgcv's
2173/// thin-plate (`tp`/`tps`), P-spline (`ps`), B-spline (`bs`), cubic-regression
2174/// (`cr`/`cs`), and cyclic (`cc`/`cp`/`cyclic`) marginals are all penalized
2175/// splines spanning the same per-axis smoothing space, so a B-spline margin
2176/// reproduces the same tensor smoothing class. Margin kinds with fundamentally
2177/// different structure (adaptive, random-effect, sphere) are NOT accepted as
2178/// tensor margins.
2179pub(crate) fn tensor_margin_bs_is_supported(margin_bs: &str) -> bool {
2180    matches!(
2181        canonicalize_smooth_type(margin_bs),
2182        "tps" | "ps" | "bs" | "bspline" | "cr" | "cs" | "cc" | "cp" | "cyclic"
2183    )
2184}
2185
2186/// Does the smooth request a periodic/cyclic axis via its options?
2187///
2188/// Mirrors the boundary-condition reading used by the periodic-aware dispatch
2189/// branches. Factored out so the type resolver and `build_smooth_basis` agree
2190/// on a single notion of "periodic requested".
2191pub(crate) fn smooth_options_declare_periodic(options: &BTreeMap<String, String>) -> bool {
2192    options.contains_key("periodic")
2193        || options.contains_key("cyclic")
2194        || options
2195            .get("boundary")
2196            .or_else(|| options.get("bc"))
2197            .map(|boundary| {
2198                boundary.to_ascii_lowercase().contains("periodic")
2199                    || boundary.to_ascii_lowercase().contains("cyclic")
2200            })
2201            .unwrap_or(false)
2202}
2203
2204/// Resolve the canonical engine-internal smooth-type name for a term.
2205///
2206/// Reads the user-facing `type=`/`bs=` selector and collapses mgcv-compatible
2207/// aliases (`tp`→`tps`, `gp`→`matern`) via [`canonicalize_smooth_type`], or
2208/// derives the default from the smooth kind/arity when no selector is given.
2209/// This is the single source of truth for the dispatch in
2210/// [`build_smooth_basis`]; other call sites (e.g. predictor-specific basis
2211/// policy) use it so the classification never drifts from the dispatch.
2212/// Is the raw `bs=`/`type=` selector a vector literal (`c('tp','tp')`,
2213/// `['tp','tp']`, `(tp, tp)`) rather than a scalar smooth-type name?
2214///
2215/// mgcv's tensor smooths take a *per-margin* basis vector
2216/// (`te(x1, x2, bs=c('tp','tp'))`). Such a value is not a scalar canonical
2217/// type and must not be fed through [`canonicalize_smooth_type`] — it has to be
2218/// recognized as a tensor request and split into per-margin types. A scalar
2219/// selector (`bs="tp"`) is left untouched.
2220pub(crate) fn bs_selector_is_vector(raw: &str) -> bool {
2221    let trimmed = raw.trim();
2222    let bracketed = (trimmed.starts_with('[') && trimmed.ends_with(']'))
2223        || (trimmed.starts_with("c(") || trimmed.starts_with("C(")) && trimmed.ends_with(')')
2224        || (trimmed.starts_with('(') && trimmed.ends_with(')'));
2225    bracketed && !parse_option_list(trimmed).is_empty()
2226}
2227
2228pub fn resolve_smooth_type_name(
2229    kind: SmoothKind,
2230    n_cols: usize,
2231    options: &BTreeMap<String, String>,
2232) -> String {
2233    let selector = options.get("type").or_else(|| options.get("bs"));
2234    // A per-margin basis vector is a tensor request, never a scalar type. Route
2235    // it to the tensor builder, which reads the per-margin types out of the
2236    // same `bs=` option. (A vector on a non-tensor smooth is ill-formed and
2237    // falls through to the scalar path below so the existing diagnostic fires.)
2238    if let Some(raw) = selector
2239        && bs_selector_is_vector(raw)
2240        && matches!(kind, SmoothKind::Te | SmoothKind::Ti | SmoothKind::T2)
2241    {
2242        return "tensor".to_string();
2243    }
2244    selector
2245        .map(|s| canonicalize_smooth_type(&s.to_ascii_lowercase()).to_string())
2246        .unwrap_or_else(|| match kind {
2247            SmoothKind::Te | SmoothKind::Ti | SmoothKind::T2 => "tensor".to_string(),
2248            SmoothKind::S if n_cols == 1 => "bspline".to_string(),
2249            // Mixed periodic Euclidean radial kernels are not separable on the
2250            // cylinder. Use a tensor product with a cyclic margin so s(theta,h)
2251            // honors seam continuity while preserving the formula-level s(...).
2252            SmoothKind::S if smooth_options_declare_periodic(options) => "tensor".to_string(),
2253            SmoothKind::S => "tps".to_string(),
2254        })
2255}
2256
2257/// Does this canonical smooth type size its basis through the generous spatial
2258/// center heuristic ([`crate::basis::default_num_centers`])?
2259///
2260/// Only the radial spatial bases (thin-plate, Matérn/GP, Duchon) route their
2261/// default basis dimension through `plan_spatial_basis(.., Default, ..)`. The
2262/// B-spline, cyclic, tensor, and factor-smooth bases use their own modest
2263/// knot-based defaults, so they are unaffected by — and must not be perturbed
2264/// by — secondary-predictor basis-parsimony adjustments (#501).
2265pub fn smooth_type_uses_spatial_center_heuristic(canonical_type: &str) -> bool {
2266    matches!(canonical_type, "tps" | "matern" | "duchon")
2267}
2268
2269pub fn build_smooth_basis(
2270    kind: SmoothKind,
2271    vars: &[String],
2272    cols: &[usize],
2273    options: &BTreeMap<String, String>,
2274    ds: &Dataset,
2275    inference_notes: &mut Vec<String>,
2276    policy: &ResourcePolicy,
2277    smooth_coordinate_count: usize,
2278) -> Result<SmoothBasisSpec, String> {
2279    // Strip the internal by-level sizing carrier before any per-kind option
2280    // allow-list runs (the `__by_col` pattern): `sizing_rows` feeds every
2281    // n-scaling BASIS DEFAULT below; explicit user counts are untouched.
2282    let stripped_sizing_options;
2283    let (options, sizing_rows) = match options.get(DEFAULT_SIZING_ROWS_OPTION) {
2284        Some(raw) => {
2285            let rows = raw.parse::<usize>().map_err(|_| {
2286                format!("internal by-level sizing rows carrier is not a count: '{raw}'")
2287            })?;
2288            let mut cleaned = options.clone();
2289            cleaned.remove(DEFAULT_SIZING_ROWS_OPTION);
2290            stripped_sizing_options = cleaned;
2291            (&stripped_sizing_options, rows)
2292        }
2293        None => (options, ds.values.nrows()),
2294    };
2295    // Fail fast on degenerate input: a smooth whose (non-categorical) coordinate
2296    // columns collapse to a SINGLE distinct point can only ever fit the response
2297    // mean — its design matrix is rank-1. For a UNIVARIATE smooth this is exactly
2298    // "the one column is constant": `smooth(x)`/`matern(x)` on constant `x` would
2299    // otherwise silently fit the mean of `y` with no visible cue (Duchon already
2300    // errors loudly via the basis layer; this makes the diagnosis explicit and
2301    // uniform). For a general MULTIVARIATE Euclidean smooth (tensor, tps,
2302    // matern, ...) a single constant coordinate is NOT degenerate — the basis
2303    // still varies along the other coordinate(s) and the penalty absorbs the
2304    // rank-deficient direction (a constant-`x2` slice of `tps(x1, x2)` is a
2305    // well-posed 1-D function of `x1`). Such a term is degenerate only when
2306    // EVERY coordinate is constant at once, i.e. the joint input is a single
2307    // point. Test the JOINT cardinality, not each column independently, so the
2308    // loud diagnosis still fires for the genuinely rank-1 case without rejecting
2309    // well-posed lower-dimensional slices.
2310    //
2311    // The SPHERE/SOS term is the exception (handled separately just below): its
2312    // spherical-harmonic / Wahba basis is intrinsically a function of BOTH
2313    // angular coordinates, so a constant latitude or longitude is not an honest
2314    // lower-D slice but an unidentifiable axis (every point on a single meridian
2315    // or parallel) — that case is rejected per-coordinate.
2316    let coord_cols: Vec<(&String, usize)> = vars
2317        .iter()
2318        .zip(cols.iter().copied())
2319        .filter(|(_, col)| !matches!(ds.column_kinds.get(*col), Some(ColumnKindTag::Categorical)))
2320        .collect();
2321    if !coord_cols.is_empty() {
2322        let views: Vec<ArrayView1<'_, f64>> = coord_cols
2323            .iter()
2324            .map(|(_, col)| ds.values.column(*col))
2325            .collect();
2326        let n_rows = views[0].len();
2327        let mut distinct_points = std::collections::HashSet::<Vec<u64>>::new();
2328        for r in 0..n_rows {
2329            let key: Vec<u64> = views
2330                .iter()
2331                .map(|v| gam_data::canonical_level_bits(v[r]))
2332                .collect();
2333            distinct_points.insert(key);
2334            if distinct_points.len() > 1 {
2335                break;
2336            }
2337        }
2338        if distinct_points.len() <= 1 {
2339            return Err(TermBuilderError::degenerate_data(if coord_cols.len() == 1 {
2340                let var = coord_cols[0].0;
2341                format!(
2342                    "smooth term over '{var}' has only one unique value in the training data \
2343                     — a smooth on a constant column is degenerate and would only fit the response mean. \
2344                     Remove `{var}` from the smooth, drop the term, or check the data."
2345                )
2346            } else {
2347                let names = coord_cols
2348                    .iter()
2349                    .map(|(v, _)| v.as_str())
2350                    .collect::<Vec<_>>()
2351                    .join(", ");
2352                format!(
2353                    "smooth term over ({names}) has only one unique joint coordinate in the training \
2354                     data — every coordinate is constant, so the smooth is degenerate and would only \
2355                     fit the response mean. Drop the term or check the data."
2356                )
2357            })
2358            .to_string());
2359        }
2360
2361        // Sphere/SOS exception: the S² smooth is intrinsically a function of
2362        // BOTH angular coordinates, so a single constant axis is unidentifiable
2363        // (every point on one meridian or one parallel), not an honest 1-D
2364        // slice. Reject it per-coordinate at fit-time with a coordinate-named
2365        // error. This runs ONLY during term construction (build_smooth_basis);
2366        // predict rebuilds the design from the frozen resolvedspec and never
2367        // re-enters this path, so a constant predict grid (e.g. a single query
2368        // point on a fixed meridian) is never re-validated (#frozen-mass).
2369        if matches!(
2370            resolve_smooth_type_name(kind, cols.len(), options).as_str(),
2371            "sphere" | "s2" | "sos"
2372        ) {
2373            for (axis, (var, col)) in coord_cols.iter().enumerate() {
2374                let column = ds.values.column(*col);
2375                let mut distinct = std::collections::HashSet::<u64>::new();
2376                for &value in column.iter() {
2377                    distinct.insert(gam_data::canonical_level_bits(value));
2378                    if distinct.len() > 1 {
2379                        break;
2380                    }
2381                }
2382                if distinct.len() <= 1 {
2383                    // Axis 0 is latitude, axis 1 longitude (formula order
2384                    // `sphere(lat, lon)`); name the collapsed slice accordingly.
2385                    let slice = if axis == 0 {
2386                        "a single parallel (constant latitude)"
2387                    } else {
2388                        "a single meridian (constant longitude)"
2389                    };
2390                    return Err(TermBuilderError::degenerate_data(format!(
2391                        "sphere smooth has a constant '{var}' column — every point lies on \
2392                         {slice}, so the 2-sphere term is degenerate and unidentifiable along \
2393                         that axis. A spherical smooth needs genuine variation in BOTH latitude \
2394                         and longitude; vary '{var}', drop the term, or fit a 1-D smooth on the \
2395                         varying coordinate."
2396                    ))
2397                    .to_string());
2398                }
2399            }
2400        }
2401    }
2402    if let Some(by_name) = options.get("by").cloned() {
2403        let by_col = options
2404            .get("__by_col")
2405            .and_then(|raw| raw.parse::<usize>().ok())
2406            .or_else(|| vars.iter().position(|v| v == &by_name).map(|idx| cols[idx]))
2407            .ok_or_else(|| format!("unknown by= column '{by_name}'"))?;
2408        let mut inner_options = options.clone();
2409        inner_options.remove("by");
2410        inner_options.remove("__by_col");
2411        inner_options.remove("id");
2412        // Size the inner basis's n-scaling defaults from the smallest
2413        // by-level's rows (see `DEFAULT_SIZING_ROWS_OPTION`); numeric-by
2414        // smooths keep pooled sizing.
2415        inject_by_level_sizing_rows(&mut inner_options, ds, by_col);
2416        let mut inner = build_smooth_basis(
2417            kind,
2418            vars,
2419            cols,
2420            &inner_options,
2421            ds,
2422            inference_notes,
2423            policy,
2424            smooth_coordinate_count,
2425        )?;
2426        // Same rule as the formula path: a continuous by-variable's constant
2427        // direction is the by-variable itself, so it stays in the penalised
2428        // block unless an explicit `identifiability=` says otherwise.
2429        if matches!(ds.column_kinds.get(by_col), Some(ColumnKindTag::Continuous))
2430            && !options.contains_key("identifiability")
2431        {
2432            crate::smooth::keep_constant_in_numeric_by_smooth(&mut inner);
2433        }
2434        let by_kind = match ds.column_kinds.get(by_col).copied() {
2435            Some(ColumnKindTag::Categorical) => ByVarKind::Factor {
2436                feature_col: by_col,
2437                ordered: option_bool(options, "ordered").unwrap_or(false),
2438                frozen_levels: None,
2439            },
2440            Some(ColumnKindTag::Continuous | ColumnKindTag::Binary) => ByVarKind::Numeric {
2441                feature_col: by_col,
2442            },
2443            None => {
2444                return Err(format!(
2445                    "internal column-kind lookup failed for by='{by_name}'"
2446                ));
2447            }
2448        };
2449        return Ok(SmoothBasisSpec::BySmooth {
2450            smooth: Box::new(inner),
2451            by_kind,
2452        });
2453    }
2454
2455    let smooth_double_penalty = option_bool(options, "double_penalty").unwrap_or(true);
2456    let type_opt = resolve_smooth_type_name(kind, cols.len(), options);
2457
2458    if matches!(type_opt.as_str(), "fs" | "sz" | "re") {
2459        if type_opt == "re" {
2460            validate_random_effect_smooth_options(options)?;
2461        } else {
2462            validate_known_options(type_opt.as_str(), options, FACTOR_SMOOTH_OPTION_KEYS)?;
2463        }
2464        if cols.len() != 2 {
2465            return Err(format!(
2466                "{} factor-smooth currently expects exactly two variables (one numeric, one categorical)",
2467                type_opt
2468            ));
2469        }
2470        let kinds = cols
2471            .iter()
2472            .map(|&c| ds.column_kinds.get(c).copied())
2473            .collect::<Vec<_>>();
2474        let (cont_idx, group_idx) = if type_opt == "re" {
2475            // mgcv random-slope examples are often s(g, x, bs="re").
2476            match (kinds[0], kinds[1]) {
2477                (Some(ColumnKindTag::Categorical), _) => (1usize, 0usize),
2478                (_, Some(ColumnKindTag::Categorical)) => (0usize, 1usize),
2479                _ => (1usize, 0usize),
2480            }
2481        } else {
2482            match (kinds[0], kinds[1]) {
2483                (_, Some(ColumnKindTag::Categorical)) => (0usize, 1usize),
2484                (Some(ColumnKindTag::Categorical), _) => (1usize, 0usize),
2485                _ => {
2486                    return Err(format!(
2487                        "{} factor-smooth requires one categorical factor variable",
2488                        type_opt
2489                    ));
2490                }
2491            }
2492        };
2493        let c = cols[cont_idx];
2494        let (minv, maxv) = col_minmax(ds.values.column(c))?;
2495        let degree = if type_opt == "re" {
2496            1
2497        } else {
2498            option_usize(options, "degree").unwrap_or(DEFAULT_BSPLINE_DEGREE)
2499        };
2500        // For a factor smooth every group's curve is fit from THAT group's rows
2501        // alone, so the marginal's flexibility must respect the least-resolved
2502        // group, not the pooled column. The pooled heuristic can hand the marginal
2503        // a basis that saturates (or exceeds) a small group's sample — e.g. the
2504        // sleepstudy panel has 8 training days per subject, and a default cubic
2505        // basis of 8 functions interpolates each subject's 8 points, leaving no
2506        // room for the wiggliness penalty to collapse the curve toward the
2507        // per-subject line. The factor smooth then fits within-group noise and
2508        // extrapolates badly (held-out forecast worse than the population mean).
2509        //
2510        // Cap the marginal basis below the minimum per-group covariate resolution
2511        // so the penalty always retains residual degrees of freedom to shrink each
2512        // group's curvature toward its linear null space (the random-slope
2513        // estimand). This small-group cap composes with a separate upper bound at
2514        // mgcv's factor-smooth default k=10 (FACTOR_SMOOTH_DEFAULT_BASIS_DIM,
2515        // applied below), so even ample-data groups get the modest SHARED marginal
2516        // a factor smooth wants rather than the full pooled basis. The explicit
2517        // `re` random-effect form takes neither cap: it is a raw linear `[1, x]`
2518        // random effect (0 internal knots), handled in the branch above.
2519        let pooled_internal = heuristic_knots_for_column(ds.values.column(c));
2520        let default_internal = if type_opt == "re" {
2521            // `bs="re"` is a PARAMETRIC random effect, not a smooth of the
2522            // covariate: `s(x, g, bs="re")` is the mgcv random intercept+slope
2523            // `(1 + x | g)`, i.e. a per-group line `[1, x]`, penalized by an iid
2524            // ridge. A degree-1 marginal with ZERO internal knots spans exactly
2525            // that linear space (2 coefficients per group). Using the pooled
2526            // knot heuristic here instead turned the marginal into a
2527            // piecewise-linear B-spline (e.g. 6 functions/group on sleepstudy),
2528            // i.e. a *smooth* with kinks rather than a random slope — many extra
2529            // collinear-across-levels coefficients that ill-condition the joint
2530            // Newton/REML solve (minutes-long fits, and a singular block when
2531            // combined with a separate random intercept `s(g, bs="re")`). The
2532            // raw linear basis is both the correct `re` semantics and fast.
2533            0
2534        } else {
2535            let min_group_resolution =
2536                min_per_group_unique_count(ds.values.column(c), ds.values.column(cols[group_idx]));
2537            // Per-group basis dim = degree + 1 + internal. Hold it well below the
2538            // smallest group's resolution (leave at least two residual points per
2539            // group) so the smooth cannot interpolate that group and the
2540            // wiggliness penalty retains the room to collapse each curve toward
2541            // its linear null space. Never drop below `degree + 2`, which keeps
2542            // exactly the linear span plus a single curvature direction — the
2543            // minimal smoother that can still bend if the data demand it.
2544            let basis_cap = min_group_resolution.saturating_sub(2).max(degree + 2);
2545            let internal_cap = basis_cap.saturating_sub(degree + 1);
2546            let capped = pooled_internal.min(internal_cap.max(1));
2547            // A factor smooth (`fs` AND `sz`) shares ONE marginal across ALL
2548            // levels, each level's curve fit from that group's rows alone. The
2549            // pooled knot heuristic (driven by the full column's sample) hands it
2550            // a much richer basis than the shared signal needs — ~24
2551            // functions/group on the gam#903 factor-smooth-recovery fixtures — so
2552            // REML has the capacity to fit within-group noise and over-fits the
2553            // shared shape (fs: edf 58 vs mgcv's k=10/edf 39; sz: gam 0.068 vs
2554            // mgcv 0.046 truth RMSE), losing the truth-recovery head-to-head with
2555            // the mature tool. mgcv's factor-smooth default `k=10` embodies the
2556            // right convention: a modest shared marginal. Cap the marginal there
2557            // (basis ≈ degree+1+internal ≈ 10) for both flavours when the
2558            // small-group cap above is not already tighter, so REML is not handed
2559            // noise-fitting capacity it does not need. An explicit `k`/`basis_dim`
2560            // overrides this (parse_ps_internal_knots); `re` is the raw linear
2561            // effect handled above.
2562            let fs_default_internal = FACTOR_SMOOTH_DEFAULT_BASIS_DIM
2563                .saturating_sub(degree + 1)
2564                .max(1);
2565            capped.min(fs_default_internal)
2566        };
2567        let (n_knots, _, effective_degree) =
2568            parse_ps_internal_knots(options, degree, default_internal)?;
2569        // `m=` is mgcv's spelling of `penalty_order=` and is resolved as its
2570        // alias here (#2791). It used to be read further down as a boolean gate
2571        // on the `Fs` null-penalty path instead, which made every value >= 1 the
2572        // same model and dropped the key entirely on `sz`.
2573        let penalty_order = parse_penalty_order_alias(options)?
2574            .unwrap_or(if effective_degree > 1 { 2 } else { 1 })
2575            .min(effective_degree);
2576        // All factor-smooth flavours (`fs`, `sz`, `re`) place their per-level
2577        // marginal on the SAME penalized B-spline (P-spline) basis. The flavours
2578        // differ ONLY in their penalty/constraint structure (handled below) —
2579        // sz: zero-sum deviation blocks with the per-level null space left
2580        // unpenalized; fs: random-effect double penalty; re: identity ridge.
2581        //
2582        // `sz` USED to route its default-degree marginal to a NATURAL cubic
2583        // regression spline (`cr`), on the belief that mgcv's `bs="sz"` does the
2584        // same and that cr recovers smooth signals more efficiently than the
2585        // (then uncapped) B-spline margin (#1074). That introduced a consistency
2586        // failure (#1605): the `cr` basis enforces the natural boundary
2587        // conditions f''(x_1)=f''(x_k)=0 and extrapolates linearly past the end
2588        // knots, so it CANNOT represent a per-group deviation curve with non-zero
2589        // curvature at the data boundary. Phase-shifted deviation shapes
2590        // (f''(0) = -(2π)² sin(φ) ≠ 0) are then biased toward "free linear +
2591        // anchored wiggle", under-shooting the amplitude — a bias that does NOT
2592        // vanish as n→∞ (n-independent: a genuine consistency failure, not
2593        // finite-sample shrinkage). The earlier #700/#1074 sz fixtures used
2594        // d_g ∝ sin(2πx), whose f'' happens to vanish at x=0 and x=1, so they
2595        // accidentally satisfied the natural BC and never exposed the gap; the
2596        // `fs` sibling, on this very B-spline marginal, recovers the SAME
2597        // phase-shifted data to the noise floor.
2598        //
2599        // The penalized B-spline marginal makes no boundary assumption, so it
2600        // represents arbitrary deviation shapes, and — with the
2601        // FACTOR_SMOOTH_DEFAULT_BASIS_DIM cap above already removing the
2602        // noise-fitting capacity that originally motivated leaving B-splines —
2603        // it recovers the BC-satisfying #700/#1074 signals just as well. Sharing
2604        // one marginal basis across all flavours also lets the B-spline degree/
2605        // knot degradation handle low-cardinality covariates uniformly (what
2606        // `fs` already does), so the `sz`-only cr data-support cap (#1541/#1542)
2607        // — and the asymmetry where only the cr-marginal `sz` spelling hard-
2608        // failed a 3-level ordinal — is no longer needed.
2609        let marginal_knotspec = resolve_nonperiodic_bspline_knotspec(
2610            options,
2611            ds.values.column(c),
2612            (minv, maxv),
2613            effective_degree,
2614            n_knots,
2615        )?;
2616        let marginal = BSplineBasisSpec {
2617            degree: effective_degree,
2618            penalty_order,
2619            knotspec: marginal_knotspec,
2620            // mgcv's `bs="fs"` is a random-effect-style smooth: EVERY per-level
2621            // coefficient, including the marginal null space, is penalized so
2622            // unobserved groups can be predicted — so `fs` keeps the null-space
2623            // (double) penalty. mgcv's `bs="sz"` is a pure across-level
2624            // *deviation* smooth that, under the default `select=FALSE`, leaves
2625            // the per-level null space UNPENALIZED; carrying the double penalty
2626            // there shrinks the genuine deviation signal and over-smooths the
2627            // recovered curves relative to mgcv (gam#700). `re` carries its own
2628            // identity ridge below and ignores this flag. Honour an explicit
2629            // user `double_penalty=` either way.
2630            double_penalty: option_bool(options, "double_penalty")
2631                .unwrap_or(type_opt.as_str() != "sz"),
2632            identifiability: BSplineIdentifiability::None,
2633            boundary_conditions: Default::default(),
2634            boundary: OneDimensionalBoundary::Open,
2635        };
2636        let flavour = match type_opt.as_str() {
2637            "fs" => FactorSmoothFlavour::Fs {},
2638            "sz" => FactorSmoothFlavour::Sz,
2639            "re" => FactorSmoothFlavour::Re,
2640            // Outer `matches!` already restricts to fs/sz/re.
2641            other => {
2642                return Err(format!(
2643                    "internal: factor-smooth flavour dispatch reached unexpected type `{}`",
2644                    other
2645                ));
2646            }
2647        };
2648        return Ok(SmoothBasisSpec::FactorSmooth {
2649            spec: FactorSmoothSpec {
2650                continuous_cols: vec![c],
2651                group_col: cols[group_idx],
2652                marginal,
2653                flavour,
2654                group_frozen_levels: None,
2655                frozen_global_orthogonality: None,
2656            },
2657        });
2658    }
2659
2660    match type_opt.as_str() {
2661        // `periodic` is the generic spelling for a periodic (wrap-continuous)
2662        // B-spline; it names the SAME `SmoothBasisSpec::BSpline1D {
2663        // PeriodicUniform }` the mgcv-style cyclic selectors (`cc`/`cp`/`cyclic`)
2664        // build, and is already recognized as that basis kind by the JSON /
2665        // override path (`smooth_overrides`) and accepted by the formula parser.
2666        // Route it through the cyclic arm so the formula path agrees with the
2667        // rest of the codebase instead of rejecting it as an unsupported type.
2668        "cyclic" | "cc" | "cp" | "cyclic-ps" | "periodic" => {
2669            validate_known_options("cyclic", options, CYCLIC_SMOOTH_OPTION_KEYS)?;
2670            if cols.len() != 1 {
2671                return Err(format!(
2672                    "periodic smooth expects one variable, got {}",
2673                    cols.len()
2674                ));
2675            }
2676            let c = cols[0];
2677            let (minv, maxv) = col_minmax(ds.values.column(c))?;
2678            let degree = option_usize(options, "degree").unwrap_or(DEFAULT_BSPLINE_DEGREE);
2679            let mut default_internal = heuristic_knots_for_column(ds.values.column(c));
2680            if ds.values.nrows() <= 32 && smooth_coordinate_count >= 5 {
2681                default_internal = default_internal.min(1);
2682            }
2683            // A periodic cubic spline has no free endpoint behaviour to spend
2684            // degrees of freedom on: the wrap constraint removes the ordinary
2685            // boundary wiggle, and the cyclic second-difference penalty leaves
2686            // only the constant direction (handled by the smooth
2687            // identifiability constraint).  An over-rich default would give
2688            // small binomial/continuation-ratio fits a large penalized nuisance
2689            // space whose REML/LAML optimum is driven by finite-sample Bernoulli
2690            // noise rather than the low-frequency periodic signal.  Cap the
2691            // cyclic default in the mgcv `bs="cc"` spirit: a modest basis unless
2692            // the caller explicitly requests `k=...`; high-frequency periodic
2693            // structure remains available through that explicit contract.  Since
2694            // gam#1680 lowered the open-spline univariate default to ≈12
2695            // functions this cap and the open-spline default coincide, so it now
2696            // acts as an explicit floor/guard that keeps the cyclic default lean
2697            // even if the open-spline heuristic is later widened.
2698            let cyclic_default_basis_cap = CYCLIC_DEFAULT_BASIS_DIM.max(degree + 1);
2699            let default_basis = (default_internal + degree + 1).min(cyclic_default_basis_cap);
2700            let num_basis = option_usize_any(options, &["k", "basis_dim", "basis-dim", "basisdim"])
2701                .unwrap_or(default_basis);
2702            if num_basis < degree + 1 {
2703                return Err(format!(
2704                    "periodic smooth: k={} too small for degree {}; expected k >= {}",
2705                    num_basis,
2706                    degree,
2707                    degree + 1
2708                ));
2709            }
2710            // The cyclic arm is periodic on its single axis by construction, so
2711            // resolve the period exactly the way the `s()`/`ps` arm does: honour
2712            // `period=`/`periods=` first (with `origin=` setting the domain
2713            // start), and fall back to the `period_start`/`period_end` endpoint
2714            // form only when `period=` is absent. Previously this arm jumped
2715            // straight to `parse_periodic_domain_1d`, so a `period=<v>`
2716            // declaration was silently dropped and the smooth wrapped at the
2717            // data range (#816). All three helpers route through
2718            // `parse_numeric_expr`, so `period=2*pi` and `period_end=2*pi` parse
2719            // identically (#815).
2720            let periodic_axes = [true];
2721            let periods = parse_periods(options, &periodic_axes)?;
2722            let origins = parse_period_origins(options, &periodic_axes)?;
2723            // Distinguish a *cyclic basis selector* (`bs='cc'`/`cp'`/`cyclic`,
2724            // this whole arm) from a generic B-spline forced periodic by a
2725            // `periodic=`/`boundary=` flag (the `ps`/`bspline` arm). Only the
2726            // latter carries the sample-dependent off-by-ε seam that #1771's
2727            // guard in `parse_periodic_domain_1d` requires an explicit period
2728            // to avoid. A bare `s(x, bs='cc')` opts INTO mgcv's `bs="cc"`
2729            // semantics — the wrap IS the observed data range — exactly like
2730            // the tensor cc-margin fallback (`te(x, z, bs=c('cc','cc'))`). The
2731            // cyclic arm was left routing through the now-strict helper when
2732            // #1771 tightened it, so a bare cyclic smooth hard-errored with
2733            // "periodic B-spline smooth requires an explicit period" even
2734            // though its period is well-defined. Honor `period=`/`periods=`
2735            // first, then the half-open `period_start`/`period_end` endpoint
2736            // form, and only otherwise wrap at the observed `[min, max]` span.
2737            let has_endpoint_decl = ["period_start", "start", "period_end", "end"]
2738                .iter()
2739                .any(|key| options.contains_key(*key));
2740            let (domain_start, period) = if let Some(p) = periods[0] {
2741                (origins[0].unwrap_or(minv), p)
2742            } else if has_endpoint_decl {
2743                parse_periodic_domain_1d(options, minv, maxv)?
2744            } else {
2745                let span = maxv - minv;
2746                if !(span.is_finite() && span > 0.0) {
2747                    return Err(format!(
2748                        "cyclic smooth requires a positive observed data range to derive \
2749                         its period, got [{minv}, {maxv}]"
2750                    ));
2751                }
2752                (origins[0].unwrap_or(minv), span)
2753            };
2754            // This arm is periodic by construction, so its structural default is
2755            // the ordinary sum-to-zero centering (the cyclic penalty leaves the
2756            // constant direction unpenalized). An explicit `identifiability=`
2757            // token overrides it; before #2783 the option was whitelisted here
2758            // and then hardcoded away, so `cyclic(x, identifiability='none')`
2759            // was bit-identical to the centered default.
2760            let identifiability = resolve_bspline_identifiability(
2761                options,
2762                BSplineIdentifiability::default(),
2763                BSplineIdentifiabilityContext {
2764                    periodic: true,
2765                    ..Default::default()
2766                },
2767            )?;
2768            Ok(SmoothBasisSpec::BSpline1D {
2769                feature_col: c,
2770                spec: BSplineBasisSpec {
2771                    degree,
2772                    penalty_order: option_usize(options, "penalty_order")
2773                        .unwrap_or(DEFAULT_PENALTY_ORDER),
2774                    knotspec: BSplineKnotSpec::PeriodicUniform {
2775                        data_range: (domain_start, domain_start + period),
2776                        num_basis,
2777                    },
2778                    double_penalty: smooth_double_penalty,
2779                    identifiability,
2780                    boundary_conditions: Default::default(),
2781                    boundary: OneDimensionalBoundary::Cyclic {
2782                        start: domain_start,
2783                        end: domain_start + period,
2784                    },
2785                },
2786            })
2787        }
2788        "bspline" | "ps" | "p-spline" | "cr" | "cs" => {
2789            // mgcv's `bs="cr"` (cubic regression spline) and `bs="cs"` (its
2790            // shrinkage twin) are penalized cubic-regression smooths that span
2791            // the same per-axis function space as gamfit's `bspline` (cubic
2792            // B-spline, second-derivative penalty). Route both through the
2793            // 1-D B-spline arm. Both recover unsupported null-space effects by
2794            // default; `double_penalty=false` is the explicit unpenalized
2795            // opt-out. Without this route, a stand-alone
2796            // `s(x, bs='cr')` (which is otherwise a routine 1-D smooth in
2797            // mgcv-compatible formulae) reached the dispatch's default arm
2798            // and aborted the whole fit with `unsupported smooth type 'cr'`,
2799            // even though the same name was already recognized as a tensor
2800            // margin (`tensor_margin_bs_is_supported`).
2801            let validation_name = match type_opt.as_str() {
2802                "cr" => "cr",
2803                "cs" => "cs",
2804                _ => "bspline",
2805            };
2806            validate_known_options(validation_name, options, BSPLINE_SMOOTH_OPTION_KEYS)?;
2807            if cols.len() != 1 {
2808                return Err(TermBuilderError::incompatible_config(format!(
2809                    "bspline smooth expects one variable, got {}",
2810                    cols.len()
2811                ))
2812                .to_string());
2813            }
2814            let c = cols[0];
2815            let (minv, maxv) = col_minmax(ds.values.column(c))?;
2816            let degree = option_usize(options, "degree").unwrap_or(DEFAULT_BSPLINE_DEGREE);
2817            let default_internal = heuristic_knots_for_column(ds.values.column(c));
2818            let (mut n_knots, inferred, effective_degree) =
2819                parse_ps_internal_knots(options, degree, default_internal)?;
2820            let periodic_axes = parse_periodic_axes(options, 1).map_err(|e| e.to_string())?;
2821            // Every period/origin declaration this arm accepts is read only
2822            // inside the `periodic_axes[0]` branch below, so one that leaves the
2823            // axis aperiodic would be silently discarded (#2781).
2824            reject_unconsumable_period_declaration(validation_name, options, &periodic_axes)?;
2825            // Periodic margins still need enough basis functions to wrap, so
2826            // surface the per-axis degree reduction as a config error when the
2827            // user explicitly asked for a periodic-but-too-small basis. The
2828            // non-periodic path silently degrades degree to match mgcv.
2829            if periodic_axes[0] && effective_degree != degree {
2830                return Err(TermBuilderError::invalid_option(format!(
2831                    "periodic smooth: k={} too small for degree {}; expected k >= {}",
2832                    effective_degree + 1,
2833                    degree,
2834                    degree + 1
2835                ))
2836                .to_string());
2837            }
2838            let heuristic_knots = n_knots;
2839            if inferred && ds.values.nrows() <= 32 && smooth_coordinate_count >= 5 {
2840                n_knots = n_knots.min(1);
2841            }
2842            if inferred {
2843                let unique = unique_count_column(ds.values.column(c));
2844                // State the rule the engine actually applied
2845                // (`heuristic_knots_for_column`: `clamp(unique/4, 4..8)`), and
2846                // the small-data reduction when it fired. The note used to
2847                // announce a `max(20, cbrt(unique))` ceiling that no code path
2848                // computed, so for every column with 36 or more unique values
2849                // it printed a rule whose own arithmetic disagreed with the
2850                // count beside it.
2851                let mut note = format!(
2852                    "Automatically set {} internal knots for smooth '{}' from {} unique values (rule: clamp(unique/4, 4..{}) = {}; basis dimension = internal knots + degree + 1).",
2853                    n_knots,
2854                    vars.join(","),
2855                    unique,
2856                    MAX_DEFAULT_INTERNAL_KNOTS,
2857                    heuristic_knots,
2858                );
2859                if n_knots != heuristic_knots {
2860                    note.push_str(&format!(
2861                        " Reduced to {} because the fit has only {} rows and {} smooth coordinates.",
2862                        n_knots,
2863                        ds.values.nrows(),
2864                        smooth_coordinate_count,
2865                    ));
2866                }
2867                note.push_str(" Override with knots=... or k=....");
2868                inference_notes.push(note);
2869            }
2870            let boundary_conditions =
2871                if periodic_axes[0] && bspline_boundary_declares_periodic_axis(options) {
2872                    BSplineBoundaryConditions::default()
2873                } else {
2874                    parse_bspline_boundary_conditions(options).map_err(|e| e.to_string())?
2875                };
2876            // An anchored endpoint (one *or* both sides) is already the model's
2877            // level-setting gauge: term-design construction suppresses the
2878            // global intercept so the fitted function itself, rather than only a
2879            // centered deviation, obeys the endpoint pin. Applying the ordinary
2880            // sum-to-zero chart as well would force the entire anchored function
2881            // to have sample mean zero. In #1867 that made a positive one-sided
2882            // anchored bump mathematically unrecoverable before REML was even
2883            // evaluated; for a two-sided anchor it additionally strips the
2884            // interior level the two pins bracket (#2297).
2885            let structural_identifiability = if boundary_conditions.has_anchor() {
2886                BSplineIdentifiability::None
2887            } else {
2888                BSplineIdentifiability::default()
2889            };
2890            // An explicit `identifiability=` token overrides that structural
2891            // default, and an unrecognised one is refused. Before #2783 the
2892            // option was whitelisted by `validate_known_options` above and then
2893            // never read, so every value — including nonsense — was accepted
2894            // and inert on this arm alone.
2895            let identifiability = resolve_bspline_identifiability(
2896                options,
2897                structural_identifiability,
2898                BSplineIdentifiabilityContext {
2899                    has_anchor: boundary_conditions.has_anchor(),
2900                    periodic: periodic_axes[0],
2901                    natural_cubic_regression: !periodic_axes[0]
2902                        && (type_opt == "cr" || type_opt == "cs"),
2903                },
2904            )?;
2905            let periods = parse_periods(options, &periodic_axes).map_err(|e| e.to_string())?;
2906            let origins =
2907                parse_period_origins(options, &periodic_axes).map_err(|e| e.to_string())?;
2908            let (knotspec, boundary) = if periodic_axes[0] {
2909                if !boundary_conditions.is_free() {
2910                    return Err(TermBuilderError::incompatible_config(
2911                        "periodic B-splines cannot also declare endpoint boundary conditions",
2912                    )
2913                    .to_string());
2914                }
2915                {
2916                    let (domain_start, p_value) = if let Some(period) = periods[0] {
2917                        (origins[0].unwrap_or(minv), period)
2918                    } else {
2919                        parse_periodic_domain_1d(options, minv, maxv).map_err(|e| e.to_string())?
2920                    };
2921                    let domain_end = domain_start + p_value;
2922                    (
2923                        BSplineKnotSpec::PeriodicUniform {
2924                            data_range: (domain_start, domain_end),
2925                            num_basis: n_knots + effective_degree + 1,
2926                        },
2927                        OneDimensionalBoundary::Cyclic {
2928                            start: domain_start,
2929                            end: domain_end,
2930                        },
2931                    )
2932                }
2933            } else if type_opt == "cr" || type_opt == "cs" {
2934                // mgcv `bs="cr"`/`"cs"`: a natural cubic regression spline whose
2935                // basis is indexed by `k` values at quantile-placed knots (#1074),
2936                // NOT a B-spline knot vector. Match gam's `k=` convention by
2937                // requesting the same total basis size the B-spline arm would
2938                // produce (`n_knots` internal + degree + 1), floored at the cr
2939                // minimum of 3 knots. `cr` vs `cs` (shrinkage) is carried by the
2940                // `double_penalty` flag resolved below, which the cr builder reads.
2941                //
2942                // Cap that request to the covariate's data support (#1541): a cr
2943                // basis cannot place more value-knots than there are distinct
2944                // covariate values, so an unclamped `k` on a low-cardinality
2945                // predictor (binary indicator, 3-level ordinal, small count) used
2946                // to hard-fail in `select_cr_knots` instead of reducing like mgcv
2947                // and gam's tensor path. Below the cr minimum (a binary covariate)
2948                // degrade to the B-spline marginal the default `s(x, k=..)` basis
2949                // already fits on the same data — never a hard error.
2950                let k_cr = (n_knots + effective_degree + 1).max(CR_MIN_KNOTS);
2951                let knotspec = match capped_cr_marginal_knotspec(
2952                    ds.values.column(c),
2953                    k_cr,
2954                    &vars.join(","),
2955                    inference_notes,
2956                )? {
2957                    Some(cr_knotspec) => cr_knotspec,
2958                    None => resolve_nonperiodic_bspline_knotspec(
2959                        options,
2960                        ds.values.column(c),
2961                        (minv, maxv),
2962                        effective_degree,
2963                        n_knots,
2964                    )?,
2965                };
2966                (knotspec, parse_cyclic_boundary(options, minv, maxv)?)
2967            } else {
2968                (
2969                    resolve_nonperiodic_bspline_knotspec(
2970                        options,
2971                        ds.values.column(c),
2972                        (minv, maxv),
2973                        effective_degree,
2974                        n_knots,
2975                    )?,
2976                    parse_cyclic_boundary(options, minv, maxv)?,
2977                )
2978            };
2979            // Both cubic-regression spellings recover unsupported null-space
2980            // effects by default. An explicit `double_penalty=false` is the
2981            // MLE-style opt-out.
2982            let double_penalty = smooth_double_penalty;
2983            // Clamp the marginal difference penalty to `<= effective_degree`
2984            // so it stays well-defined when the per-axis degree was reduced
2985            // (mirrors the tensor margin path: `create_difference_penalty_matrix`
2986            // requires order < num_basis_functions).
2987            let penalty_order = option_usize(options, "penalty_order")
2988                .unwrap_or(DEFAULT_PENALTY_ORDER)
2989                .min(effective_degree);
2990            Ok(SmoothBasisSpec::BSpline1D {
2991                feature_col: c,
2992                spec: BSplineBasisSpec {
2993                    degree: effective_degree,
2994                    penalty_order,
2995                    knotspec,
2996                    double_penalty,
2997                    identifiability,
2998                    boundary,
2999                    boundary_conditions,
3000                },
3001            })
3002        }
3003        "tps" | "thinplate" | "thin-plate" => {
3004            validate_known_options("thinplate", options, THINPLATE_SMOOTH_OPTION_KEYS)?;
3005            let plan = plan_spatial_basis(
3006                sizing_rows,
3007                cols.len(),
3008                CenterCountRequest::Default,
3009                DuchonNullspaceOrder::Linear,
3010                option_bool(options, "scale_dims").unwrap_or(false),
3011                policy,
3012            )
3013            .map_err(|e| e.to_string())?;
3014            // #1074: the mgcv-sized basis cap (`k = 10·3^(d-1)`) that used to live
3015            // here was DELETED. It masked the real defect — the n-scaling default
3016            // over-sizes a thin-plate field, producing a weakly-identified
3017            // two-penalty ρ-surface the outer optimizer stalls on (row-order
3018            // dependent, #1378), and surplus columns REML can't penalize away on
3019            // weak-signal fits. Capping the basis hid that stall instead of fixing
3020            // it. The default now uses the generic spatial center heuristic; the
3021            // root fix (a well-identified ρ-surface / optimizer that doesn't stall)
3022            // is tracked separately. Explicit `k`/`centers` still take full effect.
3023            let default_centers = plan.centers;
3024            let centers = parse_countwith_basis_alias(
3025                options,
3026                "centers",
3027                cap_default_spatial_centers(options, default_centers),
3028            )?;
3029            let center_strategy = if has_explicit_countwith_basis_alias(options, "centers") {
3030                spatial_center_strategy_for_dimension(centers, cols.len())
3031            } else {
3032                auto_spatial_center_strategy(centers, cols.len())
3033            };
3034            // `include_intercept` appends a constant column to a KERNEL basis
3035            // that has no polynomial null space of its own — which is exactly
3036            // what the Matérn basis is, and exactly what the thin-plate basis is
3037            // not: a TPS ships its polynomial null space (constant and linear)
3038            // by construction, sized by
3039            // `thin_plate_polynomial_basis_dimension`. An appended constant
3040            // would therefore be exactly collinear with a column already in the
3041            // span. The option was whitelisted here anyway and read by nothing,
3042            // so it was accepted and silently discarded (#2781's family).
3043            if options.contains_key("include_intercept") {
3044                return Err(TermBuilderError::unsupported_feature(
3045                    "thinplate() does not support include_intercept: the thin-plate basis already                      spans its polynomial null space (the constant and linear terms), so an                      appended constant column would be exactly collinear with it. matern() takes                      the option because its kernel basis carries no polynomial null space.",
3046                )
3047                .to_string());
3048            }
3049            let periodic = parse_periodic_axes_option(options, cols.len())?;
3050            reject_unconsumable_radial_period_declaration(
3051                "thinplate",
3052                options,
3053                cols.len(),
3054                periodic.as_deref(),
3055                false,
3056            )?;
3057            Ok(SmoothBasisSpec::ThinPlate {
3058                feature_cols: cols.to_vec(),
3059                spec: ThinPlateBasisSpec {
3060                    center_strategy,
3061                    periodic,
3062                    // Sentinel: leave at 0.0 when the user didn't pass an
3063                    // explicit length_scale so `auto_init_length_scale_in_place`
3064                    // can replace it with a data-derived initialization. The
3065                    // old hard-coded 1.0 was the documented basin (see
3066                    // smooth.rs `auto_init_length_scale_in_place`) that the
3067                    // spatial optimizer could not escape, leaving TPS terms
3068                    // initialized off the data scale.
3069                    length_scale: option_f64(options, "length_scale").unwrap_or(0.0),
3070                    double_penalty: smooth_double_penalty,
3071                    identifiability: parse_spatial_identifiability(options)
3072                        .map_err(|e| e.to_string())?,
3073                    radial_reparam: None,
3074                },
3075                input_scale: None,
3076            })
3077        }
3078        "sphere" | "s2" | "sos" => {
3079            validate_known_options("sphere", options, SPHERE_SMOOTH_OPTION_KEYS)?;
3080            if cols.len() != 2 {
3081                return Err(format!(
3082                    "sphere smooth expects exactly two variables (lat, lon), got {}",
3083                    cols.len()
3084                ));
3085            }
3086            let radians = option_bool(options, "radians").unwrap_or_else(|| {
3087                options
3088                    .get("units")
3089                    .map(|u| u.eq_ignore_ascii_case("radian") || u.eq_ignore_ascii_case("radians"))
3090                    .unwrap_or(false)
3091            });
3092            // An explicit `degree`/`l`/`max_degree` names a spherical-harmonic
3093            // truncation, so with no explicit kernel/method it selects the
3094            // Harmonic construction (the Wahba kernel ignores `degree` and would
3095            // silently emit a 1-column kernel design). An explicit kernel/method
3096            // still wins.
3097            let degree_requested = options.contains_key("degree")
3098                || options.contains_key("l")
3099                || options.contains_key("max_degree")
3100                || options.contains_key("max-degree");
3101            let kernel = options
3102                .get("kernel")
3103                .or_else(|| options.get("method"))
3104                .map(|raw| strip_quotes(raw).trim().to_ascii_lowercase())
3105                .unwrap_or_else(|| {
3106                    if degree_requested {
3107                        "harmonic".to_string()
3108                    } else {
3109                        "sobolev".to_string()
3110                    }
3111                });
3112            let (method, wahba_kernel) = match kernel.as_str() {
3113                "sobolev" | "wahba" | "wahba_sobolev" | "wahba-sobolev" => {
3114                    (SphereMethod::Wahba, SphereWahbaKernel::Sobolev)
3115                }
3116                "pseudo" | "mgcv" | "sos" | "wahba_pseudo" | "wahba-pseudo" => {
3117                    (SphereMethod::Wahba, SphereWahbaKernel::Pseudo)
3118                }
3119                "harmonic" | "spherical_harmonic" | "spherical-harmonic" => {
3120                    (SphereMethod::Harmonic, SphereWahbaKernel::Sobolev)
3121                }
3122                other => {
3123                    return Err(format!(
3124                        "unsupported sphere kernel '{other}'; expected sobolev, pseudo, or harmonic"
3125                    ));
3126                }
3127            };
3128            // `lmax=` states a finite spectral resolution for a Wahba kernel,
3129            // selecting the truncated variant `Σ_{ℓ=1..lmax} c_ℓ P_ℓ(cos γ)`
3130            // instead of the closed form. This is the only route from the
3131            // formula surface to `SobolevTruncated`/`PseudoTruncated`, and it
3132            // is what makes `m=1` expressible at all: the untruncated Sobolev
3133            // `m = 1` kernel is log-singular at coincidence, so it has no Gram
3134            // diagonal and the basis builder refuses it (#2475). Before this
3135            // option the refusal named a remedy no formula could reach.
3136            let wahba_kernel = match option_usize_any(options, &["lmax", "l_max", "l-max"]) {
3137                None => wahba_kernel,
3138                Some(_) if matches!(method, SphereMethod::Harmonic) => {
3139                    return Err(
3140                        "sphere smooth: lmax= states the truncation of a Wahba reproducing kernel \
3141                         and does not apply to kernel=harmonic; use degree=/max_degree= to set the \
3142                         harmonic degree"
3143                            .to_string(),
3144                    );
3145                }
3146                Some(lmax) => {
3147                    if !(SPHERE_TRUNCATION_LMAX_RANGE).contains(&lmax) {
3148                        return Err(format!(
3149                            "sphere smooth: lmax={lmax} is out of range; the truncated Wahba \
3150                             kernels support lmax in {}..={} (the device kernel bakes it in as a \
3151                             compile-time bound)",
3152                            SPHERE_TRUNCATION_LMAX_RANGE.start(),
3153                            SPHERE_TRUNCATION_LMAX_RANGE.end()
3154                        ));
3155                    }
3156                    let lmax = lmax as u16;
3157                    match wahba_kernel {
3158                        SphereWahbaKernel::Sobolev | SphereWahbaKernel::SobolevTruncated { .. } => {
3159                            SphereWahbaKernel::SobolevTruncated { lmax }
3160                        }
3161                        SphereWahbaKernel::Pseudo | SphereWahbaKernel::PseudoTruncated { .. } => {
3162                            SphereWahbaKernel::PseudoTruncated { lmax }
3163                        }
3164                    }
3165                }
3166            };
3167            let max_degree = if matches!(method, SphereMethod::Harmonic) {
3168                let degree =
3169                    option_usize_any(options, &["degree", "l", "max_degree", "max-degree"])
3170                        .or_else(|| option_usize(options, "centers"))
3171                        .or_else(|| {
3172                            option_usize_any(options, &["k", "basis_dim", "basis-dim", "basisdim"])
3173                                .and_then(|k| (1..=128).find(|&l| l * (l + 2) >= k))
3174                        })
3175                        .unwrap_or_else(|| default_spherical_harmonic_degree(sizing_rows));
3176                if degree == 0 {
3177                    return Err("sphere smooth requires degree/max_degree >= 1".to_string());
3178                }
3179                if degree > 32 {
3180                    return Err(format!(
3181                        "sphere smooth max_degree={} is too large for the dense harmonic engine (limit 32)",
3182                        degree
3183                    ));
3184                }
3185                Some(degree)
3186            } else {
3187                None
3188            };
3189            let penalty_order =
3190                parse_penalty_order_alias(options)?.unwrap_or(DEFAULT_PENALTY_ORDER);
3191            let center_strategy = if matches!(method, SphereMethod::Wahba) {
3192                let mut centers = parse_countwith_basis_alias(
3193                    options,
3194                    "centers",
3195                    default_num_centers(sizing_rows, cols.len()),
3196                )?;
3197                if penalty_order >= 4 {
3198                    centers = centers.max(30);
3199                }
3200                CenterStrategy::FarthestPoint {
3201                    num_centers: centers,
3202                }
3203            } else {
3204                CenterStrategy::FarthestPoint { num_centers: 0 }
3205            };
3206            Ok(SmoothBasisSpec::Sphere {
3207                feature_cols: cols.to_vec(),
3208                spec: SphericalSplineBasisSpec {
3209                    center_strategy,
3210                    penalty_order,
3211                    double_penalty: smooth_double_penalty,
3212                    radians,
3213                    method,
3214                    max_degree,
3215                    wahba_kernel,
3216                    identifiability: SphericalSplineIdentifiability::CenterSumToZero,
3217                },
3218            })
3219        }
3220        "curvature" => {
3221            // Constant-curvature (M_κ) geodesic-kernel smooth (#944): the
3222            // κ-generic sibling of the intrinsic S² smooth above. The feature
3223            // columns are κ-stereographic chart coordinates and the geometry
3224            // comes from `geometry::constant_curvature::ConstantCurvature`.
3225            // `kappa=` follows the mgcv-`sp=` convention (gam#2152): an EXPLICIT
3226            // value is a FIXED sectional curvature that selects the geometry
3227            // (`Sᵈ` for κ>0, `ℝᵈ` for κ=0, `Hᵈ` for κ<0) and is honoured verbatim
3228            // by the fit; OMITTING `kappa=` leaves κ free for the #944/#1464
3229            // outer ψ-coordinate estimation, seeded at the flat default 0.
3230            validate_known_options("curvature", options, CURVATURE_SMOOTH_OPTION_KEYS)?;
3231            // `kappa=` follows the mgcv-`sp=` convention: an EXPLICIT value pins
3232            // the sectional curvature (fixed geometry, honoured verbatim by the
3233            // fit — gam#2152); an OMITTED `kappa=` leaves κ free for the
3234            // #944/#1464 outer estimation, seeded at the flat default 0.
3235            let kappa_opt = option_f64(options, "kappa");
3236            let kappa_fixed = kappa_opt.is_some();
3237            let kappa = kappa_opt.unwrap_or(0.0);
3238            if !kappa.is_finite() {
3239                return Err("curvature smooth requires a finite kappa".to_string());
3240            }
3241            // `length_scale=` follows the SAME mgcv-`sp=` convention as `kappa=`
3242            // (gam#2747): an EXPLICIT value pins the kernel resolution and the fit
3243            // honours it verbatim; an OMITTED one leaves η = ln ℓ free for the
3244            // outer estimation, seeded by the auto rule. The range must be fitted
3245            // by default because it is confounded with κ — pinning it makes κ
3246            // absorb the range error rather than measure curvature.
3247            let length_scale_opt = option_f64(options, "length_scale");
3248            let length_scale_fixed = length_scale_opt.is_some();
3249            let length_scale = length_scale_opt.unwrap_or(0.0);
3250            if !length_scale.is_finite() || length_scale < 0.0 {
3251                return Err(format!(
3252                    "curvature smooth length_scale must be positive (or omitted for auto); got {length_scale}"
3253                ));
3254            }
3255            let centers = parse_countwith_basis_alias(
3256                options,
3257                "centers",
3258                default_num_centers(sizing_rows, cols.len()),
3259            )?;
3260            if centers < 2 {
3261                return Err("curvature smooth requires at least 2 centers".to_string());
3262            }
3263            let center_strategy = if has_explicit_countwith_basis_alias(options, "centers") {
3264                spatial_center_strategy_for_dimension(centers, cols.len())
3265            } else {
3266                auto_spatial_center_strategy(centers, cols.len())
3267            };
3268            Ok(SmoothBasisSpec::ConstantCurvature {
3269                feature_cols: cols.to_vec(),
3270                spec: ConstantCurvatureBasisSpec {
3271                    center_strategy,
3272                    kappa,
3273                    kappa_fixed,
3274                    // 0.0 sentinel = κ-independent auto initialization in the
3275                    // basis builder (median chart center spacing, doubled).
3276                    length_scale,
3277                    length_scale_fixed,
3278                    // Curvature smooth defaults to NO double-penalty ridge
3279                    // (#1464): the curvature-blind ridge `I` absorbs the data fit
3280                    // independently of κ and rails the fitted curvature to the
3281                    // +chart bound (hyperbolic truth recovered as spherical). The
3282                    // RKHS Gram penalty is already full-rank PD, so the ridge adds
3283                    // no stability. Honour an EXPLICIT `double_penalty=` only.
3284                    double_penalty: option_bool(options, "double_penalty").unwrap_or(false),
3285                    identifiability: ConstantCurvatureIdentifiability::CenterSumToZero,
3286                },
3287            })
3288        }
3289        "measurejet" => {
3290            // Measure-jet spline: multiscale local-jet-residual energy of the
3291            // empirical measure. The feature columns are ambient coordinates
3292            // of data concentrated near an unknown low-dimensional set; the
3293            // geometry (centers, masses, scale band) is read off the measure
3294            // at build time — magic by default, every option optional.
3295            validate_known_options("measurejet", options, MEASURE_JET_SMOOTH_OPTION_KEYS)?;
3296            let order_s = option_f64(options, "s").unwrap_or(0.0);
3297            // 0.0 = auto sentinel; explicit values must sit inside the
3298            // admissible order interval of the affine-jet (r = 2) energy.
3299            if !(order_s.is_finite() && (order_s == 0.0 || (order_s > 0.0 && order_s < 2.0))) {
3300                return Err(format!(
3301                    "measurejet smooth s must lie in (0, 2) (or be omitted for auto); got {order_s}"
3302                ));
3303            }
3304            // Default to the spec Default (α = 1, density-WEIGHTED Hessian
3305            // energy — the module-header default). The density-free α = 3/2
3306            // (q^{−2}) over-smooths low-intrinsic-dimension manifolds where the
3307            // local mass q is tiny and varies along the stratum (#1116:
3308            // 13×-worse-than-matérn on a 1-D curve in 3-D); α = 1's q^{−1} is
3309            // gentler and robust across intrinsic dimensions. An explicit
3310            // `alpha=` still overrides for full-dimensional density-free use.
3311            let alpha =
3312                option_f64(options, "alpha").unwrap_or(MeasureJetBasisSpec::default().alpha);
3313            if !alpha.is_finite() {
3314                return Err("measurejet smooth requires a finite alpha".to_string());
3315            }
3316            let tau0 = option_f64(options, "tau").unwrap_or(1e-3);
3317            if !(tau0.is_finite() && tau0 >= 0.0) {
3318                return Err(format!(
3319                    "measurejet smooth tau must be finite and nonnegative; got {tau0}"
3320                ));
3321            }
3322            let num_scales = option_usize(options, "scales").unwrap_or(0);
3323            let length_scale = option_f64(options, "length_scale").unwrap_or(0.0);
3324            if !length_scale.is_finite() || length_scale < 0.0 {
3325                return Err(format!(
3326                    "measurejet smooth length_scale must be positive (or omitted for auto); got {length_scale}"
3327                ));
3328            }
3329            let centers = parse_countwith_basis_alias(
3330                options,
3331                "centers",
3332                default_num_centers(sizing_rows, cols.len()),
3333            )?;
3334            if centers < 3 {
3335                return Err("measurejet smooth requires at least 3 centers".to_string());
3336            }
3337            let center_strategy = if has_explicit_countwith_basis_alias(options, "centers") {
3338                spatial_center_strategy_for_dimension(centers, cols.len())
3339            } else {
3340                auto_spatial_center_strategy(centers, cols.len())
3341            };
3342            // Multiscale (per-scale spectral split + (α, lnτ) ψ dials + the
3343            // affine-preserving ridge) is an explicit opt-in (#1116): default
3344            // single-scale at any center count, the Duchon/Matérn footprint.
3345            let multiscale = option_bool(options, "multiscale").unwrap_or(false);
3346            // The representer range ℓ is a design-moving basis coordinate of the
3347            // same kind as the Matérn κ: λ shrinks inside a span and cannot move
3348            // one, so a frozen ℓ is an error no smoothing parameter can repair
3349            // (#2761 measured it at 13.4x held-out RMSE on a 1-D curve in 3-D,
3350            // with the design's own span floor sitting AT the fitted value).
3351            // REML therefore selects it by default.
3352            //
3353            // An explicit `length_scale=` is a request, not a seed, so it pins ℓ
3354            // — the same short-circuit `all_spatial_terms_kappa_fixed` gives an
3355            // explicitly-scaled Matérn. `learn_length_scale=` overrides either
3356            // way.
3357            let learn_length_scale =
3358                option_bool(options, "learn_length_scale").unwrap_or(length_scale == 0.0);
3359            Ok(SmoothBasisSpec::MeasureJet {
3360                feature_cols: cols.to_vec(),
3361                spec: MeasureJetBasisSpec {
3362                    center_strategy,
3363                    order_s,
3364                    alpha,
3365                    tau0,
3366                    num_scales,
3367                    // 0.0 sentinel = auto initialization in the basis builder
3368                    // (median nearest-center spacing).
3369                    length_scale,
3370                    double_penalty: smooth_double_penalty,
3371                    learn_length_scale,
3372                    multiscale,
3373                    identifiability: MeasureJetIdentifiability::CenterSumToZero,
3374                    frozen_quadrature: None,
3375                },
3376                input_scale: None,
3377            })
3378        }
3379        "matern" => {
3380            // Catch typos like `lengt_scale=` / `nyu=` / `centerz=` before
3381            // they get silently ignored and the user wonders why their
3382            // option had no effect. The matern() term accepts exactly
3383            // these options.
3384            validate_known_options("matern", options, MATERN_SMOOTH_OPTION_KEYS)?;
3385            let plan = plan_spatial_basis(
3386                sizing_rows,
3387                cols.len(),
3388                CenterCountRequest::Default,
3389                DuchonNullspaceOrder::Zero,
3390                option_bool(options, "scale_dims").unwrap_or(false),
3391                policy,
3392            )
3393            .map_err(|e| e.to_string())?;
3394            // #1867: spline-equivalent floor so a 1-D radial basis is not
3395            // dimensioned coarser than the competing `s(x)` on identical data.
3396            let univariate_floor = if cols.len() == 1 {
3397                heuristic_knots_for_column(ds.values.column(cols[0]))
3398                    .saturating_add(DEFAULT_BSPLINE_DEGREE + 1)
3399            } else {
3400                0
3401            };
3402            let centers = parse_countwith_basis_alias(
3403                options,
3404                "centers",
3405                cap_default_spatial_centers(
3406                    options,
3407                    default_matern_center_count(
3408                        sizing_rows,
3409                        cols.len(),
3410                        plan.centers,
3411                        univariate_floor,
3412                    ),
3413                ),
3414            )?;
3415            let center_strategy = if has_explicit_countwith_basis_alias(options, "centers") {
3416                spatial_center_strategy_for_dimension(centers, cols.len())
3417            } else {
3418                auto_spatial_center_strategy(centers, cols.len())
3419            };
3420            let nu = parse_matern_nu(options.get("nu").map(String::as_str).unwrap_or("5/2"))?;
3421            // The exponential (ν = 1/2) Matérn kernel has a singular Laplacian
3422            // at zero in d ≥ 2, so the operator-collocation penalty machinery
3423            // hits a non-invertible matrix during fit. Surface the cause
3424            // up-front instead of letting the user see the generic
3425            // "Matrix conditioning issue detected" wrapper from PIRLS.
3426            if matches!(nu, MaternNu::Half) && cols.len() >= 2 {
3427                return Err(TermBuilderError::unsupported_feature(format!(
3428                    "matern() with nu=1/2 is not supported for d>=2 (got {} covariates): \
3429                     the exponential kernel's Laplacian is singular at center collisions, \
3430                     which makes the operator-collocation penalty non-invertible. \
3431                     Choose nu>=3/2 (e.g. nu=3/2 or the default nu=5/2) for multi-dimensional smooths.",
3432                    cols.len()
3433                ))
3434                .to_string());
3435            }
3436            let aniso_log_scales = if option_bool(options, "scale_dims").unwrap_or(false) {
3437                Some(vec![0.0; cols.len()])
3438            } else {
3439                None
3440            };
3441            let periodic = parse_periodic_axes_option(options, cols.len())?;
3442            reject_unconsumable_radial_period_declaration(
3443                "matern",
3444                options,
3445                cols.len(),
3446                periodic.as_deref(),
3447                false,
3448            )?;
3449            Ok(SmoothBasisSpec::Matern {
3450                feature_cols: cols.to_vec(),
3451                spec: MaternBasisSpec {
3452                    center_strategy,
3453                    periodic,
3454                    // Preserve whether the user supplied `length_scale` as typed
3455                    // provenance. The planner resolves `Auto` to the same
3456                    // data-derived wiggly-side initialization the thin-plate path
3457                    // uses (`max_range / sqrt(n)`), then lets the κ-optimizer refine
3458                    // it without ever turning it into a user-fixed scale.
3459                    //
3460                    // gam#1629: the previous `default_matern_length_scale` seeded
3461                    // the FULL data diameter — the maximally over-smoothed corner.
3462                    // Because that value looked explicit, the old auto-init was a
3463                    // no-op for Matérn, so the κ-optimizer started in the flat
3464                    // over-smoothed basin and parked there, leaving high-frequency
3465                    // 2-D surfaces unresolved (truth-RMSE ~6× worse than
3466                    // thin-plate/tensor on identical data, and insensitive to `k`).
3467                    // Typed Auto starts REML in the resolving regime it can escape
3468                    // from and cannot be confused with explicit zero.
3469                    length_scale: option_f64(options, "length_scale")
3470                        .map(MaternLengthScale::fixed)
3471                        .unwrap_or_else(MaternLengthScale::auto),
3472                    nu,
3473                    include_intercept: option_bool(options, "include_intercept").unwrap_or(false),
3474                    double_penalty: smooth_double_penalty,
3475                    identifiability: parse_matern_identifiability(options)
3476                        .map_err(|e| e.to_string())?,
3477                    aniso_log_scales,
3478                    // Cold build: let the bootstrap-κ spectral test decide whether
3479                    // the double-penalty nullspace shrinkage survives; the freeze
3480                    // step then pins that decision into the FrozenTransform so the
3481                    // κ-optimizer's rebuilds keep the count invariant (gam#787/#860).
3482                },
3483                input_scale: None,
3484            })
3485        }
3486        "duchon" | "ds" => {
3487            validate_known_options("duchon", options, DUCHON_SMOOTH_OPTION_KEYS)?;
3488            if options.contains_key("double_penalty") {
3489                return Err(TermBuilderError::incompatible_config(format!(
3490                    "Duchon smooth '{}' does not support double_penalty; the Duchon smoother already ships its native reproducing-norm penalty plus a null-space shrinkage ridge.",
3491                    vars.join(", ")
3492                ))
3493                .to_string());
3494            }
3495            let requested_nullspace_order = parse_duchon_order_opt(options)?;
3496            let length_scale = option_f64_strict(options, "length_scale")?;
3497            // Resolve `(nullspace_order, power)`. The default (magic) path is a
3498            // structural amplitude/slope/curvature smoother: an affine (`Linear`)
3499            // polynomial nullspace and spectral power `s = (d - 1)/2`, giving the
3500            // cubic kernel `r^3` in 1D. There is no nullspace-order escalation —
3501            // the structural cubic smoother is well-defined for every dimension.
3502            //
3503            // Explicit `power=...` honors the user's value verbatim against their
3504            // requested nullspace order; the kernel validator emits a precise
3505            // diagnostic for any inadmissible combination. In the scale-free
3506            // (non-hybrid) regime fractional powers are admitted and threaded as
3507            // `f64`. The hybrid Duchon-Matérn kernel (`length_scale=Some`) is
3508            // restricted to integer powers.
3509            let (nullspace_order, power) = match parse_duchon_power_policy(options)? {
3510                DuchonPowerPolicy::Explicit(req_power) => {
3511                    if length_scale.is_some() && req_power.fract() != 0.0 {
3512                        return Err(TermBuilderError::incompatible_config(format!(
3513                            "hybrid Duchon-Matern smooth '{}' (length_scale=...) requires an integer power, got power={}; \
3514                             drop length_scale to use the scale-free structural kernel with a fractional power.",
3515                            vars.join(", "),
3516                            req_power,
3517                        ))
3518                        .to_string());
3519                    }
3520                    (
3521                        requested_nullspace_order.unwrap_or(DuchonNullspaceOrder::Linear),
3522                        req_power,
3523                    )
3524                }
3525                DuchonPowerPolicy::CubicStructuralDefault => {
3526                    // Magic cubic rule (REQUEST-LAYER default): no explicit power ⇒
3527                    // affine null space + fractional spectral power s = (d-1)/2, i.e.
3528                    // the Duchon kernel φ(r)=r³ in every dimension. An EXPLICIT
3529                    // `power=0` is handled above and is honored as the s=0 Duchon
3530                    // kernel (r²·log r ≡ the thin-plate kernel in even d) — the magic
3531                    // default lives here, not in the basis builder.
3532                    // An explicit `order=` names the polynomial null space; the
3533                    // structural default then supplies only the spectral power.
3534                    // Taking the whole PAIR from the default discarded a
3535                    // caller's `order=` whenever no `power=` accompanied it, so
3536                    // `duchon(x, z, order=0)` and `order=2` were parsed,
3537                    // validated, and thrown away (#2781's family). `order=1` is
3538                    // the default null space, so every shipped
3539                    // `duchon(..., order=1)` formula is unaffected.
3540                    match length_scale {
3541                        None => {
3542                            let (default_order, s) =
3543                                crate::basis::duchon_cubic_default(cols.len());
3544                            (requested_nullspace_order.unwrap_or(default_order), s)
3545                        }
3546                        Some(_) => {
3547                            // The hybrid Matérn-blended kernel (`length_scale=Some`)
3548                            // requires an INTEGER spectral power `s` (the partial-
3549                            // fraction split `1/(ρ^{2p}(κ²+ρ²)^s)` is only defined for
3550                            // integer `s`). The fractional cubic default `s=(d-1)/2` is
3551                            // a half-integer for even `d`, and the basis builder's
3552                            // `power_as_usize` maps a NON-integer to `0` (not its
3553                            // floor) — so for even `d ≥ 4` the realized kernel has
3554                            // `2(p+s) = 2p = 4 ≤ d`, which is non-finite at the origin
3555                            // and crashes the fit (historically a non-finite
3556                            // eigendecomposition; now a fit-time validation error).
3557                            //
3558                            // Resolve to the same structural cubic default the
3559                            // scale-free path uses (affine `Linear` null space, `r³`
3560                            // kernel, fractional power `s = (d-1)/2`) but take the
3561                            // largest admissible INTEGER at or below it — `⌊(d-1)/2⌋`.
3562                            // For odd `d` this is exactly the cubic power (the hybrid
3563                            // default then agrees with the scale-free cubic default);
3564                            // for even `d` it is the nearest integer below. Either way
3565                            // `p = 2` (affine) gives spectral order
3566                            // `2(p+s) = d+3` (odd `d`) or `d+2` (even `d`), which
3567                            // clears both kernel existence `2(p+s) > d` and the D1
3568                            // collocation floor `2(p+s) > d+1` for every `d ≥ 1`.
3569                            // Flooring here at the request layer avoids the
3570                            // `power_as_usize` truncation-to-zero on the fractional
3571                            // half-integer.
3572                            let (default_order, s_frac) =
3573                                crate::basis::duchon_cubic_default(cols.len());
3574                            (
3575                                requested_nullspace_order.unwrap_or(default_order),
3576                                s_frac.floor(),
3577                            )
3578                        }
3579                    }
3580                }
3581            };
3582            let plan = plan_spatial_basis(
3583                sizing_rows,
3584                cols.len(),
3585                CenterCountRequest::Default,
3586                nullspace_order,
3587                option_bool(options, "scale_dims").unwrap_or(false),
3588                policy,
3589            )
3590            .map_err(|e| e.to_string())?;
3591            let centers_explicit = has_explicit_countwith_basis_alias(options, "centers");
3592            let polynomial_cols = match nullspace_order {
3593                DuchonNullspaceOrder::Zero => 1,
3594                DuchonNullspaceOrder::Linear => cols.len() + 1,
3595                DuchonNullspaceOrder::Degree(degree) => {
3596                    crate::basis::duchon_nullspace_dimension(cols.len(), degree)
3597                }
3598            };
3599            // #1867: spline-equivalent floor so a 1-D radial basis is not
3600            // dimensioned coarser than the competing `s(x)` on identical data.
3601            let univariate_floor = if cols.len() == 1 {
3602                heuristic_knots_for_column(ds.values.column(cols[0]))
3603                    .saturating_add(DEFAULT_BSPLINE_DEGREE + 1)
3604            } else {
3605                0
3606            };
3607            let default_centers = default_duchon_center_count(
3608                sizing_rows,
3609                cols.len(),
3610                plan.centers,
3611                polynomial_cols,
3612                univariate_floor,
3613            );
3614            let spectral_rank = option_usize(options, "rank");
3615            let center_default = if spectral_rank.is_some() {
3616                // mgcv's Duchon constructor runs `uniquecombs` FIRST and caps
3617                // at `max.knots` afterwards, so its knot budget is
3618                // `min(n_unique, 2000)`. This took the RAW row count and let
3619                // `select_r_uniform_subsample_centers` deduplicate later — so
3620                // on any data carrying a repeated coordinate row with fewer
3621                // than 2000 rows, the budget exceeded what the sampler could
3622                // supply and the fit hard-refused rather than degrading
3623                // (#2623: `prostate_gamair`, 523 requested vs 522 unique).
3624                // Counting distinct rows here makes the budget satisfiable by
3625                // construction, and leaves the retained spectral `rank` — a
3626                // separate option — untouched.
3627                count_unique_coordinate_rows(ds.values.view(), &cols).min(2000)
3628            } else {
3629                cap_default_spatial_centers(options, default_centers)
3630            };
3631            let requested_centers =
3632                parse_countwith_basis_alias(options, "centers", center_default)?;
3633            if requested_centers > ds.values.nrows() {
3634                return Err(TermBuilderError::incompatible_config(format!(
3635                    "Duchon smooth '{}' requested {requested_centers} centers but only {} rows are available",
3636                    vars.join(", "),
3637                    ds.values.nrows(),
3638                ))
3639                .to_string());
3640            }
3641            if requested_centers <= polynomial_cols {
3642                return Err(TermBuilderError::incompatible_config(format!(
3643                    "Duchon smooth '{}' requested basis dimension {} but order={:?} in {}D needs {} polynomial null-space columns; choose centers/k > {}",
3644                    vars.join(", "),
3645                    requested_centers,
3646                    nullspace_order,
3647                    cols.len(),
3648                    polynomial_cols,
3649                    polynomial_cols,
3650                ))
3651                .to_string());
3652            }
3653            if let Some(rank) = spectral_rank
3654                && (rank <= polynomial_cols || rank > requested_centers)
3655            {
3656                return Err(TermBuilderError::incompatible_config(format!(
3657                    "Duchon smooth '{}' spectral rank must satisfy {} < rank <= centers (got rank={rank}, centers={requested_centers})",
3658                    vars.join(", "),
3659                    polynomial_cols,
3660                ))
3661                .to_string());
3662            }
3663            let mut centers = requested_centers;
3664            if !centers_explicit && ds.values.nrows() <= 32 && smooth_coordinate_count >= 5 {
3665                centers = centers.max(polynomial_cols + 4);
3666            }
3667            let aniso_log_scales = if option_bool(options, "scale_dims").unwrap_or(false) {
3668                Some(vec![0.0; cols.len()])
3669            } else {
3670                None
3671            };
3672            // Formula-level `duchon(...)` is the native Duchon reproducing-norm
3673            // smoother: the always-on Primary Gram plus the polynomial trend
3674            // ridge. Do not silently add collocated mass/tension penalties here.
3675            // They add extra REML hyperparameters and an O(k)-support quadrature
3676            // build to the default 2-D path, making `duchon(x, z)` materially
3677            // slower than the equivalent thin-plate fit without a principled
3678            // accuracy gain (gam#1718). Lower-order Hilbert-scale penalties remain
3679            // available to callers that construct an explicit DuchonBasisSpec.
3680            let operator_penalties = DuchonOperatorPenaltySpec::all_disabled();
3681            // For a 1-D periodic Duchon with no EXPLICIT period, anchor the wrap
3682            // to the covariate DATA range rather than letting the basis builder
3683            // derive it from the (k-subsampled) center span. The center span is a
3684            // strict subset of the data and undershoots the true period, seaming
3685            // the curve (f(0) ≠ f(2π)); the data range is the caller's actual
3686            // domain. Honors any explicit `period=` (parse_periodic_axes_option
3687            // already threaded it) and leaves multi-D / non-periodic untouched.
3688            let mut periodic = parse_periodic_axes_option(options, cols.len())?;
3689            if cols.len() == 1
3690                && let Some(axes) = periodic.as_mut()
3691                && axes.len() == 1
3692                && axes[0].is_none()
3693            {
3694                let (minv, maxv) = col_minmax(ds.values.column(cols[0]))?;
3695                if maxv > minv {
3696                    axes[0] = Some(maxv - minv);
3697                }
3698            }
3699            let boundary = if cols.len() == 1 {
3700                let c = cols[0];
3701                let (minv, maxv) = col_minmax(ds.values.column(c))?;
3702                parse_cyclic_boundary(options, minv, maxv)?
3703            } else {
3704                OneDimensionalBoundary::Open
3705            };
3706            let is_periodic = periodic
3707                .as_ref()
3708                .is_some_and(|axes| axes.iter().any(Option::is_some))
3709                || matches!(boundary, OneDimensionalBoundary::Cyclic { .. });
3710            reject_unconsumable_radial_period_declaration(
3711                "duchon",
3712                options,
3713                cols.len(),
3714                periodic.as_deref(),
3715                matches!(boundary, OneDimensionalBoundary::Cyclic { .. }),
3716            )?;
3717            if spectral_rank.is_some() && is_periodic {
3718                return Err(TermBuilderError::incompatible_config(
3719                    "Duchon spectral rank is defined for the scale-free open-domain kernel, \
3720                     not a periodic image expansion"
3721                        .to_string(),
3722                )
3723                .to_string());
3724            }
3725            let center_strategy = if spectral_rank.is_some() {
3726                // Freeze the exact fixed-seed uniform landmark experiment used
3727                // by mgcv's Duchon constructor. Spectral rank parity requires
3728                // the same kernel matrix, not merely the same retained column
3729                // count: maximin/equal-mass landmarks define a different
3730                // finite-sample eigenspace and confound accuracy comparisons.
3731                // Materializing 2,000×d coordinates here is cheap, avoids an
3732                // O(nk) maximin pass, and makes prediction replay explicit.
3733                let mut coordinates = Array2::<f64>::zeros((ds.values.nrows(), cols.len()));
3734                for (axis, &column) in cols.iter().enumerate() {
3735                    coordinates
3736                        .column_mut(axis)
3737                        .assign(&ds.values.column(column));
3738                }
3739                let sampled = select_r_uniform_subsample_centers(coordinates.view(), centers, 1)
3740                    .map_err(|error| error.to_string())?;
3741                CenterStrategy::UserProvided(sampled)
3742            } else if is_periodic {
3743                if centers_explicit {
3744                    spatial_center_strategy_for_dimension(centers, cols.len())
3745                } else {
3746                    auto_spatial_center_strategy(centers, cols.len())
3747                }
3748            } else {
3749                duchon_center_strategy(centers, cols.len(), !centers_explicit)
3750            };
3751            let center_strategy = match spectral_rank {
3752                Some(rank) => CenterStrategy::DuchonSpectral {
3753                    knots: Box::new(center_strategy),
3754                    basis: DuchonSpectralBasis::Fresh { rank },
3755                },
3756                None => center_strategy,
3757            };
3758            Ok(SmoothBasisSpec::Duchon {
3759                feature_cols: cols.to_vec(),
3760                spec: DuchonBasisSpec {
3761                    center_strategy,
3762                    periodic,
3763                    length_scale,
3764                    power,
3765                    nullspace_order,
3766                    identifiability: parse_spatial_identifiability(options)
3767                        .map_err(|e| e.to_string())?,
3768                    aniso_log_scales,
3769                    operator_penalties,
3770                    boundary,
3771                    radial_reparam: None,
3772                },
3773                input_scale: None,
3774            })
3775        }
3776        "tensor" | "te" | "ti" | "t2" => {
3777            validate_known_options("tensor", options, TENSOR_SMOOTH_OPTION_KEYS)?;
3778            if cols.len() < 2 {
3779                return Err(TermBuilderError::incompatible_config(format!(
3780                    "tensor smooth expects at least 2 variables, got {}",
3781                    cols.len()
3782                ))
3783                .to_string());
3784            }
3785            let dim = cols.len();
3786
3787            // Tensor-product contract (#1082). `te(x1, x2, ...)` ALWAYS builds a
3788            // genuine anisotropic tensor product of per-margin bases (the arm
3789            // below), exactly as mgcv's `te()` does — one smoothing parameter per
3790            // margin, a marginal-Kronecker-sum penalty, and a separate default
3791            // function-space ridge on the joint polynomial null space. A margin
3792            // vector `bs=c('tp','tp')` requests a thin-plate FUNCTION SPACE per
3793            // axis; the tensor realizes each axis as a 1-D penalized B-spline
3794            // margin spanning that same per-axis space (tp/ps/cr/bs/cc all share
3795            // it). We deliberately do NOT silently swap the requested tensor for a
3796            // single multi-D ISOTROPIC thin-plate radial smooth (`s(x,y,bs='tp')`):
3797            // that is a different model — one isotropic smoothing parameter, no
3798            // per-margin anisotropy — and substituting it while the user wrote a
3799            // tensor formula is dishonest. A user who genuinely wants the isotropic
3800            // radial smooth asks for it directly with `s(x1, x2, bs='tp')`.
3801            // Per-margin basis vector (`bs=c('tp','tp')` / `bs=['ps','cr']`):
3802            // validate each requested margin is a penalized-spline basis that
3803            // the tensor product realizes as a 1-D B-spline margin. mgcv's
3804            // `tp`/`ps`/`cr`/`bs`/`cc` margins are all penalized splines over
3805            // the same per-axis function space, so a B-spline margin recovers
3806            // the same tensor smoothing space; genuinely different margin kinds
3807            // (e.g. adaptive `ad`, random `re`) are rejected loudly rather than
3808            // silently substituted.
3809            if let Some(raw) = options.get("bs").or_else(|| options.get("type"))
3810                && bs_selector_is_vector(raw)
3811            {
3812                let per_margin = parse_option_list(raw);
3813                if per_margin.len() != dim {
3814                    return Err(TermBuilderError::invalid_option(format!(
3815                        "tensor smooth per-margin bs vector has {} entries but the smooth has {} margins",
3816                        per_margin.len(),
3817                        dim
3818                    ))
3819                    .to_string());
3820                }
3821                for (axis, margin_bs) in per_margin.iter().enumerate() {
3822                    if !tensor_margin_bs_is_supported(margin_bs) {
3823                        return Err(TermBuilderError::unsupported_feature(format!(
3824                            "tensor smooth margin {axis} basis '{margin_bs}' is not a supported penalized-spline margin; \
3825                             tensor margins accept tp/tps/ps/bs/cr/cc"
3826                        ))
3827                        .to_string());
3828                    }
3829                }
3830            }
3831            // Validate the boundary tokens BEFORE the axis resolver reads them,
3832            // so a malformed list is refused by name rather than silently
3833            // failing the resolver's length guard.
3834            validate_tensor_boundary_tokens(options, dim)?;
3835            let periodic_axes = parse_tensor_periodic_axes(options, dim)?;
3836            reject_unconsumable_period_declaration("tensor", options, &periodic_axes)?;
3837            // The half-open endpoint spelling names a single axis's domain and
3838            // has no per-margin form, so the tensor arm never reads it — it went
3839            // in through `validate_known_options` and straight out again (#2781).
3840            if let Some(key) = PERIOD_ENDPOINT_OPTION_KEYS
3841                .iter()
3842                .find(|key| options.contains_key(**key))
3843            {
3844                return Err(TermBuilderError::invalid_option(format!(
3845                    "tensor(): `{key}=` declares one axis's periodic domain and has no per-margin \
3846                     form; on a tensor smooth give periods=[...] (with origins=[...] for the \
3847                     domain start), which name their margin"
3848                ))
3849                .to_string());
3850            }
3851            let periods_opt = parse_periods(options, &periodic_axes)?;
3852            let origins_opt = parse_period_origins(options, &periodic_axes)?;
3853            // Per-margin `degree=` / `penalty_order=`. Both keep the caller's
3854            // request as `Option` rather than collapsing it onto the default
3855            // immediately: the cr-margin routing below has to know whether the
3856            // default was ASKED FOR or merely not overridden (#2782).
3857            let requested_degrees = parse_tensor_per_axis_usize(options, "degree", dim)?;
3858            let requested_penalty_orders =
3859                parse_tensor_per_axis_usize(options, "penalty_order", dim)?;
3860            let axis_degree = |axis: usize| -> usize {
3861                requested_degrees[axis].unwrap_or(DEFAULT_BSPLINE_DEGREE)
3862            };
3863            let axis_penalty_order = |axis: usize| -> usize {
3864                requested_penalty_orders[axis]
3865                    .unwrap_or(if axis_degree(axis) > 1 { 2 } else { 1 })
3866            };
3867            let (mut k_list, k_inferred) = parse_tensor_k_list(options, cols, ds)?;
3868            if ds.values.nrows() <= 32 && smooth_coordinate_count >= 5 {
3869                for (axis, k) in k_list.iter_mut().enumerate() {
3870                    *k = (*k).min(axis_degree(axis) + 2);
3871                }
3872            }
3873            if k_inferred {
3874                inference_notes.push(format!(
3875                    "Automatically set per-margin basis sizes {:?} for tensor smooth '{}' \
3876                     (dimension-aware tensor budget: total ∏k kept near the mgcv-te default \
3877                     and within the data support, distributed geometrically across margins and \
3878                     capped per margin by each column's resolution). \
3879                     Override with k=<int> or k=[k0,k1,...].",
3880                    k_list,
3881                    vars.join(",")
3882                ));
3883            }
3884            // Per-axis requested marginal basis family. mgcv's `te()`/`ti()`
3885            // default marginal basis is the cubic regression spline (`cr`), and
3886            // the te_3d quality gap (#1074) is precisely the marginal-basis
3887            // resolution at small `k`: a `cr` margin places k value-knots at
3888            // data quantiles (finer interior resolution under natural boundary
3889            // constraints) where the cubic B-spline margin has only
3890            // `k-degree-1` interior knots. Resolve each axis to either an
3891            // explicit per-margin `bs` (vector `bs=c('cr','ps')`), a single
3892            // scalar `bs`, or the unset default — and route
3893            // `cr`/`cs`/unset/`tp`/`tps` margins through the natural cubic
3894            // regression builder (`NaturalCubicRegression` knotspec), keeping
3895            // explicit `ps`/`bs`/`bspline` on the B-spline margin.
3896            let per_axis_bs: Vec<Option<String>> =
3897                match options.get("bs").or_else(|| options.get("type")) {
3898                    Some(raw) if bs_selector_is_vector(raw) => {
3899                        let list = parse_option_list(raw);
3900                        (0..dim).map(|a| list.get(a).cloned()).collect()
3901                    }
3902                    Some(raw) => {
3903                        let scalar = raw
3904                            .trim()
3905                            .trim_matches('"')
3906                            .trim_matches('\'')
3907                            .to_ascii_lowercase();
3908                        vec![Some(scalar); dim]
3909                    }
3910                    None => vec![None; dim],
3911                };
3912            // A margin is realized as a natural cubic regression spline when it
3913            // is the (unset) mgcv default, an explicit `cr`/`cs`, or a
3914            // `tp`/`tps` (same per-axis penalized-spline space). Explicit
3915            // B-spline-family margins (`ps`/`bs`/`bspline`/`p-spline`) keep the
3916            // open B-spline margin.
3917            let margin_wants_cr = |bs: &Option<String>| -> bool {
3918                matches!(
3919                    bs.as_deref(),
3920                    None | Some("cr") | Some("cs") | Some("tp") | Some("tps")
3921                )
3922            };
3923            let requested_knot_placement = explicit_knot_placement(options)?;
3924            let mut margins: Vec<BSplineBasisSpec> = Vec::with_capacity(dim);
3925            let mut emitted_periods: Vec<Option<f64>> = Vec::with_capacity(dim);
3926            for axis in 0..dim {
3927                let c = cols[axis];
3928                let (data_min, data_max) = col_minmax(ds.values.column(c))?;
3929                // mgcv reduces a tensor margin's basis dimension to what its data
3930                // can support: a cr or B-spline margin cannot place more value
3931                // knots / basis functions than there are DISTINCT covariate
3932                // values on that axis. Without this cap an explicit `k` on a
3933                // low-cardinality margin — e.g. the binary `badh ∈ {0,1}` in
3934                // `te(age, badh, k=5)` — hard-failed in `select_cr_knots` ("cubic
3935                // regression spline with k=5 requires at least 5 distinct values,
3936                // got 2") instead of degrading to the 2-function (linear) margin
3937                // mgcv builds there. The auto-`k` path already caps per margin via
3938                // `heuristic_tensor_margin_knots`; mirror that for explicit `k`.
3939                // The cap propagates correctly: every per-axis quantity below
3940                // (effective degree, knot set, penalty order) is derived from
3941                // `k_axis`, and the marginal basis size is read from the resulting
3942                // knot spec — never from `k_list`. Floor at 2 so a margin still
3943                // carries at least a linear basis (tensor margins require k >= 2).
3944                let k_requested = k_list[axis];
3945                let n_distinct_axis = unique_count_column(ds.values.column(c));
3946                let k_axis = k_requested.min(n_distinct_axis).max(2);
3947                if k_axis < k_requested {
3948                    log::info!(
3949                        "tensor smooth: margin axis {axis} requested k={k_requested}, but the \
3950                         covariate has only {n_distinct_axis} distinct value(s); reducing this \
3951                         margin to k={k_axis} (mgcv-style data-support cap on the per-axis basis)."
3952                    );
3953                }
3954                // Per-axis effective spline degree. The B-spline basis with `k`
3955                // functions is well-defined for any `degree <= k - 1`; mgcv's
3956                // `te(...)` exploits this so a binary tensor margin
3957                // (`k=2` → linear basis) or a ternary margin (`k=3` → quadratic)
3958                // can coexist with a smoother continuous margin under one
3959                // shared `degree=` request. We mirror that: if the caller
3960                // explicitly asks for `k < degree + 1`, drop the degree on
3961                // THAT axis only to the largest feasible spline, and track the
3962                // penalty order so the marginal difference penalty stays
3963                // well-defined (`order < num_basis_functions` is required by
3964                // `create_difference_penalty_matrix`). Apply the same
3965                // per-margin degree shrinkage to periodic tensor margins too:
3966                // a cyclic marginal basis with k=3 cannot be cubic, but it is
3967                // still a valid lower-degree cyclic margin with dimension k,
3968                // matching mgcv's small-k tensor-margin behavior.
3969                if k_axis < 2 {
3970                    return Err(TermBuilderError::invalid_option(format!(
3971                        "tensor smooth: k[{axis}]={k_axis} too small; tensor margins require k >= 2"
3972                    ))
3973                    .to_string());
3974                }
3975                let degree = axis_degree(axis);
3976                let penalty_order = axis_penalty_order(axis);
3977                let effective_degree = degree.min(k_axis - 1).max(1);
3978                let effective_penalty_order = penalty_order.min(effective_degree);
3979                // A `cc`/`cp`/`cyclic` per-margin basis declares periodicity
3980                // without necessarily supplying a `period=`: mgcv's `bs="cc"`
3981                // wraps at the covariate's observed data range. Mirror the 1-D
3982                // cyclic fallback (`parse_periodic_domain_1d`) here so a bare
3983                // `te(x, z, bs=c('cc','cc'))` wraps each margin on its own
3984                // [min, max] span instead of hard-erroring (#1752).
3985                let margin_is_cc = matches!(
3986                    canonicalize_smooth_type(per_axis_bs[axis].as_deref().unwrap_or("")),
3987                    "cc" | "cp" | "cyclic"
3988                );
3989                let (knotspec, boundary, axis_period) = if periodic_axes[axis] {
3990                    // A `cc`/`cp`/`cyclic` per-margin basis declares periodicity
3991                    // without necessarily supplying a `period=`; in that case wrap
3992                    // at the covariate's observed [min, max] span, mirroring the
3993                    // 1-D cyclic fallback (`parse_periodic_domain_1d`) so a bare
3994                    // `te(x, z, bs=c('cc','cc'))` wraps each margin on its own
3995                    // range instead of hard-erroring (#1752). An axis made
3996                    // periodic by an explicit `periodic=`/`boundary=` selector
3997                    // (not a cyclic margin basis) still requires an explicit
3998                    // `period=`: a data-derived period there is a sample-dependent
3999                    // off-by-ε seam and is not inferred.
4000                    let (domain_start, period_value) = match periods_opt[axis] {
4001                        Some(period_value) => {
4002                            if !period_value.is_finite() || period_value <= 0.0 {
4003                                return Err(format!(
4004                                    "tensor smooth axis {axis}: period must be a positive finite value, got {period_value}"
4005                                ));
4006                            }
4007                            (origins_opt[axis].unwrap_or(data_min), period_value)
4008                        }
4009                        None if margin_is_cc => {
4010                            let span = data_max - data_min;
4011                            if !span.is_finite() || span <= 0.0 {
4012                                return Err(format!(
4013                                    "tensor smooth axis {axis}: cyclic margin requires a positive \
4014                                     observed data range to derive its period, got [{data_min}, {data_max}]"
4015                                ));
4016                            }
4017                            (origins_opt[axis].unwrap_or(data_min), span)
4018                        }
4019                        None => {
4020                            return Err(format!(
4021                                "tensor smooth axis {axis} is periodic but requires an explicit \
4022                                 period: pass period=<value> (scalar) or period=[..., <value>, ...]. \
4023                                 Deriving the period from the observed data range is sample-dependent \
4024                                 (off-by-ε seam), so it is not inferred."
4025                            ));
4026                        }
4027                    };
4028                    let domain_end = domain_start + period_value;
4029                    (
4030                        BSplineKnotSpec::PeriodicUniform {
4031                            data_range: (domain_start, domain_end),
4032                            num_basis: k_axis,
4033                        },
4034                        OneDimensionalBoundary::Cyclic {
4035                            start: domain_start,
4036                            end: domain_end,
4037                        },
4038                        Some(period_value),
4039                    )
4040                } else if margin_wants_cr(&per_axis_bs[axis])
4041                    && requested_knot_placement.is_none()
4042                    && requested_degrees[axis].is_none_or(|d| d == CR_MARGIN_DEGREE)
4043                    && requested_penalty_orders[axis]
4044                        .is_none_or(|m| m == CR_MARGIN_PENALTY_ORDER)
4045                    && k_axis >= 3
4046                {
4047                    // mgcv `te()`/`ti()` default cr margin: place exactly
4048                    // `k_axis` Lancaster–Salkauskas value-knots at data
4049                    // quantiles. The cr basis dimension equals the knot count,
4050                    // so this reproduces the requested per-margin `k` directly.
4051                    // A natural cubic regression spline needs at least 3 knots
4052                    // (one interior); a `k_axis < 3` margin (e.g. a binary
4053                    // tensor axis requesting a linear margin) falls through to
4054                    // the B-spline branch below, exactly as before #1074 — mgcv
4055                    // likewise does not build a `cr` margin below k=3. An
4056                    // explicit `knot_placement=quantile` also falls through:
4057                    // that option selects the generated B-spline knot strategy
4058                    // represented by `Automatic { Quantile }`, whereas the cr
4059                    // margin has already materialized its quantile value-knots.
4060                    let cr_knots = crate::basis::select_cr_knots(ds.values.column(c), k_axis)
4061                        .map_err(|e| e.to_string())?;
4062                    (
4063                        BSplineKnotSpec::NaturalCubicRegression { knots: cr_knots },
4064                        OneDimensionalBoundary::Open,
4065                        None,
4066                    )
4067                } else {
4068                    // `num_internal_knots = k - effective_degree - 1` is the
4069                    // only count that realises the requested per-margin basis
4070                    // size: a clamped degree-`d` B-spline with `m` internal
4071                    // knots has exactly `m + d + 1` functions, and zero
4072                    // internal knots (`k = degree + 1`, one polynomial piece)
4073                    // is a valid margin. A legacy `.max(1)` floor on the
4074                    // un-reduced path used to turn `k=4` into a five-function
4075                    // cubic margin, so `k=4` and `k=5` built the same tensor.
4076                    // `k_axis >= 2` and `effective_degree <= k_axis - 1`, so
4077                    // this cannot underflow.
4078                    let num_internal_knots = k_axis - effective_degree - 1;
4079                    let knotspec = match requested_knot_placement
4080                        .unwrap_or(crate::basis::BSplineKnotPlacement::Uniform)
4081                    {
4082                        crate::basis::BSplineKnotPlacement::Uniform => BSplineKnotSpec::Generate {
4083                            data_range: (data_min, data_max),
4084                            num_internal_knots,
4085                        },
4086                        crate::basis::BSplineKnotPlacement::Quantile => {
4087                            crate::basis::auto_knot_vector_1d_quantile(
4088                                ds.values.column(c),
4089                                num_internal_knots,
4090                                effective_degree,
4091                            )
4092                            .map_err(|e| e.to_string())?;
4093                            BSplineKnotSpec::Automatic {
4094                                num_internal_knots: Some(num_internal_knots),
4095                                placement: crate::basis::BSplineKnotPlacement::Quantile,
4096                            }
4097                        }
4098                    };
4099                    (knotspec, OneDimensionalBoundary::Open, None)
4100                };
4101                // Margins contribute only their roughness operators. The tensor
4102                // builder constructs exactly one joint function-space null
4103                // penalty, avoiding unused per-margin ridge candidates and
4104                // duplicate λ coordinates.
4105                margins.push(BSplineBasisSpec {
4106                    degree: effective_degree,
4107                    penalty_order: effective_penalty_order,
4108                    knotspec,
4109                    double_penalty: false,
4110                    identifiability: BSplineIdentifiability::None,
4111                    boundary,
4112                    boundary_conditions: BSplineBoundaryConditions::default(),
4113                });
4114                emitted_periods.push(axis_period);
4115            }
4116            // #1593: canonicalize the margin order so a tensor smooth is invariant
4117            // to the typed order of its covariates. `te(x, z)` and `te(z, x)` span
4118            // the IDENTICAL tensor-product space under the identical per-margin
4119            // penalty family, but the design is the Khatri–Rao product
4120            // `B_first ⊙ B_second`, so the typed order permutes the design columns
4121            // (and the per-margin penalty blocks `S_first⊗I`, `I⊗S_second`). That
4122            // permutation is a pure relabelling in exact arithmetic — REML is
4123            // invariant to it — yet it reorders the penalized normal-equation / REML
4124            // eigen/Cholesky linear algebra, and the resulting sub-ULP differences
4125            // route the outer λ optimizer to a different terminal point in te's flat
4126            // REML valley (the over-smoothed margin rails to the ρ bound while the
4127            // other lands on a materially different λ̂). So the shipped surface
4128            // drifted ~2–6 % of range with a cosmetic swap of the covariate order
4129            // (the #1378 row-permutation / #1456 rotation flat-valley gauge family).
4130            // Sorting the margins by their source feature-column index makes the same
4131            // physical model build the identical problem regardless of typed order,
4132            // so the fit — and every prediction rebuilt from the resolved spec — is
4133            // genuinely order-invariant. `ti`/`t2` share this arm and become exactly
4134            // invariant too (they were already ~1e-5 by centring each margin
4135            // separately; canonicalization makes the swap bit-identical).
4136            let canon_cols: Vec<usize> = {
4137                let mut perm: Vec<usize> = (0..dim).collect();
4138                perm.sort_by_key(|&a| cols[a]);
4139                if perm.iter().enumerate().any(|(i, &a)| i != a) {
4140                    margins = perm.iter().map(|&a| margins[a].clone()).collect();
4141                    emitted_periods = perm.iter().map(|&a| emitted_periods[a]).collect();
4142                }
4143                perm.iter().map(|&a| cols[a]).collect()
4144            };
4145            let any_periodic = emitted_periods.iter().any(|p| p.is_some());
4146            let periods_vec = if any_periodic {
4147                emitted_periods
4148            } else {
4149                Vec::new()
4150            };
4151            // The tensor's joint polynomial null space is independently
4152            // shrinkable by default, so REML can recover an unsupported surface
4153            // as zero. Explicit `double_penalty=false` remains the MLE opt-out.
4154            let tensor_double_penalty = smooth_double_penalty;
4155            Ok(SmoothBasisSpec::TensorBSpline {
4156                feature_cols: canon_cols,
4157                spec: TensorBSplineSpec {
4158                    marginalspecs: margins,
4159                    periods: periods_vec,
4160                    double_penalty: tensor_double_penalty,
4161                    identifiability: parse_tensor_identifiability(options, kind)?,
4162                    // `t2` selects mgcv's separable (Wood, Scheipl & Faraway
4163                    // 2013) decomposition. It can arrive either as the `t2(...)`
4164                    // function form (`SmoothKind::T2`) or as a `type="t2"` /
4165                    // `bs="t2"` option on an `s(...)`/`te(...)` term, in which
4166                    // case `kind` is *not* `T2` but the resolved type string is
4167                    // "t2". Keying only off `kind` silently aliased the option
4168                    // form to `te`'s Kronecker-sum penalty (gam#1185); key off
4169                    // the resolved type string as well so both routes build the
4170                    // separable penalty.
4171                    penalty_decomposition: if matches!(kind, SmoothKind::T2)
4172                        || type_opt.as_str() == "t2"
4173                    {
4174                        TensorBSplinePenaltyDecomposition::Separable
4175                    } else {
4176                        TensorBSplinePenaltyDecomposition::MarginalKroneckerSum
4177                    },
4178                },
4179            })
4180        }
4181        "pca" => {
4182            validate_known_options("pca", options, PCA_SMOOTH_OPTION_KEYS)?;
4183            let path = options
4184                .get("lazy_path")
4185                .or_else(|| options.get("pca_basis_path"))
4186                .or_else(|| options.get("path"))
4187                .map(|raw| PathBuf::from(strip_quotes(raw)));
4188            let Some(path) = path else {
4189                return Err(TermBuilderError::incompatible_config(
4190                    "pca smooth requires lazy_path=... on the formula path",
4191                )
4192                .to_string());
4193            };
4194            let k = option_usize_any(options, &["k", "basis_dim", "basis-dim", "basisdim"])
4195                .unwrap_or(0);
4196            let chunk_size = option_usize(options, "chunk_size").unwrap_or(DEFAULT_PCA_CHUNK_SIZE);
4197            Ok(SmoothBasisSpec::Pca {
4198                feature_cols: cols.to_vec(),
4199                basis_matrix: Array2::<f64>::zeros((cols.len(), k)),
4200                centered: option_bool(options, "centered").unwrap_or(true),
4201                smooth_penalty: option_f64(options, "smooth_penalty").unwrap_or(1.0),
4202                center_mean: None,
4203                pca_basis_path: Some(path),
4204                chunk_size,
4205            })
4206        }
4207        other => Err(TermBuilderError::unsupported_feature(format!(
4208            "unsupported smooth type '{other}'"
4209        ))
4210        .to_string()),
4211    }
4212}
4213
4214/// Initialise per-axis anisotropic log-scales on eligible spatial smooth specs.
4215pub fn enable_scale_dimensions(spec: &mut TermCollectionSpec) {
4216    for smooth in spec.smooth_terms.iter_mut() {
4217        // A multi-axis thin-plate term cannot carry per-axis anisotropy on its
4218        // single curvature penalty, so `scale_dimensions` was historically a
4219        // silent no-op for `bs="tp"` (gam#1676). Rewrite it to the
4220        // mathematically-equivalent anisotropic s=0 Duchon spline first; the
4221        // Duchon arm below then sees an already-seeded `aniso_log_scales` and
4222        // leaves it untouched.
4223        promote_thin_plate_for_scale_dimensions(&mut smooth.basis);
4224        match &mut smooth.basis {
4225            SmoothBasisSpec::Matern {
4226                feature_cols,
4227                spec: matern,
4228                ..
4229            } => {
4230                if matern.aniso_log_scales.is_none() {
4231                    let d = feature_cols.len();
4232                    matern.aniso_log_scales = Some(vec![0.0; d]);
4233                }
4234            }
4235            SmoothBasisSpec::Duchon {
4236                feature_cols,
4237                spec: duchon,
4238                ..
4239            } => {
4240                if duchon.aniso_log_scales.is_none() {
4241                    let d = feature_cols.len();
4242                    duchon.aniso_log_scales = Some(vec![0.0; d]);
4243                }
4244            }
4245            // Bases with no per-axis length-scale vector to seed: either
4246            // single-axis, factor-indexed, or (tensor) already anisotropic by
4247            // construction through their marginals. Enumerated rather than
4248            // wildcarded so a new basis kind has to answer this question.
4249            SmoothBasisSpec::ByVariable { .. }
4250            | SmoothBasisSpec::FactorSumToZero { .. }
4251            | SmoothBasisSpec::BSpline1D { .. }
4252            | SmoothBasisSpec::BySmooth { .. }
4253            | SmoothBasisSpec::FactorSmooth { .. }
4254            | SmoothBasisSpec::ThinPlate { .. }
4255            | SmoothBasisSpec::Sphere { .. }
4256            | SmoothBasisSpec::ConstantCurvature { .. }
4257            | SmoothBasisSpec::MeasureJet { .. }
4258            | SmoothBasisSpec::Pca { .. }
4259            | SmoothBasisSpec::TensorBSpline { .. } => {}
4260        }
4261    }
4262}
4263
4264/// Rewrite a multi-axis thin-plate term into the mathematically-equivalent
4265/// anisotropic s=0 Duchon spline so that `scale_dimensions` genuinely engages
4266/// (gam#1676).
4267///
4268/// ## Why a rewrite rather than a new field on the TPS builder
4269///
4270/// A canonical thin-plate regression spline carries a *single* curvature
4271/// penalty — the exact `∫|Dᵐ f|²` reproducing-kernel Gram. That penalty has no
4272/// per-axis structure to make one direction more or less relevant than another,
4273/// so per-axis anisotropy (`scale_dimensions`) cannot be expressed on it. The
4274/// flag was therefore a silent no-op for `bs="tp"` while it engaged for
4275/// `duchon()`/`matern()`.
4276///
4277/// The thin-plate kernel `r^{2m−d}` (the `r²·log r` log-case in even `d`) is
4278/// *exactly* the s=0 Duchon kernel (`DuchonBasisSpec::power = 0`,
4279/// `length_scale = None`) at the matching polynomial null-space order
4280/// `m = thin_plate_penalty_order(d)`. The Duchon polyharmonic family already
4281/// carries the per-axis tension ARD that `scale_dimensions` requests: its
4282/// isotropic first-order roughness penalty `Σ‖∇f‖²` splits into `d` directional
4283/// penalties `Σ(∂f/∂x_a)²`, each with its own REML `λ_a`
4284/// (`duchon_operator_penalty_candidates`). So the well-posed *anisotropic
4285/// thin-plate spline is the anisotropic s=0 Duchon spline*. Rewriting to that
4286/// representation reuses the battle-tested Duchon anisotropy / ψ-derivative /
4287/// freeze / predict machinery instead of duplicating it onto the TPS metadata
4288/// path, and keeps the polyharmonic family internally consistent. The codebase
4289/// already promotes infeasible-`k` TPS to Duchon for the same reason (the
4290/// canonical TPS single curvature penalty cannot deliver a requested
4291/// capability); per-axis anisotropy is another such capability.
4292///
4293/// This fires *only* when the user opts into `scale_dimensions`; the default
4294/// thin-plate path (`scale_dimensions` off) is left bit-for-bit unchanged.
4295/// A 1-D thin-plate term is left untouched — anisotropy is meaningless on a
4296/// single axis (its `Σ η = 0` contrast vector is empty), exactly as for a 1-D
4297/// Matérn/Duchon term.
4298fn promote_thin_plate_for_scale_dimensions(basis: &mut SmoothBasisSpec) {
4299    let SmoothBasisSpec::ThinPlate {
4300        feature_cols,
4301        spec,
4302        input_scale,
4303    } = &*basis
4304    else {
4305        return;
4306    };
4307    let d = feature_cols.len();
4308    if d <= 1 {
4309        return;
4310    }
4311    // m = thin_plate_penalty_order(d) is the TPS penalty order; the Duchon
4312    // null-space order naming is `Zero → m=1`, `Linear → m=2`,
4313    // `Degree(g) → m=g+1`, so the s=0 Duchon kernel exponent
4314    // `2(p+s) − d = 2m − d` reproduces the TPS kernel exactly.
4315    let m = thin_plate_penalty_order(d);
4316    let nullspace_order = match m {
4317        0 | 1 => DuchonNullspaceOrder::Zero,
4318        2 => DuchonNullspaceOrder::Linear,
4319        _ => DuchonNullspaceOrder::Degree(m - 1),
4320    };
4321    let duchon_spec = DuchonBasisSpec {
4322        center_strategy: spec.center_strategy.clone(),
4323        periodic: spec.periodic.clone(),
4324        // Pure, scale-free Duchon — the thin-plate kernel has no length scale
4325        // (a global TPS kernel scale is non-identifiable once REML learns the
4326        // smoothing penalty: gam#718/#721/#731/#732). The per-axis relevance
4327        // the user asked for is carried by the tension-ARD `λ_a`, not a κ axis.
4328        length_scale: None,
4329        // s = 0  ⇒  thin-plate kernel `r^{2m−d}`.
4330        power: 0.0,
4331        nullspace_order,
4332        identifiability: spec.identifiability.clone(),
4333        // All-zero geometry seed sentinel: `auto_seed_aniso_contrasts` resolves
4334        // it from the (standardized) knot cloud, and the per-axis tension split
4335        // engages on `aniso.is_some()`.
4336        aniso_log_scales: Some(vec![0.0; d]),
4337        operator_penalties: DuchonOperatorPenaltySpec::default(),
4338        boundary: OneDimensionalBoundary::Open,
4339        radial_reparam: None,
4340    };
4341    let feature_cols = feature_cols.clone();
4342    let input_scale = *input_scale;
4343    // All borrows of `*basis` (the `&*basis` destructure above) end with the
4344    // clones on the two preceding lines, so the reassignment is sound.
4345    *basis = SmoothBasisSpec::Duchon {
4346        feature_cols,
4347        spec: duchon_spec,
4348        input_scale,
4349    };
4350}
4351
4352// ---------------------------------------------------------------------------
4353// Data-aware helpers
4354// ---------------------------------------------------------------------------
4355
4356pub fn spatial_center_strategy_for_dimension(num_centers: usize, d: usize) -> CenterStrategy {
4357    if d <= 3 {
4358        // In low-dimensional spatial smooths, an explicit `k` is a resolution
4359        // request rather than a request for marginal quantile-midpoint centers.
4360        // Use deterministic maximin geometry so Matérn/GP and Duchon REML see a
4361        // well-resolved native kernel block with small fill distance instead of
4362        // compensating for holes or endpoint under-resolution by over-smoothing
4363        // low-noise signals (#504).
4364        CenterStrategy::FarthestPoint { num_centers }
4365    } else {
4366        default_spatial_center_strategy(num_centers, d)
4367    }
4368}
4369
4370/// Center geometry for a non-periodic Duchon smooth.
4371///
4372/// In one dimension the represented domain is the interval between the observed
4373/// extrema.  Equally spaced centers are the exact minimax design for that
4374/// interval: among all `k`-point center sets they minimize the largest uncovered
4375/// gap.  Greedy farthest-point sampling instead produces a dyadic mesh whose
4376/// partially filled final level clusters centers and leaves wider holes whenever
4377/// `k` is not a power-of-two refinement.  Those holes reduce the effective
4378/// resolution of an explicit `k` and caused the low-noise k=20 Duchon fit to miss
4379/// the mature-smoother accuracy bar despite having the same basis dimension.
4380///
4381/// Multidimensional Duchon terms keep the rotation-equivariant farthest-point /
4382/// equal-mass strategies, where there is no canonical coordinate-aligned grid.
4383/// The `Auto` wrapper is retained for inferred 1-D counts so adaptive resolution
4384/// can still resize the interval grid before freezing its realized centers.
4385fn duchon_center_strategy(num_centers: usize, d: usize, automatic: bool) -> CenterStrategy {
4386    let realized = if d == 1 {
4387        CenterStrategy::UniformGrid {
4388            points_per_dim: num_centers,
4389        }
4390    } else {
4391        spatial_center_strategy_for_dimension(num_centers, d)
4392    };
4393    if automatic {
4394        CenterStrategy::Auto(Box::new(realized))
4395    } else {
4396        realized
4397    }
4398}
4399
4400pub fn col_minmax(col: ArrayView1<'_, f64>) -> Result<(f64, f64), String> {
4401    let min = col.iter().fold(f64::INFINITY, |a, &b| a.min(b));
4402    let max = col.iter().fold(f64::NEG_INFINITY, |a, &b| a.max(b));
4403    if !min.is_finite() || !max.is_finite() {
4404        return Err(TermBuilderError::degenerate_data(
4405            "non-finite data encountered while inferring knot range",
4406        )
4407        .to_string());
4408    }
4409    if (max - min).abs() < 1e-12 {
4410        Ok((min, min + 1e-6))
4411    } else {
4412        Ok((min, max))
4413    }
4414}
4415
4416pub fn unique_count_column(col: ArrayView1<'_, f64>) -> usize {
4417    use std::collections::HashSet;
4418    let mut set = HashSet::<u64>::with_capacity(col.len());
4419    for &v in col {
4420        set.insert(gam_data::canonical_level_bits(v));
4421    }
4422    set.len().max(1)
4423}
4424
4425/// Minimum knot count for a natural cubic regression spline: `select_cr_knots`
4426/// places one value-knot per basis function and needs at least an interior knot,
4427/// so the sparsest representable cr basis is `{const, linear, curvature}` at
4428/// three knots. Below this a cr spline is not constructible and the caller must
4429/// degrade to the linear B-spline marginal.
4430pub(crate) const CR_MIN_KNOTS: usize = 3;
4431
4432/// Build a cubic-regression marginal knot spec capped to the covariate's data
4433/// support, mgcv-style.
4434///
4435/// A `cr`/`cs`/`sz` marginal places exactly one basis function per value-knot,
4436/// so `select_cr_knots` cannot place more knots than the covariate has DISTINCT
4437/// values — it `bail`s with "cubic regression spline with k=N requires at least
4438/// N distinct values" otherwise. An unclamped `k` on an ordinary low-cardinality
4439/// covariate (a binary indicator, a 3-level ordinal/Likert score, a small count)
4440/// therefore hard-failed the whole fit instead of reducing the basis the way
4441/// mgcv — and gam's own tensor-margin path (996f829d7, `term_builder.rs:2986` /
4442/// the `k_axis >= 3` cr gate at `:3047`) — do. This is the univariate / factor-
4443/// smooth sibling of that tensor cap (#1541, #1542).
4444///
4445/// Returns:
4446/// - `Some(NaturalCubicRegression { .. })` with `k = min(k_requested, n_distinct)`
4447///   value-knots when the data supports a cr spline (`n_distinct >= CR_MIN_KNOTS`).
4448///   A cr basis of exactly `n_distinct` knots is full-rank for the data — it can
4449///   represent any per-distinct-value structure (e.g. 3 arbitrary group means on
4450///   a ternary covariate) — so the cap never costs recoverable signal.
4451/// - `None` when `n_distinct < CR_MIN_KNOTS` (a binary covariate): too few
4452///   distinct values for ANY cr spline, so the caller degrades to the linear
4453///   B-spline marginal — exactly what the default `s(x, k=..)` basis already
4454///   builds on the same data, and what the tensor path's `< 3` branch builds.
4455///
4456/// `inference_notes` records any reduction so the user sees that `k` was capped
4457/// (mgcv emits a warning in the same situation).
4458fn capped_cr_marginal_knotspec(
4459    col: ArrayView1<'_, f64>,
4460    k_cr_requested: usize,
4461    label: &str,
4462    inference_notes: &mut Vec<String>,
4463) -> Result<Option<BSplineKnotSpec>, String> {
4464    let n_distinct = unique_count_column(col);
4465    let k_cr = k_cr_requested.min(n_distinct);
4466    if k_cr < CR_MIN_KNOTS {
4467        inference_notes.push(format!(
4468            "Smooth '{label}': cubic-regression ('cr'/'cs'/'sz') basis requested k={k_cr_requested}, \
4469             but the covariate has only {n_distinct} distinct value(s) — too few to support a cubic \
4470             regression spline (needs >= {CR_MIN_KNOTS} distinct values). Degraded to the linear \
4471             B-spline marginal the default basis builds on the same data."
4472        ));
4473        return Ok(None);
4474    }
4475    if k_cr < k_cr_requested {
4476        inference_notes.push(format!(
4477            "Smooth '{label}': cubic-regression ('cr'/'cs'/'sz') basis reduced from k={k_cr_requested} \
4478             to k={k_cr} to match the covariate's {n_distinct} distinct value(s) (mgcv-style \
4479             data-support cap; a cr basis cannot place more value-knots than the data has)."
4480        ));
4481    }
4482    let cr_knots = crate::basis::select_cr_knots(col, k_cr).map_err(|e| e.to_string())?;
4483    Ok(Some(BSplineKnotSpec::NaturalCubicRegression {
4484        knots: cr_knots,
4485    }))
4486}
4487
4488/// Smallest number of distinct covariate values seen within any single group
4489/// of `group_col`. For a factor smooth this is the resolution that bounds the
4490/// marginal basis: a group with `m` distinct covariate values can only inform
4491/// `m` basis coefficients, so a marginal richer than that interpolates the
4492/// group instead of estimating a penalized trend. Bits are compared exactly so
4493/// integer-valued covariates (days, dose levels) collapse to their true count.
4494fn min_per_group_unique_count(
4495    feature_col: ArrayView1<'_, f64>,
4496    group_col: ArrayView1<'_, f64>,
4497) -> usize {
4498    use std::collections::{HashMap, HashSet};
4499    let mut per_group: HashMap<u64, HashSet<u64>> = HashMap::new();
4500    for (xi, gi) in feature_col.iter().zip(group_col.iter()) {
4501        per_group
4502            .entry(gam_data::canonical_level_bits(*gi))
4503            .or_default()
4504            .insert(gam_data::canonical_level_bits(*xi));
4505    }
4506    per_group
4507        .values()
4508        .map(|s| s.len())
4509        .min()
4510        .unwrap_or(1)
4511        .max(1)
4512}
4513
4514/// Cap on the automatically inferred internal-knot count of a 1-D smooth.
4515/// Default cubic basis ≈ `MAX_DEFAULT_INTERNAL_KNOTS + degree + 1` = 12
4516/// functions, matching mgcv's lean univariate default. Named at module level
4517/// so the inference note reports the ceiling the engine applies rather than
4518/// one of its own.
4519pub(crate) const MAX_DEFAULT_INTERNAL_KNOTS: usize = 8;
4520
4521/// Default internal-knot count for an *additive* univariate smooth, derived
4522/// from the column's unique-value count.
4523///
4524/// The basis dimension is `internal_knots + degree + 1`, so the cap below maps
4525/// to a default cubic basis of ~12 functions — deliberately close to mgcv's
4526/// univariate default (`k = 10`). A penalized smooth controls its wiggliness
4527/// through the *penalty*, not the basis size: REML/LAML shrinks a too-rich
4528/// basis toward the null, but it cannot do so cleanly when the basis is so
4529/// over-sized that the design becomes weakly identified. Growing the basis with
4530/// `n` (the old `n^(1/3)`-ceilinged `unique/4` rule, which pinned to 20 internal
4531/// knots ⇒ a 24-function basis for any column with ≥80 unique values) therefore
4532/// *hurts* recovery on finite, weak-signal fits: a 4-smooth additive model on
4533/// n=120 asks for ~92 coefficients, the outer optimizer stalls on the resulting
4534/// flat two-penalty (range + null-space) REML surface, and the truth leaks into
4535/// surplus columns the penalty can't shrink away (gam#1680; the same defect was
4536/// documented for thin-plate fields in gam#1074). A k-sweep on the #1680 design
4537/// confirms a basis of ~10–15 recovers truth at RMSE ≈ 0.12 while the old
4538/// 24-function default lands at ≈ 0.39 (~3× worse) — *whether or not* the
4539/// covariates are collinear, so this is basis over-richness, not collinearity.
4540///
4541/// The cap is flat in `n`: a user who genuinely needs a wigglier fit raises `k`
4542/// explicitly (mgcv's contract — opt *in* to more flexibility), and the SPEC
4543/// requires the default to allow recovering the null rather than forcing the
4544/// user to opt out of overfitting. The 4-knot floor stays put because we still
4545/// need enough basis functions to fit a non-trivial smooth at all, and the
4546/// `unique/4` growth below the cap keeps small/sparse columns (n ≤ 32, where
4547/// `unique/4 ≤ 8`) on exactly their previous knot count.
4548pub fn heuristic_knots_for_column(col: ArrayView1<'_, f64>) -> usize {
4549    let unique = unique_count_column(col);
4550    (unique / 4).clamp(4, MAX_DEFAULT_INTERNAL_KNOTS)
4551}
4552
4553/// Per-margin basis sizes for a tensor-product smooth (`te`/`ti`/`t2`).
4554///
4555/// The 1-D heuristic [`heuristic_knots_for_column`] is calibrated for an
4556/// *additive* margin: a well-resolved column asks for the lean univariate
4557/// default (≈12 basis functions, the mgcv-like cap of 8 internal knots; see
4558/// gam#1680), which is sensible for a single `s(x)` term.
4559/// A tensor product, however, multiplies the per-margin sizes:
4560/// `p = ∏_d k_d`. Reusing the 1-D rule per margin makes `p` explode with the
4561/// tensor dimension — a 3-D `te(x,y,z)` at the 1-D ceiling of 12/margin is
4562/// `12³ ≈ 1728` columns, and every REML evaluation pays an O(p³) dense
4563/// penalty reparameterization (the full-tensor sum-to-zero constraint is not
4564/// Kronecker-factorable), turning model selection over tensor candidates into
4565/// a multi-minute single-threaded stall (gam#813). It also requests far more
4566/// coefficients than the data can identify whenever `p ≫ n`.
4567///
4568/// mgcv's `te(...)` uses a small per-margin default (`k = 5`, i.e. `5^d`).
4569/// We match that spirit while staying data-adaptive: budget the *total* tensor
4570/// column count `p_target` and distribute it geometrically across the margins
4571/// so `∏ k_d ≈ p_target`, never asking a margin for more functions than its
4572/// own unique values (and the data set) can support.
4573fn heuristic_tensor_margin_knots(cols: &[usize], ds: &Dataset) -> Vec<usize> {
4574    let d = cols.len().max(1);
4575    let degree = DEFAULT_BSPLINE_DEGREE;
4576    let min_k = degree + 2; // smallest margin that carries a difference penalty
4577    let n = ds.values.nrows();
4578
4579    // Per-margin 1-D ceiling: never request more basis functions than the
4580    // margin's own resolution (unique values) supports. This caps each axis
4581    // independently before the joint budget is applied.
4582    let per_margin_cap: Vec<usize> = cols
4583        .iter()
4584        .map(|&c| heuristic_knots_for_column(ds.values.column(c)).max(min_k))
4585        .collect();
4586
4587    // Total-basis budget. A tensor with ∏k ≫ n coefficients is rank-deficient
4588    // and pure REML cost; cap the product at a generous fraction of n while
4589    // honoring mgcv's small default for the common small-d case. The budget
4590    // grows with n but the geometric split below keeps each margin modest.
4591    //   d=2 → up to ~7²=49 (mgcv-`te`-like), d=3 → ~5³=125, larger d shrinks
4592    // per-margin further so the product never blows past the data support.
4593    let mgcv_like_per_margin = match d {
4594        2 => 7usize,
4595        3 => 5usize,
4596        _ => 4usize,
4597    };
4598    let mgcv_like_total = (mgcv_like_per_margin as f64).powi(d as i32);
4599    let data_budget = (n as f64) * 0.8;
4600    let p_target = mgcv_like_total
4601        .max(min_k.pow(d as u32) as f64)
4602        .min(data_budget);
4603
4604    // Geometric per-margin target so ∏k ≈ p_target, then clamp each margin to
4605    // its own 1-D resolution cap and the difference-penalty floor.
4606    let geo_per_margin = p_target.powf(1.0 / d as f64).round() as usize;
4607    let unclamped: Vec<usize> = per_margin_cap
4608        .iter()
4609        .map(|&cap| geo_per_margin.clamp(min_k, cap))
4610        .collect();
4611
4612    // The per-margin clamps can pull some axes below `geo_per_margin` (a
4613    // low-resolution column), leaving headroom in the joint budget. Redistribute
4614    // that headroom to the margins that can still grow, so the realized ∏k stays
4615    // close to p_target instead of systematically under-shooting it.
4616    let mut k_list = unclamped;
4617    loop {
4618        let product: f64 = k_list.iter().map(|&k| k as f64).product();
4619        if product >= p_target {
4620            break;
4621        }
4622        // Grow the axis with the most remaining headroom (cap − current),
4623        // breaking ties toward the largest cap. Stop when none can grow.
4624        let Some(idx) = k_list
4625            .iter()
4626            .zip(per_margin_cap.iter())
4627            .enumerate()
4628            .filter(|&(_, (k, cap))| k < cap)
4629            .max_by_key(|&(_, (k, cap))| (cap - k, *cap))
4630            .map(|(i, _)| i)
4631        else {
4632            break;
4633        };
4634        k_list[idx] += 1;
4635    }
4636    k_list
4637}
4638
4639// ---------------------------------------------------------------------------
4640// Smooth option parsers
4641// ---------------------------------------------------------------------------
4642
4643fn parse_endpoint_side(
4644    value: &str,
4645    context: &str,
4646) -> Result<BSplineEndpointBoundaryCondition, String> {
4647    match value.trim().to_ascii_lowercase().as_str() {
4648        "" | "none" | "open" | "unconstrained" | "free" => {
4649            Ok(BSplineEndpointBoundaryCondition::Free)
4650        }
4651        "clamped" | "clamp" | "zero_derivative" | "zero-derivative" => {
4652            Ok(BSplineEndpointBoundaryCondition::Clamped)
4653        }
4654        "anchored" | "anchor" | "zero" | "zero_value" | "zero-value" => {
4655            Ok(BSplineEndpointBoundaryCondition::Anchored { value: 0.0 })
4656        }
4657        other => Err(format!(
4658            "unsupported {context} boundary condition '{other}'; expected free, clamped, or anchored"
4659        )),
4660    }
4661}
4662
4663fn boundary_anchor_value(
4664    options: &BTreeMap<String, String>,
4665    side: &str,
4666    fallback: Option<f64>,
4667) -> Option<f64> {
4668    [
4669        format!("anchor_{side}"),
4670        format!("{side}_anchor"),
4671        format!("anchor-value-{side}"),
4672    ]
4673    .iter()
4674    .find_map(|key| option_f64(options, key))
4675    .or(fallback)
4676}
4677
4678fn apply_anchor_value(
4679    cond: BSplineEndpointBoundaryCondition,
4680    value: Option<f64>,
4681) -> BSplineEndpointBoundaryCondition {
4682    match cond {
4683        BSplineEndpointBoundaryCondition::Anchored { .. } => {
4684            BSplineEndpointBoundaryCondition::Anchored {
4685                value: value.unwrap_or(0.0),
4686            }
4687        }
4688        other => other,
4689    }
4690}
4691
4692fn parse_bspline_boundary_conditions(
4693    options: &BTreeMap<String, String>,
4694) -> Result<BSplineBoundaryConditions, String> {
4695    let fallback_anchor = option_f64(options, "anchor")
4696        .or_else(|| option_f64(options, "anchor_value"))
4697        .or_else(|| option_f64(options, "value"));
4698    // `boundary` is whitelisted on this arm as the third spelling of `bc` /
4699    // `boundary_conditions` and was read by NEITHER of the two functions that
4700    // consume the option (`parse_periodic_axes` reads it, but only for the
4701    // periodic tokens), so `s(x, boundary=clamped)` was accepted and inert
4702    // (#2781's family). A periodic token never reaches here: the arm skips this
4703    // function entirely once `bspline_boundary_declares_periodic_axis` fires.
4704    let global_boundary_conditions = options
4705        .get("boundary_conditions")
4706        .or_else(|| options.get("bc"))
4707        .or_else(|| options.get("boundary"));
4708    let mut boundary_conditions = BSplineBoundaryConditions::default();
4709
4710    if let Some(raw_boundary_conditions) = global_boundary_conditions {
4711        let cond = parse_endpoint_side(raw_boundary_conditions, "boundary_conditions")?;
4712        let side = options
4713            .get("side")
4714            .map(|s| s.trim().to_ascii_lowercase())
4715            .unwrap_or_else(|| "both".to_string());
4716        match side.as_str() {
4717            "both" | "all" | "endpoints" => {
4718                boundary_conditions.left = cond;
4719                boundary_conditions.right = cond;
4720            }
4721            "left" | "start" | "lower" => boundary_conditions.left = cond,
4722            "right" | "end" | "upper" => boundary_conditions.right = cond,
4723            other => {
4724                return Err(format!(
4725                    "unsupported B-spline boundary side '{other}'; expected left, right, or both"
4726                ));
4727            }
4728        }
4729    }
4730
4731    if let Some(raw) = options
4732        .get("bc_left")
4733        .or_else(|| options.get("left_bc"))
4734        .or_else(|| options.get("bc_start"))
4735        .or_else(|| options.get("start_bc"))
4736    {
4737        boundary_conditions.left = parse_endpoint_side(raw, "left endpoint")?;
4738    }
4739    if let Some(raw) = options
4740        .get("bc_right")
4741        .or_else(|| options.get("right_bc"))
4742        .or_else(|| options.get("bc_end"))
4743        .or_else(|| options.get("end_bc"))
4744    {
4745        boundary_conditions.right = parse_endpoint_side(raw, "right endpoint")?;
4746    }
4747
4748    boundary_conditions.left = apply_anchor_value(
4749        boundary_conditions.left,
4750        boundary_anchor_value(options, "left", fallback_anchor),
4751    );
4752    boundary_conditions.right = apply_anchor_value(
4753        boundary_conditions.right,
4754        boundary_anchor_value(options, "right", fallback_anchor),
4755    );
4756
4757    // `side=` says WHICH endpoint the global condition applies to, and an
4758    // anchor value says WHAT an anchored endpoint is pinned to. Neither means
4759    // anything on its own, and both were previously accepted and discarded, so
4760    // `s(x, bc_left=anchored, anchor=2.5)` pinned the endpoint at 2.5 while
4761    // `s(x, anchor=2.5)` silently pinned nothing at all (#2781's family).
4762    if options.contains_key("side") && global_boundary_conditions.is_none() {
4763        return Err(TermBuilderError::invalid_option(
4764            "`side=` selects which endpoint a boundary condition applies to, but this smooth              declares none; add bc=<condition> or drop it",
4765        )
4766        .to_string());
4767    }
4768    if !boundary_conditions.has_anchor()
4769        && let Some(key) = ANCHOR_VALUE_OPTION_KEYS
4770            .iter()
4771            .find(|key| options.contains_key(**key))
4772    {
4773        return Err(TermBuilderError::invalid_option(format!(
4774            "`{key}=` sets the value an ANCHORED endpoint is pinned to, but no endpoint of this              smooth is anchored; add bc=anchored (or bc_left=/bc_right=anchored) or drop it"
4775        ))
4776        .to_string());
4777    }
4778
4779    Ok(boundary_conditions)
4780}
4781
4782/// Option keys that carry the value an anchored endpoint is pinned to. Each is
4783/// meaningless without an `anchored` endpoint to attach it to.
4784const ANCHOR_VALUE_OPTION_KEYS: [&str; 8] = [
4785    "anchor",
4786    "anchor_value",
4787    "value",
4788    "anchor_left",
4789    "left_anchor",
4790    "anchor_right",
4791    "right_anchor",
4792    "anchor-value-left",
4793];
4794
4795/// Resolve the requested internal-knot count and effective spline degree for
4796/// a 1-D penalized B-spline smooth. This mirrors the tensor-margin per-axis
4797/// degree-reduction policy: a 1-D B-spline basis with `k` functions
4798/// is well-defined for any `degree <= k - 1`, so an explicit
4799/// `s(x, bs="ps", k=3)` with default `degree=3` is interpreted as the
4800/// largest representable spline (`effective_degree = k - 1 = 2`, quadratic)
4801/// rather than rejected. The `penalty_order` carried by the caller must be
4802/// clamped to `<= effective_degree` so the marginal roughness penalty
4803/// stays well-defined; the returned `effective_degree` makes that explicit.
4804///
4805/// An explicit `k` is honoured EXACTLY: a clamped degree-`d` B-spline with
4806/// `m` internal knots has `m + d + 1` functions, so the returned count is
4807/// `k - effective_degree - 1`, zero included. `s(x, k=4)` therefore names the
4808/// same four-function cubic as `s(x, knots=0)` — the documented identity
4809/// `k = internal_knots + degree + 1` (docs/formulas.md) holds for every `k`.
4810///
4811/// Mirrors the tensor margin treatment in the `te(...)` builder so a
4812/// standalone smooth, a factor smooth, and a tensor margin all interpret
4813/// "small k" the same way.
4814fn parse_ps_internal_knots(
4815    options: &BTreeMap<String, String>,
4816    degree: usize,
4817    default_internal_knots: usize,
4818) -> Result<(usize, bool, usize), String> {
4819    // Strict variants: reject `k=-1`, `k=1.5`, `knots=-2` etc. with a
4820    // focused error instead of silently dropping the value and using the
4821    // default. Lenient `option_usize` / `option_usize_any` silently swallow
4822    // unparseable values, which leaves the user thinking they configured
4823    // something when they did not.
4824    // A list-valued `knots=[...]` carries explicit internal positions, not a
4825    // count; it is consumed by `parse_explicit_internal_knots`. Treat it as
4826    // "count not specified" here so the strict integer parse does not reject
4827    // the bracketed value (the Provided path ignores the returned count).
4828    let knots_internal = if knots_option_is_list(options) {
4829        None
4830    } else {
4831        option_usize_strict(options, "knots")?
4832    };
4833    let basis_dim = option_usize_any_strict(options, &["k", "basis_dim", "basis-dim", "basisdim"])?;
4834    if knots_internal.is_some() && basis_dim.is_some() {
4835        return Err(TermBuilderError::incompatible_config(
4836            "ps/bspline smooth: specify either knots=<internal_knots> or k=<basis_dim> (not both)",
4837        )
4838        .to_string());
4839    }
4840    if let Some(k) = basis_dim {
4841        if k < 2 {
4842            return Err(TermBuilderError::invalid_option(format!(
4843                "ps/bspline smooth: k={} too small; B-spline basis requires k >= 2",
4844                k
4845            ))
4846            .to_string());
4847        }
4848        // `degree <= k - 1` is required for the B-spline basis to be
4849        // well-defined; reduce on this axis only when the user asked for
4850        // a smaller k than the cubic default supports. This matches mgcv's
4851        // behaviour (e.g. `s(x, bs="ps", k=3)` becomes a quadratic basis)
4852        // and the per-axis reduction the tensor builder already does.
4853        let effective_degree = degree.min(k - 1).max(1);
4854        // `k >= 2` and `effective_degree <= k - 1`, so this cannot underflow.
4855        // A floor of two internal knots used to sit on the un-reduced branch
4856        // and silently turned `k=4` and `k=5` into the six-function cubic, so
4857        // three requested dimensions collapsed onto one bit-identical fit
4858        // while the `knots=` spelling of the same bases was honoured exactly.
4859        // Zero internal knots is a valid basis (a single polynomial piece).
4860        let num_internal_knots = k - effective_degree - 1;
4861        Ok((num_internal_knots, false, effective_degree))
4862    } else {
4863        Ok((
4864            knots_internal.unwrap_or(default_internal_knots),
4865            knots_internal.is_none(),
4866            degree,
4867        ))
4868    }
4869}
4870
4871/// True when the `knots` option value is a *list* literal (`[...]`, `c(...)`,
4872/// or `(...)`) rather than a scalar count. mgcv's `knots=` accepts both: a
4873/// single integer is an internal-knot count, while a vector is explicit
4874/// internal knot positions. We disambiguate purely on the wrapper syntax so a
4875/// bare `knots=5` keeps its historical count meaning.
4876fn knots_option_is_list(options: &BTreeMap<String, String>) -> bool {
4877    options
4878        .get("knots")
4879        .map(|raw| {
4880            let t = raw.trim();
4881            t.starts_with('[') || t.starts_with("c(") || t.starts_with("C(") || t.starts_with('(')
4882        })
4883        .unwrap_or(false)
4884}
4885
4886/// Parse `knots=[k0, k1, ...]` (or `c(...)` / `(...)`) into explicit internal
4887/// knot positions. Returns `Ok(None)` when `knots` is absent or a scalar count
4888/// (handled by [`parse_ps_internal_knots`]); `Ok(Some(positions))` when it is a
4889/// non-empty numeric list; and an error for an empty or unparseable list.
4890fn parse_explicit_internal_knots(
4891    options: &BTreeMap<String, String>,
4892) -> Result<Option<Vec<f64>>, String> {
4893    if !knots_option_is_list(options) {
4894        return Ok(None);
4895    }
4896    let raw = options
4897        .get("knots")
4898        .expect("knots_option_is_list implies the key is present");
4899    let tokens = split_list_option(raw);
4900    if tokens.is_empty() {
4901        return Err(TermBuilderError::invalid_option(format!(
4902            "knots={raw} is an empty list; supply at least one internal knot position \
4903             (e.g. knots=[0.2, 0.5, 0.8]) or a scalar count (e.g. knots=8)"
4904        ))
4905        .to_string());
4906    }
4907    let mut positions = Vec::with_capacity(tokens.len());
4908    for tok in &tokens {
4909        let value = parse_numeric_expr(tok).map_err(|err| {
4910            TermBuilderError::invalid_option(format!(
4911                "knots list entry '{tok}' is not a numeric position: {err}"
4912            ))
4913            .to_string()
4914        })?;
4915        positions.push(value);
4916    }
4917    Ok(Some(positions))
4918}
4919
4920/// Resolve the `knot_placement=` option for an automatically generated knot
4921/// vector. Accepts `"uniform"` (the default, equal spacing on the data range)
4922/// and `"quantile"` (interior knots at empirical data quantiles, better for
4923/// skewed covariates). Unknown values are rejected so typos do not silently
4924/// fall back to uniform.
4925/// Parse a per-margin unsigned-integer tensor option (`degree=`,
4926/// `penalty_order=`).
4927///
4928/// Accepts the scalar form (`degree=2`), which broadcasts to every margin as
4929/// `docs/formulas.md` promises ("Margins requested as a single value are
4930/// broadcast across all margins"), and the per-margin list form
4931/// (`degree=[1, 3]`, `degree=c(1, 3)`), with `none` selecting the default on
4932/// that margin. Returns `None` per axis when the caller said nothing, so the
4933/// margin loop can tell "asked for the default" apart from "asked for a value
4934/// that happens to equal the default" — a distinction the cr-margin routing
4935/// below depends on.
4936///
4937/// Before #2782 both options were read with `option_usize`, which parses only a
4938/// bare integer: a list form silently fell back to the default, so
4939/// `te(x, z, degree=[1,3])` was bit-identical to `te(x, z)`.
4940fn parse_tensor_per_axis_usize(
4941    options: &BTreeMap<String, String>,
4942    key: &str,
4943    dim: usize,
4944) -> Result<Vec<Option<usize>>, String> {
4945    let Some(raw) = options.get(key) else {
4946        return Ok(vec![None; dim]);
4947    };
4948    let values = split_list_option(raw);
4949    let parse_one = |value: &str| -> Result<Option<usize>, String> {
4950        let trimmed = value.trim().trim_matches('"').trim_matches('\'').trim();
4951        if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("none") {
4952            return Ok(None);
4953        }
4954        trimmed.parse::<usize>().map(Some).map_err(|err| {
4955            TermBuilderError::invalid_option(format!(
4956                "tensor smooth `{key}={raw}`: '{trimmed}' is not a non-negative integer ({err})"
4957            ))
4958            .to_string()
4959        })
4960    };
4961    if values.len() == 1 {
4962        let shared = parse_one(&values[0])?;
4963        return Ok(vec![shared; dim]);
4964    }
4965    if values.len() != dim {
4966        return Err(TermBuilderError::invalid_option(format!(
4967            "tensor smooth `{key}={raw}` has {} entries but the smooth has {dim} margins; pass one \
4968             value per margin or a single value for all of them",
4969            values.len()
4970        ))
4971        .to_string());
4972    }
4973    values.iter().map(|value| parse_one(value)).collect()
4974}
4975
4976/// The polynomial degree of the natural cubic regression margin. It is not a
4977/// parameter of that basis — a "cubic regression spline" IS cubic — so a margin
4978/// that asks for any other degree cannot be realized as one.
4979const CR_MARGIN_DEGREE: usize = 3;
4980
4981/// The derivative order the natural cubic regression penalty integrates. Like
4982/// [`CR_MARGIN_DEGREE`], this is definitional rather than adjustable: the cr
4983/// penalty is the exact integrated squared SECOND derivative of the
4984/// interpolating cubic.
4985const CR_MARGIN_PENALTY_ORDER: usize = 2;
4986
4987fn parse_knot_placement(
4988    options: &BTreeMap<String, String>,
4989) -> Result<crate::basis::BSplineKnotPlacement, String> {
4990    use crate::basis::BSplineKnotPlacement;
4991    match options
4992        .get("knot_placement")
4993        .or_else(|| options.get("knot-placement"))
4994        .or_else(|| options.get("knotplacement"))
4995    {
4996        None => Ok(BSplineKnotPlacement::Uniform),
4997        Some(raw) => match raw
4998            .trim()
4999            .trim_matches('"')
5000            .trim_matches('\'')
5001            .to_ascii_lowercase()
5002            .as_str()
5003        {
5004            "uniform" | "even" | "equal" => Ok(BSplineKnotPlacement::Uniform),
5005            "quantile" | "quantiles" | "data" | "empirical" => Ok(BSplineKnotPlacement::Quantile),
5006            other => Err(TermBuilderError::invalid_option(format!(
5007                "knot_placement={other} is not recognised; expected \"uniform\" or \"quantile\""
5008            ))
5009            .to_string()),
5010        },
5011    }
5012}
5013
5014/// Like [`parse_knot_placement`] but distinguishes "unset" from an explicit
5015/// `knot_placement=uniform`.
5016///
5017/// The two are not the same request on a tensor margin: unset means "give me
5018/// mgcv's default margin", which is a natural cubic regression spline on
5019/// QUANTILE value-knots, while an explicit `uniform` asks for evenly spaced
5020/// knots — something the cr margin cannot do. Collapsing them made
5021/// `te(x, z, knot_placement='uniform')` a silent no-op that returned
5022/// quantile-placed knots (#2782).
5023fn explicit_knot_placement(
5024    options: &BTreeMap<String, String>,
5025) -> Result<Option<crate::basis::BSplineKnotPlacement>, String> {
5026    let declared = ["knot_placement", "knot-placement", "knotplacement"]
5027        .iter()
5028        .any(|key| options.contains_key(*key));
5029    if !declared {
5030        return Ok(None);
5031    }
5032    parse_knot_placement(options).map(Some)
5033}
5034
5035/// Build the non-periodic 1D B-spline knot spec for the `ps`/`bspline` and
5036/// factor-smooth marginal paths, honoring (in priority order):
5037///   1. `knots=[...]` explicit internal positions  → [`BSplineKnotSpec::Provided`]
5038///   2. `knot_placement="quantile"`                 → [`BSplineKnotSpec::Automatic`]
5039///   3. uniform generation                          → [`BSplineKnotSpec::Generate`]
5040///
5041/// `data` is the covariate column (used to clamp explicit positions to the
5042/// observed range and to drive quantile placement); `n_knots` is the resolved
5043/// internal-knot count from [`parse_ps_internal_knots`] used for the automatic
5044/// strategies.
5045fn resolve_nonperiodic_bspline_knotspec(
5046    options: &BTreeMap<String, String>,
5047    data: ArrayView1<'_, f64>,
5048    data_range: (f64, f64),
5049    degree: usize,
5050    n_knots: usize,
5051) -> Result<BSplineKnotSpec, String> {
5052    use crate::basis::{BSplineKnotPlacement, clamped_knot_vector_from_internal_positions};
5053    if let Some(positions) = parse_explicit_internal_knots(options)? {
5054        if option_usize_any_strict(options, &["k", "basis_dim", "basis-dim", "basisdim"])?.is_some()
5055        {
5056            return Err(TermBuilderError::incompatible_config(
5057                "ps/bspline smooth: specify either explicit knots=[...] positions or \
5058                 k=<basis_dim> (not both); the basis size is fixed by the knot vector",
5059            )
5060            .to_string());
5061        }
5062        let knots = clamped_knot_vector_from_internal_positions(data_range, &positions, degree)
5063            .map_err(|e| e.to_string())?;
5064        return Ok(BSplineKnotSpec::Provided(knots));
5065    }
5066    match parse_knot_placement(options)? {
5067        BSplineKnotPlacement::Uniform => Ok(BSplineKnotSpec::Generate {
5068            data_range,
5069            num_internal_knots: n_knots,
5070        }),
5071        BSplineKnotPlacement::Quantile => {
5072            // Validate the column up-front so an unfittable request surfaces a
5073            // user-correctable error at parse time rather than deep in basis
5074            // construction. The same data drives the eventual quantile knots.
5075            crate::basis::auto_knot_vector_1d_quantile(data, n_knots, degree)
5076                .map_err(|e| e.to_string())?;
5077            Ok(BSplineKnotSpec::Automatic {
5078                num_internal_knots: Some(n_knots),
5079                placement: BSplineKnotPlacement::Quantile,
5080            })
5081        }
5082    }
5083}
5084
5085/// Reject unknown option keys with a focused error that names the term and
5086/// the offending key, plus suggests near-matches from the known-key list.
5087/// Without this, typos like `lengt_scale=0.1` or `nyu=5/2` are silently
5088/// dropped, the term uses the default, and the user has no idea why their
5089/// option had no effect.
5090
5091// ---------------------------------------------------------------------------
5092// Per-smooth-kind option whitelists
5093//
5094// Hoisted out of the `validate_known_options` call sites so the guard test
5095// `no_whitelisted_smooth_option_is_accepted_and_inert` can enumerate them.
5096// `validate_known_options` answers "is this key spelled right?"; that guard
5097// answers the different question these three lists silently got wrong in
5098// #2781/#2782/#2783 — "does this key do anything?".
5099// ---------------------------------------------------------------------------
5100/// Options of the PENALIZED factor smooths, `bs='fs'` and `bs='sz'`: a shared
5101/// B-spline marginal replicated once per level of the grouping factor. Every
5102/// key here shapes that marginal, so every key here reaches the built design.
5103///
5104/// `bs='re'` used to share this list even though it builds no spline at all;
5105/// see [`RANDOM_EFFECT_SMOOTH_OPTION_KEYS`] and #2791.
5106pub(crate) const FACTOR_SMOOTH_OPTION_KEYS: &[&str] = &[
5107    "type",
5108    "bs",
5109    "k",
5110    "basis_dim",
5111    "basis-dim",
5112    "basisdim",
5113    "knots",
5114    "knot_placement",
5115    "knot-placement",
5116    "knotplacement",
5117    "degree",
5118    "penalty_order",
5119    "m",
5120    "double_penalty",
5121    "ordered",
5122];
5123
5124/// Options of `bs='re'`, the PARAMETRIC random intercept + slope.
5125///
5126/// `s(x, g, bs='re')` is mgcv's `(1 + x | g)`: the per-level design is the raw
5127/// line `[1, x − c]` and the penalty is an identity ridge per parametric
5128/// coordinate. There is no spline marginal, no knot vector and no difference
5129/// penalty, so none of the basis-shaping keys of
5130/// [`FACTOR_SMOOTH_OPTION_KEYS`] can be honoured — and until #2791 all ten of
5131/// them were accepted and silently discarded.
5132pub(crate) const RANDOM_EFFECT_SMOOTH_OPTION_KEYS: &[&str] = &["type", "bs", "ordered"];
5133
5134/// The keys `bs='re'` refuses with a reason rather than a bare "unknown
5135/// option": they are all spelled correctly and all valid on `bs='fs'`, so the
5136/// user's mistake is the flavour, not the spelling.
5137const RANDOM_EFFECT_UNSHAPEABLE_OPTION_KEYS: &[&str] = &[
5138    "k",
5139    "basis_dim",
5140    "basis-dim",
5141    "basisdim",
5142    "knots",
5143    "knot_placement",
5144    "knot-placement",
5145    "knotplacement",
5146    "degree",
5147    "penalty_order",
5148    "m",
5149    "double_penalty",
5150];
5151
5152/// Validate the option map of a `bs='re'` term.
5153///
5154/// Refuses the basis-shaping keys with a message that names the flavour that
5155/// does honour them, then falls through to the ordinary spelling check.
5156fn validate_random_effect_smooth_options(
5157    options: &BTreeMap<String, String>,
5158) -> Result<(), String> {
5159    if let Some(key) = RANDOM_EFFECT_UNSHAPEABLE_OPTION_KEYS
5160        .iter()
5161        .find(|key| options.contains_key(**key))
5162    {
5163        return Err(TermBuilderError::incompatible_config(format!(
5164            "bs='re' is a parametric random intercept + slope — the per-level line \
5165             [1, x] under an i.i.d. ridge — not a spline, so it has no basis to shape \
5166             and `{key}=` cannot be honoured. Use bs='fs' for a penalized random \
5167             smooth of x within each level (it accepts {key}=), or drop the option."
5168        ))
5169        .to_string());
5170    }
5171    validate_known_options("re", options, RANDOM_EFFECT_SMOOTH_OPTION_KEYS)
5172}
5173
5174pub(crate) const CYCLIC_SMOOTH_OPTION_KEYS: &[&str] = &[
5175    "type",
5176    "bs",
5177    "by",
5178    "k",
5179    "basis_dim",
5180    "basis-dim",
5181    "basisdim",
5182    "degree",
5183    "penalty_order",
5184    "period",
5185    "periods",
5186    "period_start",
5187    "period_end",
5188    "start",
5189    "end",
5190    "origin",
5191    "origins",
5192    "period_origin",
5193    "period-origin",
5194    "domain_origin",
5195    "double_penalty",
5196    "id",
5197    "identifiability",
5198];
5199
5200pub(crate) const BSPLINE_SMOOTH_OPTION_KEYS: &[&str] = &[
5201    "type",
5202    "bs",
5203    "by",
5204    "k",
5205    "basis_dim",
5206    "basis-dim",
5207    "basisdim",
5208    "knots",
5209    "knot_placement",
5210    "knot-placement",
5211    "knotplacement",
5212    "degree",
5213    "penalty_order",
5214    "boundary",
5215    "bc",
5216    "boundary_conditions",
5217    "bc_left",
5218    "bc_right",
5219    "left_bc",
5220    "right_bc",
5221    "start_bc",
5222    "end_bc",
5223    "side",
5224    "anchor",
5225    "anchor_value",
5226    "value",
5227    "anchor_left",
5228    "left_anchor",
5229    "anchor_right",
5230    "right_anchor",
5231    "periodic",
5232    "period",
5233    "periods",
5234    "period_start",
5235    "period_end",
5236    "origin",
5237    "double_penalty",
5238    "id",
5239    "identifiability",
5240];
5241
5242pub(crate) const THINPLATE_SMOOTH_OPTION_KEYS: &[&str] = &[
5243    "type",
5244    "bs",
5245    "by",
5246    "length_scale",
5247    "centers",
5248    "k",
5249    "basis_dim",
5250    "basis-dim",
5251    "basisdim",
5252    "knots",
5253    "include_intercept",
5254    "double_penalty",
5255    "id",
5256    "identifiability",
5257    "periodic",
5258    "cyclic",
5259    "period",
5260    "period_start",
5261    "period_end",
5262    "scale_dims",
5263];
5264
5265pub(crate) const SPHERE_SMOOTH_OPTION_KEYS: &[&str] = &[
5266    "type",
5267    "bs",
5268    "by",
5269    "centers",
5270    "k",
5271    "basis_dim",
5272    "basis-dim",
5273    "basisdim",
5274    "knots",
5275    "penalty_order",
5276    "m",
5277    "double_penalty",
5278    "id",
5279    "kernel",
5280    "method",
5281    "radians",
5282    "units",
5283    "degree",
5284    "l",
5285    "max_degree",
5286    "max-degree",
5287    "lmax",
5288    "l_max",
5289    "l-max",
5290];
5291
5292pub(crate) const CURVATURE_SMOOTH_OPTION_KEYS: &[&str] = &[
5293    "type",
5294    "bs",
5295    "by",
5296    "centers",
5297    "k",
5298    "basis_dim",
5299    "basis-dim",
5300    "basisdim",
5301    "knots",
5302    "kappa",
5303    "length_scale",
5304    "double_penalty",
5305    "id",
5306];
5307
5308pub(crate) const MEASURE_JET_SMOOTH_OPTION_KEYS: &[&str] = &[
5309    "type",
5310    "bs",
5311    "by",
5312    "centers",
5313    "k",
5314    "basis_dim",
5315    "basis-dim",
5316    "basisdim",
5317    "knots",
5318    "s",
5319    "alpha",
5320    "tau",
5321    "scales",
5322    "length_scale",
5323    "double_penalty",
5324    "multiscale",
5325    "learn_length_scale",
5326    "id",
5327];
5328
5329pub(crate) const MATERN_SMOOTH_OPTION_KEYS: &[&str] = &[
5330    "type",
5331    "bs",
5332    "by",
5333    "nu",
5334    "length_scale",
5335    "centers",
5336    "k",
5337    "basis_dim",
5338    "basis-dim",
5339    "basisdim",
5340    "knots",
5341    "include_intercept",
5342    "double_penalty",
5343    "id",
5344    "identifiability",
5345    "periodic",
5346    "cyclic",
5347    "period",
5348    "period_start",
5349    "period_end",
5350    "scale_dims",
5351];
5352
5353pub(crate) const DUCHON_SMOOTH_OPTION_KEYS: &[&str] = &[
5354    "type",
5355    "bs",
5356    "by",
5357    "length_scale",
5358    "centers",
5359    "k",
5360    "basis_dim",
5361    "basis-dim",
5362    "basisdim",
5363    "knots",
5364    "rank",
5365    "power",
5366    "p",
5367    "nullspace_order",
5368    "order",
5369    "identifiability",
5370    "periodic",
5371    "cyclic",
5372    "period",
5373    "period_start",
5374    "period_end",
5375    "scale_dims",
5376    "double_penalty",
5377    "id",
5378];
5379
5380pub(crate) const TENSOR_SMOOTH_OPTION_KEYS: &[&str] = &[
5381    "type",
5382    "bs",
5383    "by",
5384    "k",
5385    "basis_dim",
5386    "basis-dim",
5387    "basisdim",
5388    "knot_placement",
5389    "knot-placement",
5390    "knotplacement",
5391    "degree",
5392    "penalty_order",
5393    "double_penalty",
5394    "periodic",
5395    "cyclic",
5396    "period",
5397    "periods",
5398    "period_start",
5399    "period_end",
5400    "origin",
5401    "origins",
5402    "period_origin",
5403    "period-origin",
5404    "domain_origin",
5405    "boundary",
5406    "bc",
5407    "identifiability",
5408    "id",
5409];
5410
5411pub(crate) const PCA_SMOOTH_OPTION_KEYS: &[&str] = &[
5412    "type",
5413    "bs",
5414    "by",
5415    "k",
5416    "basis_dim",
5417    "basis-dim",
5418    "basisdim",
5419    "lazy_path",
5420    "path",
5421    "pca_basis_path",
5422    "chunk_size",
5423    "smooth_penalty",
5424    "centered",
5425    "double_penalty",
5426    "id",
5427];
5428
5429/// Prefix of the engine-injected option namespace. Options the pipeline adds
5430/// to a term's option map on the user's behalf — the `by=` column index the
5431/// `BySmooth` wrapper resolves (`__by_col`), the secondary-predictor center cap
5432/// ([`SECONDARY_CENTER_CAP_OPTION`]) — carry this prefix. They are never typed
5433/// in a formula, so [`validate_known_options`] does not judge them: that
5434/// validator answers "is this USER key spelled right?", and an engine key is
5435/// neither a user key nor a member of any one arm's vocabulary. Listing
5436/// `__by_col` in every arm's whitelist, and the cap in none, is how a
5437/// location-scale `noise_formula` with a `thinplate()` smooth came to refuse on
5438/// its own injected option (gam#2781 tightened the whitelists).
5439pub const ENGINE_OPTION_PREFIX: &str = "__";
5440
5441/// Whether `key` belongs to the engine-injected option namespace.
5442pub fn is_engine_option(key: &str) -> bool {
5443    key.starts_with(ENGINE_OPTION_PREFIX)
5444}
5445
5446pub fn validate_known_options(
5447    term_name: &str,
5448    options: &BTreeMap<String, String>,
5449    known: &[&str],
5450) -> Result<(), String> {
5451    let known_set: std::collections::BTreeSet<&&str> = known.iter().collect();
5452    for key in options.keys() {
5453        if is_engine_option(key) {
5454            continue;
5455        }
5456        if !known_set.contains(&key.as_str()) {
5457            if term_name == "tensor" && is_tensor_k_axis_option_key(key) {
5458                continue;
5459            }
5460            // Suggest near-matches (substring or shared prefix ≥ 3).
5461            let key_l = key.to_ascii_lowercase();
5462            let mut suggestions: Vec<&str> = known
5463                .iter()
5464                .filter(|k| {
5465                    let kl = k.to_ascii_lowercase();
5466                    kl.contains(&key_l) || key_l.contains(&kl) || {
5467                        let n = kl
5468                            .chars()
5469                            .zip(key_l.chars())
5470                            .take_while(|(a, b)| a == b)
5471                            .count();
5472                        n >= 3
5473                    }
5474                })
5475                .copied()
5476                .collect();
5477            suggestions.sort_unstable();
5478            suggestions.dedup();
5479            let hint = if suggestions.is_empty() {
5480                String::new()
5481            } else {
5482                format!(" — did you mean one of [{}]?", suggestions.join(", "))
5483            };
5484            return Err(TermBuilderError::invalid_option(format!(
5485                "{term_name}() does not accept option `{key}`{hint}. Valid options: [{}]",
5486                {
5487                    let mut sorted = known.to_vec();
5488                    sorted.sort_unstable();
5489                    sorted.join(", ")
5490                }
5491            ))
5492            .to_string());
5493        }
5494    }
5495    Ok(())
5496}
5497
5498/// Private (engine-injected) option that caps the *default* spatial center
5499/// count for a secondary (distributional) predictor's smooth — see
5500/// `solver::fit_orchestration::apply_secondary_predictor_basis_parsimony` and #501.
5501///
5502/// It is deliberately NOT one of the user-facing count aliases recognised by
5503/// [`has_explicit_countwith_basis_alias`], so it never flips the spatial basis
5504/// onto the explicit (hard) center-placement strategy: the cap lowers the
5505/// *default* count while the `Auto` strategy is retained, so the count is still
5506/// softly reduced when the data can't support it.
5507pub const SECONDARY_CENTER_CAP_OPTION: &str = "__secondary_center_cap";
5508
5509/// Apply the secondary-predictor center cap to a *default* spatial center
5510/// count. A no-op when the cap option is absent (the common case) or when the
5511/// user supplied an explicit count (then `default_count` is ignored downstream
5512/// by [`parse_countwith_basis_alias`] anyway).
5513pub(crate) fn cap_default_spatial_centers(
5514    options: &BTreeMap<String, String>,
5515    default_count: usize,
5516) -> usize {
5517    match option_usize(options, SECONDARY_CENTER_CAP_OPTION) {
5518        Some(cap) => default_count.min(cap),
5519        None => default_count,
5520    }
5521}
5522
5523fn default_matern_center_count(
5524    n: usize,
5525    d: usize,
5526    planned_count: usize,
5527    univariate_floor: usize,
5528) -> usize {
5529    // #1074: the mgcv-sized basis cap (`k = 10·3^(d-1)`) was DELETED here too — it
5530    // masked the same over-sizing/under-penalization defect by shrinking the basis
5531    // rather than fixing the optimizer. The default now uses the generic n-scaling
5532    // plan. A small-n floor against a numerically-fragile two-column kernel block
5533    // is a legitimate degenerate guard and is kept. Explicit `k`/`centers` still
5534    // take full effect upstream.
5535    let low_n_floor = (d + 4).min(n);
5536    // #1867: at small n the generic conditioning cap (`n / COND_N_DIVISOR`) in
5537    // `default_num_centers` starves a 1-D radial basis BELOW the resolution the
5538    // univariate B-spline `s(x)` is handed on the SAME data (e.g. 7 vs 11 basis
5539    // functions at n=30), so `matern(x)`/`duchon(x)` over-smooth sparse
5540    // oscillations that `s(x)` recovers cleanly. Smoothness is set by the REML
5541    // penalty λ, not by the raw center count (see `default_num_centers`), so a
5542    // radial smooth competing with `s(x)` must not be dimensioned coarser than
5543    // it. `univariate_floor` carries that spline-equivalent resolution for a 1-D
5544    // smooth (0 for d>1, where there is no direct univariate analogue) and is
5545    // bounded by n. Explicit `k`/`centers` still override upstream.
5546    planned_count
5547        .max(low_n_floor)
5548        .max(univariate_floor.min(n))
5549        .max(1)
5550}
5551
5552fn default_duchon_center_count(
5553    n: usize,
5554    d: usize,
5555    planned_count: usize,
5556    polynomial_cols: usize,
5557    univariate_floor: usize,
5558) -> usize {
5559    // #1757: Duchon fits pay a larger setup cost than Matérn/TPS because the
5560    // constrained radial block is rotated through its center Gram and several
5561    // operator-collocation penalties.  The old generic spatial default handed a
5562    // 2-D Gaussian Duchon at n≈500 more than one hundred centers, so cold fits
5563    // spent most of their time in dense O(k³) eigensolves even though the REML
5564    // smoother uses a low-rank basis.  mgcv's Duchon spline default is the
5565    // thin-plate-style `k = 10 * 3^(d - 1)` (30 in 2-D); use that as the
5566    // implicit low-rank cap while preserving the user's explicit `centers=`/`k=`
5567    // request above.  The polynomial null space must still fit, so tiny
5568    // high-order bases are raised to the smallest admissible count.
5569    let mgcv_default = 10usize.saturating_mul(3usize.saturating_pow(d.saturating_sub(1) as u32));
5570    let low_n_floor = (polynomial_cols + 1).min(n).max(1);
5571    // #1867: at small n the generic conditioning cap (`n / COND_N_DIVISOR`) in
5572    // `default_num_centers` starves `planned_count` below the univariate spline
5573    // resolution the competing `s(x)` gets on the SAME data, so `duchon(x)`
5574    // over-smooths sparse oscillations. `univariate_floor` (0 for d>1) carries
5575    // that spline-equivalent basis dimension and floors the 1-D default,
5576    // bounded by n; smoothness is set by the REML penalty, not the raw count.
5577    // Explicit `k`/`centers` still override upstream.
5578    planned_count
5579        .min(mgcv_default)
5580        .max(low_n_floor)
5581        .max(univariate_floor.min(n))
5582}
5583
5584pub fn parse_countwith_basis_alias(
5585    options: &BTreeMap<String, String>,
5586    primarykey: &str,
5587    default_count: usize,
5588) -> Result<usize, String> {
5589    // Strict: reject unparseable values (e.g. `centers=many`, `centers=-1`,
5590    // `centers=1.5`) instead of silently dropping them and falling through
5591    // to the default. Without this the user gets the auto-inferred count
5592    // silently and never realizes their explicit option was ignored.
5593    let primary = option_usize_strict(options, primarykey)?;
5594    let basis_dim = option_usize_any_strict(
5595        options,
5596        &["k", "basis_dim", "basis-dim", "basisdim", "knots"],
5597    )?;
5598    if primary.is_some() && basis_dim.is_some() {
5599        return Err(TermBuilderError::incompatible_config(format!(
5600            "specify either {}=<count> or k=<basis_dim> (not both)",
5601            primarykey
5602        ))
5603        .to_string());
5604    }
5605    Ok(primary.or(basis_dim).unwrap_or(default_count))
5606}
5607
5608/// Resolve the wiggliness penalty's derivative/difference order from its two
5609/// documented spellings.
5610///
5611/// `penalty_order=` and `m=` name the SAME knob here and in mgcv. Reading one
5612/// and falling through to the other (`option_usize(.., "penalty_order")
5613/// .or_else(|| option_usize(.., "m"))`) silently drops the loser when both are
5614/// given, and the lenient `option_usize` silently drops an unparseable value on
5615/// top of that. This refuses the conflict the way `parse_countwith_basis_alias`
5616/// refuses `centers=` together with `k=`, and parses strictly so `m=1.5` is a
5617/// user mistake rather than "m not specified".
5618pub fn parse_penalty_order_alias(
5619    options: &BTreeMap<String, String>,
5620) -> Result<Option<usize>, String> {
5621    let primary = option_usize_strict(options, "penalty_order")?;
5622    let alias = option_usize_strict(options, "m")?;
5623    match (primary, alias) {
5624        (Some(primary), Some(alias)) if primary != alias => {
5625            Err(TermBuilderError::incompatible_config(format!(
5626                "penalty_order={primary} and m={alias} are two spellings of the same \
5627                 option (the order of the penalised derivative), so they cannot disagree; \
5628                 specify one of them"
5629            ))
5630            .to_string())
5631        }
5632        (Some(primary), _) => Ok(Some(primary)),
5633        (None, alias) => Ok(alias),
5634    }
5635}
5636
5637pub fn has_explicit_countwith_basis_alias(
5638    options: &BTreeMap<String, String>,
5639    primarykey: &str,
5640) -> bool {
5641    options.contains_key(primarykey)
5642        || ["k", "basis_dim", "basis-dim", "basisdim", "knots"]
5643            .iter()
5644            .any(|alias| options.contains_key(*alias))
5645}
5646
5647pub fn parse_cyclic_boundary(
5648    options: &BTreeMap<String, String>,
5649    minv: f64,
5650    maxv: f64,
5651) -> Result<OneDimensionalBoundary, String> {
5652    let cyclic = option_bool(options, "cyclic")
5653        .or_else(|| option_bool(options, "periodic"))
5654        .unwrap_or(false);
5655    if !cyclic {
5656        return Ok(OneDimensionalBoundary::Open);
5657    }
5658    let start = match option_numeric_expr(options, "period_start")? {
5659        Some(v) => v,
5660        None => option_numeric_expr(options, "start")?.unwrap_or(minv),
5661    };
5662    let end = match option_numeric_expr(options, "period_end")? {
5663        Some(v) => v,
5664        None => option_numeric_expr(options, "end")?.unwrap_or(maxv),
5665    };
5666    if end <= start {
5667        return Err(format!(
5668            "cyclic smooth requires period_end/end ({end}) > period_start/start ({start})"
5669        ));
5670    }
5671    Ok(OneDimensionalBoundary::Cyclic { start, end })
5672}
5673
5674/// Parse the periodic-uniform domain for a one-dimensional cyclic smooth.
5675///
5676/// Returns the `(domain_start, period)` pair derived from
5677/// `period_start` / `start`, `period_end` / `end`, falling back to the
5678/// data range `[minv, maxv)` when neither bound is provided. The period
5679/// must be strictly positive.
5680pub fn parse_periodic_domain_1d(
5681    options: &BTreeMap<String, String>,
5682    minv: f64,
5683    maxv: f64,
5684) -> Result<(f64, f64), String> {
5685    let start_opt = match option_numeric_expr(options, "period_start")? {
5686        Some(v) => Some(v),
5687        None => option_numeric_expr(options, "start")?,
5688    };
5689    let end_opt = match option_numeric_expr(options, "period_end")? {
5690        Some(v) => Some(v),
5691        None => option_numeric_expr(options, "end")?,
5692    };
5693    // Reject the pure data-range fallback. A B-spline periodic smooth that takes
5694    // its wrap from the observed [min, max] is sample-dependent and silently
5695    // wrong: uniform draws on a true period of 2π land on [ε, 2π−ε], so using
5696    // (max−min) as the period seams the curve with an off-by-ε discontinuity and
5697    // the fit drifts with the sample. (Unlike the radial closed-lattice Duchon
5698    // path, whose centers DO tile a full period, so its span-derive is exact —
5699    // see `parse_periodic_axes_option`.) Require the caller to name the period
5700    // explicitly via `period=`/`period_end`. The end is only defaulted to `maxv`
5701    // when a `period_start`/`start` was given (a half-open declaration); a bare
5702    // periodic smooth with neither bound is an error.
5703    if end_opt.is_none() && start_opt.is_none() {
5704        return Err(
5705            "periodic B-spline smooth requires an explicit period: pass period=<value> \
5706             (e.g. period=2*pi) or period_start=/period_end=. Deriving the period from the \
5707             observed data range is sample-dependent and produces an off-by-ε seam, so it is \
5708             not inferred."
5709                .to_string(),
5710        );
5711    }
5712    let start = start_opt.unwrap_or(minv);
5713    let end = end_opt.unwrap_or(maxv);
5714    if !(start.is_finite() && end.is_finite()) {
5715        return Err(format!(
5716            "periodic smooth domain requires finite endpoints, got ({start}, {end})"
5717        ));
5718    }
5719    if end <= start {
5720        return Err(format!(
5721            "periodic smooth requires period_end/end ({end}) > period_start/start ({start})"
5722        ));
5723    }
5724    Ok((start, end - start))
5725}
5726
5727fn parse_matern_nu(raw: &str) -> Result<MaternNu, String> {
5728    let trimmed = raw.trim();
5729    let lowered = trimmed.to_ascii_lowercase();
5730    // Exact spellings of the half-integer smoothnesses that have closed-form
5731    // kernels; anything else falls through to the numeric parse below.
5732    let named = match lowered.as_str() {
5733        "1/2" | "0.5" | "half" => Some(MaternNu::Half),
5734        "3/2" | "1.5" => Some(MaternNu::ThreeHalves),
5735        "5/2" | "2.5" => Some(MaternNu::FiveHalves),
5736        "7/2" | "3.5" => Some(MaternNu::SevenHalves),
5737        "9/2" | "4.5" => Some(MaternNu::NineHalves),
5738        _ => None,
5739    };
5740    if let Some(nu) = named {
5741        return Ok(nu);
5742    }
5743
5744    let value = if let Some((num, den)) = trimmed.split_once('/') {
5745        let num = num
5746            .trim()
5747            .parse::<f64>()
5748            .map_err(|err| format!("{}: {err}", unsupported_matern_nu_message(raw)))?;
5749        let den = den
5750            .trim()
5751            .parse::<f64>()
5752            .map_err(|err| format!("{}: {err}", unsupported_matern_nu_message(raw)))?;
5753        if den == 0.0 || !num.is_finite() || !den.is_finite() {
5754            return Err(unsupported_matern_nu_message(raw));
5755        }
5756        num / den
5757    } else {
5758        trimmed
5759            .parse::<f64>()
5760            .map_err(|err| format!("{}: {err}", unsupported_matern_nu_message(raw)))?
5761    };
5762
5763    const TOL: f64 = 1e-12;
5764    if (value - 0.5).abs() <= TOL {
5765        Ok(MaternNu::Half)
5766    } else if (value - 1.5).abs() <= TOL {
5767        Ok(MaternNu::ThreeHalves)
5768    } else if (value - 2.5).abs() <= TOL {
5769        Ok(MaternNu::FiveHalves)
5770    } else if (value - 3.5).abs() <= TOL {
5771        Ok(MaternNu::SevenHalves)
5772    } else if (value - 4.5).abs() <= TOL {
5773        Ok(MaternNu::NineHalves)
5774    } else {
5775        Err(unsupported_matern_nu_message(raw))
5776    }
5777}
5778
5779fn unsupported_matern_nu_message(raw: &str) -> String {
5780    TermBuilderError::unsupported_feature(format!(
5781        "unsupported Matern nu '{raw}'; supported half-integer values are 1/2, 3/2, 5/2, 7/2, and 9/2"
5782    ))
5783    .to_string()
5784}
5785
5786#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
5787pub enum DuchonPowerPolicy {
5788    Explicit(f64),
5789    /// No explicit `power=` given: defer to the cubic structural default, which
5790    /// the builder resolves dimension-aware as `s = (d − 1)/2` (so `φ(r) = r³`
5791    /// in every dimension). There is no triple-operator minimum any more.
5792    CubicStructuralDefault,
5793}
5794
5795pub fn parse_duchon_power_policy(
5796    options: &BTreeMap<String, String>,
5797) -> Result<DuchonPowerPolicy, String> {
5798    if let Some(raw_nu) = options.get("nu") {
5799        return Err(TermBuilderError::incompatible_config(format!(
5800            "Duchon smooths use power=<number>, not nu='{}'. Use power=1.5, power=2, etc.",
5801            raw_nu
5802        ))
5803        .to_string());
5804    }
5805    // `p` is the Duchon whitelist's alias of `power` and was read nowhere, so
5806    // `duchon(x, z, p=2)` was accepted and silently used the structural default
5807    // (#2781's family).
5808    match options.get("power").or_else(|| options.get("p")) {
5809        Some(raw) => {
5810            let value = raw.parse::<f64>().map_err(|err| {
5811                TermBuilderError::invalid_option(format!(
5812                    "invalid Duchon power '{}'; expected a non-negative number such as power=1.5 or power=2: {}",
5813                    raw, err
5814                ))
5815                .to_string()
5816            })?;
5817            if !value.is_finite() || value < 0.0 {
5818                return Err(TermBuilderError::invalid_option(format!(
5819                    "invalid Duchon power '{}'; expected a finite non-negative number such as power=1.5 or power=2",
5820                    raw
5821                ))
5822                .to_string());
5823            }
5824            Ok(DuchonPowerPolicy::Explicit(value))
5825        }
5826        None => Ok(DuchonPowerPolicy::CubicStructuralDefault),
5827    }
5828}
5829
5830/// Like [`parse_duchon_order`] but reports ABSENCE, so a caller whose default
5831/// happens to equal `Linear` can still tell "the user named the affine null
5832/// space" apart from "the user named nothing". The `duchon` arm needs that
5833/// distinction: its structural cubic default supplies a jointly chosen
5834/// `(order, power)` PAIR, and it used to take the order from that pair even
5835/// when the caller had named one (#2781's family) — contradicting this module's
5836/// own contract that "an explicit `order=0` still selects the constant-only
5837/// space".
5838pub fn parse_duchon_order_opt(
5839    options: &BTreeMap<String, String>,
5840) -> Result<Option<DuchonNullspaceOrder>, String> {
5841    if !options.contains_key("order") && !options.contains_key("nullspace_order") {
5842        return Ok(None);
5843    }
5844    parse_duchon_order(options).map(Some)
5845}
5846
5847pub fn parse_duchon_order(
5848    options: &BTreeMap<String, String>,
5849) -> Result<DuchonNullspaceOrder, String> {
5850    // `nullspace_order` is the whitelist's alias of `order` and was read
5851    // nowhere (#2781's family).
5852    match options.get("order").or_else(|| options.get("nullspace_order")) {
5853        // Structural cubic Duchon is affine-by-default: an unspecified order is
5854        // the `Linear` (constant + linear) null space, matching the magic
5855        // default. An explicit `order=0` still selects the constant-only space.
5856        None => Ok(DuchonNullspaceOrder::Linear),
5857        Some(raw) => match raw.parse::<usize>() {
5858            Ok(0) => Ok(DuchonNullspaceOrder::Zero),
5859            Ok(1) => Ok(DuchonNullspaceOrder::Linear),
5860            Ok(other) => Ok(DuchonNullspaceOrder::Degree(other)),
5861            Err(_) => Err(TermBuilderError::invalid_option(format!(
5862                "invalid Duchon order '{}'; expected a non-negative integer such as order=0, order=1, or order=2",
5863                raw
5864            ))
5865            .to_string()),
5866        },
5867    }
5868}
5869
5870fn parse_matern_identifiability(
5871    options: &BTreeMap<String, String>,
5872) -> Result<MaternIdentifiability, TermBuilderError> {
5873    let Some(raw) = options.get("identifiability").map(String::as_str) else {
5874        return Ok(MaternIdentifiability::default());
5875    };
5876    match raw.trim().to_ascii_lowercase().as_str() {
5877        "none" => Ok(MaternIdentifiability::None),
5878        "sum_tozero" | "sum-to-zero" | "center_sum_tozero" | "center-sum-to-zero" | "centered" => {
5879            Ok(MaternIdentifiability::CenterSumToZero)
5880        }
5881        "linear" | "center_linear_orthogonal" | "center-linear-orthogonal" => {
5882            Ok(MaternIdentifiability::CenterLinearOrthogonal)
5883        }
5884        other => Err(TermBuilderError::unsupported_feature(format!(
5885            "invalid Matérn identifiability '{other}'; expected one of: none, sum_tozero, linear"
5886        ))),
5887    }
5888}
5889
5890fn parse_spatial_identifiability(
5891    options: &BTreeMap<String, String>,
5892) -> Result<SpatialIdentifiability, TermBuilderError> {
5893    let Some(raw) = options.get("identifiability").map(String::as_str) else {
5894        return Ok(SpatialIdentifiability::default());
5895    };
5896    match raw.trim().to_ascii_lowercase().as_str() {
5897        "none" => Ok(SpatialIdentifiability::None),
5898        "orthogonal"
5899        | "orthogonal_to_parametric"
5900        | "orthogonal-to-parametric"
5901        | "parametric_orthogonal" => Ok(SpatialIdentifiability::OrthogonalToParametric),
5902        "frozen" => Err(TermBuilderError::unsupported_feature(
5903            "spatial identifiability 'frozen' is internal-only; use none or orthogonal_to_parametric",
5904        )),
5905        other => Err(TermBuilderError::unsupported_feature(format!(
5906            "invalid spatial identifiability '{other}'; expected one of: none, orthogonal_to_parametric"
5907        ))),
5908    }
5909}
5910
5911#[cfg(test)]
5912mod tests;