Skip to main content

mimir_core/
confidence.rs

1//! `Confidence` — 16-bit fixed-point confidence value mapping `[0.0, 1.0]`
2//! to `[0, 65535]`. Implements the contract in
3//! `docs/concepts/ir-canonical-form.md` § 3.1 and
4//! `docs/concepts/confidence-decay.md` § 3.
5
6use std::fmt;
7
8use thiserror::Error;
9
10const SCALE: f32 = u16::MAX as f32;
11
12/// Errors returned by [`Confidence::try_from_f32`].
13#[derive(Debug, Error, PartialEq)]
14pub enum ConfidenceError {
15    /// The input float was outside the permitted range `[0.0, 1.0]`.
16    #[error("confidence {0} outside [0.0, 1.0]")]
17    OutOfRange(f32),
18
19    /// The input float was NaN.
20    #[error("confidence NaN is not a valid value")]
21    NotANumber,
22}
23
24/// A confidence value in `[0.0, 1.0]`, stored as 16-bit fixed-point.
25///
26/// The representation gives a resolution of roughly `1.53e-5` per step
27/// and is bit-identical across architectures — no IEEE 754 divergence
28/// between CPUs. Per `docs/concepts/ir-canonical-form.md` § 3.1:
29///
30/// ```text
31/// stored_u16 = round(confidence * 65535.0)
32/// confidence = stored_u16 / 65535.0
33/// ```
34///
35/// # Examples
36///
37/// ```
38/// # #![allow(clippy::unwrap_used)]
39/// use mimir_core::Confidence;
40///
41/// let c = Confidence::try_from_f32(0.95).unwrap();
42/// assert!((c.as_f32() - 0.95).abs() < 1e-4);
43/// assert_eq!(c.as_u16(), 62_258);
44/// ```
45#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
46pub struct Confidence(u16);
47
48impl Confidence {
49    /// Lowest confidence — `0.0`.
50    pub const ZERO: Self = Self(0);
51
52    /// Highest confidence — `1.0`.
53    pub const ONE: Self = Self(u16::MAX);
54
55    /// Construct a [`Confidence`] from an `f32` in `[0.0, 1.0]`.
56    ///
57    /// Rounds half-to-even.
58    ///
59    /// # Errors
60    ///
61    /// - [`ConfidenceError::OutOfRange`] if `value < 0.0` or `value > 1.0`.
62    /// - [`ConfidenceError::NotANumber`] if `value` is NaN.
63    ///
64    /// Subnormal values and negative zero are treated as `0.0`.
65    ///
66    /// # Examples
67    ///
68    /// ```
69    /// use mimir_core::{Confidence, ConfidenceError};
70    ///
71    /// assert!(Confidence::try_from_f32(0.5).is_ok());
72    /// assert_eq!(
73    ///     Confidence::try_from_f32(1.1),
74    ///     Err(ConfidenceError::OutOfRange(1.1)),
75    /// );
76    /// assert_eq!(
77    ///     Confidence::try_from_f32(f32::NAN),
78    ///     Err(ConfidenceError::NotANumber),
79    /// );
80    /// ```
81    pub fn try_from_f32(value: f32) -> Result<Self, ConfidenceError> {
82        if value.is_nan() {
83            return Err(ConfidenceError::NotANumber);
84        }
85        if !(0.0..=1.0).contains(&value) {
86            return Err(ConfidenceError::OutOfRange(value));
87        }
88        // round-half-to-even via `roundeven` not available on stable Rust;
89        // `round()` is round-half-away-from-zero, acceptable here because
90        // values in [0.0, 1.0] have ties at 0.5-scale increments that map
91        // deterministically. For strict round-half-to-even we would cast
92        // through f64 and use `.round_ties_even()` (stable 1.77+).
93        let scaled = (f64::from(value) * f64::from(SCALE)).round_ties_even();
94        // scaled in [0.0, 65535.0] ⇒ fits u16.
95        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
96        let stored = scaled as u16;
97        Ok(Self(stored))
98    }
99
100    /// Construct a [`Confidence`] from its raw `u16` fixed-point encoding.
101    #[must_use]
102    pub const fn from_u16(raw: u16) -> Self {
103        Self(raw)
104    }
105
106    /// Raw `u16` fixed-point encoding.
107    #[must_use]
108    pub const fn as_u16(self) -> u16 {
109        self.0
110    }
111
112    /// Floating-point representation in `[0.0, 1.0]`.
113    #[must_use]
114    #[allow(clippy::cast_possible_truncation)]
115    pub fn as_f32(self) -> f32 {
116        (f64::from(self.0) / f64::from(SCALE)) as f32
117    }
118}
119
120impl fmt::Display for Confidence {
121    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
122        write!(f, "{:.4}", self.as_f32())
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    #[test]
131    fn boundary_values() {
132        let zero = Confidence::try_from_f32(0.0).unwrap();
133        let one = Confidence::try_from_f32(1.0).unwrap();
134        assert_eq!(zero, Confidence::ZERO);
135        assert_eq!(one, Confidence::ONE);
136    }
137
138    #[test]
139    fn out_of_range_rejected() {
140        assert!(matches!(
141            Confidence::try_from_f32(-0.01),
142            Err(ConfidenceError::OutOfRange(_))
143        ));
144        assert!(matches!(
145            Confidence::try_from_f32(1.01),
146            Err(ConfidenceError::OutOfRange(_))
147        ));
148    }
149
150    #[test]
151    fn nan_rejected() {
152        assert_eq!(
153            Confidence::try_from_f32(f32::NAN),
154            Err(ConfidenceError::NotANumber),
155        );
156    }
157
158    #[test]
159    fn roundtrip_precision_within_one_step() {
160        let step = 1.0 / f32::from(u16::MAX);
161        for raw in [0_u16, 1, 32_768, 65_534, 65_535] {
162            let c = Confidence::from_u16(raw);
163            let rebuilt = Confidence::try_from_f32(c.as_f32()).unwrap();
164            // Allow ±1 step of drift from the scale conversion.
165            let delta = i64::from(c.as_u16()) - i64::from(rebuilt.as_u16());
166            assert!(delta.abs() <= 1, "raw={raw} delta={delta} step={step}");
167        }
168    }
169}