Skip to content

fix: widen scalar pixel_scales / shape_native at the two sites #464 missed #484

Description

@Jammy2211

Overview

The pixel_scales scalar-widening fix shipped in PyAutoArray#464 (8298d74e, 2026-08-22)
did not reach every site of the defect it closed. Two sites of the same class are live on
main, both confirmed by repro: Mask1D.__init__ hand-rolls its own type(x) is float
check and never routes through convert_pixel_scales_1d, and convert_shape_native_1d
keeps type(x) is int (declared out of scope by that commit). Both raise on ordinary user
input, with errors naming nothing the caller passed.

Plan

  • Route Mask1D.__init__ through geometry_util.convert_pixel_scales_1d, the same
    chokepoint Mask2D.__init__ already uses — closing a 1D/2D divergence rather than
    duplicating a check.
  • Widen convert_shape_native_1d to accept any concrete integer scalar (int,
    np.integer), cast to a Python int, with bool excluded.
  • Add the integer predicate to autoarray/validate.py beside is_concrete_scalar, so the
    function delegates its type test the way its convert_pixel_scales_* siblings do.
  • Test both sites, each new test confirmed to fail without the source change, and run the
    full test_autoarray suite because site 1 changes the Mask1D contract.
  • Leave tuple-entry normalisation ((1, 1) staying ints) explicitly unfixed — it changes
    return values on paths that work today.
Detailed implementation plan

Affected Repositories

  • PyAutoArray (primary, library-only — no workspace follow-up)

Branch Survey

Repository Current Branch Dirty?
./PyAutoArray main clean
./PyAutoMind main clean

No worktree claims; worktree_check_conflict returned clean.

Suggested branch: feature/mask1d-shape-native-scalar-widening

Site 1 — Mask1D.__init__ never routes through the chokepoint

autoarray/mask/mask_1d.py:71:

if type(pixel_scales) is float:
    pixel_scales = (pixel_scales,)

Confirmed on main:

aa.Mask1D(mask=np.array([False, False, True]), pixel_scales=1).pixel_scales
    ->  1                    # bare int, not (1.0,)
...that mask's .geometry.scaled_maxima
    ->  TypeError: 'int' object is not subscriptable

Mask2D.__init__ is unaffected — it calls geometry_util.convert_pixel_scales_2d at
autoarray/mask/mask_2d.py:218. Grid1D.uniform is fine for the same reason. So this is
a 1D/2D divergence, not a design choice.

Replace the hand-rolled widening with the call Mask2D makes, keeping its current position
(after invert handling, before the len(mask.shape) guard) so ordering is unchanged:

pixel_scales = geometry_util.convert_pixel_scales_1d(pixel_scales=pixel_scales)

Behaviour change to state in the PR, not suppress: convert_pixel_scales_1d runs
validate.validate_pixel_scales first, so Mask1D starts rejecting 0, negative and nan
pixel scales — exactly as Mask2D already does. No test in test_autoarray constructs a
Mask1D with such a value, and the 12 library call sites all pass real scales, but any
failure this produces must be read rather than adjusted away.

Site 2 — convert_shape_native_1d keeps an exact-type check

autoarray/geometry/geometry_util.py:27 still has type(shape_native) is int. Reachable
through Array1D.full / zeros / ones, whose sole call site is
autoarray/structures/arrays/uniform_1d.py:143 and which then does shape_native[0]:

aa.Array1D.full(fill_value=1.0, shape_native=np.int32(5))
    ->  IndexError: invalid index to scalar variable

is_concrete_scalar is the wrong predicate here — shape_native is a pixel count, so
a float must not be silently widened. Use an integer-only test excluding bool, and cast
to int so the result matches the Tuple[int] annotation. Add it as is_concrete_integer
in autoarray/validate.py beside is_concrete_scalar (validate.py:48).

Update the docstring to match, mirroring the wording 8298d74e gave convert_pixel_scales_1d.

Do not add validate.validate_shape_native here — the function performs no validation
today and adding it is a separate change.

Explicitly out of scope

Tuple entries are still returned unnormalised: convert_pixel_scales_2d((1, 1))(1, 1),
contradicting the Tuple[float, float] annotation. It changes return values on paths that
currently work, so it needs its own change and its own suite read.

Tests

Mirror the naming convention 8298d74e established
(test__convert_pixel_scales_1d__widens_any_real_scalar).

  • test_autoarray/geometry/test_geometry_util.pyconvert_shape_native_1d widens int
    and np.integer to (int,) with a Python int entry; a bool is not widened; a (5,)
    tuple is returned unchanged.
  • test_autoarray/mask/test_mask_1d.pyMask1D(mask=…, pixel_scales=1) gives
    (1.0,), likewise np.float64(1.0) and np.int32(1); .geometry.scaled_maxima no
    longer raises; a (1.0,) tuple is unchanged; ValueError on 0 / -1 / nan; a JAX
    tracer still passes through untouched so Mask1D stays jit-safe.
  • End-to-end: Array1D.full(fill_value=1.0, shape_native=np.int32(5)) builds.

Every new test must be confirmed to fail without the source change.

Verification

PYAUTO_SKIP_WORKSPACE_VERSION_CHECK=1 NUMBA_CACHE_DIR=/tmp/numba_cache \
MPLCONFIGDIR=/tmp/matplotlib PYAUTO_DISABLE_JAX=1 \
python -m pytest test_autoarray -q

Full suite, not just the touched files. Baseline the known pre-existing pynufft failures in
test_transformer.py by re-running them on a clean tree and comparing counts; 8298d74e
recorded 1145 passed / 1 skipped / 3 failed. Read any new failure rather than adjusting
the test. Then a JAX-enabled pass for the tracer case.

Key Files

  • autoarray/mask/mask_1d.py — site 1, the hand-rolled widening at line 71
  • autoarray/geometry/geometry_util.py — site 2, convert_shape_native_1d at line 27
  • autoarray/validate.py — home of is_concrete_scalar; add is_concrete_integer
  • autoarray/mask/mask_2d.py:218 — the pattern site 1 mirrors
  • autoarray/structures/arrays/uniform_1d.py:143 — sole caller of convert_shape_native_1d
  • test_autoarray/geometry/test_geometry_util.py, test_autoarray/mask/test_mask_1d.py

Original Prompt

Click to expand starting prompt

Scalar widening: the two sites the pixel_scales sweep did not reach

Type: bug
Target: autoarray
Repos:

  • @PyAutoArray
    Difficulty: small
    Autonomy: supervised
    Priority: medium
    Status: draft
    Filed: 2026-08-23

Why this exists

PyAutoArray#464 (8298d74e, 2026-08-22) fixed convert_pixel_scales_1d and
convert_pixel_scales_2d: type(x) is float became validate.is_concrete_scalar,
so int, np.integer and np.floating are all widened and cast to float.

Re-running that prompt's repro on 2026-08-23 found the sweep did not reach every site.
Two defects of the same class are live on main. Both are confirmed, not suspected.
Completion record: complete/2026/08/autoarray-pixel-scales-scalar-widening.md.

Site 1 — Mask1D.__init__ never routes through the chokepoint

autoarray/mask/mask_1d.py:71 still carries the original exact-type check and does its
own widening rather than calling convert_pixel_scales_1d:

if type(pixel_scales) is float:
    pixel_scales = (pixel_scales,)
aa.Mask1D(mask=np.array([False, False, True]), pixel_scales=1).pixel_scales
    ->  1                    # bare int, not (1.0,)
...that mask's .geometry.scaled_maxima
    ->  TypeError: 'int' object is not subscriptable

This is #464's exact reported symptom, on a public constructor. Mask2D.__init__
is unaffected — it calls geometry_util.convert_pixel_scales_2d at
autoarray/mask/mask_2d.py:218. So this is a 1D/2D divergence, not a design choice,
and Grid1D.uniform is fine because it goes through geometry_util.

Fix: replace the hand-rolled widening with the call Mask2D makes:

pixel_scales = geometry_util.convert_pixel_scales_1d(pixel_scales=pixel_scales)

Consequence to state, not suppress: convert_pixel_scales_1d runs
validate.validate_pixel_scales first, so Mask1D starts rejecting 0, negative and
nan pixel scales — exactly as Mask2D already does. No test in test_autoarray
constructs a Mask1D with such a value, and the 12 library call sites all pass real
scales, but read any failure this produces rather than adjusting the test.

Site 2 — convert_shape_native_1d keeps type(x) is int

autoarray/geometry/geometry_util.py:27. 8298d74e listed this as not-fixed-here.
Reachable through Array1D.full / zeros / ones, whose sole call site is
autoarray/structures/arrays/uniform_1d.py:143 and which then does shape_native[0]:

aa.Array1D.full(fill_value=1.0, shape_native=np.int32(5))
    ->  IndexError: invalid index to scalar variable

Fix: widen to an integer-only test — is_concrete_scalar is the wrong predicate
here, since shape_native is a pixel count and a float must not be silently widened.
Use isinstance(x, (int, np.integer)) and not isinstance(x, bool), cast to int so the
result matches the Tuple[int] annotation. Prefer adding this as is_concrete_integer
in autoarray/validate.py beside is_concrete_scalar (validate.py:48), so the
function delegates its predicate the way its convert_pixel_scales_* siblings do.

Do not add validate.validate_shape_native here — the function performs no
validation today and adding it is a separate change.

Explicitly out of scope

Tuple entries are still returned unnormalised: convert_pixel_scales_2d((1, 1))(1, 1),
contradicting the Tuple[float, float] annotation. This changes return values on paths
that currently work, so it needs its own change and its own suite read. Unfiled.

Verification

  • Mask1D(mask=…, pixel_scales=1).pixel_scales == (1.0,); same for np.float64(1.0)
    and np.int32(1); .geometry.scaled_maxima no longer raises.
  • A (1.0,) tuple is returned unchanged; a JAX tracer still passes through untouched,
    so Mask1D stays jit-safe.
  • Mask1D raises ValueError on pixel_scales of 0, -1 and nan, matching Mask2D.
  • convert_shape_native_1d widens int and np.integer to (int,) with a Python int
    entry; a bool is not widened; a (5,) tuple is unchanged.
  • Array1D.full(fill_value=1.0, shape_native=np.int32(5)) builds.
  • Every new test confirmed to fail without the source change.
  • Full test_autoarray suite, not just the touched files — site 1 changes the Mask1D
    contract. Baseline the known pre-existing pynufft failures in test_transformer.py
    against a clean tree; 8298d74e recorded 1145 passed / 1 skipped / 3 failed.

Repro environment: PYAUTO_SKIP_WORKSPACE_VERSION_CHECK=1,
NUMBA_CACHE_DIR=/tmp/numba_cache, MPLCONFIGDIR=/tmp/matplotlib,
PYAUTO_DISABLE_JAX=1.

Provenance

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions