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}