Skip to content

fix(serialization): write specs as strict JSON without NaN tokens - #134

Open
ChrisW09 wants to merge 1 commit into
mainfrom
fix/80-strict-json-specs
Open

ChrisW09 wants to merge 1 commit into
mainfrom
fix/80-strict-json-specs

Conversation

@ChrisW09

@ChrisW09 ChrisW09 commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #80.

Every spec that to_spec(path) wrote for an imputing Preprocessor, which is the default, contained bare NaN tokens for the imputers' missing_values=np.nan. RFC 8259 does not allow these, so strict JSON parsers (JavaScript's JSON.parse, Go, Rust, Jackson with its default settings) rejected the file. Only Python's own reader accepted it.

Root cause

_encode passed Python and numpy floats, float arrays and object-array elements through unchanged, including NaN and ±inf. to_spec(path) and fingerprint_ then dumped them with the default allow_nan=True, which writes NaN, Infinity and -Infinity tokens.

Fix

  • The new _encode_float (pretab/compose/serialize.py) tags a non-finite float as {"__float__": "nan"} (or "inf", "-inf"). _encode applies it to Python floats and np.floating scalars, and, through the new _map_elements helper, to the elements of a float array that holds a non-finite value. _json_safe, which builds the informational params block, tags floats as well.
  • On main, object arrays are written with a raw tolist(), and a NaN category of an un-imputed one-hot encoder sits in such an array. _encode_object_element tags the non-finite float elements of object arrays and leaves every other element as main writes it.
  • _decode turns the tag back into a float, and _decode_ndarray restores tagged elements of float and object arrays (_decode_float_element).
  • Preprocessor.to_spec(path) and Preprocessor.fingerprint_ dump with allow_nan=False.

Behaviour changes

  • Spec files and to_spec() dicts contain {"__float__": "nan"} (or "inf", "-inf") where they had NaN, Infinity or -Infinity, including in the params block. The files parse with any RFC 8259 parser.
  • fingerprint_ changes for every fitted preprocessor whose state holds a non-finite float, which is every preprocessor with an imputer. Preprocessors without imputers keep their fingerprint.
  • Specs written by 1.0.0 still load: Python's reader accepts their NaN tokens, and untagged floats decode as before. Checked with specs written by main for four configurations (default, one-hot with a NaN category, add_missing_indicator=True with bspline, ple; 5 to 9 NaN tokens each): on this branch each loads, reproduces transform byte for byte and re-saves as strict JSON. Its fingerprint is the new one, equal to that of the same fit made on this branch.

Documentation

  • docs/core_concepts/reproducibility.md ("Portable serialization"): the spec is strict JSON, non-finite floats are stored as a tagged value, and specs written by PreTab 1.0.0 with bare NaN tokens still load.
  • pretab/compose/serialize.py module docstring: strict JSON and the tag.
  • Preprocessor.to_spec docstring, path parameter: written as strict UTF-8 JSON, with the tag format.
  • Preprocessor.from_spec docstring, source parameter: specs written by PreTab 1.0.0 still load.
  • Docstrings of the new helpers.

Tests

  • test_default_spec_file_is_strict_json: a default Preprocessor fitted on data with missing values writes a file without NaN / Infinity that parses with a strict parser and reloads with identical output and fingerprint. Fails on main, passes here.
  • test_nan_category_in_an_object_array_is_strict_json: one-hot encoding without categorical imputation keeps NaN in the object array categories_; the spec is strict JSON, contains the tag, and reloads with identical output and fingerprint. Fails on main, passes here.
  • test_non_finite_floats_round_trip: the array [[1, nan], [inf, -inf]] and the scalars nan and -inf survive a strict-JSON round trip, with the array's dtype. Fails on main, passes here.
  • test_specs_with_bare_nan_tokens_still_load: a spec whose tags are rewritten to bare NaN, as 1.0.0 wrote them, loads and reproduces the output. Guard test: passes on both by design.

Reproduction

Output of the script from #80 on this branch:

NaN tokens in spec: 0
strict parse: ok
tagged values in spec: 4
node JSON.parse: ok

The script from the issue, plus a count of the tagged values and the node -e 'JSON.parse(...)' check that the issue describes.

Checks

  • just test: 1442 passed, 59 skipped, 7 xfailed; coverage 93.69% (gate 90%).
  • Minimum dependencies (CI job: Python 3.10, numpy 1.24.4, pandas 2.0.3, scipy 1.10.1, scikit-learn 1.6.0): 1429 passed, 72 skipped, 7 xfailed; scripts/quickstart.py passes.
  • just docs: strict build succeeds.
  • just check: all pre-commit and pre-push hooks pass (ruff format, ruff lint, prettier, file hygiene, pyright with 0 errors).
  • Import direction between subpackages is unchanged.

Merge notes

This change was previously part of #86, which is closed in favour of one pull request per issue.

The imputers' missing_values=np.nan (and any other non-finite float in fitted
state) was written as a bare NaN token, so every default spec file was rejected
by strict JSON parsers outside Python (JavaScript, Go, Rust, JSON schema
validators). The encoder passed floats and float arrays through unchanged, and
the spec was dumped with the default allow_nan=True.

Non-finite floats, as scalars, as float-array elements and as float elements
of object arrays (e.g. a NaN category of an un-imputed one-hot encoder), are
now encoded as a tagged value such as {"__float__": "nan"} and decoded back,
and specs are dumped with allow_nan=False, both by to_spec(path) and for
fingerprint_. Specs written by earlier versions, with bare NaN tokens, still
load and reproduce the same output. fingerprint_ changes for every fitted
preprocessor whose state holds a non-finite float (every one with an imputer).

The to_spec / from_spec docstrings, the serialize module docstring and the
reproducibility guide describe the strict format.

Fixes #80

This branch has not been deployed

No deployments
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.

Default to_spec() files contain bare NaN tokens and are not valid JSON for strict parsers

1 participant