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}