Skip to content

fix: route every pd.to_numeric call through a shared exponent-overflow guard - #409

Merged
kevincostner17 merged 1 commit into
mainfrom
fix/to-numeric-shared-guard
Sep 15, 2026
Merged

kevincostner17 merged 1 commit into
mainfrom
fix/to-numeric-shared-guard

Conversation

@kevincostner17

@kevincostner17 kevincostner17 commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Summary

Builds on the merged #407. pandas < 3 can segfault in pd.to_numeric on text that starts with a scientific-notation token whose exponent overflows a C int, even when trailing text follows (for example "81e3104049863b72"). #407 guarded dtype inference only. About 30 other direct pd.to_numeric calls in src/ could still pass such text to the parser and crash the process: domain validators, fieldcheck, CSV leading-zero detection, time-series scoring, MissForest, semantic checks and learning.

  • New helper. freshdata._numeric.safe_to_numeric(values, **kwargs) keeps unsafe cells away from pandas and otherwise forwards to pd.to_numeric unchanged (errors=, downcast=, dtype_backend=; index, name and dtype preserved). It accepts a Series, Index, array-like or scalar.
  • Which cells are guarded. Only cells whose leading exponent has ten or more significant digits, the smallest size that can overflow pandas' C int exponent accumulator.
    • Every shorter exponent parses exactly as pandas parses it, including subnormal and out-of-range values.
    • Guarded cells become missing with errors="coerce", raise pandas' own Unable to parse string error with errors="raise", and come back unchanged with errors="ignore".
  • Cost. Numeric, boolean and datetime dtypes skip the check. Text is screened as one joined string, and only a column containing an exponent marker followed by ten digits pays for the per-cell check.
  • Shared bound. The guard pieces move into _numeric.py, and steps/dtypes.py imports them, so dtype inference uses the same bound.
  • Call sites. 29 call sites are migrated, plus the dtypes guard. Calls on provably numeric dtypes stay direct: streaming/_state.py, streaming/_drift.py, imputation/missforest.py _features, and steps/memory.py.

Behaviour change: dtype inference now keeps valid subnormal and underflow values (e.g. "4.9e-324", "1e-310", and "5e-400" → 0.0). The previous guard masked any exponent beyond ±308 and dropped them to missing.

Root cause

pandas < 3 precise_xstrtod accumulates the exponent digits in a C int (n = n * 10 + digit, up to 17 digits). It adds that to a mantissa adjustment with no overflow check, and does so before it rejects trailing text. See pandas-dev/pandas#62617, #63089 and #63167; the fix is in pandas 3.0 via pandas-dev/pandas#62741. This repo pins pandas<3. #407 fixed the prefix match in dtype inference; this PR applies the guard to every other call site and narrows it to exponents that can actually overflow.

Tests

tests/test_numeric.py:

  • Parity with pd.to_numeric in every errors= mode and with downcast, across Series, Index, list, ndarray and scalars. Inputs are 4,000 seeded hex tokens plus subnormal, underflow, out-of-range and 9- vs 10-digit exponent tokens. The raw pandas baseline runs in a child interpreter, so a missed crash token fails the test instead of killing the run.
  • The exponent bound. 1e999999999 and 1e0000000001 are not guarded. 1e1000000000, 1e2147483648 and a 5000-digit exponent are.
    • The one difference from pandas is 1e-1000000000. It has a ten-digit exponent, so it is guarded, while pandas underflows it to 0.0. A test records this.
  • Edge cases. object, string, bytes, categorical, NaN/None, empty input, numeric passthrough, downcast, dtype_backend (pandas ≥ 2), invalid arguments and 2-D input.
  • Crash-token tripwire. A tripwire on pd.to_numeric checks that coerce, raise and ignore never hand an overflowing exponent to pandas.
  • Public APIs. A child process (-X faulthandler, with a timeout) runs fd.validate_fields, run_domain(..., "finance") and fd.clean on crash tokens, with the same tripwire.
  • Static guard. It parses src/freshdata and fails on any to_numeric reference outside _numeric.py, the call sites left for a follow-up, and the justified numeric-only sites.

tests/test_dtypes.py:

Verification

  • ruff: all checks passed. mypy src/freshdata: no issues (204 files).
  • pytest -m "not online and not large":
    • py3.12 / pandas 2.3.3: 5875 passed, 14 skipped
    • py3.9 / pandas 1.5.3: 5846 passed, 18 skipped
    • Changed test files run 5× on each interpreter, all green.
  • Raw pandas vs safe_to_numeric, identical on pandas 2.3.3 and 1.5.3:
Token pandas coerce helper coerce pandas raise helper raise
4.9e-324 5e-324 5e-324 5e-324 5e-324
1e-320 1e-320 1e-320 1e-320 1e-320
1e-310 1e-310 1e-310 1e-310 1e-310
5e-400 0.0 0.0 0.0 0.0
2.2e-308 2.2e-308 2.2e-308 2.2e-308 2.2e-308
1e309 / 1e400 / -1e400 nan nan ValueError ValueError
7e123456789 / 1e999999999 nan nan ValueError ValueError
1e0000000001 10.0 10.0 10.0 10.0
1e2147483648 (crashes pandas < 3) nan (crashes pandas < 3) ValueError
  • Benchmark, 1M rows. Object-column timings are medians of 15 interleaved runs.
Column Call pandas 2.3.3 pandas 1.5.3
Object, no e raw pd.to_numeric 147 ms 136 ms
Object, no e safe_to_numeric 164 ms 156 ms
Object, no e dtype-inference guard, before → after 231 → 162 ms 199 → 156 ms
float64 raw and helper 0.01 ms 0.02 ms

On a text column, the helper adds about 17 ms per 1M rows. That is the fixed cost of the joined-string screen. Float columns cost nothing.

  • Against the unmigrated code, the public-API tripwire fired for validate_fields and the finance domain validator.

Remaining call sites

Left for a follow-up PR:

  • src/freshdata/enterprise/contracts.py:779
  • src/freshdata/enterprise/contracts.py:877
  • src/freshdata/enterprise/contracts.py:948
  • src/freshdata/enterprise/contracts.py:1793

When migrating these, remove their entries from _DEFERRED in tests/test_numeric.py.

@coderabbitai

coderabbitai Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 7473c5ad-8bee-4f8e-a147-055c20cbd6ef


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.

@github-actions

Copy link
Copy Markdown

FreshData benchmark report — performance

  • freshdata: ?
  • python: ?
  • platform: ?
fixture n_rows n_cols p50 s p95 s peak MB repair % false-repair % preserve % trust monotonic export %

Authored-code reduction (Metric 6)

pandas < 3 reads the exponent digits of a numeric string into a C int with
no overflow check, before it rejects trailing text (pandas-dev/pandas#62617,
#63089, #63167; fixed in pandas 3.0 by pandas-dev/pandas#62741). #407 masked
such cells in dtype inference only; about 30 other pd.to_numeric calls
(domain validators, fieldcheck, CSV leading-zero detection, time-series
scoring, MissForest, semantic checks, learning) still handed hash-like text
such as "81e3104049863b72" straight to the parser and could crash the
process.

Add freshdata._numeric.safe_to_numeric, which keeps those cells away from
pandas and otherwise forwards to pd.to_numeric unchanged (errors=,
downcast=, dtype_backend=, index, name and dtype). A cell is guarded only
when its leading exponent has ten or more significant digits, the smallest
size that can overflow the C int accumulator; every shorter exponent,
including subnormal and out-of-range values, is parsed exactly as pandas
parses it. Numeric, boolean and datetime inputs skip the check; text is
screened as one joined string, so clean columns stay close to free.

The guard pieces move there and dtypes.py imports them, so dtype inference
uses the same bound and keeps valid subnormal and underflow values.

Calls on provably numeric dtypes stay direct, and a static test fails when
a new unguarded call appears.
@kevincostner17
kevincostner17 force-pushed the fix/to-numeric-shared-guard branch from a02b481 to 7a651fd Compare September 15, 2026 13:22
@kevincostner17
kevincostner17 merged commit 6967aa8 into main Sep 15, 2026
22 checks passed
kevincostner17 added a commit that referenced this pull request Sep 15, 2026
…#417)

On pandas < 3, pd.to_numeric reads the exponent digits of a numeric
string into a C int with no overflow check, before it rejects trailing
text (pandas-dev/pandas#62617). A cell such as "81e3104049863b72" can
segfault the process whatever errors= says.

enterprise/contracts.py still had four direct calls. The one in
_contract_values runs on any non-datetime column with min_value or
max_value, so a text column holding such a token reached the parser from
enforce_contract and compare_to_baseline(contract=...). The other three
(_profile_column, _ks_statistic, _psi_numeric) only see int/float-family
columns, but go through the guard too so the file needs no exemption.
safe_to_numeric hands input with no unsafe cell to pandas untouched, so
results are unchanged.

This completes the migration from #409: _DEFERRED in tests/test_numeric.py
is now empty and the static guard covers contracts.py. New tests run
enforce_contract, build_baseline and compare_to_baseline on crash tokens
in a child interpreter behind a to_numeric tripwire, and check parity
with raw pd.to_numeric on ordinary frames.
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.

1 participant