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}