polydat_core/library/support/float_text.rs
1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Float text, byte-identical to Rust's formatting and faster.
5//!
6//! A tile encodes an `f64` hole as `format!("{f:?}")` when the hole has
7//! no format and as `format!("{f:.N}")` under a `.N` precision (SRD 114
8//! §7.2). Those bytes are the tile's contract on every engine, so a
9//! faster writer is only admissible if it produces the same bytes for
10//! every value. This module is that writer, and
11//! `tests/float_text.rs` is the proof: a differential over edge values,
12//! arithmetic series, and a million seeded bit patterns, for the
13//! shortest form and every precision 0 through 9.
14//!
15//! **Shortest form** ([`write_shortest`]). Rust's `Debug` for `f64`
16//! prints the shortest round-trip digits, as a decimal with at least
17//! one fractional digit (`100.0`, `0.1`, `-0.0`) when the magnitude is
18//! in `[1e-4, 1e16)` and otherwise in exponent form (`1e16`,
19//! `1.5e-7`). The `ryu` crate produces the same shortest digits and,
20//! for every class but one, the same layout; the exception is
21//! `[1e-5, 1e-4)`, which ryu lays out as `0.00005` and Rust as `5e-5`.
22//! The writer takes ryu's text and re-lays out that one class from
23//! ryu's digits. The digits themselves agree except on a tie: a value
24//! whose exact decimal expansion is one digit longer than its shortest
25//! form and ends in 5 (`2231889947293916.25`, whose shortest forms
26//! `…916.2` and `…916.3` both round-trip), where ryu rounds to the
27//! even digit and Rust rounds up. The writer detects a tie exactly from
28//! the float's odd mantissa and exponent and falls back to `format!`
29//! for it, so the proof's random sweep is what establishes that no
30//! other class differs.
31//!
32//! **Fixed precision** ([`write_fixed`]). `format!("{f:.N}")` rounds
33//! the exact binary value to `N` fractional digits, half to even on the
34//! exact decimal expansion. Rounding the shortest digits is not the
35//! same operation (0.295 is below the tie in binary, so `.2` gives
36//! `0.29`, where rounding the text `0.295` half-even gives `0.30`).
37//! The writer decodes the float to `m * 2^e` and computes
38//! `round(m * 10^N * 2^e)` in `u128` arithmetic: for `e < 0` the
39//! quotient and remainder of a shift, compared against the half; for
40//! `e >= 0` a left shift with no rounding at all. That is exact
41//! wherever `m * 10^N * 2^max(e,0)` fits in 128 bits, which covers
42//! `N <= 22` and magnitudes below about `2^(75 - 3.33 N)` (`1.6e29` at
43//! `N = 9`); every other case falls back to `format!`, so the output is
44//! Rust's own where the fast path does not reach. A magnitude below the
45//! fast path's shift range is exactly zero at any supported precision
46//! and is written as such without a fallback.
47
48use std::fmt;
49
50/// Powers of ten that fit `u128` alongside a 53-bit mantissa.
51const POW10: [u128; 23] = {
52 let mut t = [1u128; 23];
53 let mut i = 1;
54 while i < 23 {
55 t[i] = t[i - 1] * 10;
56 i += 1;
57 }
58 t
59};
60
61/// The largest precision the exact fast path serves.
62const MAX_FAST_PRECISION: usize = POW10.len() - 1;
63
64/// Write `f` exactly as `format!("{f:?}")` does.
65pub fn write_shortest<W: fmt::Write>(f: f64, out: &mut W) -> fmt::Result {
66 if f.is_nan() {
67 return out.write_str("NaN");
68 }
69 if f.is_infinite() {
70 return out.write_str(if f.is_sign_negative() { "-inf" } else { "inf" });
71 }
72 let mut buf = ryu::Buffer::new();
73 let text = buf.format_finite(f);
74 if shortest_is_tie(f, text) {
75 return write!(out, "{f:?}");
76 }
77 // ryu lays out every class as Rust does except `[1e-5, 1e-4)`,
78 // where ryu writes `0.0000d...` and Rust writes `d.dddde-5`.
79 let (sign, body) = match text.strip_prefix('-') {
80 Some(rest) => ("-", rest),
81 None => ("", text),
82 };
83 let Some(digits) = body.strip_prefix("0.0000") else {
84 return out.write_str(text);
85 };
86 out.write_str(sign)?;
87 out.write_str(&digits[..1])?;
88 if digits.len() > 1 {
89 out.write_char('.')?;
90 out.write_str(&digits[1..])?;
91 }
92 out.write_str("e-5")
93}
94
95/// Whether the shortest digits of finite, non-zero `f` are a tie: the
96/// exact binary value lies exactly halfway between two shortest
97/// candidates, where ryu rounds to the even digit and Rust's `Debug`
98/// rounds up (`flt2dec::strategy::dragon::format_shortest`). `text` is
99/// ryu's rendering of `f`.
100///
101/// The exact value is `m' * 2^e'` with `m'` odd. When `e' >= 0` its
102/// decimal expansion is an integer that the shortest form reproduces
103/// or ends in an even digit, so there is no tie. When `e' < 0` the
104/// expansion is `X = m' * 5^j` over `10^j`, an odd multiple of five,
105/// so it ends in 5; it is a tie exactly when the shortest form has one
106/// digit fewer than `X`. `X` above `10^18` has more digits than any
107/// shortest form plus one, so only a small `X` needs the count.
108#[inline]
109fn shortest_is_tie(f: f64, text: &str) -> bool {
110 let bits = f.to_bits();
111 let exp_bits = ((bits >> 52) & 0x7ff) as i32;
112 let frac = bits & ((1u64 << 52) - 1);
113 let (m, e) = if exp_bits == 0 {
114 (frac, -1074)
115 } else {
116 (frac | (1u64 << 52), exp_bits - 1075)
117 };
118 let tz = m.trailing_zeros() as i32;
119 let e = e + tz;
120 if e >= 0 {
121 return false;
122 }
123 let j = (-e) as u32;
124 // `5^j` for `j > 26` is above `10^18` on its own.
125 if j > 26 {
126 return false;
127 }
128 let x = (m >> tz) as u128 * 5u128.pow(j);
129 if x >= POW10[18] {
130 return false;
131 }
132 let n = significant_digits(text);
133 POW10[n] <= x && x < POW10[n + 1]
134}
135
136/// The count of significant digits in ryu's text: its digits before
137/// any exponent, without leading zeros and without the trailing zeros
138/// the fixed layouts add (`12340000000.0`, `100.0`).
139#[inline]
140fn significant_digits(text: &str) -> usize {
141 let bytes = text.as_bytes();
142 let end = bytes.iter().position(|&b| b == b'e').unwrap_or(bytes.len());
143 let mantissa = &bytes[..end];
144 let Some(first) = mantissa.iter().position(|b| (b'1'..=b'9').contains(b)) else {
145 return 0;
146 };
147 let last = mantissa
148 .iter()
149 .rposition(|b| (b'1'..=b'9').contains(b))
150 .expect("a first nonzero digit implies a last");
151 mantissa[first..=last]
152 .iter()
153 .filter(|b| b.is_ascii_digit())
154 .count()
155}
156
157/// Whether [`write_shortest`] takes the ryu path for `f`, or falls
158/// back to `format!` on a tie. Exposed so the proof can report its
159/// fallback rate.
160pub fn shortest_is_fast(f: f64) -> bool {
161 if !f.is_finite() || f == 0.0 {
162 return true;
163 }
164 let mut buf = ryu::Buffer::new();
165 !shortest_is_tie(f, buf.format_finite(f))
166}
167
168/// Write `f` exactly as `format!("{f:.prec$}")` does.
169pub fn write_fixed<W: fmt::Write>(f: f64, prec: usize, out: &mut W) -> fmt::Result {
170 if f.is_nan() {
171 return out.write_str("NaN");
172 }
173 if f.is_infinite() {
174 return out.write_str(if f.is_sign_negative() { "-inf" } else { "inf" });
175 }
176 let Some(q) = fixed_scaled(f, prec) else {
177 return write!(out, "{f:.prec$}");
178 };
179 let mut buf = [0u8; 48];
180 let len = layout_fixed(f.is_sign_negative(), q, prec, &mut buf);
181 out.write_str(std::str::from_utf8(&buf[len..]).expect("ascii"))
182}
183
184/// `round_half_even(|f| * 10^prec)` as an integer, or `None` where the
185/// value does not fit the exact `u128` path and `format!` must serve.
186#[inline]
187fn fixed_scaled(f: f64, prec: usize) -> Option<u128> {
188 if prec > MAX_FAST_PRECISION {
189 return None;
190 }
191 let bits = f.to_bits();
192 let exp_bits = ((bits >> 52) & 0x7ff) as i32;
193 let frac = bits & ((1u64 << 52) - 1);
194 let (m, e) = if exp_bits == 0 {
195 (frac, -1074)
196 } else {
197 (frac | (1u64 << 52), exp_bits - 1075)
198 };
199 if m == 0 {
200 return Some(0);
201 }
202 // `m < 2^53` and `10^22 < 2^74`, so `t < 2^127`.
203 let t = m as u128 * POW10[prec];
204 if e >= 0 {
205 // An integer-valued float: `t << e` has no fraction to round.
206 if e as u32 > t.leading_zeros() {
207 return None;
208 }
209 return Some(t << e);
210 }
211 let s = (-e) as u32;
212 if s >= 128 {
213 // `t < 2^127 <= 2^(s-1)`, so the scaled value is below one
214 // half and rounds to zero; it cannot be a tie.
215 return Some(0);
216 }
217 let q = t >> s;
218 let rem = t & ((1u128 << s) - 1);
219 let half = 1u128 << (s - 1);
220 Some(if rem > half || (rem == half && q & 1 == 1) {
221 q + 1
222 } else {
223 q
224 })
225}
226
227/// Lay `q / 10^prec` out as sign, integer digits, and `prec`
228/// fractional digits, right-aligned in `buf`; returns the start index.
229#[inline]
230fn layout_fixed(negative: bool, q: u128, prec: usize, buf: &mut [u8; 48]) -> usize {
231 // The same digit loop in `u64` where the scaled value fits (every
232 // value a tile is likely to hold) and in `u128` otherwise; a
233 // `u128` division is several times a `u64` one.
234 macro_rules! put_scaled {
235 ($q:expr, $scale:expr) => {{
236 let mut i = buf.len();
237 let mut q = $q;
238 let mut frac = q % $scale;
239 q /= $scale;
240 for _ in 0..prec {
241 i -= 1;
242 buf[i] = b'0' + (frac % 10) as u8;
243 frac /= 10;
244 }
245 if prec > 0 {
246 i -= 1;
247 buf[i] = b'.';
248 }
249 loop {
250 i -= 1;
251 buf[i] = b'0' + (q % 10) as u8;
252 q /= 10;
253 if q == 0 {
254 break;
255 }
256 }
257 i
258 }};
259 }
260 let mut i = if q <= u64::MAX as u128 {
261 put_scaled!(q as u64, POW10[prec] as u64)
262 } else {
263 put_scaled!(q, POW10[prec])
264 };
265 if negative {
266 i -= 1;
267 buf[i] = b'-';
268 }
269 i
270}
271
272/// [`write_shortest`] into a new `String`.
273pub fn shortest_string(f: f64) -> String {
274 let mut s = String::with_capacity(24);
275 let _ = write_shortest(f, &mut s);
276 s
277}
278
279/// [`write_fixed`] into a new `String`.
280pub fn fixed_string(f: f64, prec: usize) -> String {
281 let mut s = String::with_capacity(24 + prec);
282 let _ = write_fixed(f, prec, &mut s);
283 s
284}
285
286/// Whether [`write_fixed`] takes the exact fast path for `f` at
287/// `prec`, or falls back to `format!`. Exposed so the proof can report
288/// its fallback rate.
289pub fn fixed_is_fast(f: f64, prec: usize) -> bool {
290 f.is_finite() && fixed_scaled(f, prec).is_some()
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296
297 #[test]
298 fn shortest_matches_debug_on_named_values() {
299 for f in [
300 0.0,
301 -0.0,
302 1.0,
303 100.0,
304 0.1,
305 1e-5,
306 5e-5,
307 9.99e-5,
308 1e-4,
309 1e15,
310 1e16,
311 1.5e300,
312 5e-324,
313 f64::MAX,
314 f64::MIN,
315 f64::NAN,
316 f64::INFINITY,
317 f64::NEG_INFINITY,
318 -0.000012345,
319 ] {
320 assert_eq!(
321 shortest_string(f),
322 format!("{f:?}"),
323 "{:#018x}",
324 f.to_bits()
325 );
326 }
327 }
328
329 #[test]
330 fn fixed_matches_format_on_named_values() {
331 for f in [
332 0.0,
333 -0.0,
334 0.5,
335 1.5,
336 2.5,
337 0.295,
338 4.35,
339 2.675,
340 1.005,
341 1e21,
342 1e29,
343 1e30,
344 1e300,
345 5e-324,
346 -1e-30,
347 // 123456789.123456789, as the nearest double reads.
348 123_456_789.123_456_79,
349 ] {
350 for prec in 0..=9 {
351 assert_eq!(
352 fixed_string(f, prec),
353 format!("{f:.prec$}"),
354 "{:#018x} .{prec}",
355 f.to_bits()
356 );
357 }
358 }
359 assert!(!fixed_is_fast(1e300, 2));
360 assert!(fixed_is_fast(1e21, 9));
361 assert!(fixed_is_fast(5e-324, 9));
362 }
363}