Skip to main content

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}