Skip to main content

rlevo_core/
bounds.rs

1//! Validated closed range: the [`Bounds`] newtype and [`BoundsError`].
2//!
3//! A `(f32, f32)` bounds tuple is a standing hazard: [`f32::clamp`] panics when
4//! `min > max` (or on a `NaN` bound), and the alternative `x.max(lo).min(hi)`
5//! idiom silently collapses every value to `hi` when `lo > hi` — a wrong result
6//! instead of a loud one. Both failure modes require an *invalid* range to
7//! exist in the first place.
8//!
9//! [`Bounds`] removes that possibility. It is an inclusive range `[lo, hi]` that
10//! cannot be constructed inverted (`lo > hi`) or `NaN`, so every call site that
11//! holds a `Bounds` is clamp-safe by construction — the invariant travels with
12//! the value rather than being re-checked at each boundary. A degenerate
13//! single-point range (`lo == hi`) is deliberately **allowed**: clamping to a
14//! constant is well-defined, and every search-space consumer samples with
15//! `lo + (hi - lo) * r`, so a zero-width range is safe.
16//!
17//! An infinite endpoint is **permitted** — it expresses a one-sided range such
18//! as a `[0.7, ∞)` "healthy above this height" check, and [`f32::clamp`] is
19//! well-defined with an infinite bound (it panics only on `min > max` or `NaN`).
20//! The whole invariant is therefore exactly `lo <= hi`: a `NaN` endpoint makes
21//! that comparison `false` and is rejected, an infinite one does not.
22//!
23//! This complements the [`config`](crate::config) validation convention (ADR
24//! 0026): a config field of type `Bounds` is self-validating, so its
25//! [`Validate`](crate::config::Validate) impl no longer repeats a
26//! `config::ordered(…, "bounds", …)` check for that field. `Bounds` narrows one
27//! field's invariant into the type system; it does not discharge a config's
28//! other cross-field checks. See ADR 0027.
29//!
30//! # Examples
31//!
32//! ```
33//! use rlevo_core::bounds::Bounds;
34//!
35//! // `new` is for compile-time-known endpoints and panics on a bad literal.
36//! let b = Bounds::new(-1.0, 1.0);
37//! assert_eq!(b.clamp(2.5), 1.0);
38//! assert_eq!(b.clamp(-9.0), -1.0);
39//! assert_eq!(b.clamp(0.25), 0.25);
40//!
41//! // `try_new` is for runtime / user-supplied endpoints.
42//! assert!(Bounds::try_new(1.0, -1.0).is_err()); // inverted
43//! assert!(Bounds::try_new(0.0, f32::NAN).is_err()); // NaN
44//! assert!(Bounds::try_new(3.0, 3.0).is_ok()); // single point is fine
45//! ```
46
47use serde::{Deserialize, Serialize};
48
49/// An inclusive range `[lo, hi]` over `f32`, valid by construction.
50///
51/// A `Bounds` can never hold an inverted (`lo > hi`) or `NaN` endpoint: every
52/// constructor rejects those (the invariant is exactly `lo <= hi`, which a `NaN`
53/// fails). As a result [`clamp`](Self::clamp) is total and panic-free, and the
54/// silent `lo > hi` collapse of `x.max(lo).min(hi)` is unrepresentable wherever
55/// a `Bounds` is held. A degenerate single-point range (`lo == hi`) and a
56/// one-sided infinite range (e.g. `[0.7, ∞)`) are both permitted.
57///
58/// Construct with [`new`](Self::new) for literals (panics on an invalid pair) or
59/// [`try_new`](Self::try_new) for runtime data (returns [`BoundsError`]).
60/// `Deserialize` routes through [`try_new`] via [`TryFrom`], so a range loaded
61/// from a file cannot deserialize into an invalid `Bounds`.
62///
63/// [`try_new`]: Self::try_new
64#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
65#[serde(try_from = "(f32, f32)", into = "(f32, f32)")]
66pub struct Bounds {
67    lo: f32,
68    hi: f32,
69}
70
71impl Bounds {
72    /// Builds a range from compile-time-known endpoints, panicking on an
73    /// invalid pair.
74    ///
75    /// This is the constructor for literals and `Default`s — the bad value is
76    /// right at the call site, mirroring the documented builder-setter panic
77    /// exception of ADR 0026. Prefer [`try_new`](Self::try_new) for any value
78    /// derived from runtime or user-supplied data.
79    ///
80    /// # Panics
81    ///
82    /// Panics when `lo > hi` or either endpoint is `NaN`.
83    #[must_use]
84    pub const fn new(lo: f32, hi: f32) -> Self {
85        assert!(
86            lo <= hi,
87            "Bounds::new: invalid range (lo > hi or NaN endpoint)"
88        );
89        Self { lo, hi }
90    }
91
92    /// Builds a range from runtime / user-supplied endpoints.
93    ///
94    /// # Errors
95    ///
96    /// Returns [`BoundsError`] when `lo > hi` or either endpoint is `NaN`.
97    pub fn try_new(lo: f32, hi: f32) -> Result<Self, BoundsError> {
98        if lo <= hi {
99            Ok(Self { lo, hi })
100        } else {
101            Err(BoundsError { lo, hi })
102        }
103    }
104
105    /// The inclusive lower endpoint.
106    #[must_use]
107    pub const fn lo(&self) -> f32 {
108        self.lo
109    }
110
111    /// The inclusive upper endpoint.
112    #[must_use]
113    pub const fn hi(&self) -> f32 {
114        self.hi
115    }
116
117    /// The width of the range, `hi - lo` (always `>= 0`).
118    #[must_use]
119    pub const fn span(&self) -> f32 {
120        self.hi - self.lo
121    }
122
123    /// Clamps `x` into the range. Total and panic-free: the endpoints are
124    /// guaranteed finite with `lo <= hi`, so [`f32::clamp`] never panics here.
125    #[must_use]
126    pub fn clamp(&self, x: f32) -> f32 {
127        x.clamp(self.lo, self.hi)
128    }
129
130    /// Clamps every element of `xs` into the range in place.
131    pub fn clamp_slice(&self, xs: &mut [f32]) {
132        for x in xs.iter_mut() {
133            *x = self.clamp(*x);
134        }
135    }
136}
137
138impl TryFrom<(f32, f32)> for Bounds {
139    type Error = BoundsError;
140
141    fn try_from((lo, hi): (f32, f32)) -> Result<Self, Self::Error> {
142        Self::try_new(lo, hi)
143    }
144}
145
146impl From<Bounds> for (f32, f32) {
147    fn from(b: Bounds) -> Self {
148        (b.lo, b.hi)
149    }
150}
151
152/// The single way constructing a [`Bounds`] can fail.
153///
154/// Allocation-free and `Copy`, carrying the offending endpoints. Returned by
155/// [`Bounds::try_new`] / [`Bounds::try_from`] (and thus by `Deserialize`). A
156/// dedicated error rather than [`ConfigError`](crate::config::ConfigError)
157/// because construction has no config/field name to report — a config that
158/// builds a `Bounds` from its own scalar fields wraps this as needed.
159#[derive(Debug, Clone, Copy, PartialEq, thiserror::Error)]
160#[error("invalid bounds: lo {lo} must not exceed hi {hi} (and neither may be NaN)")]
161pub struct BoundsError {
162    /// The lower endpoint that was supplied.
163    pub lo: f32,
164    /// The upper endpoint that was supplied.
165    pub hi: f32,
166}
167
168#[cfg(test)]
169mod tests {
170    use super::{Bounds, BoundsError};
171
172    #[test]
173    fn new_accepts_ordered_and_single_point() {
174        assert_eq!(Bounds::new(-5.12, 5.12).lo(), -5.12);
175        assert_eq!(Bounds::new(-5.12, 5.12).hi(), 5.12);
176        let point = Bounds::new(3.0, 3.0);
177        assert_eq!(point.lo(), point.hi());
178        assert_eq!(point.span(), 0.0);
179    }
180
181    #[test]
182    #[should_panic(expected = "invalid range")]
183    fn new_panics_on_inverted() {
184        let _ = Bounds::new(1.0, -1.0);
185    }
186
187    #[test]
188    #[should_panic(expected = "invalid range")]
189    fn new_panics_on_nan() {
190        let _ = Bounds::new(0.0, f32::NAN);
191    }
192
193    #[test]
194    fn try_new_accepts_valid_including_one_sided_infinite() {
195        assert!(Bounds::try_new(-1.0, 1.0).is_ok());
196        assert!(Bounds::try_new(2.0, 2.0).is_ok()); // single point
197        // A one-sided infinite range is a valid healthy-interval bound.
198        assert!(Bounds::try_new(0.7, f32::INFINITY).is_ok());
199        assert!(Bounds::try_new(f32::NEG_INFINITY, 0.0).is_ok());
200    }
201
202    #[test]
203    fn try_new_rejects_inverted_and_nan() {
204        assert_eq!(
205            Bounds::try_new(1.0, -1.0),
206            Err(BoundsError { lo: 1.0, hi: -1.0 })
207        );
208        assert!(Bounds::try_new(f32::NAN, 1.0).is_err());
209        assert!(Bounds::try_new(0.0, f32::NAN).is_err());
210        assert!(Bounds::try_new(f32::NAN, f32::NAN).is_err());
211    }
212
213    #[test]
214    fn span_is_nonnegative_width() {
215        assert_eq!(Bounds::new(-2.0, 3.0).span(), 5.0);
216        assert_eq!(Bounds::new(4.0, 4.0).span(), 0.0);
217    }
218
219    #[test]
220    fn clamp_below_within_and_above() {
221        let b = Bounds::new(-1.0, 1.0);
222        assert_eq!(b.clamp(-9.0), -1.0);
223        assert_eq!(b.clamp(0.25), 0.25);
224        assert_eq!(b.clamp(2.5), 1.0);
225    }
226
227    #[test]
228    fn clamp_on_single_point_pins_to_constant() {
229        let b = Bounds::new(3.0, 3.0);
230        assert_eq!(b.clamp(0.0), 3.0);
231        assert_eq!(b.clamp(9.0), 3.0);
232    }
233
234    #[test]
235    fn clamp_slice_clamps_in_place() {
236        let b = Bounds::new(0.0, 10.0);
237        let mut xs = [-5.0, 3.0, 42.0, 10.0];
238        b.clamp_slice(&mut xs);
239        assert_eq!(xs, [0.0, 3.0, 10.0, 10.0]);
240    }
241
242    #[test]
243    fn tuple_round_trip_via_try_from_and_into() {
244        // This is exactly the path `#[serde(try_from, into)]` exercises, so a
245        // deserialized range is validated and cannot be inverted.
246        let b = Bounds::try_from((-2.0, 6.0)).unwrap();
247        let back: (f32, f32) = b.into();
248        assert_eq!(back, (-2.0, 6.0));
249        assert!(Bounds::try_from((6.0, -2.0)).is_err());
250    }
251
252    #[test]
253    fn error_display_names_both_endpoints() {
254        let s = BoundsError { lo: 5.0, hi: 1.0 }.to_string();
255        assert!(s.contains('5'));
256        assert!(s.contains('1'));
257        assert!(s.contains("must not exceed"));
258    }
259}