Skip to content

Fix complex repr to use scientific notation for large integer-valued components#7634

Merged
youknowone merged 2 commits into
RustPython:mainfrom
changjoon-park:fix-complex-repr-exp
Apr 20, 2026
Merged

Fix complex repr to use scientific notation for large integer-valued components#7634
youknowone merged 2 commits into
RustPython:mainfrom
changjoon-park:fix-complex-repr-exp

Conversation

@changjoon-park

@changjoon-park changjoon-park commented Apr 20, 2026

Copy link
Copy Markdown
Contributor

Summary

repr() on a complex number whose real or imaginary part is an integer-valued float with |x| >= 1e16 emitted the full decimal expansion instead of scientific notation. CPython uses scientific on the same input.

# Before
>>> repr(1e100 + 1e100j)
'(1000000000000000015902891109759918046836080856394528138978132755774...<100 zeros>+1000000000000000015902891109759918046836080856394528138978132755774...<100 zeros>j)'

# CPython / After
>>> repr(1e100 + 1e100j)
'(1e+100+1e+100j)'

Root cause

crates/literal/src/complex.rs::to_string bifurcated each component by .fract() == 0.0:

let mut im_part = if im.fract() == 0.0 {
    im.to_string()          // Rust's default Display — never scientific
} else {
    float::to_string(im)    // Python-style: scientific for |x| < 1e-4 or |x| >= 1e16
};

Integer-valued f64 values — including 1e16, 1e17, 1e100 which are all exactly representable as integers — took the im.to_string() branch and rendered as full decimal expansion. Non-integer magnitudes took float::to_string and rendered correctly. The bifurcation didn't capture CPython's actual rule ("scientific iff |x| < 1e-4 or |x| >= 1e16"); it happened to be correct only when the two conditions coincidentally aligned.

Fix

Replace the two-branch logic with one helper that implements CPython's actual PyOS_double_to_string(format='r') rule:

fn component_to_string(value: f64) -> String {
    let lit = alloc::format!(\"{value:e}\");
    if let Some(position) = lit.find('e') {
        let significand = &lit[..position];
        let exponent = lit[position + 1..].parse::<i32>().unwrap();
        if exponent < 16 && exponent > -5 {
            value.to_string()                       // normal range
        } else {
            alloc::format!(\"{significand}e{exponent:+#03}\")  // scientific
        }
    } else {
        // nan / inf / -inf
        let mut s = value.to_string();
        s.make_ascii_lowercase();
        s
    }
}

The threshold matches float::to_string; the only behavioral difference is that complex components render 1.0 as \"1\" rather than \"1.0\" — matching CPython's (1+2j) convention rather than (1.0+2.0j).

Verification

Byte-identical CPython parity

Probe Cases Result
Primary regression set (normal / boundary / mixed magnitudes / special) 29 ✅ 29/29
Edge cases (subnormal 5e-324, f64::MAX, MIN_POSITIVE, DBL_EPSILON, threshold-straddling) 18 ✅ 18/18

Test suites

$ cargo run -- -m test test_complex
Ran 37 tests — all pass

$ cargo run -- -m test test_float test_long
Ran 101 tests (9 skipped) — all pass

$ cargo run -- -m unittest test.test_complex.ComplexTest.test_repr_str \\
      test.test_complex.ComplexTest.test_negative_zero_repr_str \\
      test.test_complex.ComplexTest.test_repr_roundtrip
Ran 3 tests in 0.004s — OK

ast unparse

The other caller of literal::complex::to_string is codegen::unparse.rs, which generates Python source from an AST. Round-trip of source containing complex literals:

$ rustpython -c 'import ast; print(ast.unparse(ast.parse(\"x = 1e100 + 1e-100j; y = 1e17 + 1j\")))'
x = 1e+100 + 1e-100j
y = 1e+17 + 1j
$ python3 -c 'import ast; print(ast.unparse(ast.parse(\"x = 1e100 + 1e-100j; y = 1e17 + 1j\")))'
x = 1e+100 + 1e-100j
y = 1e+17 + 1j

Regression snippet

extra_tests/snippets/builtin_complex.py now pins expected output at the threshold boundaries, at extremes (1e100, -1e100), with mixed magnitudes, and at the integer-vs-float rendering boundary (1+2j, 1.0+2.0j → both (1+2j)).

Scope

  • In: crates/literal/src/complex.rs::to_string (the single function that renders complex repr/str/AST constants). Both callers (PyComplex::Representable and codegen::unparse) receive the fix transitively.
  • Out: crates/common/src/format.rs::format_complex — this handles format-spec types ('{:.5e}'.format(c)) and is an independent code path. Its current output already matches CPython byte-identically across boundary precisions (verified in a separate audit). Not touched here.

Related

  • The pre-existing TODO comment at the top of PyComplex::repr_str noted the intent to consolidate complex formatting; keeping the helper in rustpython_literal::complex matches where existing callers already look for it.

Summary by CodeRabbit

  • Bug Fixes

    • Refined complex-number string formatting to match CPython rules: use scientific notation at very large/small magnitudes, omit trailing .0 for integer-valued components, and normalize NaN/Infinity rendering and signs.
  • Tests

    • Added tests covering boundary transitions and comprehensive edge cases for complex number repr formatting.

…components

repr of a complex number whose real or imaginary part is an integer-valued
float with |x| >= 1e16 emitted the full decimal expansion instead of
scientific notation, diverging from CPython:

  Before (RustPython):
    repr(1e100 + 1e100j)
      (10000000000000000000000000000000000000000000000000000000000
       0000000000000000000000000000000000000000000+1000000000000000
       000000000000000000000000000000000000000000000000000000000000
       00000000000000000000000000000000000000j)

  After / CPython:
    (1e+100+1e+100j)

Root cause in crates/literal/src/complex.rs::to_string — it bifurcated
each component by .fract() == 0.0:

  if im.fract() == 0.0 { im.to_string() }       // Rust's default Display
  else                 { float::to_string(im) } // scientific for large/small

Rust's Display never uses scientific notation, so any integer-valued f64
(including 1e16, 1e17, 1e100 which are exactly representable as integers)
routed through the wrong branch and produced the full decimal expansion.
Non-integer magnitudes reached float::to_string and rendered correctly.

The fix is to use one helper per component that implements CPython's
actual PyOS_double_to_string(format='r') rule: scientific notation when
|x| < 1e-4 or |x| >= 1e16, otherwise Rust's default Display (which drops
the trailing '.0' for integer-valued floats — matching CPython's
(1+2j) convention rather than (1.0+2.0j)). The threshold matches
float::to_string; the only behavioral difference is that complex
components render 1.0 as "1" rather than "1.0".

Verified:
  * 29 CPython reference cases (normal / boundary / extremes / special /
    signed-zero) — all byte-identical after fix.
  * 18 additional edge cases (subnormal 5e-324, f64::MAX, MIN_POSITIVE,
    DBL_EPSILON, threshold-straddling values) — all byte-identical.
  * Lib/test/test_complex.py::test_repr_str /
    test_negative_zero_repr_str / test_repr_roundtrip — all pass.
  * cargo run -- -m test test_complex — 37 passed.
  * cargo run -- -m test test_float test_long — 101 passed.
  * ast.unparse() round-trip of source containing complex literals
    (e.g. 1e100 + 1e-100j, 1e17 + 1j) produces CPython-identical output.
  * extra_tests/snippets/builtin_complex.py — 20+ new regression cases.
@coderabbitai

coderabbitai Bot commented Apr 20, 2026

Copy link
Copy Markdown
Contributor

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: 74e26d79-3070-45ad-b4a3-f8fe2dcb4842

📥 Commits

Reviewing files that changed from the base of the PR and between 962c20f and baf962a.

📒 Files selected for processing (1)
  • extra_tests/snippets/builtin_complex.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • extra_tests/snippets/builtin_complex.py

📝 Walkthrough

Walkthrough

Added a component_to_string(f64) helper to standardize complex component formatting (decimal vs. scientific, integer suppression, special values) and updated complex to_string to use it; extended tests with CPython-compatible repr() assertions covering magnitude thresholds and special float cases.

Changes

Cohort / File(s) Summary
Complex Number Formatting
crates/literal/src/complex.rs
Introduce internal component_to_string(f64) to format float components: use standard decimal for exponents in (-5, 16), zero-padded scientific otherwise, omit trailing .0 for integer-valued floats, and lowercase nan/inf. Update to_string(re, im) to use this helper consistently and remove prior fractional-value branches.
Complex Number Tests
extra_tests/snippets/builtin_complex.py
Add/expand repr() assertions for complex numbers to require scientific notation when component magnitude ≥1e16 or <1e-4, include boundary checks (1e15, 1e-4, 1e-5), verify omission of trailing .0 for integer-valued components, and assert nan/inf rendering and sign preservation.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 I hopped through floats, swift and neat,
Tamed exponents with tidy feet,
Integers shine with no .0 in sight,
NaN and Inf dressed lowercase and right,
Tests applaud the formatting light. ✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title accurately describes the main change: fixing complex number representation to use scientific notation for large integer-valued components, which is the core modification detailed in the code changes.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ 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

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

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@extra_tests/snippets/builtin_complex.py`:
- Around line 254-257: The comment above the three asserts using repr is
misleading: it currently claims "Values at the threshold boundaries must stay in
non-scientific form" but the test expects 1e-5 to use scientific notation;
update that comment to clearly state the rule (e.g., values >= 1e-4 should
remain in non-scientific decimal form while values < 1e-4 should use scientific
notation) and reference the three assertions (assert repr(1e15 + 1j), assert
repr(1e-4 + 1j), assert repr(1e-5 + 1j)) so readers understand why 1e-4 stays
non-scientific and 1e-5 becomes "1e-05".
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: 57228947-ad0b-4fe8-ac67-2850365963c0

📥 Commits

Reviewing files that changed from the base of the PR and between b18b71b and 962c20f.

📒 Files selected for processing (2)
  • crates/literal/src/complex.rs
  • extra_tests/snippets/builtin_complex.py

Comment thread extra_tests/snippets/builtin_complex.py Outdated
The comment claimed all three assertions stay in non-scientific form,
but the 1e-5 case explicitly verifies scientific notation (since
|1e-5| < 1e-4 falls outside the decimal-form range). Reworded the
header to describe the axis being tested (threshold boundary) and
added per-case inline notes indicating each assertion's expected
form.

@youknowone youknowone left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

👍

@youknowone youknowone merged commit af41d11 into RustPython:main Apr 20, 2026
20 checks passed
@changjoon-park changjoon-park deleted the fix-complex-repr-exp branch April 27, 2026 13:24
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.

2 participants