fix: stamp the small-datasets regime into FITS headers at the writer funnel - #154
Merged
Merged
Conversation
…funnel
PYAUTO_SMALL_DATASETS=1 caps simulated datasets to a reduced resolution, but
nothing on disk recorded which regime a dataset was written under. A capped
dataset could therefore survive into a later full-resolution run and be loaded
silently -- the root cause of autolens_workspace_test#260.
PyAutoArray#471 closed the imaging half of that by inferring the regime from
data.fits's shape. Inference cannot reach interferometer datasets: their
visibility count is fixed by the committed uv file while the real-space grid
behind it is capped, so a capped run writes identical NAXIS with different
values and trips no assertion at all. A silent wrong answer in a likelihood is
worse than a loud one.
Record the regime at write time instead. A card written by the same call that
writes the data is truthful by construction, needs no call-site changes, and
does not depend on the data looking different -- the only discriminant that can
reach the interferometer case.
Stamped at BOTH funnel points, which is load-bearing rather than belt-and-braces:
- hdu_list_for_output_from covers the 14 sites in PyAutoGalaxy/PyAutoLens that
build an HDUList here and then call hdu_list.writeto directly, plus the 4
that hand it to PyAutoFit's paths.save_fits (verified to writeto the list
as-is, so the stamp survives into aggregator output).
- write_hdu_list covers HDULists built elsewhere. The public API steers
callers this way: hdu_list_for_output_from is re-exported as
aa./ag./al.hdu_list_for_output_from while write_hdu_list is re-exported by
none of them.
Stamping only output_to_fits, as the issue proposed, would have missed 16 of the
18 library write sites.
The card is written in BOTH regimes (T capped, F full) rather than only when
capped. Absence then means "unknown" -- a file written before this stamp
existed -- and readers fall back to their legacy heuristic. Absence must never
be read as "full resolution", or every pre-stamp dataset would masquerade as
full and the original bug would return.
Header assignment rather than append keeps the two stamp points idempotent, so
output_to_fits (which passes through both) writes exactly one card.
The added card is byte-size neutral in practice: a FITS header block holds 36
cards and a real dataset header carries ~10, so file sizes are unchanged and the
byte-size diagnostics used in autolens_workspace_test#260 still hold.
A 9-character keyword is silently promoted to HIERARCH by astropy rather than raising, and header.get() by the short name then returns None. Readers treat None as unknown and fall back to the shape heuristic, so an over-long key would not fail loudly -- it would quietly un-fix the interferometer case the stamp exists for. Comment plus a test that pins the ceiling.
test_autonerves/files/array_out.fits is a test write target that the suite rewrites on every run, so its committed bytes must match what the tests now produce. Verified on clean main that the tree stays clean there, i.e. this dirtying is introduced by the stamp rather than pre-existing.
It reaches every FITS the PyAuto libraries author -- all 18 library write sites build their HDUList through this module even when they call hdu_list.writeto themselves. It does not reach HDULists assembled by hand elsewhere (autofit/aggregator/summary/aggregate_fits.py) or workspace scripts using raw astropy (the lenstool converter in autolens_workspace). Those read as absent, which is the safe direction and exactly why absence must never mean full.
Every FITS the stack writes now carries a SMALLDAT card whose value tracks PYAUTO_SMALL_DATASETS at write time. Several tests write into TRACKED fixture paths -- a pre-existing pattern, 14 such files across this repo and PyAutoArray -- so the bytes those tests produce had become a function of the shell: running the suite with PYAUTO_SMALL_DATASETS=1 exported, which should_simulate's own docstring calls the default for most harness runs, passed but left the working tree dirty. Verified against fresh main worktrees that this dirtying is introduced by the stamp and is not pre-existing. An autouse fixture clearing the var restores the property the stamp took away -- test output is a function of the test, not of the environment -- in one place, rather than by rewriting every fixture-writing test in a PR about a header card. Tests that need a regime set it with monkeypatch.setenv in their body, which runs after the fixture and wins. No test depended on the ambient value. Verified: suite green and tree clean both with the var exported and unset. Found by three independent review lenses, each reproducing it separately.
This was referenced Aug 22, 2026
5 tasks
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.
Summary
Closes the write half of #153.
PYAUTO_SMALL_DATASETS=1caps simulated datasets to a reduced resolution, but nothing on disk recorded which regime a dataset was written under. A capped dataset could survive into a later full-resolution run and be loaded silently — the root cause of autolens_workspace_test#260.PyAutoArray#471 closed the imaging case by inferring the regime from
data.fits's shape. That inference is structurally blind to interferometer datasets: their visibility count is fixed by the committed uv file while the real-space grid behind it is capped, so a capped run writes adata.fitswith identicalNAXISand different values. It fails silently — no shape mismatch, no assertion. A silent wrong answer in a likelihood is worse than a loud one.This records the regime at write time instead. A card written by the same call that writes the data is truthful by construction, needs no call-site changes, and does not depend on the data looking different — the only discriminant that can reach the interferometer case.
The issue's premise was wrong about where the funnel is.
output_to_fitsis the only definition of that name, but not the only write path. The real inventory is 18 library FITS writes: 2 throughwrite_hdu_list, 14 in PyAutoGalaxy/PyAutoLens callinghdu_list.writetodirectly (neither repo even importswrite_hdu_list), and 4 handing the list to PyAutoFit'spaths.save_fits(verified atautofit/non_linear/paths/directory.py:131towritetoit as-is). All 18 build throughhdu_list_for_output_from.So the stamp goes in both functions, which is load-bearing rather than belt-and-braces:
hdu_list_for_output_fromcatches the 14 + 4,write_hdu_listcatches HDULists assembled elsewhere. The public API steers callers toward the bypass —hdu_list_for_output_fromis re-exported asaa./ag./al.hdu_list_for_output_from,write_hdu_listby none of them. Stamping onlyoutput_to_fits, as #153 proposed, would have missed 16 of 18.The card is written in both regimes (
Tcapped,Ffull), not only when capped. Absence then means unknown — written before the stamp existed — and readers fall back to their legacy heuristic. Absence must never read as "full resolution", or every pre-stamp dataset masquerades as full and the original bug returns.API Changes
Additive only; nothing removed, renamed, or resignatured.
Two new public helpers in
autonerves.fitsable(stamp_small_datasets_regime, plus theSMALLDATkey/comment constants) and one inautonerves.test_mode(small_datasets()).One behaviour change worth downstream attention:
hdu_list_for_output_fromandwrite_hdu_listnow add aSMALLDATcard to the headers they produce, so every FITS the libraries author gains one header card.write_hdu_listmutates the primary header of theHDUListit is passed, in place.See full details below.
Test Plan
python -m pytest test_autonerves/— 165 passedoutput_to_fits, multi-HDU builder, externally-builtHDUList) in both regimesT/F, not a string or float — pinned by test, becausebool("F")isTrueand readers must not coerceappend)header_dictcards (PIXSCAY/PIXSCAX/ORIGINY/ORIGINX) undisturbedPYAUTO_SMALL_DATASETS=1exported and unsetReviewed adversarially before opening: 6 diverse lenses + independent refuters, 18 findings, 13 refuted, and every survivor actioned. It caught a real one — the committed FITS fixtures had become regime-dependent, so running the suite under the harness default left a dirty tree. Fixed with an autouse
conftestfixture rather than rewriting the 14 tracked test write-targets, which would have buried a header-card change under a test refactor.Full API Changes (for automation & release notes)
Added
autonerves.test_mode.small_datasets()— returnsTruewhenPYAUTO_SMALL_DATASETS == "1". Sole reader of that env var in this package.autonerves.fitsable.stamp_small_datasets_regime(header)— records the regime into a FITS header in place, idempotently; returns the header.autonerves.fitsable.SMALL_DATASETS_HEADER_KEY—"SMALLDAT". Exactly 8 characters, and that ceiling is load-bearing: a 9-character keyword is silently promoted toHIERARCHby astropy rather than raising, andheader.get("SMALLDAT")then returnsNone, which readers treat as unknown. An over-long key would not fail loudly — it would quietly un-fix the interferometer case. Pinned by test.autonerves.fitsable.SMALL_DATASETS_HEADER_COMMENT— the card's comment string.Changed Behaviour
hdu_list_for_output_from(...)— stampsSMALLDATinto the header it builds, so every HDU it produces carries it.write_hdu_list(hdu_list, ...)— stampsSMALLDATintohdu_list[0].headerin place before writing. Callers holding a reference to the passedHDUListwill observe the added card. Guarded against an empty list.Not changed
output_to_fitsis untouched — it inherits the stamp via both functions it already calls.dictable.output_to_json) and CSV (csvable.output_to_csv) funnels are deliberately not stamped. Rationale and the evidence behind it are in the follow-up prompt filed in PyAutoMind; in short, of 146 non-testoutput_to_jsoncall sites only 18 serialise a dataset, and the two FITS-less dataset directories are either regime-invariant or excluded from harness execution today.Scope, stated honestly
The stamp reaches every FITS the PyAuto libraries author. It is not universal: PyAutoFit's aggregator assembles some HDULists by hand (
autofit/aggregator/summary/aggregate_fits.py), and workspace scripts using raw astropy (the lenstool converter in autolens_workspace) write unstamped files. Those read as absent → unknown → the safe direction, which is exactly why absence must never mean "full".Migration
None required. Existing FITS without the card read as "unknown regime" and consumers fall back to their prior behaviour.
Generated by the PyAutoLabs agent workflow.
Generated by Claude Code