Skip to main content

made_core/value_objects/
score.rs

1//! [`Score`] value object — normalized quality score in `[0.0, 1.0]`.
2
3use std::cmp::Ordering;
4use std::fmt;
5
6use serde::{Deserialize, Serialize};
7
8use crate::error::DomainError;
9
10/// A normalized quality score.
11///
12/// By convention a higher score is better. Scores live in the closed
13/// range `[0.0, 1.0]`; NaN and infinities are rejected.
14#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
15#[serde(transparent)]
16pub struct Score(f64);
17
18impl Score {
19    pub const MIN: Self = Self(0.0);
20    pub const MAX: Self = Self(1.0);
21
22    pub fn new(value: f64) -> Result<Self, DomainError> {
23        if !value.is_finite() || !(0.0..=1.0).contains(&value) {
24            return Err(DomainError::OutOfRange {
25                field: "score",
26                value,
27                min: 0.0,
28                max: 1.0,
29            });
30        }
31        Ok(Self(value))
32    }
33
34    #[must_use]
35    pub fn get(self) -> f64 {
36        self.0
37    }
38}
39
40impl fmt::Display for Score {
41    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42        write!(f, "{:.4}", self.0)
43    }
44}
45
46// Scores are totally ordered because we restrict the domain to
47// non-NaN finite values in `[0.0, 1.0]`.
48impl PartialEq for Score {
49    fn eq(&self, other: &Self) -> bool {
50        self.0.to_bits() == other.0.to_bits()
51    }
52}
53impl Eq for Score {}
54
55impl PartialOrd for Score {
56    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
57        Some(self.cmp(other))
58    }
59}
60impl Ord for Score {
61    fn cmp(&self, other: &Self) -> Ordering {
62        // Safe: constructor rejects NaN.
63        self.0.partial_cmp(&other.0).unwrap_or(Ordering::Equal)
64    }
65}
66
67impl TryFrom<f64> for Score {
68    type Error = DomainError;
69    fn try_from(value: f64) -> Result<Self, Self::Error> {
70        Self::new(value)
71    }
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77
78    #[test]
79    fn bounds_are_inclusive() {
80        assert_eq!(Score::new(0.0).unwrap().get(), 0.0);
81        assert_eq!(Score::new(1.0).unwrap().get(), 1.0);
82    }
83
84    #[test]
85    fn mid_value_is_accepted() {
86        assert!((Score::new(0.5).unwrap().get() - 0.5).abs() < f64::EPSILON);
87    }
88
89    #[test]
90    fn nan_is_rejected() {
91        assert!(matches!(
92            Score::new(f64::NAN).unwrap_err(),
93            DomainError::OutOfRange { field: "score", .. }
94        ));
95    }
96
97    #[test]
98    fn infinity_is_rejected() {
99        assert!(Score::new(f64::INFINITY).is_err());
100        assert!(Score::new(f64::NEG_INFINITY).is_err());
101    }
102
103    #[test]
104    fn negative_is_rejected() {
105        assert!(Score::new(-0.01).is_err());
106    }
107
108    #[test]
109    fn above_one_is_rejected() {
110        assert!(Score::new(1.01).is_err());
111    }
112
113    #[test]
114    fn ordering_is_total_and_ascending() {
115        let mut scores = [
116            Score::new(0.9).unwrap(),
117            Score::new(0.1).unwrap(),
118            Score::new(0.5).unwrap(),
119        ];
120        scores.sort();
121        assert_eq!(scores[0].get(), 0.1);
122        assert_eq!(scores[1].get(), 0.5);
123        assert_eq!(scores[2].get(), 0.9);
124    }
125
126    #[test]
127    fn equality_is_bitwise_within_valid_domain() {
128        assert_eq!(Score::new(0.25).unwrap(), Score::new(0.25).unwrap());
129    }
130
131    #[test]
132    fn display_is_formatted() {
133        assert_eq!(Score::new(0.5).unwrap().to_string(), "0.5000");
134    }
135
136    #[test]
137    fn serde_is_transparent() {
138        assert_eq!(
139            serde_json::to_string(&Score::new(0.25).unwrap()).unwrap(),
140            "0.25"
141        );
142    }
143}