Skip to main content

gam_terms/
isotropic_scale.rs

1//! A scalar coordinate scale for isotropic Euclidean smooths.
2//!
3//! Isotropic kernels admit one uniform change of coordinate units.  Encoding
4//! that scale as a vector made anisotropic states representable in the frozen
5//! model even though the kernel and its scale contract require one value in
6//! every direction.  `IsotropicScale` makes the geometric invariant explicit:
7//! anisotropy belongs to the separate ARD parameters, never to this frame.
8
9use ndarray::Array2;
10use serde::{Deserialize, Serialize};
11use std::fmt;
12
13/// A positive, finite scalar whose reciprocal is also representable.
14///
15/// The field is private so construction, deserialization, and every frozen
16/// replay path enforce the same invariant.
17#[repr(transparent)]
18#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
19#[serde(try_from = "f64", into = "f64")]
20pub struct IsotropicScale(f64);
21
22impl IsotropicScale {
23    pub const ONE: Self = Self(1.0);
24
25    pub fn new(value: f64) -> Result<Self, IsotropicScaleError> {
26        if value.is_finite() && value > 0.0 && value.recip().is_finite() {
27            Ok(Self(value))
28        } else {
29            Err(IsotropicScaleError { value })
30        }
31    }
32
33    pub fn get(self) -> f64 {
34        self.0
35    }
36
37    pub fn reciprocal(self) -> f64 {
38        self.0.recip()
39    }
40
41    pub fn to_bits(self) -> u64 {
42        self.0.to_bits()
43    }
44
45    /// Convert a coordinate-valued scalar from original to standardized units.
46    pub fn to_standardized_units(self, value: f64) -> f64 {
47        value * self.reciprocal()
48    }
49
50    /// Apply the uniform coordinate pullback in place.
51    pub fn standardize(self, coordinates: &mut Array2<f64>) {
52        let reciprocal = self.reciprocal();
53        coordinates.mapv_inplace(|value| value * reciprocal);
54    }
55}
56
57impl TryFrom<f64> for IsotropicScale {
58    type Error = IsotropicScaleError;
59
60    fn try_from(value: f64) -> Result<Self, Self::Error> {
61        Self::new(value)
62    }
63}
64
65impl From<IsotropicScale> for f64 {
66    fn from(value: IsotropicScale) -> Self {
67        value.get()
68    }
69}
70
71#[derive(Clone, Copy, Debug, PartialEq)]
72pub struct IsotropicScaleError {
73    value: f64,
74}
75
76impl fmt::Display for IsotropicScaleError {
77    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
78        write!(
79            formatter,
80            "isotropic scale must be positive and finite with a finite reciprocal, got {}",
81            self.value
82        )
83    }
84}
85
86impl std::error::Error for IsotropicScaleError {}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91
92    #[test]
93    fn construction_enforces_the_operational_invariant() {
94        assert_eq!(IsotropicScale::new(1.0), Ok(IsotropicScale::ONE));
95        assert!(IsotropicScale::new(f64::MIN_POSITIVE).is_ok());
96        assert!(IsotropicScale::new(0.0).is_err());
97        assert!(IsotropicScale::new(-1.0).is_err());
98        assert!(IsotropicScale::new(f64::NAN).is_err());
99        assert!(IsotropicScale::new(f64::INFINITY).is_err());
100        assert!(IsotropicScale::new(f64::from_bits(1)).is_err());
101    }
102
103    #[test]
104    fn wire_representation_is_a_checked_scalar() {
105        let encoded = serde_json::to_string(&IsotropicScale::new(2.5).unwrap()).unwrap();
106        assert_eq!(encoded, "2.5");
107        assert_eq!(
108            serde_json::from_str::<IsotropicScale>(&encoded).unwrap(),
109            IsotropicScale::new(2.5).unwrap()
110        );
111        assert!(serde_json::from_str::<IsotropicScale>("[2.5,2.5]").is_err());
112        assert!(serde_json::from_str::<IsotropicScale>("0.0").is_err());
113        assert!(serde_json::from_str::<IsotropicScale>("-1.0").is_err());
114    }
115}