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