Skip to content

Commit 2ef045d

Browse files
Merge pull request #120 from FreshCode-Org/fix/int64-boundary-finalize-numeric-jwd
fix: exact int64 boundary handling in numeric finalization (#34)
2 parents a136c05 + 10685d6 commit 2ef045d

3 files changed

Lines changed: 66 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ adheres to [Semantic Versioning](https://semver.org/).
1515
count, and a dtype conversion alone no longer marks untouched values as
1616
changed. The elementwise fallback also no longer uses a Python-3.10-only
1717
`zip(strict=...)` argument, which crashed on Python 3.9 when reached (#30).
18+
- Integer finalization now checks the exact int64 range in integer space
19+
instead of a float magnitude threshold: `-2**63` and `2**63 - 1024` (the
20+
largest float64 below `2**63`) convert to int64/Int64 exactly instead of
21+
being demoted to float64, and values at or above `2**63` can never be
22+
admitted by float rounding (#34).
1823

1924
### Added
2025
- **AI Copilot (experimental)**`freshdata.experimental.ai_copilot.analyze_dataset`:

src/freshdata/steps/dtypes.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,25 @@ def _has_leading_zero_ids(sample: pd.Series) -> bool:
8282
)
8383

8484
# Largest float that safely round-trips through int64.
85-
_INT64_SAFE = float(2**63 - 1024)
85+
# int64's exact range. Bounds are compared in Python-int space (see
86+
# _fits_int64): float64 cannot represent 2**63 - 1 — it rounds up to 2**63 —
87+
# so any float-space threshold either rejects legitimate boundary values or
88+
# admits one that overflows .astype("int64").
89+
_INT64_MIN = -(2**63)
90+
_INT64_MAX = 2**63 - 1
91+
92+
93+
def _fits_int64(nonnull: pd.Series) -> bool:
94+
"""True when every value converts to int64 without wrapping.
95+
96+
Callers guarantee the series is integral (``% 1 == 0``), so ``int()`` on
97+
the min/max is an exact conversion; ``inf`` raises OverflowError and is
98+
reported as not fitting.
99+
"""
100+
try:
101+
return int(nonnull.min()) >= _INT64_MIN and int(nonnull.max()) <= _INT64_MAX
102+
except (OverflowError, ValueError):
103+
return False
86104

87105

88106
def _finalize_numeric(parsed: pd.Series) -> pd.Series:
@@ -91,7 +109,7 @@ def _finalize_numeric(parsed: pd.Series) -> pd.Series:
91109
is_integral = (
92110
len(nonnull) > 0
93111
and bool((nonnull % 1 == 0).all())
94-
and float(nonnull.abs().max()) < _INT64_SAFE
112+
and _fits_int64(nonnull)
95113
)
96114
if is_integral:
97115
return parsed.astype("int64") if not parsed.isna().any() else parsed.astype("Int64")

tests/test_dtypes.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import pytest
66

77
import freshdata as fd
8+
from freshdata.steps.dtypes import _finalize_numeric
89

910

1011
def clean1(values, **options):
@@ -156,3 +157,43 @@ def test_date_objects_normalized_to_datetime64():
156157
def test_huge_integers_stay_float_not_overflow(huge):
157158
s = clean1(huge)
158159
assert s.dtype == "float64"
160+
161+
162+
def test_finalize_numeric_int64_boundaries_no_overflow_no_demotion():
163+
"""Regression for #34: exact int64 boundary handling.
164+
165+
float64 cannot represent 2**63 - 1; it rounds up to 2**63, so any
166+
float-space threshold either rejects legitimate values or admits an
167+
overflowing one. The largest float64 below 2**63 is 2**63 - 1024 and
168+
must convert exactly; float(2**63) must stay float64, never wrap.
169+
"""
170+
top = float(2**63 - 1024)
171+
out = _finalize_numeric(pd.Series([top, 1.0]))
172+
assert str(out.dtype) == "int64"
173+
assert int(out.iloc[0]) == 2**63 - 1024
174+
175+
out = _finalize_numeric(pd.Series([float(2**63), 1.0]))
176+
assert str(out.dtype) == "float64"
177+
178+
out = _finalize_numeric(pd.Series([top, None]))
179+
assert str(out.dtype) == "Int64"
180+
assert int(out.iloc[0]) == 2**63 - 1024
181+
182+
183+
def test_finalize_numeric_int64_min_not_rejected_by_abs_asymmetry():
184+
"""int64's range is asymmetric: -2**63 is representable, +2**63 is not.
185+
A magnitude-only guard rejected the legitimate minimum."""
186+
bottom = float(-(2**63))
187+
out = _finalize_numeric(pd.Series([bottom, 0.0]))
188+
assert str(out.dtype) == "int64"
189+
assert int(out.iloc[0]) == -(2**63)
190+
191+
out = _finalize_numeric(pd.Series([float(-(2**64)), 0.0]))
192+
assert str(out.dtype) == "float64"
193+
194+
195+
def test_huge_integer_strings_still_stay_float_end_to_end():
196+
"""Pipeline-level guard: values beyond int64 parse to float64, exactly
197+
as before the boundary fix."""
198+
s = clean1(["18446744073709551616", "1"]) # 2**64
199+
assert s.dtype == "float64"

0 commit comments

Comments
 (0)