Skip to content

Commit 374d562

Browse files
committed
literal: round-half-even tie for normal-range float/complex repr
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
1 parent 5c36d5c commit 374d562

4 files changed

Lines changed: 49 additions & 13 deletions

File tree

crates/literal/src/complex.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,9 @@ fn component_to_string(value: f64) -> String {
1919
if exponent < 16 && exponent > -5 {
2020
// Normal magnitude — Rust's default Display emits "1" for 1.0,
2121
// "1.5" for 1.5, "1000000000000000" for 1e15, etc.
22-
value.to_string()
22+
float::prefer_cpython_tie_repr(value.to_string(), value)
2323
} else {
24-
alloc::format!("{significand}e{exponent:+#03}")
24+
float::prefer_cpython_tie_repr(alloc::format!("{significand}e{exponent:+#03}"), value)
2525
}
2626
} else {
2727
// nan / inf / -inf — `format!("{x:e}")` produces e.g. "NaN" with no

crates/literal/src/float.rs

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -209,11 +209,13 @@ pub fn format_general(
209209
}
210210
}
211211

212-
fn prefer_cpython_tie_repr(s: String, value: f64) -> String {
213-
let Some(exponent_pos) = s.find('e') else {
214-
return s;
215-
};
216-
let Some(digit_pos) = s[..exponent_pos].bytes().rposition(|b| b.is_ascii_digit()) else {
212+
pub(crate) fn prefer_cpython_tie_repr(s: String, value: f64) -> String {
213+
// Rust's shortest float formatter can land on the odd-digit neighbour of a
214+
// rounding tie where round-half-to-even (what `repr` uses) picks the even
215+
// one. When the last significant digit is odd and its even neighbour still
216+
// round-trips and is no further from the value, prefer the even neighbour.
217+
let boundary = s.find('e').unwrap_or(s.len());
218+
let Some(digit_pos) = s[..boundary].bytes().rposition(|b| b.is_ascii_digit()) else {
217219
return s;
218220
};
219221

@@ -258,11 +260,11 @@ fn checked_pow_u128(base: u128, exp: u32) -> Option<u128> {
258260
}
259261

260262
fn parse_decimal_rational(s: &str) -> Option<(u128, u32)> {
261-
let exponent_pos = s.find('e')?;
262-
let exponent = s[exponent_pos + 1..].parse::<i32>().ok()?;
263-
let significand = s[..exponent_pos]
264-
.strip_prefix('-')
265-
.unwrap_or(&s[..exponent_pos]);
263+
let (mantissa, exponent) = match s.find('e') {
264+
Some(pos) => (&s[..pos], s[pos + 1..].parse::<i32>().ok()?),
265+
None => (s, 0),
266+
};
267+
let significand = mantissa.strip_prefix('-').unwrap_or(mantissa);
266268
let dot_pos = significand.find('.');
267269
let frac_digits = dot_pos
268270
.map(|pos| significand.len().saturating_sub(pos + 1))
@@ -325,7 +327,7 @@ pub fn to_string(value: f64) -> String {
325327
if is_integer(value) {
326328
format!("{value:.1?}")
327329
} else {
328-
value.to_string()
330+
prefer_cpython_tie_repr(value.to_string(), value)
329331
}
330332
} else {
331333
prefer_cpython_tie_repr(format!("{significand}e{exponent:+#03}"), value)
@@ -351,6 +353,21 @@ mod tests {
351353
"6.1005353927612305e-05"
352354
);
353355
}
356+
357+
#[test]
358+
fn repr_normal_range_uses_cpython_tie_digit() {
359+
// Rust's shortest formatter yields "161852602146008.13" for this
360+
// value; round-half-to-even (what `repr` uses) picks "…08.12".
361+
assert_eq!(
362+
to_string(f64::from_bits(0x42e26687db6b9b04)),
363+
"161852602146008.12"
364+
);
365+
// Non-tie values are left untouched.
366+
assert_eq!(to_string(1.5), "1.5");
367+
assert_eq!(to_string(0.1), "0.1");
368+
assert_eq!(to_string(3.14), "3.14");
369+
assert_eq!(to_string(100.0), "100.0");
370+
}
354371
}
355372

356373
pub fn from_hex(s: &str) -> Option<f64> {

extra_tests/snippets/builtin_complex.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,3 +268,10 @@ class complex_subclass(complex):
268268
assert repr(float("-inf") + 1j) == "(-inf+1j)"
269269
assert repr(complex(1, float("nan"))) == "(1+nanj)"
270270
assert repr(complex(1, float("inf"))) == "(1+infj)"
271+
272+
# Round-half-to-even ties: Rust's shortest formatter can land on the
273+
# odd-digit neighbour where repr()'s tie-breaking picks the even one.
274+
assert repr(161852602146008.12 + 1j) == "(161852602146008.12+1j)"
275+
assert repr(-788830060729777.2 + 2j) == "(-788830060729777.2+2j)"
276+
assert repr(complex(0.0, 1959276370239205.2)) == "1959276370239205.2j"
277+
assert repr(complex(-1818262230632059.2, 0.0)) == "(-1818262230632059.2+0j)"

extra_tests/snippets/builtin_float.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -549,3 +549,15 @@ def _check_msg(call, exc_type, expected_msg):
549549
lambda: INF.__int__(), OverflowError, "cannot convert float infinity to integer"
550550
)
551551
_check_msg(lambda: NAN.__floor__(), ValueError, "cannot convert float NaN to integer")
552+
553+
# repr round-half-to-even ties: Rust's shortest formatter can land on the
554+
# odd-digit neighbour where repr()'s tie-breaking picks the even one.
555+
assert repr(161852602146008.12) == "161852602146008.12"
556+
assert repr(-788830060729777.2) == "-788830060729777.2"
557+
assert repr(1959276370239205.2) == "1959276370239205.2"
558+
assert repr(-1818262230632059.2) == "-1818262230632059.2"
559+
assert str(161852602146008.12) == "161852602146008.12"
560+
# non-tie values are unaffected
561+
assert repr(1.5) == "1.5"
562+
assert repr(0.1) == "0.1"
563+
assert repr(100.0) == "100.0"

0 commit comments

Comments
 (0)