Skip to main content

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}