Skip to content

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

Closed

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.py — 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 returned unchanged.
  • test_autoarray/mask/test_mask_1d.py — Mask1D(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

Activity

  1. Jammy2211 commented on Aug 23, 2026

    @Jammy2211
    CollaboratorAuthor

    Library PR Created

    PR: #485 (pending-release)
    Branch: feature/mask1d-shape-native-scalar-widening @ c0c00b0e

    Both sites fixed as planned. Mask1D.__init__ now routes through
    convert_pixel_scales_1d; convert_shape_native_1d uses a new
    validate.is_concrete_integer and casts to a Python int.

    Verification

    Check Result
    test_autoarray (full) 1201 passed / 0 failed
    test_autocti (full) 270 passed / 1 failed — pre-existing
    dataset_1d simulator + modeling smoke pass (exit 0)
    Real-JAX jit with traced pixel_scales compiles, passes through untouched

    The 3 pynufft failures 8298d74e baselined no longer occur, so there was nothing to
    baseline against. The one test_autocti failure
    (test_serial_eper.py::test__region_list_from__array_2d_list_from, 2D serial EPER
    extraction) was confirmed pre-existing by running it against the canonical PyAutoArray
    main checkout — it fails identically there. My first attempt to prove that used a
    git stash which was a silent no-op on an already-committed tree, so the run was
    actually with the change; the result above is the corrected run.

    Test-quality fix folded in

    #464's own widening tests asserted value only, and np.float64(1.0) == (1.0,)
    NumPy-broadcasts to array([True]) — truthy. Four of six parametrisations passed on an
    unwidened NumPy scalar and tested nothing. They now assert tuple-ness before the value,
    confirmed by reverting to the pre-#464 source and watching them fail.

    Workspace impact: none (option iii)

    PyAutoGalaxy and PyAutoLens only re-export Mask1D and construct none. autocti_workspace
    uses ac.Mask1D, which subclasses aa.Mask1D and inherits the fix — it now widens scalars
    too, where before it stored them bare. No migration needed. No /start_workspace follow-up.

    Not merged — Heart RED

    pyauto-heart readiness is red on one reason, verbatim:

    release validation FAILED
    

    It reports failures: [], 262 passed / 0 failed / 46 skipped, and its only recorded stage
    (integrate) passed — an incomplete report timestamped before this task started, against
    an older PyAutoArray sha. Unrelated to this change, and this PR does not repair it, so the
    AUTONOMY.md corrective-PR exception does not apply. The PR was opened under an explicit
    human override, and merge remains blocked pending a clean readiness verdict.

    (The other RED reason, PyAutoFit: 2 commit(s) behind origin, was cleared by a
    fast-forward pull; score 40 → 60.)

    Still open

    Tuple entries are returned unnormalised — convert_pixel_scales_2d((1, 1)) → (1, 1),
    contradicting the Tuple[float, float] annotation. Deliberately unfixed and unfiled: it
    alters return values on paths that work today.

  2. Jammy2211 commented on Aug 23, 2026

    @Jammy2211
    CollaboratorAuthor

    Shipped

    PR: #485 — MERGED 2026-08-23
    Merge commit: 9e475052 · Feature commit: c0c00b0e

    Both sites the #464 sweep missed are closed on main:

    • Mask1D.__init__ routes through convert_pixel_scales_1d instead of hand-rolling
      type(x) is float, closing the 1D/2D divergence with Mask2D. It also inherits
      validate.validate_pixel_scales, so Mask1D now rejects 0, negative and nan pixel
      scales — a deliberate contract change, matching Mask2D.
    • convert_shape_native_1d tests the new validate.is_concrete_integer and casts to a
      Python int. Integer-only on purpose: is_concrete_scalar would silently widen a
      float, and shape_native counts pixels rather than measuring them.

    Verified before merge

    Check Result
    test_autoarray (full) 1201 passed / 0 failed
    test_autocti (full) 270 passed / 1 failed — pre-existing, proven against canonical main
    dataset_1d simulator + modeling smoke pass
    Real-JAX jit, traced pixel_scales compiles, passes through untouched
    CI on c0c00b0e every run, every leg green (3/3)

    Also tightened #464's own widening tests, which were passing vacuously:
    np.float64(1.0) == (1.0,) NumPy-broadcasts to a truthy array([True]), so four of six
    parametrisations tested nothing. They now assert tuple-ness before value, confirmed by
    reverting to the pre-#464 source and watching them fail.

    Merged under an explicit human override

    pyauto-heart readiness was red at merge time on one reason, verbatim:

    release validation FAILED
    

    That report carries failures: [], 262 passed / 0 failed / 46 skipped, and its only
    recorded stage (integrate) passed — an incomplete artifact timestamped before this
    task began, against an older PyAutoArray sha. It is unrelated to this change and this PR
    did not repair it, so the AUTONOMY.md corrective-PR exception did not apply. The merge
    was a human override, recorded here rather than left implicit.

    The other RED reason, PyAutoFit: 2 commit(s) behind origin, was cleared by a
    fast-forward pull (score 40 → 60).

    Follow-up left open, deliberately unfiled

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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