Skip to content

Round float at the decimal level to match CPython's _Py_dg_dtoa#7761

Merged
youknowone merged 2 commits into
RustPython:mainfrom
changjoon-park:fix-round-float-digits-dtoa
May 2, 2026
Merged

Round float at the decimal level to match CPython's _Py_dg_dtoa#7761
youknowone merged 2 commits into
RustPython:mainfrom
changjoon-park:fix-round-float-digits-dtoa

Conversation

@changjoon-park
Copy link
Copy Markdown
Contributor

@changjoon-park changjoon-park commented May 2, 2026

Summary

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.

>>> round(2.675, 2)
# CPython 3.14.4: 2.67
# RustPython main: 2.68 ❌

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. Replacing the multiply-then-round step with format!("{:.*}", ndigits, x).parse() removes the entire phantom-tie class for ndigits >= 0.

For ndigits < 0 the existing divide-then-round path is preserved because dividing typical inputs by 10**|ndigits| produces genuine half-integer ties rather than synthesizing them.

Verification (CPython 3.14.4)

Probe set Cases Match
Targeted half-tie probes (positive ndigits) 34 34/34
Negative ndigits regression 17 17/17
Special values (NaN, ±Inf, ±0.0, NDIGITS bounds) 12 12/12
No-arg round(x) (different code path) 10 10/10
Subnormal / denormal 6 6/6
Random fuzz (mantissa × ndigits ∈ {-5..50}) 4332 4332/4332

Tests unmasked

Lib/test/test_float.py::RoundTestCase:

  • test_matches_float_format — rounds against float(format(x, '.{n}f')) across many values; this is exactly what the fix does internally.
  • test_previous_round_bugs — explicit "round-half-even" cases (e.g. round(25.0, -1) == 20.0).

Test runs

cargo run --release -- -m test test_float test_builtin test_decimal \
    test_fractions test_complex test_math test_format test_long test_int \
    test_statistics test_re

1573 tests pass, 0 regressions. All 188 extra_tests/snippets/*.py pass under the CI feature set (stdlib,importlib,stdio,encodings,sqlite,ssl-rustls,host_env).

A regression block is added to extra_tests/snippets/builtin_round.py.

Summary by CodeRabbit

  • Bug Fixes

    • Improved rounding to more closely match CPython’s behavior: better handling of halfway ties (banker’s rounding), negative ndigits, signed zeros, and non-finite inputs.
  • Tests

    • Expanded rounding tests to cover banker's rounding, negative ndigits, exact-half cases, NaN/infinity behavior, signed zero, and out-of-range ndigits.

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`.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 2, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: 741c2592-e9ac-4826-81f0-ef8d550e2a49

📥 Commits

Reviewing files that changed from the base of the PR and between 2310495 and f039f28.

📒 Files selected for processing (1)
  • crates/common/src/float_ops.rs
✅ Files skipped from review due to trivial changes (1)
  • crates/common/src/float_ops.rs

📝 Walkthrough

Walkthrough

Rewrites round_float_digits() to mirror CPython: early non-finite returns, clamps ndigits, uses format+parse for ndigits >= 0, divide-then-round with explicit 0.5 tie correction for ndigits < 0, and validates finiteness of the result; adds tests exercising banker's rounding and edge cases.

Changes

Float Rounding Implementation

Layer / File(s) Summary
Imports
crates/common/src/float_ops.rs
Removed unused Float and Zero from num_traits imports.
Behavioral/Data Shape
crates/common/src/float_ops.rs
Added early return for non-finite inputs and computed NDIGITS_MAX/NDIGITS_MIN bounds for clamping ndigits.
Core Implementation
crates/common/src/float_ops.rs
Replaced previous pow-splitting/overflow-avoidance and special-case ndigits == 0 logic. For ndigits >= 0 use format!("{:.*}", ndigits, x) then parse() back to f64; for ndigits < 0 use divide-then-round via powi with explicit 0.5 tie handling (banker's rounding). Final result is checked for finiteness and mapped to None if non-finite.
Tests / Examples
extra_tests/snippets/builtin_round.py
Added import math and ~50 assertions covering decimal banker's rounding (including IEEE-754 artifacts), exact-halfway ties, negative ndigits, NaN/infinity behavior with ndigits, signed zero preservation, and out-of-range ndigits short-circuit cases.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested reviewers

  • ShaharNaveh
  • youknowone

Poem

🐇 I hop on floats both near and far,

I nudge the digits, round like a star —
Banker's balance, ties resolved clean,
Signed zeros kept, and NaN sits serene.
Hop, hop, let's ship this rounding scene!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly describes the main change: updating float rounding to match CPython's behavior using decimal-level rounding via _Py_dg_dtoa.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
Review rate limit: 7/8 reviews remaining, refill in 7 minutes and 30 seconds.

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 2, 2026

📦 Library Dependencies

The following Lib/ modules were modified. Here are their dependencies:

[x] test: cpython/Lib/test/test_float.py (TODO: 4)
[x] test: cpython/Lib/test/test_strtod.py (TODO: 6)

dependencies:

dependent tests: (no tests depend on float)

Legend:

  • [+] path exists in CPython
  • [x] up-to-date, [ ] outdated

Copy link
Copy Markdown
Contributor

@ShaharNaveh ShaharNaveh left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

overall lgtm!
I had a minor nitpick

tysm for the patches:)

Comment thread crates/common/src/float_ops.rs Outdated
let pow1 = 10.0f64.powi(-ndigits);
let y = x / pow1;
let z = y.round();
#[allow(clippy::float_cmp)]
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
#[allow(clippy::float_cmp)]
#[expect(clippy::float_cmp, reason = "...")]

Can you please change it to expect and add a reason to this cfg?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good catch i'll keep this convention following prs too thanks !

Copy link
Copy Markdown
Contributor

@ShaharNaveh ShaharNaveh left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tysm:)

@changjoon-park
Copy link
Copy Markdown
Contributor Author

tysm:)

thanks for the quick review !!

@youknowone youknowone merged commit c2910c0 into RustPython:main May 2, 2026
21 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants