1use super::*;
11use crate::basis::{OneDimensionalBoundary, SphereMethod};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
17pub enum BasisScaleFamily {
18 ByVariableNumeric,
19 ByVariableFactor,
20 FactorSumToZero,
21 OpenBSpline,
22 CyclicBSpline,
23 NaturalCubic,
24 BySmoothNumeric,
25 BySmoothFactor,
26 FactorSmoothFs,
27 FactorSmoothSz,
28 FactorSmoothRe,
29 ThinPlate,
30 SphereWahba,
31 SphereHarmonic,
32 ConstantCurvature,
33 Matern,
34 MeasureJet,
35 PureDuchon,
36 HybridDuchon,
37 Pca,
38 TensorBSpline,
39}
40
41impl BasisScaleFamily {
42 pub const ALL: [Self; 21] = [
45 Self::ByVariableNumeric,
46 Self::ByVariableFactor,
47 Self::FactorSumToZero,
48 Self::OpenBSpline,
49 Self::CyclicBSpline,
50 Self::NaturalCubic,
51 Self::BySmoothNumeric,
52 Self::BySmoothFactor,
53 Self::FactorSmoothFs,
54 Self::FactorSmoothSz,
55 Self::FactorSmoothRe,
56 Self::ThinPlate,
57 Self::SphereWahba,
58 Self::SphereHarmonic,
59 Self::ConstantCurvature,
60 Self::Matern,
61 Self::MeasureJet,
62 Self::PureDuchon,
63 Self::HybridDuchon,
64 Self::Pca,
65 Self::TensorBSpline,
66 ];
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum BasisCoordinateScaleAction {
72 PositiveAffineAbscissa,
74 IndependentPositiveAffineAxes,
76 UniformEuclideanScale,
81 IntrinsicAngularUnitConversion,
83 ConstantCurvatureChartSimilarity,
85 PcaScoreGauge,
87 NumericModulation,
89 DiscreteReplication,
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub enum BasisDesignScaleLaw {
96 Invariant,
99 NumericMultiplierDegreeOne,
102 ReplicatedInner,
104 TensorProductOfMarginals,
106 RandomInterceptSlopeDegreesZeroAndOne,
110 ChartSimilarityDegreeOne,
127}
128
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub enum BasisPenaltyScaleLaw {
132 FrobeniusNormalizedRawPower(i32),
136 FrobeniusNormalizedDerivativeOrder { order: usize },
140 FrobeniusNormalizedPerMarginalDerivativeOrder,
143 FrobeniusNormalizedInvariant,
146 InvariantPhysicalRkhsGram,
149 ReplicatedInner,
152 InvariantFunctionMass,
155 PhysicalRkhsGramDegreeOne,
162}
163
164#[derive(Debug, Clone, Copy, PartialEq, Eq)]
166pub enum BasisDerivativeScaleLaw {
167 InverseCoordinatePower { maximum_order: usize },
169 InverseCoordinatePowerAndInvariantLogRange { maximum_order: usize },
172 ConstantCurvatureParameterPowers,
178 IntrinsicAngularChainRule,
181 NumericModulationProductRule,
183 DelegatedToInner,
185 TensorMarginalProductRule { maximum_order: usize },
188}
189
190#[derive(Debug, Clone, Copy, PartialEq, Eq)]
194pub enum BasisNullGeometryScaleLaw {
195 PolynomialPullback,
196 CyclicConstantMode,
197 TensorProductPullback,
198 EuclideanPolynomialPullback,
199 CenterConstraintPullback,
200 MeasureJetAffineHeadPullback,
201 IntrinsicHarmonicSubspace,
202 ConstantCurvatureCenterConstraint,
203 PcaScoreCongruence,
204 FullRankRandomEffect,
205 ReplicatedInner,
206}
207
208#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
210pub enum DimensionfulBasisParameter {
211 Knots,
212 DomainEndpoints,
213 Periods,
214 Centers,
215 IsotropicInputScale,
216 LengthScale,
217 MeasureJetScaleBand,
218 MeasureJetCoordinateNoise,
219 Curvature,
220 PcaCenterMean,
221 PcaLoadings,
222 NumericByMultiplier,
223}
224
225#[derive(Debug, Clone, Copy, PartialEq, Eq)]
230pub struct DimensionfulParameterScale {
231 pub parameter: DimensionfulBasisParameter,
232 pub power: i8,
233}
234
235impl DimensionfulParameterScale {
236 const fn new(parameter: DimensionfulBasisParameter, power: i8) -> Self {
237 Self { parameter, power }
238 }
239}
240
241#[derive(Debug, Clone, Copy, PartialEq, Eq)]
245enum InputFrameNormalization {
246 Parameterized,
247 AutoStandardizedOriginalUnits,
248 AutoStandardizedFreshOriginalReplayRealized,
249 Intrinsic,
250 PcaGauge,
251 Delegated,
252}
253
254#[derive(Debug, Clone, PartialEq, Eq)]
256pub struct BasisScaleContract {
257 pub family: BasisScaleFamily,
258 pub coordinate_action: BasisCoordinateScaleAction,
259 pub design: BasisDesignScaleLaw,
260 pub penalty: BasisPenaltyScaleLaw,
261 pub derivatives: BasisDerivativeScaleLaw,
262 pub null_geometry: BasisNullGeometryScaleLaw,
263 pub dimensionful_parameters: Vec<DimensionfulParameterScale>,
264 pub children: Vec<BasisScaleContract>,
267 input_frame: InputFrameNormalization,
268}
269
270pub(super) struct NormalizedEuclideanFrame {
272 pub coordinates: Array2<f64>,
273 pub input_scale: crate::IsotropicScale,
274 pub length_scale: Option<crate::StandardizedUnits>,
279}
280
281fn standardize_resolved_center_strategy(
282 strategy: &mut crate::basis::CenterStrategy,
283 input_scale: crate::IsotropicScale,
284) {
285 match strategy {
286 crate::basis::CenterStrategy::Auto(inner) => {
287 standardize_resolved_center_strategy(inner, input_scale);
288 }
289 crate::basis::CenterStrategy::DuchonSpectral { knots, .. } => {
290 standardize_resolved_center_strategy(knots, input_scale);
291 }
292 crate::basis::CenterStrategy::UserProvided(centers) => {
293 input_scale.standardize(centers);
294 }
295 crate::basis::CenterStrategy::EqualMass { .. }
296 | crate::basis::CenterStrategy::EqualMassCovarRepresentative { .. }
297 | crate::basis::CenterStrategy::FarthestPoint { .. }
298 | crate::basis::CenterStrategy::KMeans { .. }
299 | crate::basis::CenterStrategy::UniformGrid { .. } => {}
300 }
301}
302
303impl BasisScaleContract {
304 fn leaf(
305 family: BasisScaleFamily,
306 coordinate_action: BasisCoordinateScaleAction,
307 design: BasisDesignScaleLaw,
308 penalty: BasisPenaltyScaleLaw,
309 derivatives: BasisDerivativeScaleLaw,
310 null_geometry: BasisNullGeometryScaleLaw,
311 dimensionful_parameters: Vec<DimensionfulParameterScale>,
312 input_frame: InputFrameNormalization,
313 ) -> Self {
314 Self {
315 family,
316 coordinate_action,
317 design,
318 penalty,
319 derivatives,
320 null_geometry,
321 dimensionful_parameters,
322 children: Vec::new(),
323 input_frame,
324 }
325 }
326
327 fn wrapper(
328 family: BasisScaleFamily,
329 coordinate_action: BasisCoordinateScaleAction,
330 design: BasisDesignScaleLaw,
331 derivatives: BasisDerivativeScaleLaw,
332 child: BasisScaleContract,
333 dimensionful_parameters: Vec<DimensionfulParameterScale>,
334 ) -> Self {
335 Self {
336 family,
337 coordinate_action,
338 design,
339 penalty: BasisPenaltyScaleLaw::ReplicatedInner,
340 derivatives,
341 null_geometry: BasisNullGeometryScaleLaw::ReplicatedInner,
342 dimensionful_parameters,
343 children: vec![child],
344 input_frame: InputFrameNormalization::Delegated,
345 }
346 }
347
348 pub(super) fn normalize_euclidean_frame(
358 &self,
359 mut coordinates: Array2<f64>,
360 stored_scale: Option<crate::IsotropicScale>,
361 spec_length_scale: Option<f64>,
362 center_strategy: &mut crate::basis::CenterStrategy,
363 ) -> Result<NormalizedEuclideanFrame, BasisError> {
364 let replay = stored_scale.is_some();
365 let replay_range_is_already_realized = match self.input_frame {
366 InputFrameNormalization::AutoStandardizedOriginalUnits => false,
367 InputFrameNormalization::AutoStandardizedFreshOriginalReplayRealized => true,
368 InputFrameNormalization::Parameterized
369 | InputFrameNormalization::Intrinsic
370 | InputFrameNormalization::PcaGauge
371 | InputFrameNormalization::Delegated => {
372 return Err(BasisError::InvalidInput(format!(
373 "basis {:?} does not declare Euclidean auto-standardization",
374 self.family
375 )));
376 }
377 };
378 let input_scale = match stored_scale {
379 Some(scale) => scale,
380 None => estimate_isotropic_scale(coordinates.view())?,
381 };
382
383 input_scale.standardize(&mut coordinates);
384 if !replay {
398 standardize_resolved_center_strategy(center_strategy, input_scale);
399 }
400 let transformed_length = if replay_range_is_already_realized && replay {
401 spec_length_scale.map(crate::StandardizedUnits::new)
405 } else if replay_range_is_already_realized {
406 spec_length_scale.map(|ell| {
407 if ell > 0.0 {
408 input_scale.to_standardized_units(crate::OriginalUnits::new(ell))
409 } else {
410 crate::StandardizedUnits::new(ell)
413 }
414 })
415 } else {
416 spec_length_scale
417 .map(|ell| input_scale.to_standardized_units(crate::OriginalUnits::new(ell)))
418 };
419
420 Ok(NormalizedEuclideanFrame {
421 coordinates,
422 input_scale,
423 length_scale: transformed_length,
424 })
425 }
426}
427
428fn scale(power: i8, parameter: DimensionfulBasisParameter) -> DimensionfulParameterScale {
429 DimensionfulParameterScale::new(parameter, power)
430}
431
432fn bspline_contract(spec: &BSplineBasisSpec) -> BasisScaleContract {
433 let (family, null_geometry, mut parameters) = match (&spec.knotspec, &spec.boundary) {
434 (BSplineKnotSpec::NaturalCubicRegression { .. }, _) => (
435 BasisScaleFamily::NaturalCubic,
436 BasisNullGeometryScaleLaw::PolynomialPullback,
437 vec![scale(1, DimensionfulBasisParameter::Knots)],
438 ),
439 (BSplineKnotSpec::PeriodicUniform { .. }, _)
440 | (_, OneDimensionalBoundary::Cyclic { .. }) => (
441 BasisScaleFamily::CyclicBSpline,
442 BasisNullGeometryScaleLaw::CyclicConstantMode,
443 vec![
444 scale(1, DimensionfulBasisParameter::Knots),
445 scale(1, DimensionfulBasisParameter::DomainEndpoints),
446 scale(1, DimensionfulBasisParameter::Periods),
447 ],
448 ),
449 (BSplineKnotSpec::Generate { .. }, OneDimensionalBoundary::Open)
450 | (BSplineKnotSpec::Automatic { .. }, OneDimensionalBoundary::Open)
451 | (BSplineKnotSpec::Provided(_), OneDimensionalBoundary::Open) => (
452 BasisScaleFamily::OpenBSpline,
453 BasisNullGeometryScaleLaw::PolynomialPullback,
454 vec![scale(1, DimensionfulBasisParameter::Knots)],
455 ),
456 };
457 if matches!(spec.knotspec, BSplineKnotSpec::Generate { .. }) {
458 parameters.push(scale(1, DimensionfulBasisParameter::DomainEndpoints));
459 }
460 let penalty = if family == BasisScaleFamily::NaturalCubic {
461 BasisPenaltyScaleLaw::FrobeniusNormalizedRawPower(-3)
462 } else {
463 BasisPenaltyScaleLaw::FrobeniusNormalizedDerivativeOrder {
464 order: spec.penalty_order,
465 }
466 };
467 BasisScaleContract::leaf(
468 family,
469 BasisCoordinateScaleAction::PositiveAffineAbscissa,
470 BasisDesignScaleLaw::Invariant,
471 penalty,
472 BasisDerivativeScaleLaw::InverseCoordinatePower { maximum_order: 2 },
473 null_geometry,
474 parameters,
475 InputFrameNormalization::Parameterized,
476 )
477}
478
479fn spatial_parameters(
480 include_length: bool,
481 include_periods: bool,
482) -> Vec<DimensionfulParameterScale> {
483 let mut parameters = vec![
484 scale(1, DimensionfulBasisParameter::Centers),
485 scale(1, DimensionfulBasisParameter::IsotropicInputScale),
486 ];
487 if include_length {
488 parameters.push(scale(1, DimensionfulBasisParameter::LengthScale));
489 }
490 if include_periods {
491 parameters.push(scale(1, DimensionfulBasisParameter::Periods));
492 }
493 parameters
494}
495
496impl SmoothBasisSpec {
497 pub fn scale_contract(&self) -> BasisScaleContract {
503 match self {
504 SmoothBasisSpec::ByVariable { inner, by, .. } => match by {
505 ByVariableSpec::Numeric => BasisScaleContract::wrapper(
506 BasisScaleFamily::ByVariableNumeric,
507 BasisCoordinateScaleAction::NumericModulation,
508 BasisDesignScaleLaw::NumericMultiplierDegreeOne,
509 BasisDerivativeScaleLaw::NumericModulationProductRule,
510 inner.scale_contract(),
511 vec![scale(1, DimensionfulBasisParameter::NumericByMultiplier)],
512 ),
513 ByVariableSpec::Level { .. } => BasisScaleContract::wrapper(
514 BasisScaleFamily::ByVariableFactor,
515 BasisCoordinateScaleAction::DiscreteReplication,
516 BasisDesignScaleLaw::ReplicatedInner,
517 BasisDerivativeScaleLaw::DelegatedToInner,
518 inner.scale_contract(),
519 Vec::new(),
520 ),
521 },
522 SmoothBasisSpec::FactorSumToZero { inner, .. } => BasisScaleContract::wrapper(
523 BasisScaleFamily::FactorSumToZero,
524 BasisCoordinateScaleAction::DiscreteReplication,
525 BasisDesignScaleLaw::ReplicatedInner,
526 BasisDerivativeScaleLaw::DelegatedToInner,
527 inner.scale_contract(),
528 Vec::new(),
529 ),
530 SmoothBasisSpec::BSpline1D { spec, .. } => bspline_contract(spec),
531 SmoothBasisSpec::BySmooth { smooth, by_kind } => match by_kind {
532 ByVarKind::Numeric { .. } => BasisScaleContract::wrapper(
533 BasisScaleFamily::BySmoothNumeric,
534 BasisCoordinateScaleAction::NumericModulation,
535 BasisDesignScaleLaw::NumericMultiplierDegreeOne,
536 BasisDerivativeScaleLaw::NumericModulationProductRule,
537 smooth.scale_contract(),
538 vec![scale(1, DimensionfulBasisParameter::NumericByMultiplier)],
539 ),
540 ByVarKind::Factor { .. } => BasisScaleContract::wrapper(
541 BasisScaleFamily::BySmoothFactor,
542 BasisCoordinateScaleAction::DiscreteReplication,
543 BasisDesignScaleLaw::ReplicatedInner,
544 BasisDerivativeScaleLaw::DelegatedToInner,
545 smooth.scale_contract(),
546 Vec::new(),
547 ),
548 },
549 SmoothBasisSpec::FactorSmooth { spec } => match &spec.flavour {
550 FactorSmoothFlavour::Fs { .. } => BasisScaleContract::wrapper(
551 BasisScaleFamily::FactorSmoothFs,
552 BasisCoordinateScaleAction::DiscreteReplication,
553 BasisDesignScaleLaw::ReplicatedInner,
554 BasisDerivativeScaleLaw::DelegatedToInner,
555 bspline_contract(&spec.marginal),
556 Vec::new(),
557 ),
558 FactorSmoothFlavour::Sz => BasisScaleContract::wrapper(
559 BasisScaleFamily::FactorSmoothSz,
560 BasisCoordinateScaleAction::DiscreteReplication,
561 BasisDesignScaleLaw::ReplicatedInner,
562 BasisDerivativeScaleLaw::DelegatedToInner,
563 bspline_contract(&spec.marginal),
564 Vec::new(),
565 ),
566 FactorSmoothFlavour::Re => BasisScaleContract {
567 family: BasisScaleFamily::FactorSmoothRe,
568 coordinate_action: BasisCoordinateScaleAction::PositiveAffineAbscissa,
569 design: BasisDesignScaleLaw::RandomInterceptSlopeDegreesZeroAndOne,
570 penalty: BasisPenaltyScaleLaw::FrobeniusNormalizedInvariant,
571 derivatives: BasisDerivativeScaleLaw::InverseCoordinatePower {
572 maximum_order: 1,
573 },
574 null_geometry: BasisNullGeometryScaleLaw::FullRankRandomEffect,
575 dimensionful_parameters: vec![scale(1, DimensionfulBasisParameter::Knots)],
576 children: vec![bspline_contract(&spec.marginal)],
577 input_frame: InputFrameNormalization::Delegated,
578 },
579 },
580 SmoothBasisSpec::ThinPlate { .. } => BasisScaleContract::leaf(
581 BasisScaleFamily::ThinPlate,
582 BasisCoordinateScaleAction::UniformEuclideanScale,
583 BasisDesignScaleLaw::Invariant,
584 BasisPenaltyScaleLaw::FrobeniusNormalizedInvariant,
585 BasisDerivativeScaleLaw::InverseCoordinatePowerAndInvariantLogRange {
586 maximum_order: 2,
587 },
588 BasisNullGeometryScaleLaw::EuclideanPolynomialPullback,
589 spatial_parameters(true, true),
590 InputFrameNormalization::AutoStandardizedOriginalUnits,
591 ),
592 SmoothBasisSpec::Sphere { spec, .. } => {
593 let (family, parameters) = match spec.method {
594 SphereMethod::Wahba => (
595 BasisScaleFamily::SphereWahba,
596 vec![scale(1, DimensionfulBasisParameter::Centers)],
597 ),
598 SphereMethod::Harmonic => (BasisScaleFamily::SphereHarmonic, Vec::new()),
599 };
600 BasisScaleContract::leaf(
601 family,
602 BasisCoordinateScaleAction::IntrinsicAngularUnitConversion,
603 BasisDesignScaleLaw::Invariant,
604 BasisPenaltyScaleLaw::FrobeniusNormalizedInvariant,
605 BasisDerivativeScaleLaw::IntrinsicAngularChainRule,
606 BasisNullGeometryScaleLaw::IntrinsicHarmonicSubspace,
607 parameters,
608 InputFrameNormalization::Intrinsic,
609 )
610 }
611 SmoothBasisSpec::ConstantCurvature { .. } => BasisScaleContract::leaf(
612 BasisScaleFamily::ConstantCurvature,
613 BasisCoordinateScaleAction::ConstantCurvatureChartSimilarity,
614 BasisDesignScaleLaw::ChartSimilarityDegreeOne,
615 BasisPenaltyScaleLaw::PhysicalRkhsGramDegreeOne,
616 BasisDerivativeScaleLaw::ConstantCurvatureParameterPowers,
617 BasisNullGeometryScaleLaw::ConstantCurvatureCenterConstraint,
618 vec![
619 scale(1, DimensionfulBasisParameter::Centers),
620 scale(1, DimensionfulBasisParameter::LengthScale),
621 scale(-2, DimensionfulBasisParameter::Curvature),
622 ],
623 InputFrameNormalization::Intrinsic,
624 ),
625 SmoothBasisSpec::Matern { .. } => BasisScaleContract::leaf(
626 BasisScaleFamily::Matern,
627 BasisCoordinateScaleAction::UniformEuclideanScale,
628 BasisDesignScaleLaw::Invariant,
629 BasisPenaltyScaleLaw::FrobeniusNormalizedInvariant,
630 BasisDerivativeScaleLaw::InverseCoordinatePowerAndInvariantLogRange {
631 maximum_order: 2,
632 },
633 BasisNullGeometryScaleLaw::CenterConstraintPullback,
634 spatial_parameters(true, true),
635 InputFrameNormalization::AutoStandardizedOriginalUnits,
636 ),
637 SmoothBasisSpec::MeasureJet { .. } => BasisScaleContract::leaf(
638 BasisScaleFamily::MeasureJet,
639 BasisCoordinateScaleAction::UniformEuclideanScale,
640 BasisDesignScaleLaw::Invariant,
641 BasisPenaltyScaleLaw::FrobeniusNormalizedInvariant,
642 BasisDerivativeScaleLaw::InverseCoordinatePowerAndInvariantLogRange {
643 maximum_order: 2,
644 },
645 BasisNullGeometryScaleLaw::MeasureJetAffineHeadPullback,
646 {
647 let mut parameters = spatial_parameters(true, false);
648 parameters.push(scale(1, DimensionfulBasisParameter::MeasureJetScaleBand));
649 parameters.push(scale(
650 1,
651 DimensionfulBasisParameter::MeasureJetCoordinateNoise,
652 ));
653 parameters
654 },
655 InputFrameNormalization::AutoStandardizedFreshOriginalReplayRealized,
656 ),
657 SmoothBasisSpec::Duchon { spec, .. } => {
658 let family = if spec.length_scale.is_some() {
659 BasisScaleFamily::HybridDuchon
660 } else {
661 BasisScaleFamily::PureDuchon
662 };
663 BasisScaleContract::leaf(
664 family,
665 BasisCoordinateScaleAction::UniformEuclideanScale,
666 BasisDesignScaleLaw::Invariant,
667 BasisPenaltyScaleLaw::FrobeniusNormalizedInvariant,
668 if spec.length_scale.is_some() {
669 BasisDerivativeScaleLaw::InverseCoordinatePowerAndInvariantLogRange {
670 maximum_order: 2,
671 }
672 } else {
673 BasisDerivativeScaleLaw::InverseCoordinatePower { maximum_order: 2 }
674 },
675 BasisNullGeometryScaleLaw::EuclideanPolynomialPullback,
676 spatial_parameters(spec.length_scale.is_some(), true),
677 InputFrameNormalization::AutoStandardizedOriginalUnits,
678 )
679 }
680 SmoothBasisSpec::Pca { .. } => BasisScaleContract::leaf(
681 BasisScaleFamily::Pca,
682 BasisCoordinateScaleAction::PcaScoreGauge,
683 BasisDesignScaleLaw::Invariant,
684 BasisPenaltyScaleLaw::InvariantFunctionMass,
685 BasisDerivativeScaleLaw::InverseCoordinatePower { maximum_order: 1 },
686 BasisNullGeometryScaleLaw::PcaScoreCongruence,
687 vec![
688 scale(1, DimensionfulBasisParameter::PcaCenterMean),
689 scale(-1, DimensionfulBasisParameter::PcaLoadings),
690 ],
691 InputFrameNormalization::PcaGauge,
692 ),
693 SmoothBasisSpec::TensorBSpline { spec, .. } => BasisScaleContract {
694 family: BasisScaleFamily::TensorBSpline,
695 coordinate_action: BasisCoordinateScaleAction::IndependentPositiveAffineAxes,
696 design: BasisDesignScaleLaw::TensorProductOfMarginals,
697 penalty: BasisPenaltyScaleLaw::FrobeniusNormalizedPerMarginalDerivativeOrder,
698 derivatives: BasisDerivativeScaleLaw::TensorMarginalProductRule {
699 maximum_order: 2,
700 },
701 null_geometry: BasisNullGeometryScaleLaw::TensorProductPullback,
702 dimensionful_parameters: Vec::new(),
703 children: spec.marginalspecs.iter().map(bspline_contract).collect(),
704 input_frame: InputFrameNormalization::Delegated,
705 },
706 }
707 }
708
709 pub fn validate_scale_configuration(&self) -> Result<(), BasisError> {
712 let contract = self.scale_contract();
713 match self {
714 SmoothBasisSpec::ByVariable { inner, .. }
715 | SmoothBasisSpec::FactorSumToZero { inner, .. } => {
716 inner.validate_scale_configuration()
717 }
718 SmoothBasisSpec::BSpline1D { .. } => Ok(()),
719 SmoothBasisSpec::BySmooth { smooth, .. } => smooth.validate_scale_configuration(),
720 SmoothBasisSpec::FactorSmooth { .. } => Ok(()),
721 SmoothBasisSpec::ThinPlate {
722 feature_cols,
723 input_scale: _,
724 ..
725 }
726 | SmoothBasisSpec::Matern {
727 feature_cols,
728 input_scale: _,
729 ..
730 }
731 | SmoothBasisSpec::MeasureJet {
732 feature_cols,
733 input_scale: _,
734 ..
735 }
736 | SmoothBasisSpec::Duchon {
737 feature_cols,
738 input_scale: _,
739 ..
740 } => {
741 if feature_cols.is_empty() {
742 return Err(BasisError::InvalidInput(format!(
743 "basis {:?} requires at least one coordinate axis",
744 contract.family
745 )));
746 }
747 Ok(())
748 }
749 SmoothBasisSpec::Sphere { feature_cols, .. }
750 | SmoothBasisSpec::ConstantCurvature { feature_cols, .. } => {
751 if feature_cols.is_empty() {
752 return Err(BasisError::InvalidInput(format!(
753 "basis {:?} requires at least one coordinate axis",
754 contract.family
755 )));
756 }
757 Ok(())
758 }
759 SmoothBasisSpec::Pca { .. } => Ok(()),
760 SmoothBasisSpec::TensorBSpline { feature_cols, spec } => {
761 if feature_cols.len() != spec.marginalspecs.len() {
762 return Err(BasisError::DimensionMismatch(format!(
763 "TensorBSpline has {} feature axes but {} marginal scale contracts",
764 feature_cols.len(),
765 spec.marginalspecs.len()
766 )));
767 }
768 Ok(())
769 }
770 }
771 }
772}
773
774#[cfg(test)]
775mod tests {
776 use super::*;
777 use crate::basis::{BSplineBoundaryConditions, BasisWorkspace, DuchonNullspaceOrder, DuchonOperatorPenaltySpec, MaternIdentifiability, MaternNu, MeasureJetIdentifiability};
778 use ndarray::{Array1, Array2, array};
779 use std::collections::HashSet;
780
781 fn open_marginal(scale: f64) -> BSplineBasisSpec {
782 BSplineBasisSpec {
783 degree: 3,
784 penalty_order: 2,
785 knotspec: BSplineKnotSpec::Provided(Array1::from(vec![
786 0.0,
787 0.0,
788 0.0,
789 0.0,
790 0.25 * scale,
791 0.6 * scale,
792 scale,
793 scale,
794 scale,
795 scale,
796 ])),
797 double_penalty: false,
798 identifiability: BSplineIdentifiability::None,
799 boundary: OneDimensionalBoundary::Open,
800 boundary_conditions: BSplineBoundaryConditions::default(),
801 }
802 }
803
804 fn basis(feature_col: usize, scale: f64) -> SmoothBasisSpec {
805 SmoothBasisSpec::BSpline1D {
806 feature_col,
807 spec: open_marginal(scale),
808 }
809 }
810
811 fn factor_spec(flavour: FactorSmoothFlavour) -> SmoothBasisSpec {
812 SmoothBasisSpec::FactorSmooth {
813 spec: FactorSmoothSpec {
814 continuous_cols: vec![0],
815 group_col: 1,
816 marginal: open_marginal(1.0),
817 flavour,
818 group_frozen_levels: Some(vec![0.0_f64.to_bits(), 1.0_f64.to_bits()]),
819 frozen_global_orthogonality: None,
820 },
821 }
822 }
823
824 fn scale_contract_zoo() -> Vec<SmoothBasisSpec> {
825 let by_level_bits = 1.0_f64.to_bits();
826 let mut sphere_harmonic = SphericalSplineBasisSpec::default();
827 sphere_harmonic.method = SphereMethod::Harmonic;
828 sphere_harmonic.max_degree = Some(3);
829 vec![
830 SmoothBasisSpec::ByVariable {
831 inner: Box::new(basis(0, 1.0)),
832 by_col: 1,
833 kind: BySmoothKind::Numeric,
834 by: ByVariableSpec::Numeric,
835 },
836 SmoothBasisSpec::ByVariable {
837 inner: Box::new(basis(0, 1.0)),
838 by_col: 1,
839 kind: BySmoothKind::Level {
840 level_bits: by_level_bits,
841 },
842 by: ByVariableSpec::Level {
843 value_bits: by_level_bits,
844 label: "one".to_string(),
845 },
846 },
847 SmoothBasisSpec::FactorSumToZero {
848 inner: Box::new(basis(0, 1.0)),
849 by_col: 1,
850 levels: vec![0.0_f64.to_bits(), by_level_bits],
851 frozen_global_orthogonality: None,
852 },
853 basis(0, 1.0),
854 SmoothBasisSpec::BSpline1D {
855 feature_col: 0,
856 spec: BSplineBasisSpec {
857 knotspec: BSplineKnotSpec::PeriodicUniform {
858 data_range: (0.0, 1.0),
859 num_basis: 8,
860 },
861 boundary: OneDimensionalBoundary::Cyclic {
862 start: 0.0,
863 end: 1.0,
864 },
865 ..open_marginal(1.0)
866 },
867 },
868 SmoothBasisSpec::BSpline1D {
869 feature_col: 0,
870 spec: BSplineBasisSpec {
871 knotspec: BSplineKnotSpec::NaturalCubicRegression {
872 knots: array![0.0, 0.2, 0.5, 0.8, 1.0],
873 },
874 ..open_marginal(1.0)
875 },
876 },
877 SmoothBasisSpec::BySmooth {
878 smooth: Box::new(basis(0, 1.0)),
879 by_kind: ByVarKind::Numeric { feature_col: 1 },
880 },
881 SmoothBasisSpec::BySmooth {
882 smooth: Box::new(basis(0, 1.0)),
883 by_kind: ByVarKind::Factor {
884 feature_col: 1,
885 ordered: false,
886 frozen_levels: Some(vec![0.0_f64.to_bits(), by_level_bits]),
887 },
888 },
889 factor_spec(FactorSmoothFlavour::Fs {}),
890 factor_spec(FactorSmoothFlavour::Sz),
891 factor_spec(FactorSmoothFlavour::Re),
892 SmoothBasisSpec::ThinPlate {
893 feature_cols: vec![0, 1],
894 spec: ThinPlateBasisSpec {
895 center_strategy: CenterStrategy::FarthestPoint { num_centers: 8 },
896 periodic: None,
897 length_scale: 0.7,
898 double_penalty: false,
899 identifiability: SpatialIdentifiability::None,
900 radial_reparam: None,
901 },
902 input_scale: None,
903 },
904 SmoothBasisSpec::Sphere {
905 feature_cols: vec![0, 1],
906 spec: SphericalSplineBasisSpec::default(),
907 },
908 SmoothBasisSpec::Sphere {
909 feature_cols: vec![0, 1],
910 spec: sphere_harmonic,
911 },
912 SmoothBasisSpec::ConstantCurvature {
913 feature_cols: vec![0, 1],
914 spec: ConstantCurvatureBasisSpec::default(),
915 },
916 SmoothBasisSpec::Matern {
917 feature_cols: vec![0, 1],
918 spec: MaternBasisSpec {
919 center_strategy: CenterStrategy::FarthestPoint { num_centers: 8 },
920 periodic: None,
921 length_scale: crate::basis::MaternLengthScale::fixed(0.7),
922 nu: MaternNu::ThreeHalves,
923 include_intercept: false,
924 double_penalty: false,
925 identifiability: MaternIdentifiability::None,
926 aniso_log_scales: None,
927 },
928 input_scale: None,
929 },
930 SmoothBasisSpec::MeasureJet {
931 feature_cols: vec![0, 1],
932 spec: MeasureJetBasisSpec::default(),
933 input_scale: None,
934 },
935 SmoothBasisSpec::Duchon {
936 feature_cols: vec![0, 1],
937 spec: DuchonBasisSpec {
938 center_strategy: CenterStrategy::FarthestPoint { num_centers: 8 },
939 periodic: None,
940 length_scale: None,
941 power: 0.0,
942 nullspace_order: DuchonNullspaceOrder::Linear,
943 identifiability: SpatialIdentifiability::None,
944 aniso_log_scales: None,
945 operator_penalties: DuchonOperatorPenaltySpec::default(),
946 boundary: OneDimensionalBoundary::Open,
947 radial_reparam: None,
948 },
949 input_scale: None,
950 },
951 SmoothBasisSpec::Duchon {
952 feature_cols: vec![0, 1],
953 spec: DuchonBasisSpec {
954 center_strategy: CenterStrategy::FarthestPoint { num_centers: 8 },
955 periodic: None,
956 length_scale: Some(0.7),
957 power: 1.0,
958 nullspace_order: DuchonNullspaceOrder::Linear,
959 identifiability: SpatialIdentifiability::None,
960 aniso_log_scales: None,
961 operator_penalties: DuchonOperatorPenaltySpec::default(),
962 boundary: OneDimensionalBoundary::Open,
963 radial_reparam: None,
964 },
965 input_scale: None,
966 },
967 SmoothBasisSpec::Pca {
968 feature_cols: vec![0, 1],
969 basis_matrix: array![[1.0], [0.0]],
970 centered: true,
971 smooth_penalty: 1.0,
972 center_mean: Some(array![0.0, 0.0]),
973 pca_basis_path: None,
974 chunk_size: 32,
975 },
976 SmoothBasisSpec::TensorBSpline {
977 feature_cols: vec![0, 1],
978 spec: TensorBSplineSpec {
979 marginalspecs: vec![open_marginal(1.0), open_marginal(1.0)],
980 periods: vec![None, None],
981 double_penalty: false,
982 identifiability: TensorBSplineIdentifiability::None,
983 penalty_decomposition: TensorBSplinePenaltyDecomposition::MarginalKroneckerSum,
984 },
985 },
986 ]
987 }
988
989 fn zoo_basis(family: BasisScaleFamily) -> SmoothBasisSpec {
990 scale_contract_zoo()
991 .into_iter()
992 .find(|basis| basis.scale_contract().family == family)
993 .unwrap_or_else(|| panic!("scale-contract zoo is missing {family:?}"))
994 }
995
996 #[test]
997 fn scale_contract_registry_is_exhaustive_unique_and_typed_2315() {
998 let zoo = scale_contract_zoo();
999 let observed: HashSet<_> = zoo
1000 .iter()
1001 .map(|basis| basis.scale_contract().family)
1002 .collect();
1003 let expected: HashSet<_> = BasisScaleFamily::ALL.into_iter().collect();
1004 assert_eq!(zoo.len(), BasisScaleFamily::ALL.len());
1005 assert_eq!(observed, expected);
1006
1007 for basis in &zoo {
1008 let contract = basis.scale_contract();
1009 match contract.family {
1010 BasisScaleFamily::ByVariableNumeric
1011 | BasisScaleFamily::ByVariableFactor
1012 | BasisScaleFamily::FactorSumToZero
1013 | BasisScaleFamily::BySmoothNumeric
1014 | BasisScaleFamily::BySmoothFactor
1015 | BasisScaleFamily::FactorSmoothFs
1016 | BasisScaleFamily::FactorSmoothSz
1017 | BasisScaleFamily::FactorSmoothRe => {
1018 assert_eq!(contract.children.len(), 1, "{:?}", contract.family);
1019 }
1020 BasisScaleFamily::TensorBSpline => {
1021 assert_eq!(contract.children.len(), 2);
1022 }
1023 BasisScaleFamily::OpenBSpline
1024 | BasisScaleFamily::CyclicBSpline
1025 | BasisScaleFamily::NaturalCubic
1026 | BasisScaleFamily::ThinPlate
1027 | BasisScaleFamily::SphereWahba
1028 | BasisScaleFamily::SphereHarmonic
1029 | BasisScaleFamily::ConstantCurvature
1030 | BasisScaleFamily::Matern
1031 | BasisScaleFamily::MeasureJet
1032 | BasisScaleFamily::PureDuchon
1033 | BasisScaleFamily::HybridDuchon
1034 | BasisScaleFamily::Pca => assert!(contract.children.is_empty()),
1035 }
1036 let unique_parameters: HashSet<_> = contract
1037 .dimensionful_parameters
1038 .iter()
1039 .map(|parameter| parameter.parameter)
1040 .collect();
1041 assert_eq!(
1042 unique_parameters.len(),
1043 contract.dimensionful_parameters.len(),
1044 "duplicate dimensionful parameter in {:?}",
1045 contract.family
1046 );
1047 }
1048 }
1049
1050 fn assert_matrix_close(actual: &Array2<f64>, expected: &Array2<f64>, tolerance: f64) {
1051 assert_matrix_close_with_context(actual, expected, tolerance, "matrix");
1052 }
1053
1054 fn assert_matrix_close_with_context(
1055 actual: &Array2<f64>,
1056 expected: &Array2<f64>,
1057 tolerance: f64,
1058 context: &str,
1059 ) {
1060 assert_eq!(actual.dim(), expected.dim());
1061 for ((row, col), &target) in expected.indexed_iter() {
1062 let observed = actual[[row, col]];
1063 assert!(
1064 (observed - target).abs() <= tolerance * (1.0 + target.abs()),
1065 "{context}: matrix[{row},{col}] differs: observed={observed:.16e}, target={target:.16e}; actual={actual:?}; expected={expected:?}"
1066 );
1067 }
1068 }
1069
1070 fn assert_build_geometry_close(
1071 actual: &BasisBuildResult,
1072 expected: &BasisBuildResult,
1073 tolerance: f64,
1074 ) {
1075 assert_matrix_close(
1076 &actual.design.to_dense(),
1077 &expected.design.to_dense(),
1078 tolerance,
1079 );
1080 assert_eq!(
1081 actual.active_penalties.len(),
1082 expected.active_penalties.len()
1083 );
1084 for (observed, target) in actual
1085 .active_penalties
1086 .iter()
1087 .zip(expected.active_penalties.iter())
1088 {
1089 assert_eq!(observed.info.source, target.info.source);
1090 assert_eq!(observed.info.effective_rank, target.info.effective_rank);
1091 assert_eq!(observed.nullity, target.nullity);
1092 assert_matrix_close(&observed.matrix, &target.matrix, tolerance);
1093 }
1094 }
1095
1096 fn assert_local_geometry_close(
1097 actual: &LocalSmoothTermBuild,
1098 expected: &LocalSmoothTermBuild,
1099 tolerance: f64,
1100 context: &str,
1101 ) {
1102 assert_matrix_close_with_context(
1103 &actual.design.to_dense(),
1104 &expected.design.to_dense(),
1105 tolerance,
1106 &format!("{context} design"),
1107 );
1108 assert_eq!(
1109 actual.active_penalties.len(),
1110 expected.active_penalties.len()
1111 );
1112 for (observed, target) in actual
1113 .active_penalties
1114 .iter()
1115 .zip(expected.active_penalties.iter())
1116 {
1117 assert_eq!(observed.info.source, target.info.source);
1118 assert_eq!(observed.info.effective_rank, target.info.effective_rank);
1119 assert_eq!(observed.nullity, target.nullity);
1120 assert_matrix_close_with_context(
1121 &observed.matrix,
1122 &target.matrix,
1123 tolerance,
1124 &format!("{context} penalty source {:?}", target.info.source),
1125 );
1126 }
1127 }
1128
1129 fn scaled_cyclic_marginal(scale: f64) -> BSplineBasisSpec {
1130 BSplineBasisSpec {
1131 degree: 3,
1132 penalty_order: 2,
1133 knotspec: BSplineKnotSpec::PeriodicUniform {
1134 data_range: (-0.4 * scale, 1.6 * scale),
1135 num_basis: 9,
1136 },
1137 double_penalty: false,
1138 identifiability: BSplineIdentifiability::None,
1139 boundary: OneDimensionalBoundary::Cyclic {
1140 start: -0.4 * scale,
1141 end: 1.6 * scale,
1142 },
1143 boundary_conditions: BSplineBoundaryConditions::default(),
1144 }
1145 }
1146
1147 fn scaled_natural_marginal(scale: f64) -> BSplineBasisSpec {
1148 BSplineBasisSpec {
1149 degree: 3,
1150 penalty_order: 2,
1151 knotspec: BSplineKnotSpec::NaturalCubicRegression {
1152 knots: array![0.0, 0.17, 0.43, 0.71, 1.0].mapv(|value| value * scale),
1153 },
1154 double_penalty: false,
1155 identifiability: BSplineIdentifiability::None,
1156 boundary: OneDimensionalBoundary::Open,
1157 boundary_conditions: BSplineBoundaryConditions::default(),
1158 }
1159 }
1160
1161 #[test]
1162 fn declared_spline_builders_obey_design_penalty_and_null_scale_laws_2315() {
1163 let open_points = array![0.0, 0.05, 0.3, 0.55, 0.72, 0.95, 1.0];
1164 let cyclic_points = array![-2.4, -0.4, -0.13, 0.2, 1.1, 1.6, 3.6];
1165 let natural_points = array![-0.35, 0.0, 0.09, 0.43, 0.86, 1.0, 1.28];
1166
1167 for (family, points, build_spec) in [
1168 (
1169 BasisScaleFamily::OpenBSpline,
1170 open_points,
1171 open_marginal as fn(f64) -> BSplineBasisSpec,
1172 ),
1173 (
1174 BasisScaleFamily::CyclicBSpline,
1175 cyclic_points,
1176 scaled_cyclic_marginal as fn(f64) -> BSplineBasisSpec,
1177 ),
1178 (
1179 BasisScaleFamily::NaturalCubic,
1180 natural_points,
1181 scaled_natural_marginal as fn(f64) -> BSplineBasisSpec,
1182 ),
1183 ] {
1184 let reference_spec = build_spec(1.0);
1185 assert_eq!(bspline_contract(&reference_spec).family, family);
1186 let reference = build_bspline_basis_1d(points.view(), &reference_spec)
1187 .expect("reference scalar basis");
1188 for factor in [1e-9_f64, 1.0, 1e9] {
1189 let actual = build_bspline_basis_1d(
1190 points.mapv(|value| value * factor).view(),
1191 &build_spec(factor),
1192 )
1193 .expect("rescaled scalar basis");
1194 assert_build_geometry_close(&actual, &reference, 8e-10);
1195 for (observed, target) in actual
1196 .active_penalties
1197 .iter()
1198 .zip(reference.active_penalties.iter())
1199 {
1200 let rescaled = observed.info.normalization_scale * factor.powi(3);
1201 assert!(
1202 (rescaled - target.info.normalization_scale).abs()
1203 <= 2e-8 * (1.0 + target.info.normalization_scale.abs()),
1204 "{family:?} raw penalty normalizer violated a^-3 at factor {factor}"
1205 );
1206 }
1207 }
1208 }
1209
1210 let tensor_data = array![
1211 [0.00, 0.13],
1212 [0.08, 0.91],
1213 [0.22, 0.37],
1214 [0.41, 1.00],
1215 [0.58, 0.02],
1216 [0.73, 0.66],
1217 [0.89, 0.48],
1218 [1.00, 0.00]
1219 ];
1220 let build_tensor = |x_scale: f64, y_scale: f64| {
1221 let mut scaled = tensor_data.clone();
1222 scaled.column_mut(0).mapv_inplace(|value| value * x_scale);
1223 scaled.column_mut(1).mapv_inplace(|value| value * y_scale);
1224 let spec = TensorBSplineSpec {
1225 marginalspecs: vec![open_marginal(x_scale), open_marginal(y_scale)],
1226 periods: vec![None, None],
1227 double_penalty: false,
1228 identifiability: TensorBSplineIdentifiability::None,
1229 penalty_decomposition: TensorBSplinePenaltyDecomposition::MarginalKroneckerSum,
1230 };
1231 build_tensor_bspline_basis(scaled.view(), &[0, 1], &spec)
1232 .expect("rescaled tensor basis")
1233 };
1234 let reference = build_tensor(1.0, 1.0);
1235 for x_scale in [1e-9_f64, 1.0, 1e9] {
1236 for y_scale in [1e-9_f64, 1.0, 1e9] {
1237 let actual = build_tensor(x_scale, y_scale);
1238 assert_build_geometry_close(&actual, &reference, 1e-9);
1239 }
1240 }
1241 }
1242
1243 fn wrapper_basis(family: BasisScaleFamily, abscissa_scale: f64) -> SmoothBasisSpec {
1244 let inner = || Box::new(basis(0, abscissa_scale));
1245 let levels = vec![0.0_f64.to_bits(), 1.0_f64.to_bits(), 2.0_f64.to_bits()];
1246 let factor_smooth = |flavour| SmoothBasisSpec::FactorSmooth {
1247 spec: FactorSmoothSpec {
1248 continuous_cols: vec![0],
1249 group_col: 1,
1250 marginal: open_marginal(abscissa_scale),
1251 flavour,
1252 group_frozen_levels: Some(levels.clone()),
1253 frozen_global_orthogonality: None,
1254 },
1255 };
1256 match family {
1257 BasisScaleFamily::ByVariableNumeric => SmoothBasisSpec::ByVariable {
1258 inner: inner(),
1259 by_col: 1,
1260 kind: BySmoothKind::Numeric,
1261 by: ByVariableSpec::Numeric,
1262 },
1263 BasisScaleFamily::ByVariableFactor => SmoothBasisSpec::ByVariable {
1264 inner: inner(),
1265 by_col: 1,
1266 kind: BySmoothKind::Level {
1267 level_bits: 1.0_f64.to_bits(),
1268 },
1269 by: ByVariableSpec::Level {
1270 value_bits: 1.0_f64.to_bits(),
1271 label: "one".to_string(),
1272 },
1273 },
1274 BasisScaleFamily::FactorSumToZero => SmoothBasisSpec::FactorSumToZero {
1275 inner: inner(),
1276 by_col: 1,
1277 levels,
1278 frozen_global_orthogonality: None,
1279 },
1280 BasisScaleFamily::BySmoothNumeric => SmoothBasisSpec::BySmooth {
1281 smooth: inner(),
1282 by_kind: ByVarKind::Numeric { feature_col: 1 },
1283 },
1284 BasisScaleFamily::BySmoothFactor => SmoothBasisSpec::BySmooth {
1285 smooth: inner(),
1286 by_kind: ByVarKind::Factor {
1287 feature_col: 1,
1288 ordered: false,
1289 frozen_levels: Some(levels),
1290 },
1291 },
1292 BasisScaleFamily::FactorSmoothFs => factor_smooth(FactorSmoothFlavour::Fs {}),
1293 BasisScaleFamily::FactorSmoothSz => factor_smooth(FactorSmoothFlavour::Sz),
1294 BasisScaleFamily::FactorSmoothRe => factor_smooth(FactorSmoothFlavour::Re),
1295 BasisScaleFamily::OpenBSpline
1296 | BasisScaleFamily::CyclicBSpline
1297 | BasisScaleFamily::NaturalCubic
1298 | BasisScaleFamily::ThinPlate
1299 | BasisScaleFamily::SphereWahba
1300 | BasisScaleFamily::SphereHarmonic
1301 | BasisScaleFamily::ConstantCurvature
1302 | BasisScaleFamily::Matern
1303 | BasisScaleFamily::MeasureJet
1304 | BasisScaleFamily::PureDuchon
1305 | BasisScaleFamily::HybridDuchon
1306 | BasisScaleFamily::Pca
1307 | BasisScaleFamily::TensorBSpline => {
1308 panic!("{family:?} is not a wrapper fixture")
1309 }
1310 }
1311 }
1312
1313 fn build_local(data: &Array2<f64>, basis: SmoothBasisSpec) -> LocalSmoothTermBuild {
1314 build_single_local_smooth_term(
1315 data.view(),
1316 &SmoothTermSpec {
1317 frozen_parametric_residualization: None,
1318 name: "scale-contract-wrapper".to_string(),
1319 basis,
1320 shape: ShapeConstraint::None,
1321 joint_null_rotation: None,
1322 },
1323 &mut BasisWorkspace::new(),
1324 )
1325 .expect("wrapper scale fixture must build")
1326 }
1327
1328 #[test]
1329 fn every_wrapper_preserves_its_declared_inner_abscissa_pullback_2315() {
1330 let data = array![
1331 [0.00, 0.0],
1332 [0.08, 1.0],
1333 [0.17, 2.0],
1334 [0.26, 0.0],
1335 [0.35, 1.0],
1336 [0.44, 2.0],
1337 [0.56, 0.0],
1338 [0.65, 1.0],
1339 [0.74, 2.0],
1340 [0.83, 0.0],
1341 [0.92, 1.0],
1342 [1.00, 2.0]
1343 ];
1344 let invariant_families = [
1345 BasisScaleFamily::ByVariableNumeric,
1346 BasisScaleFamily::ByVariableFactor,
1347 BasisScaleFamily::FactorSumToZero,
1348 BasisScaleFamily::BySmoothNumeric,
1349 BasisScaleFamily::BySmoothFactor,
1350 BasisScaleFamily::FactorSmoothFs,
1351 BasisScaleFamily::FactorSmoothSz,
1352 ];
1353 for family in invariant_families {
1354 let reference = build_local(&data, wrapper_basis(family, 1.0));
1355 for factor in [1e-9_f64, 1.0, 1e9] {
1356 let mut scaled = data.clone();
1357 scaled.column_mut(0).mapv_inplace(|value| factor * value);
1358 let actual = build_local(&scaled, wrapper_basis(family, factor));
1359 assert_local_geometry_close(
1360 &actual,
1361 &reference,
1362 2e-8,
1363 &format!("{family:?} factor={factor:e}"),
1364 );
1365 }
1366 }
1367
1368 let family = BasisScaleFamily::FactorSmoothRe;
1371 let reference = build_local(&data, wrapper_basis(family, 1.0));
1372 for factor in [1e-9_f64, 1.0, 1e9] {
1373 let mut scaled = data.clone();
1374 scaled.column_mut(0).mapv_inplace(|value| factor * value);
1375 let actual = build_local(&scaled, wrapper_basis(family, factor));
1376 let mut pulled_back = actual.design.to_dense();
1377 for slope_col in (1..pulled_back.ncols()).step_by(2) {
1378 pulled_back
1379 .column_mut(slope_col)
1380 .mapv_inplace(|value| value / factor);
1381 }
1382 assert_matrix_close(&pulled_back, &reference.design.to_dense(), 2e-9);
1383 assert_eq!(
1384 actual.active_penalties.len(),
1385 reference.active_penalties.len()
1386 );
1387 for (observed, target) in actual
1388 .active_penalties
1389 .iter()
1390 .zip(reference.active_penalties.iter())
1391 {
1392 assert_matrix_close(&observed.matrix, &target.matrix, 2e-10);
1393 assert_eq!(observed.nullity, target.nullity);
1394 }
1395 assert_eq!(
1396 joint_unpenalized_dim(actual.dim, &actual.active_penalties),
1397 0,
1398 "the combined random-intercept/slope penalty must be full rank"
1399 );
1400 }
1401 }
1402
1403 #[test]
1404 fn numeric_modulator_has_exact_degree_one_design_and_invariant_penalty_2315() {
1405 let data = array![
1406 [0.00, 0.4],
1407 [0.08, 0.7],
1408 [0.17, 1.1],
1409 [0.26, 0.8],
1410 [0.35, 1.4],
1411 [0.44, 0.6],
1412 [0.56, 1.2],
1413 [0.65, 0.9],
1414 [0.74, 1.5],
1415 [0.83, 0.5],
1416 [0.92, 1.3],
1417 [1.00, 1.0]
1418 ];
1419 for family in [
1420 BasisScaleFamily::ByVariableNumeric,
1421 BasisScaleFamily::BySmoothNumeric,
1422 ] {
1423 let reference = build_local(&data, wrapper_basis(family, 1.0));
1424 for factor in [1e-9_f64, 1.0, 1e9] {
1425 let mut scaled = data.clone();
1426 scaled.column_mut(1).mapv_inplace(|value| factor * value);
1427 let actual = build_local(&scaled, wrapper_basis(family, 1.0));
1428 let pulled_back = actual.design.to_dense().mapv(|value| value / factor);
1429 assert_matrix_close(&pulled_back, &reference.design.to_dense(), 2e-10);
1430 assert_eq!(
1431 actual.active_penalties.len(),
1432 reference.active_penalties.len()
1433 );
1434 for (observed, target) in actual
1435 .active_penalties
1436 .iter()
1437 .zip(reference.active_penalties.iter())
1438 {
1439 assert_matrix_close(&observed.matrix, &target.matrix, 2e-10);
1440 }
1441 }
1442 }
1443 }
1444
1445 #[test]
1446 fn euclidean_registry_frames_are_similarity_invariant_for_every_declared_family_2315() {
1447 let coordinates = array![
1448 [-0.9, 0.2],
1449 [-0.4, 0.8],
1450 [0.1, -0.6],
1451 [0.7, 0.4],
1452 [1.2, -0.1]
1453 ];
1454 let cases = [
1455 (BasisScaleFamily::ThinPlate, Some(0.7)),
1456 (BasisScaleFamily::Matern, Some(0.7)),
1457 (BasisScaleFamily::MeasureJet, Some(0.7)),
1458 (BasisScaleFamily::PureDuchon, None),
1459 (BasisScaleFamily::HybridDuchon, Some(0.7)),
1460 ];
1461 for (family, length_scale) in cases {
1462 let basis = zoo_basis(family);
1463 let contract = basis.scale_contract();
1464 assert_eq!(contract.family, family);
1465 let mut reference_centers = CenterStrategy::FarthestPoint { num_centers: 3 };
1466 let reference = contract
1467 .normalize_euclidean_frame(
1468 coordinates.clone(),
1469 None,
1470 length_scale,
1471 &mut reference_centers,
1472 )
1473 .expect("reference scale frame");
1474 for factor in [1e-9_f64, 1.0, 1e9] {
1475 let mut actual_centers = CenterStrategy::FarthestPoint { num_centers: 3 };
1476 let actual = contract
1477 .normalize_euclidean_frame(
1478 coordinates.mapv(|value| factor * value),
1479 None,
1480 length_scale.map(|ell| factor * ell),
1481 &mut actual_centers,
1482 )
1483 .expect("rescaled frame");
1484 assert_matrix_close(&actual.coordinates, &reference.coordinates, 3e-12);
1485 match (actual.length_scale, reference.length_scale) {
1486 (Some(observed), Some(target)) => {
1487 let (observed, target) =
1488 (observed.standardized_value(), target.standardized_value());
1489 assert!(
1490 (observed - target).abs() <= 3e-12 * (1.0 + target.abs()),
1491 "{family:?} effective range changed at factor {factor}: {observed} vs {target}"
1492 );
1493 }
1494 (None, None) => {}
1495 pair => panic!("{family:?} changed optional range shape: {pair:?}"),
1496 }
1497 }
1498 }
1499 }
1500
1501 #[test]
1502 fn euclidean_frame_scales_nested_resolved_centers_once_2623() {
1503 let coordinates = array![[-1.2, 0.4], [-0.3, 1.1], [0.6, -0.8], [1.7, 0.2]];
1504 let original_centers = array![[-1.2, 0.4], [0.6, -0.8], [1.7, 0.2]];
1505 let mut strategy = CenterStrategy::DuchonSpectral {
1506 knots: Box::new(CenterStrategy::UserProvided(original_centers.clone())),
1507 basis: crate::basis::DuchonSpectralBasis::Fresh { rank: 3 },
1508 };
1509 let contract = zoo_basis(BasisScaleFamily::PureDuchon).scale_contract();
1510 let fresh = contract
1511 .normalize_euclidean_frame(coordinates.clone(), None, None, &mut strategy)
1512 .expect("fresh Euclidean frame");
1513 let expected_centers = original_centers.mapv(|value| value / fresh.input_scale.get());
1514 let CenterStrategy::DuchonSpectral { knots, .. } = &strategy else {
1515 panic!("Duchon spectral strategy changed shape");
1516 };
1517 let CenterStrategy::UserProvided(fresh_centers) = knots.as_ref() else {
1518 panic!("Duchon spectral knot strategy changed shape");
1519 };
1520 assert_matrix_close(fresh_centers, &expected_centers, 0.0);
1521
1522 let frozen_centers = fresh_centers.clone();
1523 let replay = contract
1524 .normalize_euclidean_frame(coordinates, Some(fresh.input_scale), None, &mut strategy)
1525 .expect("frozen Euclidean replay frame");
1526 assert_matrix_close(&replay.coordinates, &fresh.coordinates, 0.0);
1527 let CenterStrategy::DuchonSpectral { knots, .. } = &strategy else {
1528 panic!("Duchon spectral strategy changed shape on replay");
1529 };
1530 let CenterStrategy::UserProvided(replay_centers) = knots.as_ref() else {
1531 panic!("Duchon spectral knot strategy changed shape on replay");
1532 };
1533 assert_matrix_close(replay_centers, &frozen_centers, 0.0);
1534 }
1535
1536 fn euclidean_basis(family: BasisScaleFamily, factor: f64) -> SmoothBasisSpec {
1537 let centers = CenterStrategy::FarthestPoint { num_centers: 8 };
1538 match family {
1539 BasisScaleFamily::ThinPlate => SmoothBasisSpec::ThinPlate {
1540 feature_cols: vec![0, 1],
1541 spec: ThinPlateBasisSpec {
1542 center_strategy: centers,
1543 periodic: None,
1544 length_scale: 0.55 * factor,
1545 double_penalty: false,
1546 identifiability: SpatialIdentifiability::None,
1547 radial_reparam: None,
1548 },
1549 input_scale: None,
1550 },
1551 BasisScaleFamily::Matern => SmoothBasisSpec::Matern {
1552 feature_cols: vec![0, 1],
1553 spec: MaternBasisSpec {
1554 center_strategy: centers,
1555 periodic: None,
1556 length_scale: crate::basis::MaternLengthScale::fixed(0.55 * factor),
1557 nu: MaternNu::ThreeHalves,
1558 include_intercept: false,
1559 double_penalty: false,
1560 identifiability: MaternIdentifiability::None,
1561 aniso_log_scales: None,
1562 },
1563 input_scale: None,
1564 },
1565 BasisScaleFamily::MeasureJet => SmoothBasisSpec::MeasureJet {
1566 feature_cols: vec![0, 1],
1567 spec: MeasureJetBasisSpec {
1568 center_strategy: centers,
1569 order_s: 1.5,
1570 alpha: 1.0,
1571 tau0: 1e-3,
1572 num_scales: 3,
1573 length_scale: 0.55 * factor,
1574 double_penalty: false,
1575 learn_length_scale: false,
1576 multiscale: false,
1577 identifiability: MeasureJetIdentifiability::CenterSumToZero,
1578 frozen_quadrature: None,
1579 },
1580 input_scale: None,
1581 },
1582 BasisScaleFamily::PureDuchon | BasisScaleFamily::HybridDuchon => {
1583 SmoothBasisSpec::Duchon {
1584 feature_cols: vec![0, 1],
1585 spec: DuchonBasisSpec {
1586 center_strategy: centers,
1587 periodic: None,
1588 length_scale: (family == BasisScaleFamily::HybridDuchon)
1589 .then_some(0.55 * factor),
1590 power: if family == BasisScaleFamily::HybridDuchon {
1591 1.0
1592 } else {
1593 0.0
1594 },
1595 nullspace_order: DuchonNullspaceOrder::Linear,
1596 identifiability: SpatialIdentifiability::None,
1597 aniso_log_scales: None,
1598 operator_penalties: DuchonOperatorPenaltySpec::all_disabled(),
1599 boundary: OneDimensionalBoundary::Open,
1600 radial_reparam: None,
1601 },
1602 input_scale: None,
1603 }
1604 }
1605 BasisScaleFamily::ByVariableNumeric
1606 | BasisScaleFamily::ByVariableFactor
1607 | BasisScaleFamily::FactorSumToZero
1608 | BasisScaleFamily::OpenBSpline
1609 | BasisScaleFamily::CyclicBSpline
1610 | BasisScaleFamily::NaturalCubic
1611 | BasisScaleFamily::BySmoothNumeric
1612 | BasisScaleFamily::BySmoothFactor
1613 | BasisScaleFamily::FactorSmoothFs
1614 | BasisScaleFamily::FactorSmoothSz
1615 | BasisScaleFamily::FactorSmoothRe
1616 | BasisScaleFamily::SphereWahba
1617 | BasisScaleFamily::SphereHarmonic
1618 | BasisScaleFamily::ConstantCurvature
1619 | BasisScaleFamily::Pca
1620 | BasisScaleFamily::TensorBSpline => {
1621 panic!("{family:?} is not a Euclidean spatial fixture")
1622 }
1623 }
1624 }
1625
1626 #[test]
1627 fn every_euclidean_spatial_builder_obeys_its_registry_pullback_2315() {
1628 let data = array![
1629 [-0.95, -0.22],
1630 [-0.82, 0.31],
1631 [-0.63, -0.47],
1632 [-0.48, 0.62],
1633 [-0.27, -0.08],
1634 [-0.11, 0.41],
1635 [0.06, -0.55],
1636 [0.21, 0.17],
1637 [0.38, 0.73],
1638 [0.54, -0.31],
1639 [0.69, 0.49],
1640 [0.83, -0.66],
1641 [0.97, 0.04],
1642 [1.08, 0.58],
1643 [1.19, -0.39],
1644 [1.31, 0.26]
1645 ];
1646 for family in [
1647 BasisScaleFamily::ThinPlate,
1648 BasisScaleFamily::Matern,
1649 BasisScaleFamily::MeasureJet,
1650 BasisScaleFamily::PureDuchon,
1651 BasisScaleFamily::HybridDuchon,
1652 ] {
1653 let reference = build_local(&data, euclidean_basis(family, 1.0));
1654 for factor in [1e-9_f64, 1.0, 1e9] {
1655 let scaled = data.mapv(|value| factor * value);
1656 let actual = build_local(&scaled, euclidean_basis(family, factor));
1657 assert_local_geometry_close(
1658 &actual,
1659 &reference,
1660 3e-7,
1661 &format!("{family:?} factor={factor:e}"),
1662 );
1663 }
1664 }
1665 }
1666
1667 #[test]
1668 fn legacy_or_vector_isotropic_scale_wire_states_are_refused_2319() {
1669 let basis = zoo_basis(BasisScaleFamily::Matern);
1670 let mut legacy = serde_json::to_value(&basis).unwrap();
1671 let fields = legacy
1672 .get_mut("Matern")
1673 .and_then(serde_json::Value::as_object_mut)
1674 .unwrap();
1675 fields.remove("input_scale");
1676 fields.insert("input_scales".to_string(), serde_json::json!([1.0, 2.0]));
1677 assert!(serde_json::from_value::<SmoothBasisSpec>(legacy).is_err());
1678
1679 let mut vector = serde_json::to_value(&basis).unwrap();
1680 vector
1681 .get_mut("Matern")
1682 .and_then(serde_json::Value::as_object_mut)
1683 .unwrap()
1684 .insert("input_scale".to_string(), serde_json::json!([1.0, 1.0]));
1685 assert!(serde_json::from_value::<SmoothBasisSpec>(vector).is_err());
1686 }
1687
1688 #[test]
1689 fn frozen_spatial_spec_requires_the_realized_scalar_frame_2319() {
1690 let frozen = TermCollectionSpec {
1691 linear_terms: Vec::new(),
1692 random_effect_terms: Vec::new(),
1693 smooth_terms: vec![SmoothTermSpec {
1694 frozen_parametric_residualization: None,
1695 name: "matern".to_string(),
1696 basis: zoo_basis(BasisScaleFamily::Matern),
1697 shape: ShapeConstraint::None,
1698 joint_null_rotation: None,
1699 }],
1700 };
1701
1702 let error = frozen
1703 .validate_frozen("model payload")
1704 .expect_err("an unresolved isotropic frame is not frozen");
1705 assert!(error.contains("Matern input_scale is missing"), "{error}");
1706 }
1707}