1use 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
30pub 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
64pub 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 }
206 }
207 }
208
209 Ok(TermCollectionSpec {
210 linear_terms,
211 random_effect_terms: random_terms,
212 smooth_terms,
213 })
214}
215
216pub 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 let (nullspace_order, power) = match parse_duchon_power_policy(options)? {
411 DuchonPowerPolicy::Explicit(req_power) => {
412 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
535pub 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
564pub 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
585pub 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 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
616pub 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
630pub 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}