Skip to content

Commit c2910c0

Browse files
Round float at the decimal level to match CPython's _Py_dg_dtoa (RustPython#7761)
* Round float at the decimal level to match CPython's _Py_dg_dtoa CPython's `float.__round__` (Objects/floatobject.c) routes through `_Py_dg_dtoa` and rounds at the decimal level. The previous `round_float_digits` multiplied by 10**ndigits and rounded at the IEEE 754 binary level, which diverges for values that aren't exactly representable. For example, 2.675 stores as 2.67499...; dtoa correctly rounds it down to 2.67, but `(2.675 * 100.0).round() / 100.0` lands on 2.68 because the multiplication produces a phantom 267.5 tie that round-half-to-even snaps up. Rust's `{:.*}` float formatting uses dtoa-style algorithms (Grisu3 + Dragon4 fallback) and matches CPython's `_Py_dg_dtoa` byte-for-byte. Replace the multiply-then-round step with `format!` + `parse` for ndigits >= 0. The ndigits < 0 path is unchanged because dividing typical inputs by 10**|ndigits| produces genuine ties rather than synthesizing them. Verified byte-identical with CPython 3.14.4 over a 108-case random fuzz plus targeted half-tie probes. Unmasks `test_float.RoundTestCase.test_matches_float_format` and `test_previous_round_bugs`. * Use #[expect] with reason for float_cmp suppression Co-authored-by: ShaharNaveh <50263213+ShaharNaveh@users.noreply.github.com> --------- Co-authored-by: ShaharNaveh <50263213+ShaharNaveh@users.noreply.github.com>
1 parent ac3e067 commit c2910c0

3 files changed

Lines changed: 96 additions & 59 deletions

File tree

Lib/test/test_float.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -965,7 +965,6 @@ def test_overflow(self):
965965
self.assertRaises(OverflowError, round, 1.6e308, -308)
966966
self.assertRaises(OverflowError, round, -1.7e308, -308)
967967

968-
@unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: 56294995342131.51 != 56294995342131.5
969968
@unittest.skipUnless(getattr(sys, 'float_repr_style', '') == 'short',
970969
"applies only when using short float repr style")
971970
def test_previous_round_bugs(self):
@@ -984,7 +983,6 @@ def test_previous_round_bugs(self):
984983
self.assertEqual(round(85.0, -1), 80.0)
985984
self.assertEqual(round(95.0, -1), 100.0)
986985

987-
@unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: 0.01 != 0.0
988986
@unittest.skipUnless(getattr(sys, 'float_repr_style', '') == 'short',
989987
"applies only when using short float repr style")
990988
def test_matches_float_format(self):

crates/common/src/float_ops.rs

Lines changed: 46 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use core::f64;
22
use malachite_bigint::{BigInt, ToBigInt};
3-
use num_traits::{Float, Signed, ToPrimitive, Zero};
3+
use num_traits::{Signed, ToPrimitive};
44

55
pub const fn decompose_float(value: f64) -> (f64, i32) {
66
if 0.0 == value {
@@ -208,64 +208,53 @@ pub fn ulp(x: f64) -> f64 {
208208
}
209209

210210
pub fn round_float_digits(x: f64, ndigits: i32) -> Option<f64> {
211-
let float = if ndigits.is_zero() {
212-
let fract = x.fract();
213-
if (fract.abs() - 0.5).abs() < f64::EPSILON {
214-
if x.trunc() % 2.0 == 0.0 {
215-
x - fract
216-
} else {
217-
x + fract
218-
}
219-
} else {
220-
x.round()
221-
}
222-
} else {
223-
const NDIGITS_MAX: i32 =
224-
((f64::MANTISSA_DIGITS as i32 - f64::MIN_EXP) as f64 * f64::consts::LOG10_2) as i32;
225-
const NDIGITS_MIN: i32 = -(((f64::MAX_EXP + 1) as f64 * f64::consts::LOG10_2) as i32);
226-
if ndigits > NDIGITS_MAX {
227-
x
228-
} else if ndigits < NDIGITS_MIN {
229-
0.0f64.copysign(x)
230-
} else {
231-
let (y, pow1, pow2) = if ndigits >= 0 {
232-
// according to cpython: pow1 and pow2 are each safe from overflow, but
233-
// pow1*pow2 ~= pow(10.0, ndigits) might overflow
234-
let (pow1, pow2) = if ndigits > 22 {
235-
(10.0.powf((ndigits - 22) as f64), 1e22)
236-
} else {
237-
(10.0.powf(ndigits as f64), 1.0)
238-
};
239-
let y = (x * pow1) * pow2;
240-
if !y.is_finite() {
241-
return Some(x);
242-
}
243-
(y, pow1, Some(pow2))
244-
} else {
245-
let pow1 = 10.0.powf((-ndigits) as f64);
246-
(x / pow1, pow1, None)
247-
};
248-
let z = y.round();
249-
#[allow(clippy::float_cmp)]
250-
let z = if (y - z).abs() == 0.5 {
251-
2.0 * (y / 2.0).round()
252-
} else {
253-
z
254-
};
255-
let z = if let Some(pow2) = pow2 {
256-
// ndigits >= 0
257-
(z / pow2) / pow1
258-
} else {
259-
z * pow1
260-
};
211+
// Mirror CPython's `float.__round__` (Objects/floatobject.c), which uses
212+
// `_Py_dg_dtoa` to round at the decimal level. Multiplying by 10**ndigits
213+
// and rounding at the IEEE 754 binary level diverges for values that
214+
// aren't exactly representable: 2.675 stores as 2.67499..., which dtoa
215+
// correctly rounds down to 2.67, but `(2.675 * 100.0).round() / 100.0`
216+
// lands on 2.68 because the multiplication produces a phantom 267.5 tie.
217+
// Rust's `{:.*}` float formatting uses dtoa-style algorithms and matches
218+
// CPython's `_Py_dg_dtoa` byte-for-byte.
219+
if !x.is_finite() {
220+
return Some(x);
221+
}
261222

262-
if !z.is_finite() {
263-
// overflow
264-
return None;
265-
}
223+
const NDIGITS_MAX: i32 =
224+
((f64::MANTISSA_DIGITS as i32 - f64::MIN_EXP) as f64 * f64::consts::LOG10_2) as i32;
225+
const NDIGITS_MIN: i32 = -(((f64::MAX_EXP + 1) as f64 * f64::consts::LOG10_2) as i32);
226+
227+
if ndigits > NDIGITS_MAX {
228+
return Some(x);
229+
}
230+
if ndigits < NDIGITS_MIN {
231+
return Some(0.0f64.copysign(x));
232+
}
266233

234+
let result: f64 = if ndigits >= 0 {
235+
let s = format!("{:.*}", ndigits as usize, x);
236+
s.parse().ok()?
237+
} else {
238+
// ndigits < 0: divide-then-round avoids the phantom-tie problem
239+
// because dividing typical inputs by 10**|ndigits| produces genuine
240+
// half-integer ties rather than synthesizing them.
241+
let pow1 = 10.0f64.powi(-ndigits);
242+
let y = x / pow1;
243+
let z = y.round();
244+
#[expect(
245+
clippy::float_cmp,
246+
reason = "exact half-tie detection for banker's rounding correction"
247+
)]
248+
let z = if (y - z).abs() == 0.5 {
249+
2.0 * (y / 2.0).round()
250+
} else {
267251
z
268-
}
252+
};
253+
z * pow1
269254
};
270-
Some(float)
255+
256+
if !result.is_finite() {
257+
return None;
258+
}
259+
Some(result)
271260
}

extra_tests/snippets/builtin_round.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import math
2+
13
from testutils import assert_raises
24

35
assert round(1.2) == 1
@@ -43,3 +45,51 @@ def __round__(self, ndigits=None):
4345
assert round(X(), 1) == 1.1
4446
assert round(X(), None) == 1.1
4547
assert round(X()) == 1.1
48+
49+
50+
# Banker's rounding at the decimal level: values like 2.675 store as
51+
# 2.67499... in IEEE 754, so a multiply-then-round implementation creates
52+
# a phantom 267.5 tie that round-half-to-even snaps up to 2.68. CPython's
53+
# `_Py_dg_dtoa` rounds at the decimal level and returns 2.67.
54+
assert round(2.675, 2) == 2.67
55+
assert round(2.685, 2) == 2.69
56+
assert round(-2.675, 2) == -2.67
57+
assert round(0.05, 1) == 0.1
58+
assert round(0.15, 1) == 0.1
59+
assert round(0.35, 1) == 0.3
60+
assert round(0.45, 1) == 0.5
61+
assert round(0.65, 1) == 0.7
62+
assert round(0.85, 1) == 0.8
63+
assert round(0.95, 1) == 0.9
64+
assert round(0.645, 2) == 0.65
65+
assert round(0.665, 2) == 0.67
66+
assert round(0.685, 2) == 0.69
67+
assert round(0.695, 2) == 0.69
68+
assert round(1.685, 2) == 1.69
69+
assert round(3.745, 2) == 3.75
70+
71+
# Exact-halfway ties at integer level use banker's (round-half-to-even).
72+
assert round(0.5, 0) == 0.0
73+
assert round(1.5, 0) == 2.0
74+
assert round(2.5, 0) == 2.0
75+
76+
# Negative ndigits uses divide-then-round; banker's still applies.
77+
assert round(1235, -1) == 1240
78+
assert round(1245, -1) == 1240
79+
assert round(1234.5, -1) == 1230.0
80+
assert round(150, -2) == 200
81+
assert round(250, -2) == 200
82+
83+
# NaN and infinities round to themselves with no error when ndigits is given.
84+
assert math.isnan(round(float("nan"), 2))
85+
assert round(float("inf"), 2) == float("inf")
86+
assert round(float("-inf"), 2) == float("-inf")
87+
88+
# Signed zero is preserved through rounding.
89+
assert math.copysign(1.0, round(-0.0, 2)) == -1.0
90+
assert math.copysign(1.0, round(0.0, 2)) == 1.0
91+
92+
# Out-of-range ndigits short-circuits without overflow.
93+
assert round(1.0, 1000) == 1.0
94+
assert round(1.0, -1000) == 0.0
95+
assert round(1.7976931348623157e308, 0) == 1.7976931348623157e308

0 commit comments

Comments
 (0)