Skip to main content

gam_terms/
term_builder.rs

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