66SMALL_DATASETS_SHAPE_NATIVE = (16 , 16 )
77SMALL_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
1018def 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+
100143def _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+
131217def 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
0 commit comments