Skip to main content

noyalib/
lossless_float.rs

1//! A float that refuses to silently lose information.
2//!
3//! [`LosslessFloat`](crate::lossless_float::LosslessFloat) is the floating-point sibling of the
4//! `lossless-u64` feature's integer guarantee: an opt-in "refuse to
5//! silently lose precision" number type. Deserialization rejects
6//! infinities, NaN, and any value that does not survive a
7//! format-and-reparse round trip, so a config field declared
8//! `LosslessFloat` can never quietly hold a distorted number.
9//!
10//! Formerly `noyalib::robotics::StrictFloat` — the mechanism was
11//! never robotics-specific, only the label; the `robotics` module
12//! carried deprecated aliases for one release (v0.0.29) and was
13//! removed in v0.0.30.
14//!
15//! # Examples
16//!
17//! ```
18//! use noyalib::lossless_float::LosslessFloat;
19//!
20//! let f: LosslessFloat = noyalib::from_str("3.14159").unwrap();
21//! assert!((f.get() - 3.14159).abs() < 1e-10);
22//!
23//! // Infinity is rejected.
24//! let result: Result<LosslessFloat, _> = noyalib::from_str(".inf");
25//! assert!(result.is_err());
26//! ```
27
28// SPDX-License-Identifier: MIT OR Apache-2.0
29// Copyright (c) 2026 Noyalib. All rights reserved.
30
31// The glob, not `std` or piecemeal names: `lossless-float` without
32// `std` is a valid combination (checked by the weekly
33// feature-powerset sweep), and which prelude names the round-trip
34// check needs shifts with `fast-float` (`format!` without it,
35// `ToOwned` with it) — a glob import stays correct and warning-free
36// under every pairing.
37use crate::prelude::*;
38
39/// A float that rejects values outside f64's precise representation range.
40///
41/// The round-trip invariant is: if a value loses precision when converted
42/// to `f64` and back, construction fails. This catches values like
43/// `1e308 * 2` (infinity) or subnormals that cannot be faithfully
44/// represented.
45///
46/// The check runs on the decoded `f64` value, after the parser's own
47/// scalar resolution — it guards the *value*, not the source lexeme.
48///
49/// # Examples
50///
51/// ```rust
52/// use noyalib::lossless_float::LosslessFloat;
53///
54/// let f: LosslessFloat = noyalib::from_str("3.14159").unwrap();
55/// assert!((f.get() - 3.14159).abs() < 1e-10);
56///
57/// // Infinity is rejected.
58/// let result: Result<LosslessFloat, _> = noyalib::from_str(".inf");
59/// assert!(result.is_err());
60/// ```
61#[derive(Debug, Clone, Copy, PartialEq)]
62pub struct LosslessFloat(f64);
63
64/// Error returned when a float value fails the precision check.
65///
66/// # Examples
67///
68/// ```
69/// use noyalib::lossless_float::LosslessFloat;
70/// let err = LosslessFloat::try_from(f64::INFINITY).unwrap_err();
71/// assert!(err.to_string().contains("not precisely representable"));
72/// ```
73#[derive(Debug, Clone, Copy)]
74pub struct LosslessFloatError(f64);
75
76impl fmt::Display for LosslessFloatError {
77    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
78        write!(f, "value {} is not precisely representable as f64", self.0)
79    }
80}
81
82impl TryFrom<f64> for LosslessFloat {
83    type Error = LosslessFloatError;
84
85    fn try_from(value: f64) -> Result<Self, Self::Error> {
86        if value.is_infinite() || value.is_nan() {
87            return Err(LosslessFloatError(value));
88        }
89        // Check round-trip: format and re-parse to verify no precision loss.
90        #[cfg(feature = "fast-float")]
91        let repr = ryu::Buffer::new().format(value).to_owned();
92        #[cfg(not(feature = "fast-float"))]
93        let repr = format!("{value:?}");
94        let roundtrip: f64 = repr.parse().unwrap_or(f64::NAN);
95        if roundtrip != value {
96            return Err(LosslessFloatError(value));
97        }
98        Ok(Self(value))
99    }
100}
101
102// Manual impls against `serde_core` rather than derives: the old
103// `robotics` home needed `dep:serde` only for the derive machinery —
104// this module needs nothing beyond the mandatory `serde_core`.
105impl serde_core::Serialize for LosslessFloat {
106    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
107    where
108        S: serde_core::Serializer,
109    {
110        serializer.serialize_f64(self.0)
111    }
112}
113
114impl<'de> serde_core::Deserialize<'de> for LosslessFloat {
115    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
116    where
117        D: serde_core::Deserializer<'de>,
118    {
119        let v = f64::deserialize(deserializer)?;
120        Self::try_from(v).map_err(serde_core::de::Error::custom)
121    }
122}
123
124impl LosslessFloat {
125    /// Returns the inner `f64` value.
126    ///
127    /// # Examples
128    ///
129    /// ```
130    /// use noyalib::lossless_float::LosslessFloat;
131    /// let f = LosslessFloat::try_from(2.5).unwrap();
132    /// assert_eq!(f.get(), 2.5);
133    /// ```
134    #[must_use]
135    pub fn get(self) -> f64 {
136        self.0
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn accepts_precise() {
146        let f: LosslessFloat = crate::from_str("1.23456789").unwrap();
147        assert!((f.get() - 1.234_567_89).abs() < 1e-15);
148    }
149
150    #[test]
151    fn rejects_infinity() {
152        let result: Result<LosslessFloat, _> = crate::from_str(".inf");
153        assert!(result.is_err());
154    }
155
156    #[test]
157    fn rejects_nan() {
158        let result: Result<LosslessFloat, _> = crate::from_str(".nan");
159        assert!(result.is_err());
160    }
161
162    #[test]
163    fn zero() {
164        let f: LosslessFloat = crate::from_str("0.0").unwrap();
165        assert!((f.get()).abs() < 1e-15);
166    }
167
168    #[test]
169    fn negative() {
170        let f: LosslessFloat = crate::from_str("-1.5").unwrap();
171        assert!((f.get() + 1.5).abs() < 1e-15);
172    }
173
174    #[test]
175    fn serializes_transparently() {
176        let f = LosslessFloat::try_from(2.5).unwrap();
177        let yaml = crate::to_string(&f).unwrap();
178        assert!(yaml.contains("2.5"));
179    }
180}