rlevo_core/probability.rs
1//! Validated unit-interval probability: the [`Probability`] newtype and
2//! [`ProbabilityError`].
3//!
4//! A mutation/crossover **rate** carried as a bare `f32` is a standing hazard.
5//! Every rate is consumed as a Bernoulli threshold — `u.lower_elem(p)`,
6//! `rng < p`, `rng >= rate` — and a `NaN` rate silently degenerates the
7//! operator: `x < NaN` is `false` for all `x`, so the mask goes all-false and
8//! the operator becomes a no-op or a full clone of one parent. Worse, the
9//! Cartesian-GP host path compares with the *opposite* sense (`rng >= rate`),
10//! so a `NaN` there mutates **every** gene — the same bad input, the opposite
11//! wrong answer. An out-of-range `p > 1` / `p < 0` saturates just as silently.
12//!
13//! [`Probability`] removes that possibility. It is a value in the closed unit
14//! interval `[0, 1]` that cannot be constructed `NaN`, `Inf`, negative, or
15//! above one: the whole invariant is `0.0 <= p <= 1.0`, which a `NaN`/`Inf`
16//! fails. Every operator that takes a `Probability` is therefore well-behaved
17//! by construction — the invariant travels with the value rather than being
18//! re-checked (or, today, silently skipped) at each boundary.
19//!
20//! This complements the [`config`](crate::config) validation convention (ADR
21//! 0026), exactly as [`Bounds`](crate::bounds::Bounds) does (ADR 0027): a config
22//! field of type `Probability` is self-validating, so its
23//! [`Validate`](crate::config::Validate) impl no longer repeats a
24//! `config::in_range(…, 0.0, 1.0, …)` check for that field. See ADR 0031.
25//!
26//! For a non-negative but *unbounded* rate (a step size or expansion factor
27//! such as BLX-α or Gaussian σ) see [`NonNegativeRate`](crate::rate::NonNegativeRate).
28//!
29//! # Examples
30//!
31//! ```
32//! use rlevo_core::probability::Probability;
33//!
34//! // `new` is for compile-time-known rates and panics on a bad literal.
35//! let p = Probability::new(0.5);
36//! assert_eq!(p.get(), 0.5);
37//!
38//! // `try_new` is for runtime / user-supplied rates.
39//! assert!(Probability::try_new(1.0).is_ok()); // endpoints are inclusive
40//! assert!(Probability::try_new(0.0).is_ok());
41//! assert!(Probability::try_new(1.5).is_err()); // above one
42//! assert!(Probability::try_new(-0.1).is_err()); // below zero
43//! assert!(Probability::try_new(f32::NAN).is_err()); // NaN
44//! ```
45
46use serde::{Deserialize, Serialize};
47
48/// A probability in the closed unit interval `[0, 1]`, valid by construction.
49///
50/// A `Probability` can never hold a `NaN`, an infinity, a negative, or a value
51/// above one: every constructor enforces `0.0 <= p <= 1.0` (a `NaN`/`Inf` fails
52/// the comparison). As a result the silent all-false-mask degeneracy of
53/// `u.lower_elem(p)` / `rng < p` on a bad rate is unrepresentable wherever a
54/// `Probability` is held.
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 [`ProbabilityError`]).
58/// `Deserialize` routes through [`try_new`] via [`TryFrom`], so a rate loaded
59/// from a file cannot deserialize into an out-of-range `Probability`.
60///
61/// [`try_new`]: Self::try_new
62#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
63#[serde(try_from = "f32", into = "f32")]
64pub struct Probability(f32);
65
66impl Probability {
67 /// Builds a probability from a compile-time-known value, panicking on an
68 /// invalid one.
69 ///
70 /// This is the constructor for literals and `Default`s — the bad value is
71 /// right at the call site, mirroring the documented builder-setter panic
72 /// exception of ADR 0026. Prefer [`try_new`](Self::try_new) for any value
73 /// derived from runtime or user-supplied data.
74 ///
75 /// # Panics
76 ///
77 /// Panics when `p` is outside `[0, 1]` (including `NaN` or infinite).
78 #[must_use]
79 pub const fn new(p: f32) -> Self {
80 assert!(
81 p >= 0.0 && p <= 1.0,
82 "Probability::new: value outside [0, 1] (or NaN)"
83 );
84 Self(p)
85 }
86
87 /// Builds a probability from a runtime / user-supplied value.
88 ///
89 /// # Errors
90 ///
91 /// Returns [`ProbabilityError`] when `p` is outside `[0, 1]` (including
92 /// `NaN` or infinite).
93 pub fn try_new(p: f32) -> Result<Self, ProbabilityError> {
94 // `(0.0..=1.0).contains(&p)` is false for NaN and out-of-range values.
95 if (0.0..=1.0).contains(&p) {
96 Ok(Self(p))
97 } else {
98 Err(ProbabilityError { got: p })
99 }
100 }
101
102 /// The wrapped probability, guaranteed to lie in `[0, 1]`.
103 #[must_use]
104 pub const fn get(self) -> f32 {
105 self.0
106 }
107}
108
109impl TryFrom<f32> for Probability {
110 type Error = ProbabilityError;
111
112 fn try_from(p: f32) -> Result<Self, Self::Error> {
113 Self::try_new(p)
114 }
115}
116
117impl From<Probability> for f32 {
118 fn from(p: Probability) -> Self {
119 p.0
120 }
121}
122
123/// The single way constructing a [`Probability`] can fail.
124///
125/// Allocation-free and `Copy`, carrying the offending value. Returned by
126/// [`Probability::try_new`] / [`Probability::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 `Probability` from its
130/// own scalar field wraps this as needed (ADR 0027 §5).
131#[derive(Debug, Clone, Copy, PartialEq, thiserror::Error)]
132#[error(
133 "invalid probability: {got} is outside the closed unit interval [0, 1] (and must not be NaN)"
134)]
135pub struct ProbabilityError {
136 /// The value that was supplied.
137 pub got: f32,
138}
139
140#[cfg(test)]
141mod tests {
142 use super::{Probability, ProbabilityError};
143
144 #[test]
145 fn new_accepts_interval_including_endpoints() {
146 assert_eq!(Probability::new(0.0).get(), 0.0);
147 assert_eq!(Probability::new(1.0).get(), 1.0);
148 assert_eq!(Probability::new(0.25).get(), 0.25);
149 }
150
151 #[test]
152 #[should_panic(expected = "outside [0, 1]")]
153 fn new_panics_above_one() {
154 let _ = Probability::new(1.5);
155 }
156
157 #[test]
158 #[should_panic(expected = "outside [0, 1]")]
159 fn new_panics_below_zero() {
160 let _ = Probability::new(-0.1);
161 }
162
163 #[test]
164 #[should_panic(expected = "outside [0, 1]")]
165 fn new_panics_on_nan() {
166 let _ = Probability::new(f32::NAN);
167 }
168
169 #[test]
170 fn try_new_accepts_valid() {
171 assert!(Probability::try_new(0.0).is_ok());
172 assert!(Probability::try_new(1.0).is_ok());
173 assert!(Probability::try_new(0.5).is_ok());
174 }
175
176 #[test]
177 fn try_new_rejects_out_of_range_nan_and_inf() {
178 assert_eq!(
179 Probability::try_new(1.5),
180 Err(ProbabilityError { got: 1.5 })
181 );
182 assert!(Probability::try_new(-0.1).is_err());
183 assert!(Probability::try_new(f32::NAN).is_err());
184 assert!(Probability::try_new(f32::INFINITY).is_err());
185 assert!(Probability::try_new(f32::NEG_INFINITY).is_err());
186 }
187
188 #[test]
189 fn round_trip_via_try_from_and_into() {
190 // This is exactly the path `#[serde(try_from, into)]` exercises, so a
191 // deserialized rate is validated and cannot be out of range.
192 let p = Probability::try_from(0.7).unwrap();
193 let back: f32 = p.into();
194 assert_eq!(back, 0.7);
195 assert!(Probability::try_from(2.0).is_err());
196 }
197
198 #[test]
199 fn error_display_names_the_value() {
200 let s = ProbabilityError { got: 1.5 }.to_string();
201 assert!(s.contains("1.5"));
202 assert!(s.contains("[0, 1]"));
203 }
204}