Skip to content

Commit 82b5d16

Browse files
authored
Merge pull request #474 from PyAutoLabs/claude/small-datasets-regime-stamp-s3i9o7
fix: prefer the FITS regime stamp over the shape heuristic in should_simulate Takes the resolution regime from the SMALLDAT card stamped by PyAutoNerves, keeping the shape heuristic as the fallback for unstamped datasets. Closes the silent interferometer case, and corroborates a destructive stamp against the data so a full-resolution dataset stamped under the cap is never deleted.
2 parents 0aa08d4 + 33a23cf commit 82b5d16

4 files changed

Lines changed: 392 additions & 21 deletions

File tree

autoarray/util/dataset_util.py

Lines changed: 167 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@
66
SMALL_DATASETS_SHAPE_NATIVE = (16, 16)
77
SMALL_DATASETS_PIXEL_SCALES = 0.6
88

9+
# The FITS header card ``autonerves.fitsable.stamp_small_datasets_regime`` writes
10+
# on every array the stack outputs. Deliberately duplicated here rather than
11+
# imported: ``pyproject.toml`` floors autonerves at a release that predates the
12+
# stamp, so an import would hard-fail against a legitimately-resolved older
13+
# autonerves. Reading the card by name degrades to "absent" instead, which is
14+
# exactly the fallback path below. Keep in sync with PyAutoNerves#153.
15+
SMALL_DATASETS_HEADER_KEY = "SMALLDAT"
16+
917

1018
def cap_array_2d_for_small_datasets(array_2d, pixel_scales):
1119
"""
@@ -97,6 +105,41 @@ def _on_disk_shape_native(data_path):
97105
return None
98106

99107

108+
def _small_datasets_stamp_on_disk(dataset_path):
109+
"""
110+
Returns the small-datasets regime recorded in ``data.fits``'s header, as a
111+
tri-state: ``True`` (written by a capped run), ``False`` (written at full
112+
resolution), or ``None`` (no usable stamp -- unknown).
113+
114+
``None`` is returned for a missing file, an unreadable file, a file with no
115+
``SMALLDAT`` card, **and** a card whose value is not a genuine FITS boolean.
116+
That last case is not pedantry: ``bool("F")`` is ``True`` in Python, so
117+
coercing a hand-edited or third-party string card would invert the regime
118+
and hand a ``True`` to a predicate that ends in ``shutil.rmtree``. A card
119+
this code did not write is not a card this code can trust.
120+
121+
Callers must treat ``None`` as "leave the dataset alone" and fall back to
122+
:func:`_is_small_datasets_on_disk`. Unknown must never mean "full".
123+
"""
124+
from astropy.io import fits
125+
126+
data_path = Path(dataset_path) / "data.fits"
127+
128+
if not data_path.exists():
129+
return None
130+
131+
try:
132+
with fits.open(data_path) as hdu_list:
133+
for hdu in hdu_list:
134+
value = hdu.header.get(SMALL_DATASETS_HEADER_KEY)
135+
if isinstance(value, bool):
136+
return value
137+
except Exception:
138+
return None
139+
140+
return None
141+
142+
100143
def _is_small_datasets_on_disk(dataset_path):
101144
"""
102145
Returns True if the dataset on disk at ``dataset_path`` was written by a
@@ -128,6 +171,49 @@ def _is_small_datasets_on_disk(dataset_path):
128171
return _on_disk_shape_native(data_path) == SMALL_DATASETS_SHAPE_NATIVE
129172

130173

174+
def _stamp_contradicted_by_shape(dataset_path):
175+
"""
176+
Returns True when ``data.fits`` claims ``SMALLDAT = T`` but its shape says it
177+
cannot have been written by a capped run.
178+
179+
The stamp and this predicate are not about the same thing, and that gap is
180+
the whole reason this guard exists. ``stamp_small_datasets_regime`` records
181+
*"the env var was set in the writing process"*. ``should_simulate`` acts on
182+
*"this data is capped, therefore stale and disposable"*. The library itself
183+
already makes those two diverge:
184+
185+
- ``Kernel2D.from_gaussian`` passes ``respect_small_datasets=False``
186+
(``convolver.py``) because a kernel's shape is intrinsic to the
187+
convolution operator, so a PSF written under the cap is full resolution;
188+
- ``Interferometer.from_fits`` applies no cap at all;
189+
- and any user converting real telescope data in a shell that exports
190+
``PYAUTO_SMALL_DATASETS=1`` -- the documented harness default -- stamps
191+
``T`` on genuinely full-resolution data.
192+
193+
Every capped 2D image, by contrast, is rewritten to *exactly*
194+
``SMALL_DATASETS_SHAPE_NATIVE`` by ``Grid2D.uniform`` or ``Mask2D.circular``.
195+
So a stamp of ``T`` on an image larger than the cap in **both** axes is a
196+
self-contradiction, and a predicate ending in ``shutil.rmtree`` must resolve
197+
a contradiction toward *keep*.
198+
199+
Both axes, never either: interferometer ``data.fits`` is ``(n_visibilities,
200+
2)`` -- 108384 x 2 for the committed sdp81 dataset -- so an "either axis"
201+
test would refuse to delete the one family the stamp exists to catch. Real
202+
imaging (151x151, 209x209, 300x300) trips both and is protected.
203+
204+
Unknown shape means not contradicted: this guard only ever *blocks* a
205+
deletion, so failing to read the file must not silently protect a genuinely
206+
stale dataset the stamp correctly identified.
207+
"""
208+
shape = _on_disk_shape_native(Path(dataset_path) / "data.fits")
209+
210+
return (
211+
shape is not None
212+
and shape[0] > SMALL_DATASETS_SHAPE_NATIVE[0]
213+
and shape[1] > SMALL_DATASETS_SHAPE_NATIVE[1]
214+
)
215+
216+
131217
def should_simulate(dataset_path):
132218
"""
133219
Returns True if the dataset at ``dataset_path`` needs to be simulated.
@@ -143,8 +229,11 @@ def should_simulate(dataset_path):
143229
mask/grid.
144230
- Entering the **full** regime, a dataset left behind by an earlier capped
145231
run is likewise deleted. Existence alone cannot distinguish the two, so
146-
the regime is inferred from the data on disk
147-
(``_is_small_datasets_on_disk``).
232+
the regime is taken from the ``SMALLDAT`` header card that
233+
``autonerves.fitsable`` stamps into every FITS the stack writes
234+
(``_small_datasets_stamp_on_disk``), falling back to inferring it from
235+
the data's shape (``_is_small_datasets_on_disk``) for datasets written
236+
before that stamp existed.
148237
149238
That second check is what makes a local FAIL mean something. ``dataset/``
150239
is gitignored in the workspaces, so CI clones fresh and always simulates,
@@ -160,29 +249,91 @@ def should_simulate(dataset_path):
160249
if aa.util.dataset.should_simulate(dataset_path):
161250
subprocess.run([sys.executable, "scripts/.../simulator.py"], check=True)
162251
252+
Precedence
253+
----------
254+
The stamp wins over the shape heuristic, and the three states are not
255+
interchangeable:
256+
257+
- ``SMALLDAT = T`` -- delete, **unless the data contradicts the card**. The
258+
stamp is preferred over the shape heuristic but it is not unfalsifiable:
259+
it records that the env var was set at write time, which is not the same
260+
proposition as "this array was capped" (see
261+
:func:`_stamp_contradicted_by_shape`). Without that corroboration this
262+
predicate would delete a full-resolution dataset the pre-stamp heuristic
263+
explicitly refused to delete -- a strict weakening of the safety property
264+
PyAutoArray#471 established.
265+
- ``SMALLDAT = F`` -- **keep**, unconditionally, without consulting shape.
266+
This also retires a false positive in the heuristic: a dataset that is
267+
legitimately 16x16 at full resolution used to be deleted on every run.
268+
- **absent** -- unknown, so fall back to the shape heuristic. Absence must
269+
never be read as "full resolution": every dataset written before the
270+
stamp landed is absent, and treating those as full would resurrect the
271+
original bug.
272+
273+
Interferometer datasets are covered by the stamp and were not covered
274+
before: their visibility count is fixed by the committed uv file while the
275+
real-space grid behind it is capped, so a capped run writes a ``data.fits``
276+
with *identical* ``NAXIS`` and different values. That fails silently -- no
277+
shape mismatch, no assertion -- which is why a shape heuristic could never
278+
reach it (PyAutoNerves#153).
279+
163280
Known gap
164281
---------
165-
The full-regime check reads ``data.fits``, so it covers imaging-style
166-
datasets only. It cannot see a stale capped dataset whose corruption is not
167-
visible in that file's shape:
168-
169-
- point-source and weak-lensing datasets, which are JSON with no FITS;
170-
- interferometer datasets, whose visibility count is fixed by the uv file
171-
while the real-space grid behind it is capped, so the capped and full
172-
files share a shape and differ only in values.
173-
174-
Those regress to the previous existence-only behaviour rather than being
175-
fixed here. Closing them needs the regime recorded at write time rather
176-
than inferred at read time.
282+
This reads ``<dataset_path>/data.fits`` and nothing else, which covers
283+
roughly 228 of the 253 ``should_simulate`` call sites in autolens_workspace.
284+
The rest have no file of that name at that level and so get no verdict:
285+
286+
- interferometer **datacube** datasets, whose FITS sit in ``channel_XXX/``
287+
subdirectories;
288+
- **multi_dataset** datasets, which prefix the name (``{waveband}_data.fits``);
289+
- **sample** datasets, which nest under ``dataset_N/``;
290+
- the two FITS-less directories, ``dataset/weak/simple`` and
291+
``dataset/point_source/multiple_sources``, which a FITS-header stamp
292+
cannot reach under any placement.
293+
294+
All of those fail *safe*: no ``data.fits`` means no stamp, which means
295+
unknown, which means keep. Nothing is deleted that should not be.
296+
297+
Of those, only the first three can actually harbour the stale-capped-dataset
298+
bug. The FITS-less pair, which look like the worst gap, are the least
299+
urgent: ``dataset/weak/simple`` is regime-**invariant** -- nothing in its
300+
write path reads ``PYAUTO_SMALL_DATASETS``, so a capped run and a full run
301+
produce an identical ``dataset.json`` and there is nothing to detect -- and
302+
``dataset/point_source/multiple_sources``, which *is* regime-dependent, is
303+
excluded from harness execution by ``config/build/no_run.yaml`` pending
304+
PyAutoLens#480. Both of those facts will expire; see the follow-up.
305+
306+
Widening the lookup is deliberately **not** done here. This predicate ends
307+
in ``shutil.rmtree``, and every trap recorded in autolens_workspace_test#260
308+
was about a widened match hitting a file it should not have -- a bare
309+
``*.fits`` glob would delete every PSF-carrying dataset on every run. Growing
310+
the reach of a destructive predicate is its own change, with its own review,
311+
not a rider on the one that changes where its input comes from.
312+
313+
Note that point-source datasets are **not** in this gap: they write a
314+
top-level ``data.fits`` alongside their JSON and are covered normally. The
315+
original issue text grouped them with weak lensing as "JSON with no FITS";
316+
that is true of weak lensing only.
177317
"""
178318
if os.environ.get("PYAUTO_SMALL_DATASETS") == "1":
179319
if Path(dataset_path).exists():
180320
shutil.rmtree(dataset_path)
181321

182322
return not Path(dataset_path).exists()
183323

184-
if Path(dataset_path).exists() and _is_small_datasets_on_disk(dataset_path):
185-
shutil.rmtree(dataset_path)
324+
if Path(dataset_path).exists():
325+
stamp = _small_datasets_stamp_on_disk(dataset_path)
326+
327+
if stamp is True and not _stamp_contradicted_by_shape(dataset_path):
328+
# Written by a capped run, on the writer's own authority -- and the
329+
# data does not contradict it.
330+
shutil.rmtree(dataset_path)
331+
elif stamp is None and _is_small_datasets_on_disk(dataset_path):
332+
# No stamp to trust, so fall back to inferring from shape.
333+
shutil.rmtree(dataset_path)
334+
# stamp is False -> known full resolution -> keep.
335+
# stamp is True but contradicted by the data -> keep. The stamp is
336+
# preferred over the shape heuristic, but it is not unfalsifiable.
186337

187338
return not Path(dataset_path).exists()
188339

test_autoarray/conftest.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,3 +317,26 @@ def pytest_collection_modifyitems(config, items):
317317
)
318318
if needs_nufftax:
319319
item.add_marker(skip_nufftax)
320+
321+
322+
@pytest.fixture(autouse=True)
323+
def _regime_independent_test_output(monkeypatch):
324+
"""
325+
Clear ``PYAUTO_SMALL_DATASETS`` for every test unless the test sets it.
326+
327+
Since PyAutoNerves#153 every FITS the stack writes carries a ``SMALLDAT``
328+
card whose value tracks this env var at write time. Several tests write into
329+
**tracked** fixture paths (14 of them across this repo and PyAutoNerves --
330+
a pre-existing pattern), so without this the bytes those tests produce
331+
depend on the ambient environment: run the suite in a shell exporting
332+
``PYAUTO_SMALL_DATASETS=1`` -- which ``should_simulate``'s own docstring calls
333+
the default for most harness runs -- and the suite passes but leaves the
334+
working tree dirty.
335+
336+
Pinning it here restores the property the stamp took away, that test output
337+
is a function of the test and not of the shell, and does so in one place
338+
rather than by rewriting every fixture-writing test. Tests that need a
339+
regime set it with ``monkeypatch.setenv`` in their body, which runs after
340+
this fixture and wins.
341+
"""
342+
monkeypatch.delenv("PYAUTO_SMALL_DATASETS", raising=False)
Binary file not shown.

0 commit comments

Comments
 (0)