Skip to content

Commit eda405b

Browse files
committed
Fix intword() rounding carry for very large numbers
intword() detected the post-rounding carry into the next magnitude with 'rounded_value * power == powers[ordinal + 1]'. Above ~10**22 that product is evaluated in floating point and no longer equals the exact next power, so the carry was skipped and the value was rendered against the lower magnitude: intword(10**24 - 1) returned '1000.0 sextillion' instead of '1.0 septillion' (same for 10**27, 10**30, 10**33). Compare rounded_value against the exact integer ratio powers[ordinal+1] // power instead, keeping the comparison in exact integer/short-float terms. This mirrors the recently fixed carry handling in metric() (#328) and naturalsize() (#329).
1 parent 4c85c35 commit eda405b

2 files changed

Lines changed: 47 additions & 2 deletions

File tree

src/humanize/number.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -255,8 +255,12 @@ def intword(value: NumberOrString, format: str = "%.1f") -> str:
255255
chopped = value / power
256256
rounded_value = float(format % chopped)
257257

258-
if not largest_ordinal and rounded_value * power == powers[ordinal + 1]:
259-
# After rounding, we end up just at the next power
258+
if not largest_ordinal and rounded_value == powers[ordinal + 1] // power:
259+
# After rounding, we end up just at the next power. Compare against the
260+
# integer ratio between the two powers instead of ``rounded_value * power``:
261+
# for values above ~10**22 the latter is evaluated in floating point and
262+
# no longer equals the exact ``powers[ordinal + 1]``, so the carry was
263+
# silently skipped (e.g. 10**24 - 1 rendered as "1000.0 sextillion").
260264
ordinal += 1
261265
rounded_value = 1.0
262266

tests/test_number.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,47 @@ def test_intword(test_args: list[str], expected: str) -> None:
141141
assert humanize.intword(*test_args) == expected
142142

143143

144+
def test_intword_rounding_rollover() -> None:
145+
"""Values that round up to the next power must carry to the next unit.
146+
147+
Regression: for magnitudes above ~10**22 the carry was checked in floating
148+
point (``rounded_value * power == powers[ordinal + 1]``) and no longer
149+
matched the exact integer power, so e.g. ``10**24 - 1`` was rendered as
150+
"1000.0 sextillion" instead of "1.0 septillion".
151+
"""
152+
units = [
153+
"thousand",
154+
"million",
155+
"billion",
156+
"trillion",
157+
"quadrillion",
158+
"quintillion",
159+
"sextillion",
160+
"septillion",
161+
"octillion",
162+
"nonillion",
163+
"decillion",
164+
]
165+
# value = 10**e - 1 rounds up to 10**e with "%.1f"/"%.0f", i.e. exactly 1.0
166+
# of the unit sitting at 10**e (units[i] where 10**e == powers[i]).
167+
for i, exponent in enumerate(range(6, 34, 3), start=1):
168+
value = 10**exponent - 1
169+
assert humanize.intword(value) == f"1.0 {units[i]}"
170+
assert humanize.intword(value, "%.0f") == f"1 {units[i]}"
171+
172+
# The mantissa must never render at or above 1000 for values below a
173+
# decillion; a bare "1000.0" is only expected in the sparse gap between
174+
# decillion and googol, which has no dedicated unit.
175+
for exponent in range(6, 34, 3):
176+
rendered = humanize.intword(10**exponent - 1)
177+
mantissa = float(rendered.split(" ", 1)[0])
178+
assert mantissa < 1000
179+
180+
# The documented decillion..googol gap must be left untouched.
181+
assert humanize.intword(10**36) == "1000.0 decillion"
182+
assert humanize.intword(2 * 10**100) == "2.0 googol"
183+
184+
144185
@pytest.mark.parametrize(
145186
"test_input, expected",
146187
[

0 commit comments

Comments
 (0)