From 8e30b94eef20176dc52bbf07d481b31af7a4c165 Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:42:08 +0900 Subject: [PATCH] literal: round-half-even tie for normal-range float/complex repr (#8243) to_string's normal-magnitude branch and complex::component_to_string returned Rust's shortest formatting directly, which can pick the odd-digit neighbour of a rounding tie where repr() picks the even one (e.g. bits 0x42e26687db6b9b04 formats as 161852602146008.13 vs repr's ...08.12). Route both through prefer_cpython_tie_repr, generalized to fixed-notation strings. Add unit tests and float/complex repr snippet tests. Assisted-by: Claude --- crates/literal/src/complex.rs | 4 +-- crates/literal/src/float.rs | 39 ++++++++++++++++++------- extra_tests/snippets/builtin_complex.py | 7 +++++ extra_tests/snippets/builtin_float.py | 12 ++++++++ 4 files changed, 49 insertions(+), 13 deletions(-) diff --git a/crates/literal/src/complex.rs b/crates/literal/src/complex.rs index 20d694f8e..bc193f0f2 100644 --- a/crates/literal/src/complex.rs +++ b/crates/literal/src/complex.rs @@ -19,9 +19,9 @@ fn component_to_string(value: f64) -> String { if exponent < 16 && exponent > -5 { // Normal magnitude — Rust's default Display emits "1" for 1.0, // "1.5" for 1.5, "1000000000000000" for 1e15, etc. - value.to_string() + float::prefer_cpython_tie_repr(value.to_string(), value) } else { - alloc::format!("{significand}e{exponent:+#03}") + float::prefer_cpython_tie_repr(alloc::format!("{significand}e{exponent:+#03}"), value) } } else { // nan / inf / -inf — `format!("{x:e}")` produces e.g. "NaN" with no diff --git a/crates/literal/src/float.rs b/crates/literal/src/float.rs index 56a2b5429..2db4fd084 100644 --- a/crates/literal/src/float.rs +++ b/crates/literal/src/float.rs @@ -209,11 +209,13 @@ pub fn format_general( } } -fn prefer_cpython_tie_repr(s: String, value: f64) -> String { - let Some(exponent_pos) = s.find('e') else { - return s; - }; - let Some(digit_pos) = s[..exponent_pos].bytes().rposition(|b| b.is_ascii_digit()) else { +pub(crate) fn prefer_cpython_tie_repr(s: String, value: f64) -> String { + // Rust's shortest float formatter can land on the odd-digit neighbour of a + // rounding tie where round-half-to-even (what `repr` uses) picks the even + // one. When the last significant digit is odd and its even neighbour still + // round-trips and is no further from the value, prefer the even neighbour. + let boundary = s.find('e').unwrap_or(s.len()); + let Some(digit_pos) = s[..boundary].bytes().rposition(|b| b.is_ascii_digit()) else { return s; }; @@ -258,11 +260,11 @@ fn checked_pow_u128(base: u128, exp: u32) -> Option { } fn parse_decimal_rational(s: &str) -> Option<(u128, u32)> { - let exponent_pos = s.find('e')?; - let exponent = s[exponent_pos + 1..].parse::().ok()?; - let significand = s[..exponent_pos] - .strip_prefix('-') - .unwrap_or(&s[..exponent_pos]); + let (mantissa, exponent) = match s.find('e') { + Some(pos) => (&s[..pos], s[pos + 1..].parse::().ok()?), + None => (s, 0), + }; + let significand = mantissa.strip_prefix('-').unwrap_or(mantissa); let dot_pos = significand.find('.'); let frac_digits = dot_pos .map(|pos| significand.len().saturating_sub(pos + 1)) @@ -325,7 +327,7 @@ pub fn to_string(value: f64) -> String { if is_integer(value) { format!("{value:.1?}") } else { - value.to_string() + prefer_cpython_tie_repr(value.to_string(), value) } } else { prefer_cpython_tie_repr(format!("{significand}e{exponent:+#03}"), value) @@ -351,6 +353,21 @@ mod tests { "6.1005353927612305e-05" ); } + + #[test] + fn repr_normal_range_uses_cpython_tie_digit() { + // Rust's shortest formatter yields "161852602146008.13" for this + // value; round-half-to-even (what `repr` uses) picks "…08.12". + assert_eq!( + to_string(f64::from_bits(0x42e26687db6b9b04)), + "161852602146008.12" + ); + // Non-tie values are left untouched. + assert_eq!(to_string(1.5), "1.5"); + assert_eq!(to_string(0.1), "0.1"); + assert_eq!(to_string(12.34), "12.34"); + assert_eq!(to_string(100.0), "100.0"); + } } pub fn from_hex(s: &str) -> Option { diff --git a/extra_tests/snippets/builtin_complex.py b/extra_tests/snippets/builtin_complex.py index 55ab38c88..ae84cac53 100644 --- a/extra_tests/snippets/builtin_complex.py +++ b/extra_tests/snippets/builtin_complex.py @@ -277,3 +277,10 @@ assert repr(float("inf") + 1j) == "(inf+1j)" assert repr(float("-inf") + 1j) == "(-inf+1j)" assert repr(complex(1, float("nan"))) == "(1+nanj)" assert repr(complex(1, float("inf"))) == "(1+infj)" + +# Round-half-to-even ties: Rust's shortest formatter can land on the +# odd-digit neighbour where repr()'s tie-breaking picks the even one. +assert repr(161852602146008.12 + 1j) == "(161852602146008.12+1j)" +assert repr(-788830060729777.2 + 2j) == "(-788830060729777.2+2j)" +assert repr(complex(0.0, 1959276370239205.2)) == "1959276370239205.2j" +assert repr(complex(-1818262230632059.2, 0.0)) == "(-1818262230632059.2+0j)" diff --git a/extra_tests/snippets/builtin_float.py b/extra_tests/snippets/builtin_float.py index f0fcae5d1..1417c5ae1 100644 --- a/extra_tests/snippets/builtin_float.py +++ b/extra_tests/snippets/builtin_float.py @@ -549,3 +549,15 @@ _check_msg( lambda: INF.__int__(), OverflowError, "cannot convert float infinity to integer" ) _check_msg(lambda: NAN.__floor__(), ValueError, "cannot convert float NaN to integer") + +# repr round-half-to-even ties: Rust's shortest formatter can land on the +# odd-digit neighbour where repr()'s tie-breaking picks the even one. +assert repr(161852602146008.12) == "161852602146008.12" +assert repr(-788830060729777.2) == "-788830060729777.2" +assert repr(1959276370239205.2) == "1959276370239205.2" +assert repr(-1818262230632059.2) == "-1818262230632059.2" +assert str(161852602146008.12) == "161852602146008.12" +# non-tie values are unaffected +assert repr(1.5) == "1.5" +assert repr(0.1) == "0.1" +assert repr(100.0) == "100.0"