fix: serialise NumPy scalars in search output rather than crashing - #1479
Merged
Conversation
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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A successful fit could die at its output step.
DirectoryPaths.save_jsonandSamples.info_to_jsonboth used a barejson.dump, and there is no JSON encoder anywhere inautofit/, so a single NumPy scalar in an otherwise ordinary dict raised:Reproduced against the unfixed tree:
Why it stayed hidden
np.float64subclasses Python'sfloat, sojsonserialises it without help.np.float32subclasses nothingjsonknows — and neither doesnp.int32/np.int64(not a Pythoninton every platform) ornp.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/mgeprofiling 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
NumpyEncoderinautofit/tools/util.py:np.ndarray→tolist()np.generic→item()(float32/64 →float, any int width →int,bool_→bool)Wired into both output-path writers:
DirectoryPaths.save_jsonandSamples.info_to_json.Coercion belongs at the encoder rather than at each producer. The producers are search-specific and new ones keep appearing —
samples_infoin 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
float64is unchanged, asserted byte-for-byte against a barejson.dumps..item()widens float32 to a Python double, and the test asserts the result equalsfloat(np.float32(0.1))rather than a re-rounded decimal.json.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 insamples_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