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, HashMap};
8
9use ndarray::ArrayView1;
10
11use crate::basis::{
12    BSplineBasisSpec, BSplineIdentifiability, BSplineKnotSpec, CenterCountRequest, CenterStrategy,
13    DuchonBasisSpec, DuchonNullspaceOrder, DuchonOperatorPenaltySpec, MaternBasisSpec,
14    MaternIdentifiability, MaternNu, SpatialIdentifiability, ThinPlateBasisSpec,
15    auto_spatial_center_strategy, default_num_centers, default_spatial_center_strategy,
16    plan_spatial_basis, resolve_duchon_orders,
17};
18use crate::inference::data::{EncodedDataset as Dataset, missing_column_message};
19use crate::inference::formula_dsl::{
20    ParsedTerm, SmoothKind, option_bool, option_f64, option_usize, option_usize_any,
21};
22use crate::inference::model::ColumnKindTag;
23use crate::resource::ResourcePolicy;
24use crate::smooth::{
25    LinearCoefficientGeometry, LinearTermSpec, RandomEffectTermSpec, ShapeConstraint,
26    SmoothBasisSpec, SmoothTermSpec, TensorBSplineIdentifiability, TensorBSplineSpec,
27    TermCollectionSpec,
28};
29
30// ---------------------------------------------------------------------------
31// Column resolution
32// ---------------------------------------------------------------------------
33
34pub fn resolve_col(col_map: &HashMap<String, usize>, name: &str) -> Result<usize, String> {
35    col_map
36        .get(name)
37        .copied()
38        .ok_or_else(|| missing_column_message(col_map, name, None))
39}
40
41pub fn resolve_role_col(
42    col_map: &HashMap<String, usize>,
43    name: &str,
44    role: &str,
45) -> Result<usize, String> {
46    col_map
47        .get(name)
48        .copied()
49        .ok_or_else(|| missing_column_message(col_map, name, Some(role)))
50}
51
52pub fn column_map_with_alias(
53    col_map: &HashMap<String, usize>,
54    alias: &str,
55    target_column: &str,
56) -> HashMap<String, usize> {
57    let mut aliased = col_map.clone();
58    if let Some(idx) = col_map.get(target_column).copied() {
59        aliased.entry(alias.to_string()).or_insert(idx);
60    }
61    aliased
62}
63
64// ---------------------------------------------------------------------------
65// ParsedTerm[] + Dataset → TermCollectionSpec
66// ---------------------------------------------------------------------------
67
68pub fn build_termspec(
69    terms: &[ParsedTerm],
70    ds: &Dataset,
71    col_map: &HashMap<String, usize>,
72    inference_notes: &mut Vec<String>,
73    policy: &ResourcePolicy,
74) -> Result<TermCollectionSpec, String> {
75    let mut linear_terms = Vec::<LinearTermSpec>::new();
76    let mut random_terms = Vec::<RandomEffectTermSpec>::new();
77    let mut smooth_terms = Vec::<SmoothTermSpec>::new();
78    let smooth_coordinate_count = terms
79        .iter()
80        .map(|term| match term {
81            ParsedTerm::Smooth { vars, .. } => vars.len(),
82            _ => 0,
83        })
84        .sum::<usize>();
85
86    for t in terms {
87        match t {
88            ParsedTerm::Linear {
89                name,
90                explicit,
91                coefficient_min,
92                coefficient_max,
93            } => {
94                let col = resolve_col(col_map, name)?;
95                let auto_kind =
96                    ds.column_kinds.get(col).copied().ok_or_else(|| {
97                        format!("internal column-kind lookup failed for '{name}'")
98                    })?;
99                if *explicit {
100                    linear_terms.push(LinearTermSpec {
101                        name: name.clone(),
102                        feature_col: col,
103                        double_penalty: true,
104                        coefficient_geometry: LinearCoefficientGeometry::Unconstrained,
105                        coefficient_min: *coefficient_min,
106                        coefficient_max: *coefficient_max,
107                    });
108                } else {
109                    match auto_kind {
110                        ColumnKindTag::Continuous | ColumnKindTag::Binary => {
111                            linear_terms.push(LinearTermSpec {
112                                name: name.clone(),
113                                feature_col: col,
114                                double_penalty: true,
115                                coefficient_geometry: LinearCoefficientGeometry::Unconstrained,
116                                coefficient_min: *coefficient_min,
117                                coefficient_max: *coefficient_max,
118                            });
119                        }
120                        ColumnKindTag::Categorical => {
121                            if coefficient_min.is_some() || coefficient_max.is_some() {
122                                return Err(format!(
123                                    "coefficient constraints are not supported for categorical auto-random-effect term '{name}'; use group({name}) or an unconstrained numeric term"
124                                ));
125                            }
126                            random_terms.push(RandomEffectTermSpec {
127                                name: name.clone(),
128                                feature_col: col,
129                                drop_first_level: false,
130                                frozen_levels: None,
131                            });
132                        }
133                    }
134                }
135            }
136            ParsedTerm::BoundedLinear {
137                name,
138                min,
139                max,
140                prior,
141            } => {
142                let col = resolve_col(col_map, name)?;
143                let auto_kind =
144                    ds.column_kinds.get(col).copied().ok_or_else(|| {
145                        format!("internal column-kind lookup failed for '{name}'")
146                    })?;
147                if !matches!(auto_kind, ColumnKindTag::Continuous | ColumnKindTag::Binary) {
148                    return Err(format!(
149                        "bounded() currently supports only numeric columns, got categorical '{name}'"
150                    ));
151                }
152                linear_terms.push(LinearTermSpec {
153                    name: name.clone(),
154                    feature_col: col,
155                    double_penalty: false,
156                    coefficient_geometry: LinearCoefficientGeometry::Bounded {
157                        min: *min,
158                        max: *max,
159                        prior: prior.clone(),
160                    },
161                    coefficient_min: None,
162                    coefficient_max: None,
163                });
164            }
165            ParsedTerm::RandomEffect { name } => {
166                let col = resolve_col(col_map, name)?;
167                random_terms.push(RandomEffectTermSpec {
168                    name: name.clone(),
169                    feature_col: col,
170                    drop_first_level: false,
171                    frozen_levels: None,
172                });
173            }
174            ParsedTerm::Smooth {
175                label,
176                vars,
177                kind,
178                options,
179            } => {
180                let cols = vars
181                    .iter()
182                    .map(|v| resolve_col(col_map, v))
183                    .collect::<Result<Vec<_>, _>>()?;
184                let basis = build_smooth_basis(
185                    *kind,
186                    vars,
187                    &cols,
188                    options,
189                    ds,
190                    inference_notes,
191                    policy,
192                    smooth_coordinate_count,
193                )?;
194                smooth_terms.push(SmoothTermSpec {
195                    name: label.clone(),
196                    basis,
197                    shape: ShapeConstraint::None,
198                });
199            }
200            ParsedTerm::LinkWiggle { .. }
201            | ParsedTerm::TimeWiggle { .. }
202            | ParsedTerm::LinkConfig { .. }
203            | ParsedTerm::SurvivalConfig { .. } => {
204                // Consumed at formula level, not design terms.
205            }
206        }
207    }
208
209    Ok(TermCollectionSpec {
210        linear_terms,
211        random_effect_terms: random_terms,
212        smooth_terms,
213    })
214}
215
216// ---------------------------------------------------------------------------
217// Smooth basis spec construction
218// ---------------------------------------------------------------------------
219
220pub fn build_smooth_basis(
221    kind: SmoothKind,
222    vars: &[String],
223    cols: &[usize],
224    options: &BTreeMap<String, String>,
225    ds: &Dataset,
226    inference_notes: &mut Vec<String>,
227    policy: &ResourcePolicy,
228    smooth_coordinate_count: usize,
229) -> Result<SmoothBasisSpec, String> {
230    let smooth_double_penalty = option_bool(options, "double_penalty").unwrap_or(true);
231    let type_opt = options
232        .get("type")
233        .map(|s| s.to_ascii_lowercase())
234        .unwrap_or_else(|| match kind {
235            SmoothKind::Te => "tensor".to_string(),
236            SmoothKind::S if cols.len() == 1 => "bspline".to_string(),
237            SmoothKind::S => "tps".to_string(),
238        });
239
240    match type_opt.as_str() {
241        "tensor" | "te" | "tensor-bspline" => {
242            if cols.len() < 2 {
243                return Err(format!(
244                    "tensor smooth requires >=2 variables: {}",
245                    vars.join(", ")
246                ));
247            }
248            let degree = 3usize;
249            let default_internal = cols
250                .iter()
251                .map(|&c| heuristic_knots_for_column(ds.values.column(c)))
252                .max()
253                .unwrap_or_else(|| heuristic_knots(ds.values.nrows()));
254            let (mut n_knots, inferred) =
255                parse_ps_internal_knots(options, degree, default_internal)?;
256            if ds.values.nrows() <= 32 && smooth_coordinate_count >= 5 {
257                n_knots = n_knots.min(1);
258            }
259            if inferred {
260                inference_notes.push(format!(
261                    "Automatically set {} internal knots per margin for tensor smooth '{}' (max unique/4 rule across margins, clamped to [4,20]). Override with knots=... or k=....",
262                    n_knots,
263                    vars.join(",")
264                ));
265            }
266            let specs = cols
267                .iter()
268                .map(|&c| {
269                    let (minv, maxv) = col_minmax(ds.values.column(c))?;
270                    Ok(BSplineBasisSpec {
271                        degree,
272                        penalty_order: 2,
273                        knotspec: BSplineKnotSpec::Generate {
274                            data_range: (minv, maxv),
275                            num_internal_knots: n_knots,
276                        },
277                        double_penalty: smooth_double_penalty,
278                        identifiability: BSplineIdentifiability::None,
279                    })
280                })
281                .collect::<Result<Vec<_>, String>>()?;
282            Ok(SmoothBasisSpec::TensorBSpline {
283                feature_cols: cols.to_vec(),
284                spec: TensorBSplineSpec {
285                    marginalspecs: specs,
286                    double_penalty: smooth_double_penalty,
287                    identifiability: parse_tensor_identifiability(options)?,
288                },
289            })
290        }
291        "bspline" | "ps" | "p-spline" => {
292            if cols.len() != 1 {
293                return Err(format!(
294                    "bspline smooth expects one variable, got {}",
295                    cols.len()
296                ));
297            }
298            let c = cols[0];
299            let (minv, maxv) = col_minmax(ds.values.column(c))?;
300            let degree = option_usize(options, "degree").unwrap_or(3);
301            let default_internal = heuristic_knots_for_column(ds.values.column(c));
302            let (mut n_knots, inferred) =
303                parse_ps_internal_knots(options, degree, default_internal)?;
304            if ds.values.nrows() <= 32 && smooth_coordinate_count >= 5 {
305                n_knots = n_knots.min(1);
306            }
307            if inferred {
308                let unique = unique_count_column(ds.values.column(c));
309                let ceiling = ((unique as f64).cbrt() as usize).max(20);
310                inference_notes.push(format!(
311                    "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=....",
312                    n_knots,
313                    vars.join(","),
314                    unique,
315                    ceiling,
316                ));
317            }
318            Ok(SmoothBasisSpec::BSpline1D {
319                feature_col: c,
320                spec: BSplineBasisSpec {
321                    degree,
322                    penalty_order: option_usize(options, "penalty_order").unwrap_or(2),
323                    knotspec: BSplineKnotSpec::Generate {
324                        data_range: (minv, maxv),
325                        num_internal_knots: n_knots,
326                    },
327                    double_penalty: smooth_double_penalty,
328                    identifiability: BSplineIdentifiability::default(),
329                },
330            })
331        }
332        "tps" | "thinplate" | "thin-plate" => {
333            let plan = plan_spatial_basis(
334                ds.values.nrows(),
335                cols.len(),
336                CenterCountRequest::Default,
337                DuchonNullspaceOrder::Linear,
338                option_bool(options, "scale_dims").unwrap_or(false),
339                policy,
340            )
341            .map_err(|e| e.to_string())?;
342            let centers = parse_countwith_basis_alias(options, "centers", plan.centers)?;
343            let center_strategy = if has_explicit_countwith_basis_alias(options, "centers") {
344                spatial_center_strategy_for_dimension(centers, cols.len())
345            } else {
346                auto_spatial_center_strategy(centers, cols.len())
347            };
348            Ok(SmoothBasisSpec::ThinPlate {
349                feature_cols: cols.to_vec(),
350                spec: ThinPlateBasisSpec {
351                    center_strategy,
352                    length_scale: option_f64(options, "length_scale").unwrap_or(1.0),
353                    double_penalty: smooth_double_penalty,
354                    identifiability: parse_spatial_identifiability(options)?,
355                    radial_reparam: None,
356                },
357                input_scales: None,
358            })
359        }
360        "matern" => {
361            let plan = plan_spatial_basis(
362                ds.values.nrows(),
363                cols.len(),
364                CenterCountRequest::Default,
365                DuchonNullspaceOrder::Zero,
366                option_bool(options, "scale_dims").unwrap_or(false),
367                policy,
368            )
369            .map_err(|e| e.to_string())?;
370            let centers = parse_countwith_basis_alias(options, "centers", plan.centers)?;
371            let center_strategy = if has_explicit_countwith_basis_alias(options, "centers") {
372                spatial_center_strategy_for_dimension(centers, cols.len())
373            } else {
374                auto_spatial_center_strategy(centers, cols.len())
375            };
376            let nu = parse_matern_nu(options.get("nu").map(String::as_str).unwrap_or("5/2"))?;
377            let aniso_log_scales = if option_bool(options, "scale_dims").unwrap_or(false) {
378                Some(vec![0.0; cols.len()])
379            } else {
380                None
381            };
382            Ok(SmoothBasisSpec::Matern {
383                feature_cols: cols.to_vec(),
384                spec: MaternBasisSpec {
385                    center_strategy,
386                    length_scale: option_f64(options, "length_scale").unwrap_or(1.0),
387                    nu,
388                    include_intercept: option_bool(options, "include_intercept").unwrap_or(false),
389                    double_penalty: smooth_double_penalty,
390                    identifiability: parse_matern_identifiability(options)?,
391                    aniso_log_scales,
392                },
393                input_scales: None,
394            })
395        }
396        "duchon" => {
397            if options.contains_key("double_penalty") {
398                return Err(format!(
399                    "Duchon smooth '{}' does not support double_penalty; Duchon uses mass, tension, and stiffness operator penalties.",
400                    vars.join(", ")
401                ));
402            }
403            let requested_nullspace_order = parse_duchon_order(options)?;
404            let length_scale = option_f64(options, "length_scale");
405            // Resolve `(nullspace_order, power)` against the joint constraints
406            // (operator collocation + pure-mode CPD). Explicit power keeps the
407            // user's nullspace as-is (validator will reject inconsistent combos);
408            // the policy path may auto-escalate the nullspace order in pure mode
409            // when CPD requires a richer polynomial absorption space.
410            let (nullspace_order, power) = match parse_duchon_power_policy(options)? {
411                DuchonPowerPolicy::Explicit(req_power) => {
412                    // Honor the user's nullspace_order, but auto-escalate
413                    // explicit power when it sits below the minimum needed
414                    // for the active operator-penalty triple
415                    // (mass + tension + stiffness ⇒ D2 collocation requires
416                    // 2(p+s) > d+2). Without this escalation the basis
417                    // builder later rejects the user's combination with an
418                    // opaque "Duchon D2 collocation requires …" diagnostic
419                    // even though the requested kernel exists — the user's
420                    // power was simply too low for the *operator* derivative
421                    // order, not for the kernel itself. Only escalate when
422                    // CPD does not also force a different nullspace order;
423                    // when CPD bumps the nullspace, the explicit power is
424                    // bound to the user-requested order, so leave it alone
425                    // and let the kernel validator emit a precise
426                    // diagnostic for that combination.
427                    let (resolved_nullspace, min_admissible_power) = resolve_duchon_orders(
428                        cols.len(),
429                        requested_nullspace_order,
430                        2,
431                        length_scale,
432                    );
433                    let final_power = if resolved_nullspace == requested_nullspace_order {
434                        req_power.max(min_admissible_power)
435                    } else {
436                        req_power
437                    };
438                    if final_power != req_power {
439                        inference_notes.push(format!(
440                            "Note: explicit Duchon power={} is below the minimum admissible \
441                             power={} for D2 (stiffness) collocation at dimension={}, \
442                             nullspace_order={:?} (requires 2(p+s) > d+2). Auto-escalated \
443                             to power={} so all three Duchon operator penalties (mass, \
444                             tension, stiffness) remain active.",
445                            req_power,
446                            min_admissible_power,
447                            cols.len(),
448                            requested_nullspace_order,
449                            final_power,
450                        ));
451                    }
452                    (requested_nullspace_order, final_power)
453                }
454                DuchonPowerPolicy::MinimumAdmissibleForTripleOperator => {
455                    let resolved = resolve_duchon_orders(
456                        cols.len(),
457                        requested_nullspace_order,
458                        2,
459                        length_scale,
460                    );
461                    if resolved.0 != requested_nullspace_order {
462                        inference_notes.push(format!(
463                            "Note: pure Duchon CPD against polynomial nullspace requires order ≥ {:?} \
464                             at dimension {} (Wendland 8.17, 2s < d); auto-escalated from {:?}. \
465                             Specify length_scale=... to use hybrid Duchon and bypass this constraint.",
466                            resolved.0,
467                            cols.len(),
468                            requested_nullspace_order,
469                        ));
470                    }
471                    resolved
472                }
473            };
474            let plan = plan_spatial_basis(
475                ds.values.nrows(),
476                cols.len(),
477                CenterCountRequest::Default,
478                nullspace_order,
479                option_bool(options, "scale_dims").unwrap_or(false),
480                policy,
481            )
482            .map_err(|e| e.to_string())?;
483            let requested_centers = parse_countwith_basis_alias(options, "centers", plan.centers)?;
484            let polynomial_cols = match nullspace_order {
485                DuchonNullspaceOrder::Zero => 1,
486                DuchonNullspaceOrder::Linear => cols.len() + 1,
487                DuchonNullspaceOrder::Degree(degree) => {
488                    crate::basis::duchon_nullspace_dimension(cols.len(), degree)
489                }
490            };
491            if requested_centers <= polynomial_cols {
492                return Err(format!(
493                    "Duchon smooth '{}' requested basis dimension {} but order={:?} in {}D needs {} polynomial null-space columns; choose centers/k > {}",
494                    vars.join(", "),
495                    requested_centers,
496                    nullspace_order,
497                    cols.len(),
498                    polynomial_cols,
499                    polynomial_cols,
500                ));
501            }
502            let mut centers = requested_centers;
503            if ds.values.nrows() <= 32 && smooth_coordinate_count >= 5 {
504                centers = centers.max(polynomial_cols + 4);
505            }
506            let center_strategy = if has_explicit_countwith_basis_alias(options, "centers") {
507                spatial_center_strategy_for_dimension(centers, cols.len())
508            } else {
509                auto_spatial_center_strategy(centers, cols.len())
510            };
511            let aniso_log_scales = if option_bool(options, "scale_dims").unwrap_or(false) {
512                Some(vec![0.0; cols.len()])
513            } else {
514                None
515            };
516            let operator_penalties = DuchonOperatorPenaltySpec::default();
517            Ok(SmoothBasisSpec::Duchon {
518                feature_cols: cols.to_vec(),
519                spec: DuchonBasisSpec {
520                    center_strategy,
521                    length_scale,
522                    power,
523                    nullspace_order,
524                    identifiability: parse_spatial_identifiability(options)?,
525                    aniso_log_scales,
526                    operator_penalties,
527                },
528                input_scales: None,
529            })
530        }
531        other => Err(format!("unsupported smooth type '{other}'")),
532    }
533}
534
535/// Initialise per-axis anisotropic log-scales on eligible spatial smooth specs.
536pub fn enable_scale_dimensions(spec: &mut TermCollectionSpec) {
537    for smooth in spec.smooth_terms.iter_mut() {
538        match &mut smooth.basis {
539            SmoothBasisSpec::Matern {
540                feature_cols,
541                spec: matern,
542                ..
543            } => {
544                if matern.aniso_log_scales.is_none() {
545                    let d = feature_cols.len();
546                    matern.aniso_log_scales = Some(vec![0.0; d]);
547                }
548            }
549            SmoothBasisSpec::Duchon {
550                feature_cols,
551                spec: duchon,
552                ..
553            } => {
554                if duchon.aniso_log_scales.is_none() {
555                    let d = feature_cols.len();
556                    duchon.aniso_log_scales = Some(vec![0.0; d]);
557                }
558            }
559            _ => {}
560        }
561    }
562}
563
564// ---------------------------------------------------------------------------
565// Data-aware helpers
566// ---------------------------------------------------------------------------
567
568pub fn spatial_center_strategy_for_dimension(num_centers: usize, d: usize) -> CenterStrategy {
569    default_spatial_center_strategy(num_centers, d)
570}
571
572pub fn col_minmax(col: ArrayView1<'_, f64>) -> Result<(f64, f64), String> {
573    let min = col.iter().fold(f64::INFINITY, |a, &b| a.min(b));
574    let max = col.iter().fold(f64::NEG_INFINITY, |a, &b| a.max(b));
575    if !min.is_finite() || !max.is_finite() {
576        return Err("non-finite data encountered while inferring knot range".to_string());
577    }
578    if (max - min).abs() < 1e-12 {
579        Ok((min, min + 1e-6))
580    } else {
581        Ok((min, max))
582    }
583}
584
585/// Default knot count for an n-row design, growing the ceiling with n^(1/3).
586///
587/// Wood (2017, sec 5.5) shows the optimal smoothing-spline rank for an
588/// asymptotically smooth target grows as n^(1/(2k+1)) where k is the order
589/// of the penalty (k=2 cubic ⇒ n^(1/5)) and as n^(1/3) under a fixed-knot
590/// regression-spline regime (k → ∞ in the rank-bias trade-off). Capping the
591/// floor heuristic `floor(sqrt(n))` at 30 strangles biobank fits at n = 3e5
592/// where the underlying signal can support ~60 knots without overfitting.
593/// We retain the sqrt() floor as the starting point, raise the ceiling with
594/// n^(1/3) so it crosses 30 around n = 27_000 and reaches ~100 at n = 1e6,
595/// and keep the lower bound at 6 to guarantee enough degrees of freedom for
596/// monotone constraints.
597pub fn heuristic_knots(n: usize) -> usize {
598    let n_f = n as f64;
599    let base = n_f.sqrt() as usize;
600    let n_cbrt = n_f.cbrt();
601    // Ceiling grows with n^(1/3): 30 at n ≈ 27k, 60 at n ≈ 216k, 100 at n ≈ 1M.
602    let ceiling = (n_cbrt as usize).max(30);
603    base.clamp(6, ceiling)
604}
605
606pub fn unique_count_column(col: ArrayView1<'_, f64>) -> usize {
607    use std::collections::HashSet;
608    let mut set = HashSet::<u64>::with_capacity(col.len());
609    for &v in col {
610        let norm = if v == 0.0 { 0.0 } else { v };
611        set.insert(norm.to_bits());
612    }
613    set.len().max(1)
614}
615
616/// Per-column knot count from the unique-value count, with the same n^(1/3)
617/// ceiling growth as `heuristic_knots` so per-column smooths can support more
618/// detail at biobank scale. The 4-knot floor stays put because we still need
619/// enough basis functions to fit a non-trivial smooth at all.
620pub fn heuristic_knots_for_column(col: ArrayView1<'_, f64>) -> usize {
621    let unique = unique_count_column(col);
622    let ceiling = ((unique as f64).cbrt() as usize).max(20);
623    (unique / 4).clamp(4, ceiling)
624}
625
626pub fn heuristic_centers(n: usize, d: usize) -> usize {
627    default_num_centers(n, d)
628}
629
630// ---------------------------------------------------------------------------
631// Smooth option parsers
632// ---------------------------------------------------------------------------
633
634pub fn parse_ps_internal_knots(
635    options: &BTreeMap<String, String>,
636    degree: usize,
637    default_internal_knots: usize,
638) -> Result<(usize, bool), String> {
639    let knots_internal = option_usize(options, "knots");
640    let basis_dim = option_usize_any(options, &["k", "basis_dim", "basis-dim", "basisdim"]);
641    if knots_internal.is_some() && basis_dim.is_some() {
642        return Err(
643            "ps/bspline smooth: specify either knots=<internal_knots> or k=<basis_dim> (not both)"
644                .to_string(),
645        );
646    }
647    if let Some(k) = basis_dim {
648        let min_k = degree + 1;
649        if k < min_k {
650            return Err(format!(
651                "ps/bspline smooth: k={} too small for degree {}; expected k >= {}",
652                k, degree, min_k
653            ));
654        }
655        Ok((k - min_k, false))
656    } else {
657        Ok((
658            knots_internal.unwrap_or(default_internal_knots),
659            knots_internal.is_none(),
660        ))
661    }
662}
663
664pub fn parse_countwith_basis_alias(
665    options: &BTreeMap<String, String>,
666    primarykey: &str,
667    default_count: usize,
668) -> Result<usize, String> {
669    let primary = option_usize(options, primarykey);
670    let basis_dim = option_usize_any(
671        options,
672        &["k", "basis_dim", "basis-dim", "basisdim", "knots"],
673    );
674    if primary.is_some() && basis_dim.is_some() {
675        return Err(format!(
676            "specify either {}=<count> or k=<basis_dim> (not both)",
677            primarykey
678        ));
679    }
680    Ok(primary.or(basis_dim).unwrap_or(default_count))
681}
682
683fn has_explicit_countwith_basis_alias(
684    options: &BTreeMap<String, String>,
685    primarykey: &str,
686) -> bool {
687    options.contains_key(primarykey)
688        || ["k", "basis_dim", "basis-dim", "basisdim", "knots"]
689            .iter()
690            .any(|alias| options.contains_key(*alias))
691}
692
693pub fn parse_matern_nu(raw: &str) -> Result<MaternNu, String> {
694    match raw.trim().to_ascii_lowercase().as_str() {
695        "1/2" | "0.5" | "half" => Ok(MaternNu::Half),
696        "3/2" | "1.5" => Ok(MaternNu::ThreeHalves),
697        "5/2" | "2.5" => Ok(MaternNu::FiveHalves),
698        "7/2" | "3.5" => Ok(MaternNu::SevenHalves),
699        "9/2" | "4.5" => Ok(MaternNu::NineHalves),
700        _ => Err(format!("unsupported Matern nu '{raw}'")),
701    }
702}
703
704#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
705pub enum DuchonPowerPolicy {
706    Explicit(usize),
707    MinimumAdmissibleForTripleOperator,
708}
709
710pub fn parse_duchon_power_policy(
711    options: &BTreeMap<String, String>,
712) -> Result<DuchonPowerPolicy, String> {
713    if let Some(raw_nu) = options.get("nu") {
714        return Err(format!(
715            "Duchon smooths use power=<integer>, not nu='{}'. Use power=0, power=1, etc.",
716            raw_nu
717        ));
718    }
719    match options.get("power") {
720        Some(raw) => raw.parse::<usize>().map(DuchonPowerPolicy::Explicit).map_err(|_| {
721            format!(
722                "invalid Duchon power '{}'; expected a non-negative integer such as power=0 or power=1",
723                raw
724            )
725        }),
726        None => Ok(DuchonPowerPolicy::MinimumAdmissibleForTripleOperator),
727    }
728}
729
730pub fn parse_duchon_power(options: &BTreeMap<String, String>) -> Result<usize, String> {
731    match parse_duchon_power_policy(options)? {
732        DuchonPowerPolicy::Explicit(power) => Ok(power),
733        DuchonPowerPolicy::MinimumAdmissibleForTripleOperator => Ok(2),
734    }
735}
736
737pub fn parse_duchon_order(
738    options: &BTreeMap<String, String>,
739) -> Result<DuchonNullspaceOrder, String> {
740    match options.get("order") {
741        None => Ok(DuchonNullspaceOrder::Zero),
742        Some(raw) => match raw.parse::<usize>() {
743            Ok(0) => Ok(DuchonNullspaceOrder::Zero),
744            Ok(1) => Ok(DuchonNullspaceOrder::Linear),
745            Ok(other) => Ok(DuchonNullspaceOrder::Degree(other)),
746            Err(_) => Err(format!(
747                "invalid Duchon order '{}'; expected a non-negative integer such as order=0, order=1, or order=2",
748                raw
749            )),
750        },
751    }
752}
753
754fn parse_matern_identifiability(
755    options: &BTreeMap<String, String>,
756) -> Result<MaternIdentifiability, String> {
757    let Some(raw) = options.get("identifiability").map(String::as_str) else {
758        return Ok(MaternIdentifiability::default());
759    };
760    match raw.trim().to_ascii_lowercase().as_str() {
761        "none" => Ok(MaternIdentifiability::None),
762        "sum_tozero" | "sum-to-zero" | "center_sum_tozero" | "center-sum-to-zero" | "centered" => {
763            Ok(MaternIdentifiability::CenterSumToZero)
764        }
765        "linear" | "center_linear_orthogonal" | "center-linear-orthogonal" => {
766            Ok(MaternIdentifiability::CenterLinearOrthogonal)
767        }
768        other => Err(format!(
769            "invalid Matérn identifiability '{other}'; expected one of: none, sum_tozero, linear"
770        )),
771    }
772}
773
774fn parse_spatial_identifiability(
775    options: &BTreeMap<String, String>,
776) -> Result<SpatialIdentifiability, String> {
777    let Some(raw) = options.get("identifiability").map(String::as_str) else {
778        return Ok(SpatialIdentifiability::default());
779    };
780    match raw.trim().to_ascii_lowercase().as_str() {
781        "none" => Ok(SpatialIdentifiability::None),
782        "orthogonal"
783        | "orthogonal_to_parametric"
784        | "orthogonal-to-parametric"
785        | "parametric_orthogonal" => Ok(SpatialIdentifiability::OrthogonalToParametric),
786        "frozen" => Err(
787            "spatial identifiability 'frozen' is internal-only; use none or orthogonal_to_parametric".to_string(),
788        ),
789        other => Err(format!(
790            "invalid spatial identifiability '{other}'; expected one of: none, orthogonal_to_parametric"
791        )),
792    }
793}
794
795fn parse_tensor_identifiability(
796    options: &BTreeMap<String, String>,
797) -> Result<TensorBSplineIdentifiability, String> {
798    let Some(raw) = options.get("identifiability").map(String::as_str) else {
799        return Ok(TensorBSplineIdentifiability::default());
800    };
801    match raw.trim().to_ascii_lowercase().as_str() {
802        "none" => Ok(TensorBSplineIdentifiability::None),
803        "sum_tozero" | "sum-to-zero" | "centered" => Ok(TensorBSplineIdentifiability::SumToZero),
804        "frozen" | "frozen_transform" | "frozen-transform" => Err(
805            "tensor identifiability 'frozen' is internal-only; use none or sum_tozero".to_string(),
806        ),
807        other => Err(format!(
808            "invalid tensor identifiability '{other}'; expected one of: none, sum_tozero"
809        )),
810    }
811}