Skip to content

fix: serialise NumPy scalars in search output rather than crashing - #1479

Merged
Jammy2211 merged 1 commit into
mainfrom
claude/autofit-save-json-numpy
Aug 16, 2026
Merged

fix: serialise NumPy scalars in search output rather than crashing#1479
Jammy2211 merged 1 commit into
mainfrom
claude/autofit-save-json-numpy

Conversation

@Jammy2211

Copy link
Copy Markdown
Collaborator

A successful fit could die at its output step. DirectoryPaths.save_json and Samples.info_to_json both used a bare json.dump, and there is no JSON encoder anywhere in autofit/, so a single NumPy scalar in an otherwise ordinary dict raised:

TypeError: Object of type float32 is not JSON serializable

Reproduced against the unfixed tree:

paths.save_json(name="c", object_dict={"clipped": np.float32(4.0)})
# TypeError: Object of type float32 is not JSON serializable

Why it stayed hidden

np.float64 subclasses Python's float, so json serialises it without help. np.float32 subclasses nothing json knows — and neither does np.int32/np.int64 (not a Python int on every platform) or np.bool_.

So a float64 run is fine, and a float32 run is fine right up until the moment it writes its results — at which point the entire computation is thrown away at the last step.

It was found on the imaging/mge profiling cell during the Clipper prototype (autolens_profiling#128), on a code path that cell had apparently never taken: it did not fire while 14 of 16 lanes were dead, because the float32 values never reached the saved object. It fires precisely when lane survival improves — so it stands directly in the way of the phase-2 Clipper validation campaign, which exists to make lanes survive.

The fix

NumpyEncoder in autofit/tools/util.py:

  • np.ndarraytolist()
  • np.genericitem() (float32/64 → float, any int width → int, bool_bool)
  • anything else deferred to the base class, so a genuinely unserialisable object still raises

Wired into both output-path writers: DirectoryPaths.save_json and Samples.info_to_json.

Coercion belongs at the encoder rather than at each producer. The producers are search-specific and new ones keep appearing — samples_info in particular is a search's own diagnostic channel, so every new counter added to it is another chance to reintroduce this. The encoder closes the class; chasing producers closes one instance.

Guarantees held

  • float64 is unchanged, asserted byte-for-byte against a bare json.dumps.
  • No invented precision.item() widens float32 to a Python double, and the test asserts the result equals float(np.float32(0.1)) rather than a re-rounded decimal.
  • Still strictjson.dumps({"x": object()}, cls=NumpyEncoder) raises.

Testing

13 new tests in test_autofit/tools/test_numpy_encoder.py, covering the parametrised scalar matrix (float16/32, int32/64, uint8, bool_), arrays, the float64 no-op, precision, the still-raises case, and both output-path writers end to end.

Full suite: 1804 passed, 4 skipped, 1 failed. The failure (test_nautilus.py::test__single_core_builds_no_pool) is pre-existing and reproduces identically on a clean tree.

Related

Independent of #1478 (branched from main, no overlap), but the two meet in samples_info: that PR adds counters to it, and this one makes the dict safe to write whatever a search puts there.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FzF2XmKQaqZRWZfZMxvTNR


Generated by Claude Code

A successful fit could die at its output step. `DirectoryPaths.save_json` and
`Samples.info_to_json` both used a bare `json.dump`, and there was no JSON
encoder anywhere in `autofit/`, so a single NumPy scalar in an otherwise
ordinary dict raised:

    TypeError: Object of type float32 is not JSON serializable

Reproduced against the unfixed tree: `paths.save_json(name="c",
object_dict={"clipped": np.float32(4.0)})` raises exactly that.

Why it stayed hidden. `np.float64` subclasses Python's `float`, so `json`
serialises it without help; `np.float32` subclasses nothing `json` knows, and
neither does `np.int32`/`np.int64` (not a Python `int` on every platform) or
`np.bool_`. A float64 run is fine, and a float32 run is fine right up until the
moment it writes its results -- at which point the whole computation is thrown
away at the last step.

It was found on the `imaging/mge` profiling cell during the Clipper prototype
(autolens_profiling#128), on a code path that cell had apparently never taken:
it did not fire while 14 of 16 lanes were dead, because the float32 values
never reached the saved object. It fires precisely when lane survival improves,
so it stands directly in the way of the phase-2 Clipper validation campaign.

Adds `NumpyEncoder` in `autofit/tools/util.py` -- `np.ndarray` -> `tolist()`,
`np.generic` -> `item()`, anything else deferred to the base class so a
genuinely unserialisable object still raises. Wired into both output-path
writers.

Coercion belongs at the encoder rather than at each producer. The producers are
search-specific and new ones keep appearing: `samples_info` in particular is a
search's own diagnostic channel, so every new counter added to it is another
chance to reintroduce this. The encoder closes the class; chasing producers
closes one instance.

`float64` is unchanged, asserted byte-for-byte against a bare `json.dumps`, and
`.item()` widens float32 to a Python double without inventing precision.

13 new tests. Full suite: 1804 passed, 4 skipped, 1 failed -- the failure
(`test_nautilus.py::test__single_core_builds_no_pool`) is pre-existing and
reproduces identically on a clean tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FzF2XmKQaqZRWZfZMxvTNR
@Jammy2211
Jammy2211 merged commit b6e89cd into main Aug 16, 2026
3 checks passed
Jammy2211 pushed a commit that referenced this pull request Aug 16, 2026
Resolves the one conflict, in `DirectoryPaths.save_json`, by keeping BOTH
fixes rather than either: #1479's `cls=NumpyEncoder` and this branch's
`open_atomic`. They answer different halves of the same incident -- the
encoder stops the float32 write failing, the atomic write stops ANY failed
write destroying the previous file -- so taking one alone would reintroduce
the other's bug.

Full suite on the merged result: 1819 passed, 4 skipped, 1 failed (the
pre-existing nautilus single-core pool test).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FzF2XmKQaqZRWZfZMxvTNR
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