Skip to main content

fugue_evo/genome/
bounds.rs

1//! Bounds for genome values
2//!
3//! This module provides bounds types for constraining genome values.
4
5use serde::{Deserialize, Serialize};
6
7use crate::error::GenomeError;
8
9/// Bounds for a single dimension
10#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
11pub struct Bounds {
12    /// Lower bound (inclusive)
13    pub min: f64,
14    /// Upper bound (inclusive)
15    pub max: f64,
16}
17
18impl Bounds {
19    /// Create new bounds.
20    ///
21    /// A degenerate (`min == max`) bound is allowed and represents a dimension
22    /// pinned to a single constant value; see [`normalize`](Self::normalize) and
23    /// [`denormalize`](Self::denormalize) for how such bounds behave.
24    ///
25    /// # Panics
26    /// Panics if `min > max`. Use [`try_new`](Self::try_new) for a fallible
27    /// constructor that returns a [`GenomeError`] instead of panicking.
28    pub fn new(min: f64, max: f64) -> Self {
29        Self::try_new(min, max).unwrap_or_else(|e| panic!("{e}"))
30    }
31
32    /// Fallibly create new bounds, rejecting `min > max`.
33    ///
34    /// Returns `Err(GenomeError::InvalidStructure)` when `min > max`. A
35    /// degenerate (`min == max`) bound is permitted.
36    pub fn try_new(min: f64, max: f64) -> Result<Self, GenomeError> {
37        // Reject `min > max` and any NaN operand. `partial_cmp` returns `None`
38        // when either side is NaN, so NaN bounds are rejected exactly as the
39        // prior `!(min <= max)` guard did (a NaN comparison is always false),
40        // matching the invariant the original `assert!(min <= max)` held.
41        if matches!(
42            min.partial_cmp(&max),
43            None | Some(std::cmp::Ordering::Greater)
44        ) {
45            return Err(GenomeError::InvalidStructure(format!(
46                "Invalid bounds: min ({min}) must be <= max ({max})"
47            )));
48        }
49        Ok(Self { min, max })
50    }
51
52    /// Create symmetric bounds centered at 0
53    pub fn symmetric(half_width: f64) -> Self {
54        Self::new(-half_width, half_width)
55    }
56
57    /// Create unit bounds [0, 1]
58    pub fn unit() -> Self {
59        Self::new(0.0, 1.0)
60    }
61
62    /// Get the range (max - min)
63    pub fn range(&self) -> f64 {
64        self.max - self.min
65    }
66
67    /// Get the center point
68    pub fn center(&self) -> f64 {
69        (self.min + self.max) / 2.0
70    }
71
72    /// Check if a value is within bounds
73    pub fn contains(&self, value: f64) -> bool {
74        value >= self.min && value <= self.max
75    }
76
77    /// Clamp a value to be within bounds
78    pub fn clamp(&self, value: f64) -> f64 {
79        value.clamp(self.min, self.max)
80    }
81
82    /// Normalize a value from bounds to `[0, 1]`.
83    ///
84    /// For a degenerate (`min == max`) bound the range is zero, so there is no
85    /// meaningful position within `[0, 1]`; this returns `0.5` (the midpoint)
86    /// rather than dividing by zero and producing `NaN`/`±inf`.
87    pub fn normalize(&self, value: f64) -> f64 {
88        let range = self.range();
89        if range <= 0.0 {
90            return 0.5;
91        }
92        (value - self.min) / range
93    }
94
95    /// Denormalize a value from `[0, 1]` to bounds.
96    ///
97    /// For a degenerate (`min == max`) bound the range is zero, so every input
98    /// maps to the single legal value `min`.
99    pub fn denormalize(&self, value: f64) -> f64 {
100        let range = self.range();
101        if range <= 0.0 {
102            return self.min;
103        }
104        self.min + value * range
105    }
106}
107
108impl Default for Bounds {
109    fn default() -> Self {
110        Self::symmetric(5.12) // Common default for optimization benchmarks
111    }
112}
113
114impl From<(f64, f64)> for Bounds {
115    fn from((min, max): (f64, f64)) -> Self {
116        Self::new(min, max)
117    }
118}
119
120/// Multi-dimensional bounds
121#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
122pub struct MultiBounds {
123    /// Bounds for each dimension
124    pub bounds: Vec<Bounds>,
125}
126
127impl MultiBounds {
128    /// Create new multi-dimensional bounds
129    pub fn new(bounds: Vec<Bounds>) -> Self {
130        Self { bounds }
131    }
132
133    /// Create uniform bounds for all dimensions
134    pub fn uniform(bound: Bounds, dimension: usize) -> Self {
135        Self {
136            bounds: vec![bound; dimension],
137        }
138    }
139
140    /// Create symmetric bounds for all dimensions
141    pub fn symmetric(half_width: f64, dimension: usize) -> Self {
142        Self::uniform(Bounds::symmetric(half_width), dimension)
143    }
144
145    /// Get number of dimensions
146    pub fn dimension(&self) -> usize {
147        self.bounds.len()
148    }
149
150    /// Get bounds for a specific dimension
151    pub fn get(&self, index: usize) -> Option<&Bounds> {
152        self.bounds.get(index)
153    }
154
155    /// Clamp a vector to be within bounds
156    pub fn clamp_vec(&self, values: &mut [f64]) {
157        for (i, value) in values.iter_mut().enumerate() {
158            if let Some(b) = self.bounds.get(i) {
159                *value = b.clamp(*value);
160            }
161        }
162    }
163
164    /// Check if all values are within bounds
165    pub fn contains_vec(&self, values: &[f64]) -> bool {
166        values
167            .iter()
168            .enumerate()
169            .all(|(i, &v)| self.bounds.get(i).is_some_and(|b| b.contains(v)))
170    }
171}
172
173impl FromIterator<Bounds> for MultiBounds {
174    fn from_iter<I: IntoIterator<Item = Bounds>>(iter: I) -> Self {
175        Self {
176            bounds: iter.into_iter().collect(),
177        }
178    }
179}
180
181impl FromIterator<(f64, f64)> for MultiBounds {
182    fn from_iter<I: IntoIterator<Item = (f64, f64)>>(iter: I) -> Self {
183        Self {
184            bounds: iter.into_iter().map(Bounds::from).collect(),
185        }
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    #[test]
194    fn test_bounds_new() {
195        let b = Bounds::new(-5.0, 5.0);
196        assert_eq!(b.min, -5.0);
197        assert_eq!(b.max, 5.0);
198    }
199
200    #[test]
201    #[should_panic(expected = "Invalid bounds")]
202    fn test_bounds_invalid() {
203        Bounds::new(5.0, -5.0);
204    }
205
206    #[test]
207    fn test_bounds_symmetric() {
208        let b = Bounds::symmetric(3.0);
209        assert_eq!(b.min, -3.0);
210        assert_eq!(b.max, 3.0);
211    }
212
213    #[test]
214    fn test_bounds_unit() {
215        let b = Bounds::unit();
216        assert_eq!(b.min, 0.0);
217        assert_eq!(b.max, 1.0);
218    }
219
220    #[test]
221    fn test_bounds_range() {
222        let b = Bounds::new(-5.0, 5.0);
223        assert_eq!(b.range(), 10.0);
224    }
225
226    #[test]
227    fn test_bounds_center() {
228        let b = Bounds::new(-2.0, 6.0);
229        assert_eq!(b.center(), 2.0);
230    }
231
232    #[test]
233    fn test_bounds_contains() {
234        let b = Bounds::new(-5.0, 5.0);
235        assert!(b.contains(0.0));
236        assert!(b.contains(-5.0));
237        assert!(b.contains(5.0));
238        assert!(!b.contains(-5.1));
239        assert!(!b.contains(5.1));
240    }
241
242    #[test]
243    fn test_bounds_clamp() {
244        let b = Bounds::new(-5.0, 5.0);
245        assert_eq!(b.clamp(0.0), 0.0);
246        assert_eq!(b.clamp(-10.0), -5.0);
247        assert_eq!(b.clamp(10.0), 5.0);
248    }
249
250    #[test]
251    fn test_bounds_normalize() {
252        let b = Bounds::new(0.0, 10.0);
253        assert_eq!(b.normalize(0.0), 0.0);
254        assert_eq!(b.normalize(5.0), 0.5);
255        assert_eq!(b.normalize(10.0), 1.0);
256    }
257
258    #[test]
259    fn test_bounds_denormalize() {
260        let b = Bounds::new(0.0, 10.0);
261        assert_eq!(b.denormalize(0.0), 0.0);
262        assert_eq!(b.denormalize(0.5), 5.0);
263        assert_eq!(b.denormalize(1.0), 10.0);
264    }
265
266    #[test]
267    fn test_bounds_try_new_rejects_min_gt_max() {
268        // regression: EV-56 — the fallible constructor rejects min > max.
269        let result = Bounds::try_new(5.0, -5.0);
270        assert!(result.is_err());
271        assert!(Bounds::try_new(-5.0, 5.0).is_ok());
272        // Degenerate min == max is allowed.
273        assert!(Bounds::try_new(3.0, 3.0).is_ok());
274    }
275
276    #[test]
277    fn test_bounds_try_new_rejects_nan() {
278        // regression: NaN bounds must be rejected. `partial_cmp` yields `None`
279        // for a NaN operand, preserving the previous `!(min <= max)` behavior.
280        assert!(Bounds::try_new(f64::NAN, 5.0).is_err());
281        assert!(Bounds::try_new(-5.0, f64::NAN).is_err());
282        assert!(Bounds::try_new(f64::NAN, f64::NAN).is_err());
283    }
284
285    #[test]
286    fn test_bounds_degenerate_normalize_denormalize() {
287        // regression: EV-56 — degenerate (min == max) bounds must not divide by
288        // zero. normalize() previously returned NaN (0.0/0.0) for value == min.
289        let b = Bounds::new(3.0, 3.0);
290        assert_eq!(b.range(), 0.0);
291
292        // normalize returns the midpoint 0.5 for any input (documented behavior),
293        // and crucially is finite (never NaN / inf).
294        assert_eq!(b.normalize(3.0), 0.5);
295        assert!(b.normalize(3.0).is_finite());
296        assert_eq!(b.normalize(100.0), 0.5);
297        assert!(b.normalize(100.0).is_finite());
298
299        // denormalize returns the single legal value min for any input.
300        assert_eq!(b.denormalize(0.0), 3.0);
301        assert_eq!(b.denormalize(0.5), 3.0);
302        assert_eq!(b.denormalize(1.0), 3.0);
303    }
304
305    #[test]
306    fn test_multi_bounds_uniform() {
307        let mb = MultiBounds::symmetric(5.0, 3);
308        assert_eq!(mb.dimension(), 3);
309        assert_eq!(mb.get(0), Some(&Bounds::symmetric(5.0)));
310        assert_eq!(mb.get(1), Some(&Bounds::symmetric(5.0)));
311        assert_eq!(mb.get(2), Some(&Bounds::symmetric(5.0)));
312        assert_eq!(mb.get(3), None);
313    }
314
315    #[test]
316    fn test_multi_bounds_clamp_vec() {
317        let mb = MultiBounds::symmetric(5.0, 3);
318        let mut values = vec![-10.0, 0.0, 10.0];
319        mb.clamp_vec(&mut values);
320        assert_eq!(values, vec![-5.0, 0.0, 5.0]);
321    }
322
323    #[test]
324    fn test_multi_bounds_contains_vec() {
325        let mb = MultiBounds::symmetric(5.0, 3);
326        assert!(mb.contains_vec(&[0.0, 0.0, 0.0]));
327        assert!(mb.contains_vec(&[-5.0, 5.0, 0.0]));
328        assert!(!mb.contains_vec(&[-6.0, 0.0, 0.0]));
329    }
330
331    #[test]
332    fn test_multi_bounds_from_tuples() {
333        let mb: MultiBounds = vec![(0.0, 1.0), (-10.0, 10.0)].into_iter().collect();
334        assert_eq!(mb.dimension(), 2);
335        assert_eq!(mb.get(0), Some(&Bounds::new(0.0, 1.0)));
336        assert_eq!(mb.get(1), Some(&Bounds::new(-10.0, 10.0)));
337    }
338}