Skip to main content

khive_score/
score.rs

1//! Core `DeterministicScore` — fixed-point integer scoring (ADR-006).
2//!
3//! Cross-platform deterministic by converting f64 to i64 with 2^32 scaling.
4//! NaN → 0 (neutral ranking), +Inf → MAX, -Inf → NEG_INF.
5
6#[cfg(feature = "serde")]
7use serde::{Deserialize, Serialize};
8use std::cmp::Ordering;
9use std::fmt;
10use std::hash::{Hash, Hasher};
11use std::ops::{Add, Div, Mul, Sub};
12
13#[derive(Copy, Clone, Eq, PartialEq)]
14#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
15#[repr(transparent)]
16pub struct DeterministicScore(i64);
17
18impl DeterministicScore {
19    const SCALE: f64 = 4_294_967_296.0; // 2^32
20
21    pub const MAX: Self = Self(i64::MAX);
22    pub const NEG_INF: Self = Self(i64::MIN + 1);
23    pub const ZERO: Self = Self(0);
24
25    #[inline]
26    pub const fn from_raw(raw: i64) -> Self {
27        Self(raw)
28    }
29
30    #[inline]
31    pub const fn to_raw(self) -> i64 {
32        self.0
33    }
34
35    #[inline]
36    pub fn from_f64(val: f64) -> Self {
37        if val.is_nan() {
38            return Self::ZERO;
39        }
40        if val.is_infinite() {
41            return if val.is_sign_positive() {
42                Self::MAX
43            } else {
44                Self::NEG_INF
45            };
46        }
47
48        let scaled = (val * Self::SCALE).round();
49        Self::from_rounded_arithmetic(scaled)
50    }
51
52    #[inline]
53    pub fn from_f32(val: f32) -> Self {
54        Self::from_f64(val as f64)
55    }
56
57    #[inline]
58    pub fn to_f64(self) -> f64 {
59        if self.0 == Self::MAX.0 {
60            return f64::INFINITY;
61        }
62        if self.0 == Self::NEG_INF.0 {
63            return f64::NEG_INFINITY;
64        }
65        self.0 as f64 / Self::SCALE
66    }
67
68    #[inline]
69    pub const fn is_infinite(self) -> bool {
70        self.0 == i64::MAX || self.0 == Self::NEG_INF.0
71    }
72
73    #[inline]
74    fn from_arithmetic_raw(raw: i128) -> Self {
75        if raw >= i64::MAX as i128 {
76            Self::MAX
77        } else if raw <= Self::NEG_INF.0 as i128 {
78            Self::NEG_INF
79        } else {
80            Self(raw as i64)
81        }
82    }
83
84    #[inline]
85    fn from_rounded_arithmetic(raw: f64) -> Self {
86        if raw.is_nan() {
87            Self::ZERO
88        } else if raw.is_sign_positive() && !raw.is_finite() {
89            Self::MAX
90        } else if !raw.is_finite() {
91            Self::NEG_INF
92        } else if raw >= i64::MAX as f64 {
93            Self::MAX
94        } else if raw <= i64::MIN as f64 {
95            Self::NEG_INF
96        } else {
97            Self(raw as i64)
98        }
99    }
100}
101
102impl Ord for DeterministicScore {
103    #[inline]
104    fn cmp(&self, other: &Self) -> Ordering {
105        self.0.cmp(&other.0)
106    }
107}
108
109impl PartialOrd for DeterministicScore {
110    #[inline]
111    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
112        Some(self.cmp(other))
113    }
114}
115
116impl Hash for DeterministicScore {
117    fn hash<H: Hasher>(&self, state: &mut H) {
118        self.0.hash(state);
119    }
120}
121
122impl Default for DeterministicScore {
123    fn default() -> Self {
124        Self::ZERO
125    }
126}
127
128impl Add for DeterministicScore {
129    type Output = Self;
130    #[inline]
131    fn add(self, rhs: Self) -> Self::Output {
132        Self::from_arithmetic_raw(self.0 as i128 + rhs.0 as i128)
133    }
134}
135
136impl Sub for DeterministicScore {
137    type Output = Self;
138    #[inline]
139    fn sub(self, rhs: Self) -> Self::Output {
140        Self::from_arithmetic_raw(self.0 as i128 - rhs.0 as i128)
141    }
142}
143
144impl Mul<i64> for DeterministicScore {
145    type Output = Self;
146    #[inline]
147    fn mul(self, rhs: i64) -> Self::Output {
148        let result = (self.0 as i128).saturating_mul(rhs as i128);
149        Self::from_arithmetic_raw(result)
150    }
151}
152
153impl Mul<f64> for DeterministicScore {
154    type Output = Self;
155    #[inline]
156    fn mul(self, rhs: f64) -> Self::Output {
157        if rhs.is_nan() {
158            return Self::ZERO;
159        }
160        let product = (self.0 as f64) * rhs;
161        Self::from_rounded_arithmetic(product.round())
162    }
163}
164
165impl Div<i64> for DeterministicScore {
166    type Output = Self;
167    #[inline]
168    fn div(self, rhs: i64) -> Self::Output {
169        if rhs == 0 {
170            return if self.0 == 0 {
171                Self::ZERO
172            } else if self.0 > 0 {
173                Self::MAX
174            } else {
175                Self::NEG_INF
176            };
177        }
178        Self::from_arithmetic_raw(self.0.saturating_div(rhs) as i128)
179    }
180}
181
182impl fmt::Debug for DeterministicScore {
183    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
184        if *self == Self::MAX {
185            write!(f, "DeterministicScore(+Inf)")
186        } else if *self == Self::NEG_INF {
187            write!(f, "DeterministicScore(-Inf)")
188        } else {
189            write!(f, "DeterministicScore({:.9})", self.to_f64())
190        }
191    }
192}
193
194impl fmt::Display for DeterministicScore {
195    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
196        if *self == Self::MAX {
197            write!(f, "+Inf")
198        } else if *self == Self::NEG_INF {
199            write!(f, "-Inf")
200        } else {
201            write!(f, "{:.6}", self.to_f64())
202        }
203    }
204}
205
206impl From<f64> for DeterministicScore {
207    fn from(val: f64) -> Self {
208        Self::from_f64(val)
209    }
210}
211
212impl From<f32> for DeterministicScore {
213    fn from(val: f32) -> Self {
214        Self::from_f32(val)
215    }
216}
217
218impl From<DeterministicScore> for f64 {
219    fn from(score: DeterministicScore) -> Self {
220        score.to_f64()
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    #[test]
229    fn roundtrip_f64() {
230        let s = DeterministicScore::from_f64(0.5);
231        assert!((s.to_f64() - 0.5).abs() < 1e-9);
232    }
233
234    #[test]
235    fn nan_maps_to_zero() {
236        let s = DeterministicScore::from_f64(f64::NAN);
237        assert_eq!(s, DeterministicScore::ZERO);
238        assert!((s.to_f64() - 0.0).abs() < 1e-15);
239    }
240
241    #[test]
242    fn infinity() {
243        assert_eq!(
244            DeterministicScore::from_f64(f64::INFINITY),
245            DeterministicScore::MAX
246        );
247        assert_eq!(
248            DeterministicScore::from_f64(f64::NEG_INFINITY),
249            DeterministicScore::NEG_INF
250        );
251    }
252
253    #[test]
254    fn ordering() {
255        let a = DeterministicScore::from_f64(0.1);
256        let b = DeterministicScore::from_f64(0.5);
257        assert!(a < b);
258    }
259
260    #[test]
261    fn arithmetic() {
262        let a = DeterministicScore::from_f64(0.3);
263        let b = DeterministicScore::from_f64(0.4);
264        let sum = a + b;
265        assert!((sum.to_f64() - 0.7).abs() < 1e-9);
266    }
267
268    #[test]
269    fn scaling_by_f64() {
270        let s = DeterministicScore::from_f64(0.5);
271        let scaled = s * 2.0;
272        assert!((scaled.to_f64() - 1.0).abs() < 1e-9);
273    }
274
275    #[test]
276    fn div_by_zero() {
277        let pos = DeterministicScore::from_f64(0.5);
278        assert_eq!(pos / 0, DeterministicScore::MAX);
279        let neg = DeterministicScore::from_f64(-0.5);
280        assert_eq!(neg / 0, DeterministicScore::NEG_INF);
281        assert_eq!(DeterministicScore::ZERO / 0, DeterministicScore::ZERO);
282    }
283
284    #[test]
285    fn raw_scale_known_value() {
286        assert_eq!(
287            DeterministicScore::from_f64(1.0).to_raw(),
288            4_294_967_296_i64
289        );
290    }
291
292    #[test]
293    fn display_formatting() {
294        let s = format!("{}", DeterministicScore::from_f64(0.1234567));
295        assert_eq!(s, "0.123457");
296        assert_eq!(format!("{}", DeterministicScore::MAX), "+Inf");
297        assert_eq!(format!("{}", DeterministicScore::NEG_INF), "-Inf");
298    }
299
300    #[test]
301    fn debug_formatting() {
302        assert_eq!(
303            format!("{:?}", DeterministicScore::MAX),
304            "DeterministicScore(+Inf)"
305        );
306        assert_eq!(
307            format!("{:?}", DeterministicScore::NEG_INF),
308            "DeterministicScore(-Inf)"
309        );
310        let s = format!("{:?}", DeterministicScore::from_f64(0.5));
311        assert!(
312            s.starts_with("DeterministicScore(0.5"),
313            "unexpected debug output: {s}"
314        );
315    }
316
317    #[test]
318    fn add_saturates_at_max() {
319        assert_eq!(
320            DeterministicScore::MAX + DeterministicScore::from_raw(1),
321            DeterministicScore::MAX
322        );
323    }
324
325    #[test]
326    fn sub_saturates_at_neg_inf() {
327        assert_eq!(
328            DeterministicScore::NEG_INF - DeterministicScore::from_raw(1),
329            DeterministicScore::NEG_INF
330        );
331    }
332
333    #[test]
334    fn mul_i64_saturates_at_max() {
335        let large = DeterministicScore::from_raw(i64::MAX / 2);
336        assert_eq!(large * 3_i64, DeterministicScore::MAX);
337    }
338
339    #[test]
340    fn mul_f64_nan_yields_zero() {
341        let s = DeterministicScore::from_f64(1.0);
342        assert_eq!(s * f64::NAN, DeterministicScore::ZERO);
343    }
344}