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    /// Reserved raw sentinel at `i64::MIN`. Public arithmetic and float conversion
23    /// never produce this value — see `NEG_INF` for the lowest reachable score.
24    /// Lean proof: `MIN` is the reserved NaN sentinel; runtime values are
25    /// `RuntimeValid` (NEG_INF ≤ x ≤ MAX) and disjoint from `MIN`.
26    pub const MIN: Self = Self(i64::MIN);
27    /// Lowest reachable runtime score (= `i64::MIN + 1`). Underflow clamps here,
28    /// `-Infinity` maps here. Distinct from `MIN`, which is reserved.
29    pub const NEG_INF: Self = Self(i64::MIN + 1);
30    pub const ZERO: Self = Self(0);
31
32    #[inline]
33    pub const fn from_raw(raw: i64) -> Self {
34        Self(raw)
35    }
36
37    #[inline]
38    pub const fn to_raw(self) -> i64 {
39        self.0
40    }
41
42    #[inline]
43    pub fn from_f64(val: f64) -> Self {
44        if val.is_nan() {
45            return Self::ZERO;
46        }
47        if val.is_infinite() {
48            return if val.is_sign_positive() {
49                Self::MAX
50            } else {
51                Self::NEG_INF
52            };
53        }
54
55        let scaled = (val * Self::SCALE).round();
56        Self::from_rounded_arithmetic(scaled)
57    }
58
59    #[inline]
60    pub fn from_f32(val: f32) -> Self {
61        Self::from_f64(val as f64)
62    }
63
64    #[inline]
65    pub fn to_f64(self) -> f64 {
66        if self.0 == Self::MAX.0 {
67            return f64::INFINITY;
68        }
69        if self.0 == Self::NEG_INF.0 {
70            return f64::NEG_INFINITY;
71        }
72        self.0 as f64 / Self::SCALE
73    }
74
75    #[inline]
76    pub const fn is_infinite(self) -> bool {
77        self.0 == Self::MAX.0 || self.0 == Self::NEG_INF.0
78    }
79
80    /// Saturating arithmetic clamps to `[NEG_INF, MAX]`. Per the Lean proof,
81    /// the reserved `MIN` (i64::MIN) sentinel is never produced.
82    #[inline]
83    fn from_arithmetic_raw(raw: i128) -> Self {
84        if raw >= Self::MAX.0 as i128 {
85            Self::MAX
86        } else if raw <= Self::NEG_INF.0 as i128 {
87            Self::NEG_INF
88        } else {
89            Self(raw as i64)
90        }
91    }
92
93    /// Float conversion: NaN → ZERO, +Inf → MAX, -Inf → NEG_INF, finite → clamped
94    /// to `[NEG_INF, MAX]`. Reserved `MIN` is never produced.
95    #[inline]
96    fn from_rounded_arithmetic(raw: f64) -> Self {
97        if raw.is_nan() {
98            Self::ZERO
99        } else if raw.is_sign_positive() && !raw.is_finite() {
100            Self::MAX
101        } else if !raw.is_finite() {
102            Self::NEG_INF
103        } else if raw >= Self::MAX.0 as f64 {
104            Self::MAX
105        } else if raw <= Self::NEG_INF.0 as f64 {
106            Self::NEG_INF
107        } else {
108            Self(raw as i64)
109        }
110    }
111}
112
113impl Ord for DeterministicScore {
114    #[inline]
115    fn cmp(&self, other: &Self) -> Ordering {
116        self.0.cmp(&other.0)
117    }
118}
119
120impl PartialOrd for DeterministicScore {
121    #[inline]
122    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
123        Some(self.cmp(other))
124    }
125}
126
127impl Hash for DeterministicScore {
128    fn hash<H: Hasher>(&self, state: &mut H) {
129        self.0.hash(state);
130    }
131}
132
133impl Default for DeterministicScore {
134    fn default() -> Self {
135        Self::ZERO
136    }
137}
138
139impl Add for DeterministicScore {
140    type Output = Self;
141    #[inline]
142    fn add(self, rhs: Self) -> Self::Output {
143        Self::from_arithmetic_raw(self.0 as i128 + rhs.0 as i128)
144    }
145}
146
147impl Sub for DeterministicScore {
148    type Output = Self;
149    #[inline]
150    fn sub(self, rhs: Self) -> Self::Output {
151        Self::from_arithmetic_raw(self.0 as i128 - rhs.0 as i128)
152    }
153}
154
155impl Mul<i64> for DeterministicScore {
156    type Output = Self;
157    #[inline]
158    fn mul(self, rhs: i64) -> Self::Output {
159        let result = (self.0 as i128).saturating_mul(rhs as i128);
160        Self::from_arithmetic_raw(result)
161    }
162}
163
164impl Mul<f64> for DeterministicScore {
165    type Output = Self;
166    #[inline]
167    fn mul(self, rhs: f64) -> Self::Output {
168        if rhs.is_nan() {
169            return Self::ZERO;
170        }
171        let product = (self.0 as f64) * rhs;
172        Self::from_rounded_arithmetic(product.round())
173    }
174}
175
176impl Div<i64> for DeterministicScore {
177    type Output = Self;
178    #[inline]
179    fn div(self, rhs: i64) -> Self::Output {
180        if rhs == 0 {
181            return if self.0 == 0 {
182                Self::ZERO
183            } else if self.0 > 0 {
184                Self::MAX
185            } else {
186                Self::NEG_INF
187            };
188        }
189        Self::from_arithmetic_raw(self.0.saturating_div(rhs) as i128)
190    }
191}
192
193impl fmt::Debug for DeterministicScore {
194    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
195        if *self == Self::MAX {
196            write!(f, "DeterministicScore(+Inf)")
197        } else if *self == Self::NEG_INF {
198            write!(f, "DeterministicScore(-Inf)")
199        } else {
200            write!(f, "DeterministicScore({:.9})", self.to_f64())
201        }
202    }
203}
204
205impl fmt::Display for DeterministicScore {
206    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
207        if *self == Self::MAX {
208            write!(f, "+Inf")
209        } else if *self == Self::NEG_INF {
210            write!(f, "-Inf")
211        } else {
212            write!(f, "{:.6}", self.to_f64())
213        }
214    }
215}
216
217impl From<f64> for DeterministicScore {
218    fn from(val: f64) -> Self {
219        Self::from_f64(val)
220    }
221}
222
223impl From<f32> for DeterministicScore {
224    fn from(val: f32) -> Self {
225        Self::from_f32(val)
226    }
227}
228
229impl From<DeterministicScore> for f64 {
230    fn from(score: DeterministicScore) -> Self {
231        score.to_f64()
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    #[test]
240    fn roundtrip_f64() {
241        let s = DeterministicScore::from_f64(0.5);
242        assert!((s.to_f64() - 0.5).abs() < 1e-9);
243    }
244
245    #[test]
246    fn nan_maps_to_zero() {
247        let s = DeterministicScore::from_f64(f64::NAN);
248        assert_eq!(s, DeterministicScore::ZERO);
249        assert!((s.to_f64() - 0.0).abs() < 1e-15);
250    }
251
252    #[test]
253    fn infinity() {
254        assert_eq!(
255            DeterministicScore::from_f64(f64::INFINITY),
256            DeterministicScore::MAX
257        );
258        assert_eq!(
259            DeterministicScore::from_f64(f64::NEG_INFINITY),
260            DeterministicScore::NEG_INF
261        );
262    }
263
264    #[test]
265    fn ordering() {
266        let a = DeterministicScore::from_f64(0.1);
267        let b = DeterministicScore::from_f64(0.5);
268        assert!(a < b);
269    }
270
271    #[test]
272    fn arithmetic() {
273        let a = DeterministicScore::from_f64(0.3);
274        let b = DeterministicScore::from_f64(0.4);
275        let sum = a + b;
276        assert!((sum.to_f64() - 0.7).abs() < 1e-9);
277    }
278
279    #[test]
280    fn scaling_by_f64() {
281        let s = DeterministicScore::from_f64(0.5);
282        let scaled = s * 2.0;
283        assert!((scaled.to_f64() - 1.0).abs() < 1e-9);
284    }
285
286    #[test]
287    fn div_by_zero() {
288        let pos = DeterministicScore::from_f64(0.5);
289        assert_eq!(pos / 0, DeterministicScore::MAX);
290        let neg = DeterministicScore::from_f64(-0.5);
291        assert_eq!(neg / 0, DeterministicScore::NEG_INF);
292        assert_eq!(DeterministicScore::ZERO / 0, DeterministicScore::ZERO);
293    }
294
295    #[test]
296    fn raw_scale_known_value() {
297        assert_eq!(
298            DeterministicScore::from_f64(1.0).to_raw(),
299            4_294_967_296_i64
300        );
301    }
302
303    #[test]
304    fn display_formatting() {
305        let s = format!("{}", DeterministicScore::from_f64(0.1234567));
306        assert_eq!(s, "0.123457");
307        assert_eq!(format!("{}", DeterministicScore::MAX), "+Inf");
308        assert_eq!(format!("{}", DeterministicScore::NEG_INF), "-Inf");
309    }
310
311    #[test]
312    fn debug_formatting() {
313        assert_eq!(
314            format!("{:?}", DeterministicScore::MAX),
315            "DeterministicScore(+Inf)"
316        );
317        assert_eq!(
318            format!("{:?}", DeterministicScore::NEG_INF),
319            "DeterministicScore(-Inf)"
320        );
321        let s = format!("{:?}", DeterministicScore::from_f64(0.5));
322        assert!(
323            s.starts_with("DeterministicScore(0.5"),
324            "unexpected debug output: {s}"
325        );
326    }
327
328    #[test]
329    fn add_saturates_at_max() {
330        assert_eq!(
331            DeterministicScore::MAX + DeterministicScore::from_raw(1),
332            DeterministicScore::MAX
333        );
334    }
335
336    #[test]
337    fn sub_saturates_at_neg_inf() {
338        assert_eq!(
339            DeterministicScore::NEG_INF - DeterministicScore::from_raw(1),
340            DeterministicScore::NEG_INF
341        );
342    }
343
344    #[test]
345    fn mul_i64_saturates_at_max() {
346        let large = DeterministicScore::from_raw(i64::MAX / 2);
347        assert_eq!(large * 3_i64, DeterministicScore::MAX);
348    }
349
350    #[test]
351    fn mul_f64_nan_yields_zero() {
352        let s = DeterministicScore::from_f64(1.0);
353        assert_eq!(s * f64::NAN, DeterministicScore::ZERO);
354    }
355
356    // NEG_INF = i64::MIN + 1; MIN (i64::MIN) is reserved sentinel (Lean: `MIN`)
357    #[test]
358    fn neg_inf_is_i64_min_plus_one() {
359        assert_eq!(DeterministicScore::NEG_INF.to_raw(), i64::MIN + 1);
360    }
361
362    #[test]
363    fn min_sentinel_is_i64_min() {
364        assert_eq!(DeterministicScore::MIN.to_raw(), i64::MIN);
365    }
366
367    #[test]
368    fn min_sentinel_distinct_from_neg_inf() {
369        assert_ne!(DeterministicScore::MIN, DeterministicScore::NEG_INF);
370        assert!(DeterministicScore::MIN < DeterministicScore::NEG_INF);
371    }
372
373    #[test]
374    fn neg_infinity_maps_to_neg_inf() {
375        assert_eq!(
376            DeterministicScore::from_f64(f64::NEG_INFINITY),
377            DeterministicScore::NEG_INF
378        );
379    }
380
381    #[test]
382    fn underflow_clamps_to_neg_inf_not_min() {
383        // Arithmetic must clamp at NEG_INF (= i64::MIN + 1), never produce MIN.
384        let result = DeterministicScore::from_raw(i64::MIN + 1) - DeterministicScore::from_raw(1);
385        assert_eq!(result, DeterministicScore::NEG_INF);
386        assert_ne!(result, DeterministicScore::MIN);
387    }
388}