Skip to content

fix: stamp the small-datasets regime into FITS headers at the writer funnel - #154

Merged
Jammy2211 merged 5 commits into
mainfrom
claude/small-datasets-regime-stamp-s3i9o7
Aug 22, 2026
Merged

fix: stamp the small-datasets regime into FITS headers at the writer funnel#154
Jammy2211 merged 5 commits into
mainfrom
claude/small-datasets-regime-stamp-s3i9o7

Conversation

@Jammy2211

Copy link
Copy Markdown
Collaborator

Summary

Closes the write half of #153.

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 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 a data.fits with identical NAXIS and 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_fits is the only definition of that name, but not the only write path. The real inventory is 18 library FITS writes: 2 through write_hdu_list, 14 in PyAutoGalaxy/PyAutoLens calling hdu_list.writeto directly (neither repo even imports write_hdu_list), and 4 handing the list to PyAutoFit's paths.save_fits (verified at autofit/non_linear/paths/directory.py:131 to writeto it as-is). All 18 build through hdu_list_for_output_from.

So the stamp goes in both functions, which is load-bearing rather than belt-and-braces: hdu_list_for_output_from catches the 14 + 4, write_hdu_list catches HDULists assembled elsewhere. The public API steers callers toward the bypass — hdu_list_for_output_from is re-exported as aa./ag./al.hdu_list_for_output_from, write_hdu_list by none of them. Stamping only output_to_fits, as #153 proposed, would have missed 16 of 18.

The card is written in both regimes (T capped, F full), 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 the SMALLDAT key/comment constants) and one in autonerves.test_mode (small_datasets()).

One behaviour change worth downstream attention: hdu_list_for_output_from and write_hdu_list now add a SMALLDAT card to the headers they produce, so every FITS the libraries author gains one header card. write_hdu_list mutates the primary header of the HDUList it is passed, in place.

See full details below.

Test Plan

  • python -m pytest test_autonerves/165 passed
  • Stamp verified across all three write paths (output_to_fits, multi-HDU builder, externally-built HDUList) in both regimes
  • Boolean round-trips as a genuine FITS T/F, not a string or float — pinned by test, because bool("F") is True and readers must not coerce
  • Exactly one card despite both funnels stamping (assignment, not append)
  • Existing header_dict cards (PIXSCAY/PIXSCAX/ORIGINY/ORIGINX) undisturbed
  • Suite green and working tree clean both with PYAUTO_SMALL_DATASETS=1 exported and unset

Reviewed 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 conftest fixture 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() — returns True when PYAUTO_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 to HIERARCH by astropy rather than raising, and header.get("SMALLDAT") then returns None, 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(...) — stamps SMALLDAT into the header it builds, so every HDU it produces carries it.
  • write_hdu_list(hdu_list, ...) — stamps SMALLDAT into hdu_list[0].header in place before writing. Callers holding a reference to the passed HDUList will observe the added card. Guarded against an empty list.
  • Net effect: every FITS written through this module gains one header card. Verified byte-size neutral in practice — a FITS header block holds 36 cards and a real dataset header carries ~10, so file sizes are unchanged (5760 → 5760 B) and the byte-size diagnostics used in autolens_workspace_test#260 still hold. At a 36-card boundary a header would spill into a second block; no PyAuto header is near that.

Not changed

  • output_to_fits is untouched — it inherits the stamp via both functions it already calls.
  • The JSON (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-test output_to_json call 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

claude added 5 commits August 22, 2026 15:03
…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.
@Jammy2211 Jammy2211 added the pending-release PR queued for the next release build label Aug 22, 2026 — with Claude
@Jammy2211
Jammy2211 merged commit 4ae8e8f into main Aug 22, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pending-release PR queued for the next release build

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants