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}