Repository navigation
Description
Overview
Phase 2 (PyAutoArray half) of @rhayes777's 2026-05-23 API audit, covering the five findings in #333. Array2D/Mask2D accept pixel_scales of 0.0, negative and nan; Mask2D.circular_annular accepts swapped radii and silently returns an empty mask; Grid2D.uniform accepts a zero dimension; Imaging accepts a noise_map whose shape disagrees with data; and reg.Constant accepts a negative coefficient. All five were re-verified against main on 2026-07-28 and all five still reproduce.
Each is "raise a clear error at construction instead of a confusing NumPy/numba traceback three calls later". The task also settles the shared-validation-helper home question that has blocked phase 2 — PyAutoArray is the floor both PyAutoGalaxy and PyAutoLens build on, so the helper landed here is what the sibling PyAutoGalaxy#440 task will import.
Epic #415 stays open for phases 3-4. Phase 1 shipped 2026-07-28 (#417, PyAutoLens#662).
Plan
- Establish one home for validation helpers in PyAutoArray (
autoarray/validate.py) — the exact blocker recorded against phase 2, and what unblocks the PyAutoGalaxy sibling. - Make every helper JAX-safe by construction: validate concrete Python/NumPy scalars only, pass anything unrecognised (a tracer) straight through. Tested explicitly, not assumed.
- Guard pixel scales at the single chokepoint every mask and grid funnels through — B6 fixed once, not at ~20 call sites.
- Guard degenerate shapes in
Mask2D.__init__— every factory andGrid2D.uniformreturns through it, so B8 is likewise fixed once. - Guard annulus radii in
circular_annularand itselliptical_annularsibling, which has the identical hole (B7). - Guard data/noise-map shape agreement in
AbstractDataset, coveringImaging,Interferometerand every subclass rather than the one reported class (B5). - Guard regularization coefficients at all thirteen entry points, not just the reported
Constant— this also closes the un-squared negative-weight leak inregularization_weights_from(B13). - Triage, don't suppress, existing tests that construct degenerate objects on purpose; add one regression test per finding from the reporter's own snippets.
Detailed implementation plan
Work Classification
Library (single repo).
Affected Repositories
- PyAutoArray (primary)
Branch Survey
| Repository | Current Branch | Dirty? |
|---|---|---|
| PyAutoArray | main (@ 5867db0) |
clean — read via remote, web-github session |
26 remote branches; no feature/api-validation-guards exists, so this is a clean start. No active.md entry claims PyAutoArray (phase 1 released its worktree), so there is no conflict.
Suggested branch: feature/autoarray-input-validation-guards
Worktree root: ~/Code/PyAutoLabs-wt/autoarray-input-validation-guards/ (created later by /start_library)
Binding constraints
- These constructors sit on JAX-traced paths.
coefficientin particular is a free model parameter, so under a traced fit the constructor is handed a JAX tracer. A plain Pythonif coefficient < 0:on a tracer raisesTracerBoolConversionError. Every guard must be type-gated on a concrete scalar and skip anything it does not recognise — never a truth-test on an arbitrary value. The house idiom already exists atautoarray/dataset/imaging/simulator.py:271; follow it. - One helper home, decided before writing it. Three repos writing their own helpers is the failure mode to avoid.
- Guards raise, they do not warn. All five are unambiguously invalid input. (The judgement-call finding,
z_lens > z_source, is phase 4 and HELD — not in this task.)
Implementation Steps
-
autoarray/validate.py(new). Stdlib + numpy only:is_concrete_scalar(value)→isinstance(value, (int, float, np.integer, np.floating)) and not isinstance(value, bool). The single gate that makes every guard tracer-safe.validate_positive_finite(value, name, exc_type)— rejects<= 0,nan,inf.validate_shape_native(shape, name)— rejects any zero/negative dimension.- Follow the style of
_validate_convolve_over_sample_size(autoarray/dataset/imaging/dataset.py:24) and the message quality ofautoarray/inversion/pixelization.py:155— name the offender, say why, list the fix.
-
B6 — pixel scales.
autoarray/geometry/geometry_util.py:190 convert_pixel_scales_2d: validate each element after the tuple normalisation. Same inconvert_pixel_scales_1d(line 32) so 1D/2D messages don't drift. Raiseexc.MaskException. -
B8 — degenerate shape.
autoarray/mask/mask_2d.py:47 Mask2D.__init__: after the array coercion, reject any zero dimension inmask.shape.Grid2D.uniform((0, 5))reaches it viaGrid2D.no_mask→Mask2D.all_false(autoarray/structures/grids/uniform_2d.py:272). -
B7 — annulus radii.
autoarray/mask/mask_2d.py:393 circular_annularand:504 elliptical_annular: rejectinner_radius >= outer_radiusand negative radii, with a message naming the swap as the likely cause. -
B5 — data/noise-map shape.
autoarray/dataset/abstract/dataset.py AbstractDataset.__init__, beside the existingnoise_covariance_matrixraise: rejectdata.shape_native != noise_map.shape_nativewithexc.DatasetException. -
B13 — regularization coefficients. One
_validate_coefficientcall per constructor inautoarray/inversion/regularization/. Eight classes assignself.coefficient(constant,zeroth,brightness_zeroth,curvature_mask,fourth_order_mask,exponential_kernel,gaussian_kernel,matern_kernel);adaptandmatern_adapt_kernelcarryinner_coefficient/outer_coefficient;constant_zerothcarriescoefficient_neighbor/coefficient_zeroth.ConstantSplitandAdaptSplitinherit. This is the finding with real tracer exposure — theis_concrete_scalargate is load-bearing here, not defensive padding. -
Tests.
test_autoarray/geometry/,test_autoarray/mask/,test_autoarray/dataset/,test_autoarray/inversion/regularizations/test_constant.py: onepytest.raisesper finding built from the reporter's snippets, plus a JAX-traced construction test asserting the guards stay inert. Then full-suite triage.
Key trade-off
The guards sit at chokepoints (max coverage, one fix per class of failure) rather than at the five reported call sites (minimal blast radius). Chokepoints are correct here — the reported sites are symptoms — but the existing suite will surface degenerate constructions that were previously legal. Each is triaged individually; no bulk suppression.
Key Files
autoarray/validate.py— new, the shared helper homeautoarray/geometry/geometry_util.py—convert_pixel_scales_2d(190),convert_pixel_scales_1d(32) — B6autoarray/mask/mask_2d.py—__init__(47) for B8;circular_annular(393) +elliptical_annular(504) for B7autoarray/dataset/abstract/dataset.py—AbstractDataset.__init__— B5autoarray/inversion/regularization/*.py— 13 constructor entry points — B13autoarray/exc.py— existingMaskException/ArrayException/GridException/DatasetExceptionautoarray/dataset/imaging/dataset.py:24— the validator-style precedentautoarray/dataset/imaging/simulator.py:271— the JAX-safe-guard precedent
Adjacent defect (observed, deliberately out of scope)
convert_pixel_scales_2d only converts when type(pixel_scales) is float, so an int input is never widened to a tuple. Worth its own prompt; not fixed here.
Definition of done
- All five reproductions from the Missing input validation across Array2D, Grid2D, Mask2D, Imaging, regularization #333 body raise a clear, named error at construction.
- Guards proven inert under tracing (a JAX-traced construction still works).
- One regression test per finding, asserting the failure.
- The shared helper has one documented home the PyAutoGalaxy#440 sibling can import.
- Full
test_autoarraysuite green, with each surfaced degenerate construction triaged. - Missing input validation across Array2D, Grid2D, Mask2D, Imaging, regularization #333 gets a closing comment; epic epic: @rhayes777 API audit — 16 verified findings across 3 repos #415 stays open for phases 3-4.
Original Prompt
Click to expand starting prompt
# PyAutoArray#333 — constructor input-validation guards (B5, B6, B7, B8, B13)
Type: bug
Target: autoarray
Repos:
- @PyAutoArray
Difficulty: medium
Autonomy: supervised
Priority: high
Status: draft
## Why this exists
This is the **PyAutoArray half of phase 2** of @rhayes777's 2026-05-23 API audit, split
out of `draft/bug/autoarray/rhayes_audit_validation_and_crashes.md` per the Mind rule
*one prompt = one task = one PR*. That parent prompt carries the full audit, the phase-1
completion record, and the phases 2-4 table; read it for context, not for scope.
Scope here is **PyAutoArray#333 only** — the five findings that live in PyAutoArray. The
PyAutoGalaxy half of phase 2 (#440: B9, B11, B12) and the negative-redshift item from
PyAutoLens#532 are a **sibling task**, deliberately sequenced *after* this one because
they consume the shared validation helper this task establishes.
All five findings were re-verified against `main` on 2026-07-28 and **all five still
reproduce**. Epic: PyAutoArray#415 (stays open). Phase 1 shipped 2026-07-28 as
PyAutoArray#417 + PyAutoLens#662.
## The five findings
| ID | Reproduction (verified 2026-07-28) | Today's behaviour |
|---|---|---|
| B6 | `Array2D` / `Mask2D` with `pixel_scales=0.0`, `-0.1`, `nan` | all accepted; `ps=0` then `ZeroDivisionError` on the first `derive_grid`; negative silently flips the geometry |
| B7 | `Mask2D.circular_annular(inner_radius=0.8, outer_radius=0.3)` | accepted, `pixels_in_mask = 0` (a silently empty mask from swapped arguments) |
| B8 | `Grid2D.uniform(shape_native=(0, 0))` and `(0, 5)` | accepted, `shape_slim = 0` |
| B5 | `Imaging(data=<10x10>, noise_map=<5x5>)` | constructed; `shape_native` reports `(10, 10)`, the conflict is swallowed |
| B13 | `reg.Constant(coefficient=-1.0)` | accepted and stored as `-1.0` |
**B13 — the sharpened finding (not in the reporter's original).** `constant.py:44` does
`regularization_coefficient = coefficient * coefficient`, which is why `log_evidence` is
identical for `+1.0` and `-1.0` — the reporter concluded the value is inert. It is not:
`Constant.regularization_weights_from` (`constant.py:126`) returns the **raw, un-squared**
`self.coefficient`, so a negative value leaks negative regularization weights into every
consumer of that method. Rejecting at construction is what closes this.
## Binding constraints (carried from phase 1)
1. **These constructors sit on JAX-traced paths.** `coefficient` in particular is a free
model parameter, so under a traced fit the constructor is handed a JAX tracer. A plain
Python `if coefficient < 0:` on a tracer raises `TracerBoolConversionError`. Every
guard must therefore be **type-gated on a concrete scalar** and skip anything it does
not recognise — never a truth-test on an arbitrary value. The house idiom already
exists at `autoarray/dataset/imaging/simulator.py:271`; follow it.
2. **Decide where the shared `_validate_*` helper lives before writing it.** This was the
stated blocker on phase 2. PyAutoArray is the natural floor — PyAutoGalaxy and
PyAutoLens both depend on it — and the sibling #440 task will import whatever this task
lands. Three repos writing their own helpers is the failure mode to avoid.
3. **Guards raise, they do not warn.** All five are unambiguously invalid input. (The one
genuinely judgement-call finding in the audit, `z_lens > z_source`, is phase 4 and is
HELD on the reporter's answer — it is not in this task.)
## Code sites (read against PyAutoArray `main` @ 5867db0)
- **B6** — `autoarray/geometry/geometry_util.py:190 convert_pixel_scales_2d` is the single
chokepoint: every `Mask2D` factory and `Grid2D.uniform` funnel through it. Its 1D sibling
is `convert_pixel_scales_1d` at line 32.
- **B7** — `autoarray/mask/mask_2d.py:393 Mask2D.circular_annular`. Its sibling
`elliptical_annular` (line 504) has the same inner/outer shape and the same hole.
- **B8** — `autoarray/mask/mask_2d.py:47 Mask2D.__init__` is the chokepoint; every factory
returns through `cls(...)`, and `Grid2D.uniform` → `Grid2D.no_mask` →
`Mask2D.all_false` (`uniform_2d.py:272`) lands there too.
- **B5** — `autoarray/dataset/abstract/dataset.py AbstractDataset.__init__`, where
`self.data` and `self.noise_map` are assigned. Guarding here covers `Imaging`,
`Interferometer` and every other dataset subclass, not just the reported one.
- **B13** — `autoarray/inversion/regularization/`. Eight classes assign `self.coefficient`
(`constant`, `zeroth`, `brightness_zeroth`, `curvature_mask`, `fourth_order_mask`,
`exponential_kernel`, `gaussian_kernel`, `matern_kernel`); `adapt` and
`matern_adapt_kernel` carry `inner_coefficient`/`outer_coefficient`; `constant_zeroth`
carries `coefficient_neighbor`/`coefficient_zeroth`. `ConstantSplit` and `AdaptSplit`
inherit. The reported class is one of thirteen entry points.
## Precedents to follow (do not invent a new style)
- `autoarray/dataset/imaging/dataset.py:24 _validate_convolve_over_sample_size` — the
existing module-level private validator, raising `TypeError` for a wrong type and
`exc.DatasetException` for a bad value.
- `autoarray/inversion/pixelization.py:155` — phase 1's own construction-time rejection,
and the template for message quality: it names the offending classes, explains *why* the
combination is unsupported, and lists the working alternatives.
- `autoarray/exc.py` already has `MaskException`, `ArrayException`, `GridException`,
`DatasetException` — use the matching one rather than a bare `ValueError` where the
module already has a home exception.
## Definition of done
- All five reproductions from the issue body raise a clear, named error at construction.
- Guards are proven inert under tracing (a JAX-traced construction still works).
- Regression tests assert the **failure**, one per finding, built from the reporter's own
snippets.
- The shared helper has one documented home in PyAutoArray that the sibling #440 task can
import.
- Full `test_autoarray` suite green — a guard at a chokepoint this central will surface
existing tests that construct degenerate objects on purpose; each one is triaged, not
bulk-suppressed.
- #333 gets a closing comment; epic #415 stays open for phases 3-4.
## Verification recipe
Snippets in the issue body are self-contained. Environment:
`PYAUTO_SKIP_WORKSPACE_VERSION_CHECK=1`, `NUMBA_CACHE_DIR=/tmp/numba_cache`,
`MPLCONFIGDIR=/tmp/matplotlib`, `PYAUTO_DISABLE_JAX=1`. Note the PSF constructor is
`al.Convolver.from_gaussian` — `al.Kernel2D` no longer exists.
Activity