Skip to main content

rlevo_core/
rate.rs

1//! Validated non-negative rate: the [`NonNegativeRate`] newtype and
2//! [`NonNegativeRateError`].
3//!
4//! Some evolutionary-operator parameters are non-negative magnitudes that are
5//! *not* probabilities — they have no upper bound of one. BLX-α's expansion
6//! factor `α` (conventionally `0.5`, but legitimately larger) widens the
7//! crossover sampling box, and Gaussian mutation's step size `σ` scales the
8//! injected noise. Carried as bare `f32`s, a `NaN` or `Inf` value poisons the
9//! whole offspring tensor: `diff * NaN` and `noise * Inf` propagate silently
10//! into every gene with no error.
11//!
12//! [`NonNegativeRate`] removes that possibility. It is a **finite**, non-negative
13//! `f32` — the invariant is `is_finite() && r >= 0.0`, which rejects `NaN`,
14//! `±∞`, and negatives while permitting any finite magnitude (including `0.0`,
15//! which is a well-defined "no expansion / no mutation" rate). The invariant
16//! travels with the value, so an operator that takes a `NonNegativeRate` cannot
17//! be handed a poisoning scalar.
18//!
19//! This complements the [`config`](crate::config) validation convention (ADR
20//! 0026) exactly as [`Bounds`](crate::bounds::Bounds) does (ADR 0027): a config
21//! field of type `NonNegativeRate` is self-validating, so its
22//! [`Validate`](crate::config::Validate) impl no longer repeats a
23//! `config::in_range(…, 0.0, ∞, …)` check for that field. See ADR 0031.
24//!
25//! For a rate that must additionally be bounded above by one (a Bernoulli
26//! probability) see [`Probability`](crate::probability::Probability).
27//!
28//! # Examples
29//!
30//! ```
31//! use rlevo_core::rate::NonNegativeRate;
32//!
33//! // `new` is for compile-time-known rates and panics on a bad literal.
34//! let sigma = NonNegativeRate::new(0.3);
35//! assert_eq!(sigma.get(), 0.3);
36//!
37//! // Unlike a probability, values above one are fine.
38//! assert!(NonNegativeRate::try_new(2.5).is_ok());
39//! assert!(NonNegativeRate::try_new(0.0).is_ok()); // zero is a valid rate
40//! assert!(NonNegativeRate::try_new(-0.1).is_err()); // negative
41//! assert!(NonNegativeRate::try_new(f32::NAN).is_err()); // NaN
42//! assert!(NonNegativeRate::try_new(f32::INFINITY).is_err()); // infinite
43//! ```
44
45use serde::{Deserialize, Serialize};
46
47/// A finite, non-negative `f32` rate, valid by construction.
48///
49/// A `NonNegativeRate` can never hold a `NaN`, an infinity, or a negative: the
50/// invariant is `is_finite() && r >= 0.0`. Unlike
51/// [`Probability`](crate::probability::Probability) it has **no** upper bound —
52/// it is the right type for an unbounded magnitude such as BLX-α's expansion
53/// factor or Gaussian mutation's σ, where the hazard is a `NaN`/`Inf` poisoning
54/// the offspring tensor rather than an out-of-`[0,1]` saturation.
55///
56/// Construct with [`new`](Self::new) for literals (panics on an invalid value)
57/// or [`try_new`](Self::try_new) for runtime data (returns
58/// [`NonNegativeRateError`]). `Deserialize` routes through [`try_new`] via
59/// [`TryFrom`], so a rate loaded from a file cannot deserialize into a
60/// non-finite or negative `NonNegativeRate`.
61///
62/// [`try_new`]: Self::try_new
63#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
64#[serde(try_from = "f32", into = "f32")]
65pub struct NonNegativeRate(f32);
66
67impl NonNegativeRate {
68    /// Builds a rate from a compile-time-known value, panicking on an invalid
69    /// one.
70    ///
71    /// This is the constructor for literals and `Default`s — the bad value is
72    /// right at the call site, mirroring the documented builder-setter panic
73    /// exception of ADR 0026. Prefer [`try_new`](Self::try_new) for any value
74    /// derived from runtime or user-supplied data.
75    ///
76    /// # Panics
77    ///
78    /// Panics when `r` is negative, `NaN`, or infinite.
79    #[must_use]
80    pub const fn new(r: f32) -> Self {
81        assert!(
82            r.is_finite() && r >= 0.0,
83            "NonNegativeRate::new: value must be finite and >= 0 (NaN/Inf/negative rejected)"
84        );
85        Self(r)
86    }
87
88    /// Builds a rate from a runtime / user-supplied value.
89    ///
90    /// # Errors
91    ///
92    /// Returns [`NonNegativeRateError`] when `r` is negative, `NaN`, or
93    /// infinite.
94    pub fn try_new(r: f32) -> Result<Self, NonNegativeRateError> {
95        if r.is_finite() && r >= 0.0 {
96            Ok(Self(r))
97        } else {
98            Err(NonNegativeRateError { got: r })
99        }
100    }
101
102    /// The wrapped rate, guaranteed finite and `>= 0`.
103    #[must_use]
104    pub const fn get(self) -> f32 {
105        self.0
106    }
107}
108
109impl TryFrom<f32> for NonNegativeRate {
110    type Error = NonNegativeRateError;
111
112    fn try_from(r: f32) -> Result<Self, Self::Error> {
113        Self::try_new(r)
114    }
115}
116
117impl From<NonNegativeRate> for f32 {
118    fn from(r: NonNegativeRate) -> Self {
119        r.0
120    }
121}
122
123/// The single way constructing a [`NonNegativeRate`] can fail.
124///
125/// Allocation-free and `Copy`, carrying the offending value. Returned by
126/// [`NonNegativeRate::try_new`] / [`NonNegativeRate::try_from`] (and thus by
127/// `Deserialize`). A dedicated error rather than
128/// [`ConfigError`](crate::config::ConfigError) because construction has no
129/// config/field name to report — a config that builds a `NonNegativeRate` from
130/// its own scalar field wraps this as needed (ADR 0027 §5).
131#[derive(Debug, Clone, Copy, PartialEq, thiserror::Error)]
132#[error("invalid rate: {got} must be finite and >= 0 (NaN, infinite, and negative are rejected)")]
133pub struct NonNegativeRateError {
134    /// The value that was supplied.
135    pub got: f32,
136}
137
138#[cfg(test)]
139mod tests {
140    use super::{NonNegativeRate, NonNegativeRateError};
141
142    #[test]
143    fn new_accepts_zero_and_large_finite() {
144        assert_eq!(NonNegativeRate::new(0.0).get(), 0.0);
145        assert_eq!(NonNegativeRate::new(0.5).get(), 0.5);
146        assert_eq!(NonNegativeRate::new(42.0).get(), 42.0); // above one is fine
147    }
148
149    #[test]
150    #[should_panic(expected = "finite and >= 0")]
151    fn new_panics_on_negative() {
152        let _ = NonNegativeRate::new(-0.1);
153    }
154
155    #[test]
156    #[should_panic(expected = "finite and >= 0")]
157    fn new_panics_on_nan() {
158        let _ = NonNegativeRate::new(f32::NAN);
159    }
160
161    #[test]
162    #[should_panic(expected = "finite and >= 0")]
163    fn new_panics_on_infinite() {
164        let _ = NonNegativeRate::new(f32::INFINITY);
165    }
166
167    #[test]
168    fn try_new_accepts_valid_including_large() {
169        assert!(NonNegativeRate::try_new(0.0).is_ok());
170        assert!(NonNegativeRate::try_new(0.3).is_ok());
171        assert!(NonNegativeRate::try_new(100.0).is_ok());
172    }
173
174    #[test]
175    fn try_new_rejects_negative_nan_and_inf() {
176        assert_eq!(
177            NonNegativeRate::try_new(-1.0),
178            Err(NonNegativeRateError { got: -1.0 })
179        );
180        assert!(NonNegativeRate::try_new(f32::NAN).is_err());
181        assert!(NonNegativeRate::try_new(f32::INFINITY).is_err());
182        assert!(NonNegativeRate::try_new(f32::NEG_INFINITY).is_err());
183    }
184
185    #[test]
186    fn round_trip_via_try_from_and_into() {
187        // This is exactly the path `#[serde(try_from, into)]` exercises, so a
188        // deserialized rate is validated and cannot be non-finite/negative.
189        let r = NonNegativeRate::try_from(0.3).unwrap();
190        let back: f32 = r.into();
191        assert_eq!(back, 0.3);
192        assert!(NonNegativeRate::try_from(f32::INFINITY).is_err());
193    }
194
195    #[test]
196    fn error_display_names_the_value() {
197        let s = NonNegativeRateError { got: -2.0 }.to_string();
198        assert!(s.contains("-2"));
199        assert!(s.contains("finite"));
200    }
201}