fix: reject invalid constructor inputs (#333 — B5-B8, B13) - #440
Merged
Conversation
Five findings from @rhayes777's API audit, all still reproducing on main. Each was "accepted silently, then a confusing traceback (or nothing) several calls later"; each now raises at construction with a message naming the parameter. - B6 pixel_scales of 0.0 / negative / nan -> guarded at geometry_util.convert_pixel_scales_{1d,2d}, the chokepoint every Mask2D factory and Grid2D.uniform funnel through. - B8 shape_native with a zero-length axis -> guarded in Mask2D.__init__, which every factory returns through (Grid2D.uniform reaches it via no_mask -> all_false). - B7 annulus with inner >= outer -> guarded in circular_annular and in elliptical_annular, which had the identical hole. - B5 noise_map shape disagreeing with data -> guarded in AbstractDataset, so Imaging, Interferometer and every other subclass are covered. - B13 negative regularization coefficient -> guarded at all 14 schemes, not only the reported Constant. This closes a real leak, not a cosmetic one: regularization_matrix_from squares the coefficient (hiding the sign), but regularization_weights_from returns it unsquared, so a negative value fed negative regularization weights to every consumer of that method. The shared helper lives in autoarray/validate.py — the home decision this task owned, since the PyAutoGalaxy#440 and PyAutoLens#532 prompts import it rather than restating the same rules with different wording. Guards are tracer-safe. Coefficients are free model parameters, so under a traced fit a constructor receives a JAX tracer; every value guard is gated on is_concrete_scalar and passes non-concrete values straight through, so a Python truth-test is never applied to a tracer. Verified against real JAX: construction under jax.jit works and jax.grad flows through, while concrete negatives are still rejected. Shape checks need no gate — shapes are static under tracing. Tests: 44 new cases in test_autoarray/test_validate.py, one per finding built from the reporter's own snippets plus a control per finding so a guard cannot pass by rejecting everything. Suite is 980 passed; the 3 pynufft failures in test_transformer.py are pre-existing on clean main (baselined) and tracked separately. Closes #333. Epic #415 stays open for phases 3-4. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013PgqSCLTemK5bApVAwhVM4
This was referenced Aug 9, 2026
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.
Closes #333. Implements #439. Phase 2 (PyAutoArray half) of the @rhayes777 API audit epic #415, which stays open for phases 3-4.
What this fixes
Five findings from @rhayes777's 2026-05-23 audit, all still reproducing on
mainwhen this branch was cut. Each was "accepted silently, then a confusing traceback (or nothing at all) several calls later"; each now raises at construction with a message naming the offending parameter.pixel_scalesof0.0/-0.1/nanZeroDivisionErroron the firstderive_grid, or a silently flipped coordinate systemValueErrornamingpixel_scalescircular_annular(inner_radius=0.8, outer_radius=0.3)pixels_in_mask == 0MaskExceptionnaming both radiiGrid2D.uniform(shape_native=(0, 5))shape_slim == 0MaskExceptionnaming the axisImaging(data 10x10, noise_map 5x5)shape_nativereported(10, 10), mismatch swallowedDatasetExceptionreporting both shapesreg.Constant(coefficient=-1.0)-1.0ValueErrornamingcoefficientGuards sit at chokepoints rather than at the five reported call sites, so coverage is wider than the report:
geometry_util.convert_pixel_scales_{1d,2d}, which everyMask2Dfactory andGrid2D.uniformfunnel through.Mask2D.__init__, which every factory returns through (Grid2D.uniformreaches it viano_mask→all_false).circular_annularandelliptical_annular, which had the identical hole.AbstractDataset.__init__, soInterferometerand every other subclass are covered, not just the reportedImaging.Constant. Thirteen siblings had the same hole.B13 is a real leak, not a cosmetic one
The reporter read a negative coefficient as inert because
log_evidenceis identical for+1.0and-1.0. That isregularization_matrix_fromsquaring it (constant.py:43) and hiding the sign. Butregularization_weights_fromreturns the coefficient unsquared, so a negative value fed negative regularization weights to every consumer of that method. Confirmed empirically before fixing:Rejecting at construction is what closes it.
The shared
_validate_*home — the decision this task ownedautoarray/validate.py, public. PyAutoArray is the floor PyAutoGalaxy and PyAutoLens both build on, so the blocked sibling prompts (PyAutoGalaxy#440, PyAutoLens#532) import it —from autoarray import validate— rather than restating the same rules with different wording in three repos.Message shape, applied everywhere: name the parameter, state the rule, show the received value, plus an optional sentence of guidance.
Tracer safety (the binding constraint from phase 1)
Coefficients are free model parameters, so under a traced fit a constructor receives a JAX tracer, not a number — a plain
if value < 0would raiseTracerBoolConversionError. Every value guard is gated onis_concrete_scalarand passes non-concrete values straight through. Shape checks need no gate: shapes are static under tracing.This was verified against real JAX (0.11.0), not just asserted:
reg.Constant/reg.Adaptinsidejax.jitwith tracer coefficients — worksjax.gradthrough a traced coefficient — flows (grad16.0forsum((c·1)²)atc=2over 4 params, as expected)-1.0outside a trace — still rejectedThe committed tests stay numpy-only per phase 1, and assert the property against the concreteness gate itself.
API Changes
No signatures, names or return types change. The change is in accepted input domain: inputs that were previously accepted and produced degenerate or misleading objects now raise at construction.
pixel_scalesmust be finite and> 0(previously0.0, negative andnanwere accepted).shape_nativemust have no zero-length axis (previously accepted, givingshape_slim == 0).inner_radius < outer_radius(previously accepted, giving an empty mask).noise_mapmust match itsdatashape (previously accepted).>= 0(previously any value accepted). Zero is still permitted — a degenerate but meaningful "no regularization" request.No workspace script relies on any of the newly-rejected inputs; the full library suite contained no test that did either (see below).
Test Plan
test_autoarray/test_validate.py— 44 cases. One regression test per finding built from the reporter's own snippets, asserting the failure and that the message names the parameter; plus a control per finding asserting the valid input still works, so no guard can pass by rejecting everything.980 passed, 52 skipped.test_autoarray/operators/test_transformer.pyfail identically on unmodifiedmain— baselined by stashing this branch's changes and re-running. Tracked separately.One note worth recording: the anticipated risk was that guards this central would surface existing tests constructing degenerate objects on purpose, each needing triage. That count was zero — nothing in the suite relied on a zero pixel scale, an empty shape, a swapped annulus, a mismatched noise map, or a negative coefficient.
Out of scope
Tracerguards — sibling prompts, unblocked by this PR's helper.adapt_imagesprecondition legibility — phase 3, independent.z_lens > z_source— phase 4, HELD on the reporter's answer.convert_pixel_scales_2dteststype(pixel_scales) is float, so anintpixel scale is never widened to a tuple. Worth its own issue.Generated by Claude Code