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