Skip to main content

khive_score/
score.rs

1//! Fixed-point scoring: f64 → i64 at 2^32 scale for cross-platform determinism.
2
3#[cfg(feature = "serde")]
4use serde::{Deserialize, Serialize};
5use std::cmp::Ordering;
6use std::fmt;
7use std::hash::{Hash, Hasher};
8use std::ops::{Add, Div, Mul, Sub};
9
10/// Fixed-point `i64` score scaled by `2^32`, with saturating deterministic arithmetic.
11///
12/// The raw `i64::MIN` value is reserved. See
13/// `crates/khive-score/docs/api/deterministic-score.md`.
14#[derive(Copy, Clone, Eq, PartialEq)]
15#[cfg_attr(feature = "serde", derive(Serialize))]
16#[repr(transparent)]
17pub struct DeterministicScore(i64);
18
19#[cfg(feature = "serde")]
20impl<'de> Deserialize<'de> for DeterministicScore {
21    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
22    where
23        D: serde::Deserializer<'de>,
24    {
25        let raw = i64::deserialize(deserializer)?;
26        DeterministicScore::from_raw_checked(raw).ok_or_else(|| {
27            serde::de::Error::custom(
28                "DeterministicScore raw value i64::MIN is the reserved sentinel and cannot be stored as a runtime value"
29            )
30        })
31    }
32}
33
34impl DeterministicScore {
35    const SCALE: f64 = 4_294_967_296.0; // 2^32
36
37    /// Highest representable score (`i64::MAX`); maps to `+Infinity` in float conversion.
38    pub const MAX: Self = Self(i64::MAX);
39    /// Reserved sentinel at `i64::MIN`; never produced by arithmetic or float conversion.
40    pub const MIN: Self = Self(i64::MIN);
41    /// Lowest reachable runtime score (`i64::MIN + 1`); underflow and `-Infinity` clamp here.
42    pub const NEG_INF: Self = Self(i64::MIN + 1);
43    /// Score of exactly zero.
44    pub const ZERO: Self = Self(0);
45
46    /// Construct from a raw `i64` without validation. Passing `i64::MIN` creates the reserved sentinel.
47    #[inline]
48    pub const fn from_raw(raw: i64) -> Self {
49        Self(raw)
50    }
51
52    /// Construct from a raw `i64`, mapping `i64::MIN` to [`NEG_INF`][Self::NEG_INF].
53    #[inline]
54    pub const fn from_raw_saturating(raw: i64) -> Self {
55        if raw == i64::MIN {
56            Self::NEG_INF
57        } else {
58            Self(raw)
59        }
60    }
61
62    /// Construct from a raw `i64`, returning `None` if `raw == i64::MIN`.
63    #[inline]
64    pub const fn from_raw_checked(raw: i64) -> Option<Self> {
65        if raw == i64::MIN {
66            None
67        } else {
68            Some(Self(raw))
69        }
70    }
71
72    /// Return the underlying `i64` fixed-point representation.
73    #[inline]
74    pub const fn to_raw(self) -> i64 {
75        self.0
76    }
77
78    /// Convert an `f64` to a `DeterministicScore` (NaN → ZERO, ±Inf → MAX/NEG_INF).
79    #[inline]
80    pub fn from_f64(val: f64) -> Self {
81        if val.is_nan() {
82            return Self::ZERO;
83        }
84        if val.is_infinite() {
85            return if val.is_sign_positive() {
86                Self::MAX
87            } else {
88                Self::NEG_INF
89            };
90        }
91
92        let scaled = (val * Self::SCALE).round();
93        Self::from_rounded_arithmetic(scaled)
94    }
95
96    /// Convert an `f32` to a `DeterministicScore` via `from_f64`.
97    #[inline]
98    pub fn from_f32(val: f32) -> Self {
99        Self::from_f64(val as f64)
100    }
101
102    /// Convert this score back to `f64` (sentinels map to ±Infinity).
103    #[inline]
104    pub fn to_f64(self) -> f64 {
105        if self.0 == Self::MAX.0 {
106            return f64::INFINITY;
107        }
108        if self.0 == Self::NEG_INF.0 {
109            return f64::NEG_INFINITY;
110        }
111        self.0 as f64 / Self::SCALE
112    }
113
114    /// Return `true` if the score is the `MAX` or `NEG_INF` sentinel.
115    #[inline]
116    pub const fn is_infinite(self) -> bool {
117        self.0 == Self::MAX.0 || self.0 == Self::NEG_INF.0
118    }
119
120    /// Saturating arithmetic helper: clamps to `[NEG_INF, MAX]`, never produces `MIN`.
121    #[inline]
122    fn from_arithmetic_raw(raw: i128) -> Self {
123        if raw >= Self::MAX.0 as i128 {
124            Self::MAX
125        } else if raw <= Self::NEG_INF.0 as i128 {
126            Self::NEG_INF
127        } else {
128            Self(raw as i64)
129        }
130    }
131
132    /// Float conversion helper: NaN → ZERO, ±Inf → MAX/NEG_INF, finite → clamped to `[NEG_INF, MAX]`.
133    #[inline]
134    fn from_rounded_arithmetic(raw: f64) -> Self {
135        if raw.is_nan() {
136            Self::ZERO
137        } else if raw.is_sign_positive() && !raw.is_finite() {
138            Self::MAX
139        } else if !raw.is_finite() {
140            Self::NEG_INF
141        } else if raw >= Self::MAX.0 as f64 {
142            Self::MAX
143        } else if raw <= Self::NEG_INF.0 as f64 {
144            Self::NEG_INF
145        } else {
146            Self(raw as i64)
147        }
148    }
149}
150
151impl Ord for DeterministicScore {
152    #[inline]
153    fn cmp(&self, other: &Self) -> Ordering {
154        self.0.cmp(&other.0)
155    }
156}
157
158impl PartialOrd for DeterministicScore {
159    #[inline]
160    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
161        Some(self.cmp(other))
162    }
163}
164
165impl Hash for DeterministicScore {
166    fn hash<H: Hasher>(&self, state: &mut H) {
167        self.0.hash(state);
168    }
169}
170
171impl Default for DeterministicScore {
172    fn default() -> Self {
173        Self::ZERO
174    }
175}
176
177impl Add for DeterministicScore {
178    type Output = Self;
179    #[inline]
180    fn add(self, rhs: Self) -> Self::Output {
181        Self::from_arithmetic_raw(self.0 as i128 + rhs.0 as i128)
182    }
183}
184
185impl Sub for DeterministicScore {
186    type Output = Self;
187    #[inline]
188    fn sub(self, rhs: Self) -> Self::Output {
189        Self::from_arithmetic_raw(self.0 as i128 - rhs.0 as i128)
190    }
191}
192
193impl Mul<i64> for DeterministicScore {
194    type Output = Self;
195    #[inline]
196    fn mul(self, rhs: i64) -> Self::Output {
197        let result = (self.0 as i128).saturating_mul(rhs as i128);
198        Self::from_arithmetic_raw(result)
199    }
200}
201
202impl Mul<f64> for DeterministicScore {
203    type Output = Self;
204    #[inline]
205    fn mul(self, rhs: f64) -> Self::Output {
206        if rhs.is_nan() {
207            return Self::ZERO;
208        }
209        let product = (self.0 as f64) * rhs;
210        Self::from_rounded_arithmetic(product.round())
211    }
212}
213
214impl Div<i64> for DeterministicScore {
215    type Output = Self;
216    #[inline]
217    fn div(self, rhs: i64) -> Self::Output {
218        if rhs == 0 {
219            return if self.0 == 0 {
220                Self::ZERO
221            } else if self.0 > 0 {
222                Self::MAX
223            } else {
224                Self::NEG_INF
225            };
226        }
227        Self::from_arithmetic_raw(self.0.saturating_div(rhs) as i128)
228    }
229}
230
231impl fmt::Debug for DeterministicScore {
232    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
233        if *self == Self::MAX {
234            write!(f, "DeterministicScore(+Inf)")
235        } else if *self == Self::NEG_INF {
236            write!(f, "DeterministicScore(-Inf)")
237        } else {
238            write!(f, "DeterministicScore({:.9})", self.to_f64())
239        }
240    }
241}
242
243impl fmt::Display for DeterministicScore {
244    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
245        if *self == Self::MAX {
246            write!(f, "+Inf")
247        } else if *self == Self::NEG_INF {
248            write!(f, "-Inf")
249        } else {
250            write!(f, "{:.6}", self.to_f64())
251        }
252    }
253}
254
255impl From<f64> for DeterministicScore {
256    fn from(val: f64) -> Self {
257        Self::from_f64(val)
258    }
259}
260
261impl From<f32> for DeterministicScore {
262    fn from(val: f32) -> Self {
263        Self::from_f32(val)
264    }
265}
266
267impl From<DeterministicScore> for f64 {
268    fn from(score: DeterministicScore) -> Self {
269        score.to_f64()
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    #[test]
278    fn roundtrip_f64() {
279        let s = DeterministicScore::from_f64(0.5);
280        assert!((s.to_f64() - 0.5).abs() < 1e-9);
281    }
282
283    #[test]
284    fn nan_maps_to_zero() {
285        let s = DeterministicScore::from_f64(f64::NAN);
286        assert_eq!(s, DeterministicScore::ZERO);
287        assert!((s.to_f64() - 0.0).abs() < 1e-15);
288    }
289
290    #[test]
291    fn infinity() {
292        assert_eq!(
293            DeterministicScore::from_f64(f64::INFINITY),
294            DeterministicScore::MAX
295        );
296        assert_eq!(
297            DeterministicScore::from_f64(f64::NEG_INFINITY),
298            DeterministicScore::NEG_INF
299        );
300    }
301
302    #[test]
303    fn ordering() {
304        let a = DeterministicScore::from_f64(0.1);
305        let b = DeterministicScore::from_f64(0.5);
306        assert!(a < b);
307    }
308
309    #[test]
310    fn arithmetic() {
311        let a = DeterministicScore::from_f64(0.3);
312        let b = DeterministicScore::from_f64(0.4);
313        let sum = a + b;
314        assert!((sum.to_f64() - 0.7).abs() < 1e-9);
315    }
316
317    #[test]
318    fn scaling_by_f64() {
319        let s = DeterministicScore::from_f64(0.5);
320        let scaled = s * 2.0;
321        assert!((scaled.to_f64() - 1.0).abs() < 1e-9);
322    }
323
324    #[test]
325    fn div_by_zero() {
326        let pos = DeterministicScore::from_f64(0.5);
327        assert_eq!(pos / 0, DeterministicScore::MAX);
328        let neg = DeterministicScore::from_f64(-0.5);
329        assert_eq!(neg / 0, DeterministicScore::NEG_INF);
330        assert_eq!(DeterministicScore::ZERO / 0, DeterministicScore::ZERO);
331    }
332
333    #[test]
334    fn raw_scale_known_value() {
335        assert_eq!(
336            DeterministicScore::from_f64(1.0).to_raw(),
337            4_294_967_296_i64
338        );
339    }
340
341    #[test]
342    fn display_formatting() {
343        let s = format!("{}", DeterministicScore::from_f64(0.1234567));
344        assert_eq!(s, "0.123457");
345        assert_eq!(format!("{}", DeterministicScore::MAX), "+Inf");
346        assert_eq!(format!("{}", DeterministicScore::NEG_INF), "-Inf");
347    }
348
349    #[test]
350    fn debug_formatting() {
351        assert_eq!(
352            format!("{:?}", DeterministicScore::MAX),
353            "DeterministicScore(+Inf)"
354        );
355        assert_eq!(
356            format!("{:?}", DeterministicScore::NEG_INF),
357            "DeterministicScore(-Inf)"
358        );
359        let s = format!("{:?}", DeterministicScore::from_f64(0.5));
360        assert!(
361            s.starts_with("DeterministicScore(0.5"),
362            "unexpected debug output: {s}"
363        );
364    }
365
366    #[test]
367    fn add_saturates_at_max() {
368        assert_eq!(
369            DeterministicScore::MAX + DeterministicScore::from_raw(1),
370            DeterministicScore::MAX
371        );
372    }
373
374    #[test]
375    fn sub_saturates_at_neg_inf() {
376        assert_eq!(
377            DeterministicScore::NEG_INF - DeterministicScore::from_raw(1),
378            DeterministicScore::NEG_INF
379        );
380    }
381
382    #[test]
383    fn mul_i64_saturates_at_max() {
384        let large = DeterministicScore::from_raw(i64::MAX / 2);
385        assert_eq!(large * 3_i64, DeterministicScore::MAX);
386    }
387
388    #[test]
389    fn mul_f64_nan_yields_zero() {
390        let s = DeterministicScore::from_f64(1.0);
391        assert_eq!(s * f64::NAN, DeterministicScore::ZERO);
392    }
393
394    // NEG_INF = i64::MIN + 1; MIN (i64::MIN) is reserved sentinel (Lean: `MIN`)
395    #[test]
396    fn neg_inf_is_i64_min_plus_one() {
397        assert_eq!(DeterministicScore::NEG_INF.to_raw(), i64::MIN + 1);
398    }
399
400    #[test]
401    fn min_sentinel_is_i64_min() {
402        assert_eq!(DeterministicScore::MIN.to_raw(), i64::MIN);
403    }
404
405    #[test]
406    fn min_sentinel_distinct_from_neg_inf() {
407        assert_ne!(DeterministicScore::MIN, DeterministicScore::NEG_INF);
408        assert!(DeterministicScore::MIN < DeterministicScore::NEG_INF);
409    }
410
411    #[test]
412    fn neg_infinity_maps_to_neg_inf() {
413        assert_eq!(
414            DeterministicScore::from_f64(f64::NEG_INFINITY),
415            DeterministicScore::NEG_INF
416        );
417    }
418
419    #[test]
420    fn underflow_clamps_to_neg_inf_not_min() {
421        // Arithmetic must clamp at NEG_INF (= i64::MIN + 1), never produce MIN.
422        let result = DeterministicScore::from_raw(i64::MIN + 1) - DeterministicScore::from_raw(1);
423        assert_eq!(result, DeterministicScore::NEG_INF);
424        assert_ne!(result, DeterministicScore::MIN);
425    }
426
427    #[test]
428    fn from_raw_saturating_min_maps_to_neg_inf() {
429        let s = DeterministicScore::from_raw_saturating(i64::MIN);
430        assert_eq!(
431            s,
432            DeterministicScore::NEG_INF,
433            "i64::MIN must saturate to NEG_INF, not the reserved MIN sentinel"
434        );
435    }
436
437    #[test]
438    fn from_raw_saturating_neg_inf_raw_is_identity() {
439        let s = DeterministicScore::from_raw_saturating(i64::MIN + 1);
440        assert_eq!(s, DeterministicScore::NEG_INF);
441    }
442
443    #[test]
444    fn from_raw_saturating_normal_value_is_identity() {
445        let s = DeterministicScore::from_raw_saturating(42);
446        assert_eq!(s.to_raw(), 42);
447    }
448
449    #[test]
450    fn from_raw_checked_min_returns_none() {
451        assert!(
452            DeterministicScore::from_raw_checked(i64::MIN).is_none(),
453            "i64::MIN should be rejected by from_raw_checked"
454        );
455    }
456
457    #[test]
458    fn from_raw_checked_valid_value_returns_some() {
459        let s = DeterministicScore::from_raw_checked(i64::MIN + 1).unwrap();
460        assert_eq!(s, DeterministicScore::NEG_INF);
461    }
462
463    #[test]
464    fn from_raw_checked_zero_returns_some() {
465        let s = DeterministicScore::from_raw_checked(0).unwrap();
466        assert_eq!(s, DeterministicScore::ZERO);
467    }
468
469    #[cfg(feature = "serde")]
470    #[test]
471    fn serde_deserialize_rejects_i64_min() {
472        let raw_json = format!("{}", i64::MIN);
473        let result: Result<DeterministicScore, _> = serde_json::from_str(&raw_json);
474        assert!(
475            result.is_err(),
476            "deserializing i64::MIN must fail: got {:?}",
477            result
478        );
479    }
480
481    #[cfg(feature = "serde")]
482    #[test]
483    fn serde_deserialize_accepts_neg_inf_raw() {
484        let raw_json = format!("{}", i64::MIN + 1);
485        let s: DeterministicScore = serde_json::from_str(&raw_json).unwrap();
486        assert_eq!(s, DeterministicScore::NEG_INF);
487    }
488
489    #[cfg(feature = "serde")]
490    #[test]
491    fn serde_roundtrip_zero() {
492        let original = DeterministicScore::ZERO;
493        let json = serde_json::to_string(&original).unwrap();
494        let restored: DeterministicScore = serde_json::from_str(&json).unwrap();
495        assert_eq!(original, restored);
496    }
497}