Skip to content

Add array-api-strict test backend and array-escape triage tooling#942

Merged
mwcraig merged 19 commits into
astropy:mainfrom
mwcraig:array-api-strict-triage-tooling
Jul 14, 2026
Merged

Add array-api-strict test backend and array-escape triage tooling#942
mwcraig merged 19 commits into
astropy:mainfrom
mwcraig:array-api-strict-triage-tooling

Conversation

@mwcraig

@mwcraig mwcraig commented Jul 7, 2026

Copy link
Copy Markdown
Member

Closes #937
Closes #939

Adds the array-api-strict test backend and the array-escape triage tooling from the array-API review follow-ups. The triage results produced by this tooling are recorded in #941.

array-api-strict backend (#937)

  • CCDPROC_ARRAY_LIBRARY=array-api-strict (or array_api_strict) selects array_api_strict as the testing array library, with fixture arrays placed on the non-default Device("device1"). Conversion to numpy then raises — the same failure signal as CuPy's implicit device-to-host conversion, with no GPU required. The device can be overridden via CCDPROC_ARRAY_DEVICE.
  • New py313-strict tox environment and ubuntu-py313-strict CI job.
  • Documented in docs/array_api.rst.

Triage tooling (#939)

  • Escape-site dedupe report (CCDPROC_TRIAGE_ESCAPES=1): groups test failures by the innermost non-test ccdproc frame and prints a ranked summary at session end (ccdproc/tests/_escape_triage.py).
  • Per-backend markers: @pytest.mark.backend_xfail("cupy", "array-api-strict", reason=...) and @pytest.mark.backend_skip(...), applied via pytest_collection_modifyitems, so backend runs report only regressions rather than known gaps.
  • Escape logger (CCDPROC_LOG_ARRAY_ESCAPES=1): monkeypatches np.asarray/np.asanyarray/np.ma.asanyarray to warn (with the ccdproc call site) whenever they receive a non-numpy array-API array — catches backends like dask and JAX that convert silently instead of raising like CuPy.

Tests of documented limitations (astroscrappy, scipy.ndimage.median_filter, reproject, block_reduce/block_replicate) are marked backend_xfail for array-api-strict.

Verification

🤖 Generated with Claude Code

https://claude.ai/code/session_01Qri8rDgkWjCT7Yb7QKJrhf

Wire array-api-strict (on a non-default device) into the
CCDPROC_ARRAY_LIBRARY test-backend mechanism as a CPU-only proxy for
CuPy's implicit device-to-host conversion errors, with a py313-strict
tox environment and CI job (astropy#937).

Add triage tooling for array-library escapes (astropy#939):

- an escape-site dedupe report (CCDPROC_TRIAGE_ESCAPES=1) that groups
  test failures by the innermost non-test ccdproc frame
- backend_xfail/backend_skip markers applied per array backend
- an escape logger (CCDPROC_LOG_ARRAY_ESCAPES=1) that warns whenever a
  non-numpy array-API array is passed to a numpy coercion function

Mark tests of documented limitations (astroscrappy, scipy.ndimage,
reproject, block_reduce/block_replicate) as backend_xfail for
array-api-strict.

Closes astropy#937
Closes astropy#939

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01Qri8rDgkWjCT7Yb7QKJrhf
@mwcraig mwcraig added this to the 3.0 milestone Jul 7, 2026
@mwcraig mwcraig requested a review from Copilot July 7, 2026 02:30

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds an array-api-strict test backend (as a CPU-only proxy for CuPy-style device/host conversion failures) and introduces pytest tooling to triage “array-API escapes” and manage per-backend expected failures.

Changes:

  • Add array-api-strict as a selectable test array backend (including non-default device placement) plus a dedicated tox env / CI job.
  • Add triage tooling: escape-site traceback deduping, per-backend backend_xfail/backend_skip markers, and optional logging of numpy coercion “escapes”.
  • Document the new backend and tooling in docs and release notes, and mark known limitations as backend-specific xfails in relevant tests.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
tox.ini Adds py313-strict factor wiring and dependency for array-api-strict.
pyproject.toml Registers new pytest markers (backend_xfail, backend_skip).
docs/array_api.rst Documents the array-api-strict backend and device behavior.
CHANGES.rst Adds release notes entries for the new backend and triage tooling.
ccdproc/tests/test_cosmicray.py Marks known numpy-only dependencies as backend-specific xfails for array-api-strict.
ccdproc/tests/test_ccdproc.py Adds backend-specific xfails for documented limitations (block ops, reproject, scipy median filter).
ccdproc/tests/pytest_fixtures.py Places fixture arrays on the configured test backend device.
ccdproc/tests/_escape_triage.py New pytest hook module to dedupe failures by “escape site” and print a summary.
ccdproc/conftest.py Implements array-api-strict backend selection, backend-specific markers, and optional escape logging.
.github/workflows/ci_tests.yml Adds CI job to run tox py313-strict.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread ccdproc/conftest.py Outdated
Comment thread ccdproc/conftest.py Outdated
Comment thread tox.ini Outdated
The py313-strict job fails by design until the remaining array-API bugs are fixed; with the default fail-fast it would cancel the whole matrix.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01Qri8rDgkWjCT7Yb7QKJrhf
@codecov

codecov Bot commented Jul 7, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 96.56%. Comparing base (16dcaa1) to head (d329ac5).
⚠️ Report is 3 commits behind head on main.

Additional details and impacted files
@@           Coverage Diff           @@
##             main     #942   +/-   ##
=======================================
  Coverage   96.56%   96.56%           
=======================================
  Files           8        8           
  Lines        1571     1571           
=======================================
  Hits         1517     1517           
  Misses         54       54           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

mwcraig added 2 commits July 6, 2026 21:40
The py313-strict job is expected to fail until the remaining array-API bugs are fixed; continue-on-error keeps its failure from failing the workflow or cancelling the other matrix jobs, while restoring fail-fast for real failures elsewhere.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01Qri8rDgkWjCT7Yb7QKJrhf
The py313-strict job is expected to fail until the remaining array-API bugs are fixed. In its own matrix its failure stays visible but cannot fail-fast-cancel the main test matrix. Reverts the earlier continue-on-error approach, which hid the failure entirely.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01Qri8rDgkWjCT7Yb7QKJrhf
Comment thread CHANGES.rst Outdated
Comment thread ccdproc/conftest.py Outdated
Comment thread ccdproc/tests/_escape_triage.py
Comment thread ccdproc/tests/_escape_triage.py
mwcraig added 3 commits July 6, 2026 22:13
- Fix CCDPROC_ARRAY_DEVICE=default to select the library CPU device as documented; unset still selects device1
- Reword the device comment in conftest to match the actual behavior
- Require array-api-strict>=2.0 in the strict tox factor
- Shorten the changelog entries
- Add docstrings to _is_foreign_array and the escape-triage pytest hooks
- Deduplicate _env_truthy (now imported from _escape_triage)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01Qri8rDgkWjCT7Yb7QKJrhf
The py313-strict CI job existed to surface the escape-site report, but the strict tox factor never set CCDPROC_TRIAGE_ESCAPES, so the summary was not generated.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01Qri8rDgkWjCT7Yb7QKJrhf
On CI the repository checkout is itself named ccdproc, so testing for /ccdproc/ anywhere in a frame's path classified every frame -- including tox's site-packages under the workspace -- as a ccdproc frame, and the triage report pointed at array_api_strict internals instead of the ccdproc call site.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01Qri8rDgkWjCT7Yb7QKJrhf

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.

Comment thread ccdproc/conftest.py
@mwcraig

mwcraig commented Jul 7, 2026

Copy link
Copy Markdown
Member Author

Review

Overview: This PR adds an array-api-strict test backend (with fixture arrays on a non-default device so np.asarray raises, emulating CuPy device behavior), per-backend backend_xfail/backend_skip markers, an escape-site triage report (CCDPROC_TRIAGE_ESCAPES), and an escape logger (CCDPROC_LOG_ARRAY_ESCAPES), plus a py313-strict tox env and CI job.

I verified the happy paths locally: the default numpy backend still passes 331/331 (5 skipped), the strict backend applies the markers correctly, and the triage summary groups failures by sensible sites. However, the escape logger has three real bugs, one of which makes its main output wrong in every case.

Bug 1 (high): the escape logger always reports its own wrapper as the escape site

_describe_escape_site() in ccdproc/conftest.py drops only its own frame (extract_stack()[:-1]), but the caller is the monkeypatch wrapper defined in ccdproc/conftest.py — which is itself inside the package root and not under ccdproc/tests/, so locate_escape_site() always selects it as the "innermost non-test ccdproc frame". Every escape message points at the wrapper, never at the real call site:

array-API escape: numpy.asarray() called on a <class 'array_api_strict._array_object.Array'> array
(namespace=...) at /Users/.../ccdproc/conftest.py:231 wrapper

(Reproduced by wrapping np.asarray via _make_escape_logging_wrapper and triggering an escape from a frame compiled with a ccdproc/core.py filename — the report names conftest.py:231 wrapper instead of core.py.)

Since the call site is the entire point of the logger, this defeats the feature. Dropping two frames ([:-2]) fixes the immediate symptom, but a more robust fix covers Bug 3 as well: have the frame classifiers treat ccdproc/conftest.py as test infrastructure (i.e., exclude it alongside ccdproc/tests/), so it can never win "innermost non-test ccdproc frame".

Bug 2 (medium): dask escapes are never detected, contradicting the stated purpose

The PR body and the comments in conftest.py say the logger "catches backends like dask and JAX that convert silently". But _is_foreign_array() keys on obj.__array_namespace__, and dask arrays don't have that attribute — even via array_api_compat.dask.array (verified with dask 2025.7.0 / array-api-compat 1.12.0: hasattr(a, "__array_namespace__") is False, while array_api_compat.array_namespace(a) correctly returns the compat dask namespace). So for the dask backend the logger silently reports nothing — the exact failure mode it was written to prevent.

Suggested fix: detect via array_api_compat.array_namespace(obj) in a try/except TypeError instead of the raw dunder (the isinstance(obj, np.ndarray) short-circuit already handles plain numpy, and the existing _NUMPY_LIKE_NAMESPACES exclusion still applies).

Bug 3 (medium): enabling both env vars corrupts the triage report

When CCDPROC_LOG_ARRAY_ESCAPES=1 and CCDPROC_TRIAGE_ESCAPES=1 are set together, any failure whose exception is raised inside the wrapped np.asarray carries the wrapper's frame as the innermost non-test ccdproc frame, so the triage summary attributes it to conftest.py … wrapper rather than the real site. Reproduced on this branch:

$ CCDPROC_ARRAY_LIBRARY=array-api-strict CCDPROC_TRIAGE_ESCAPES=1 CCDPROC_LOG_ARRAY_ESCAPES=1 \
    pytest ccdproc/tests/test_ccdproc.py ccdproc/tests/test_combiner.py
...
/Users/.../ccdproc/conftest.py:242 wrapper  (1 failures)

These are exactly the failures the dedupe report is supposed to localize (the ones that would raise on CuPy), so grouping them under wrapper hides the root cause. Excluding conftest.py in the frame classifiers (the Bug 1 fix) resolves this too.

Bug 4 (medium, usability): logger output is invisible in the scenario it targets

_escape_logger.warning(...) goes through logging, and pytest only displays captured log records for failing tests. The logger's stated purpose is catching silent escapes on dask/JAX — where the test passes — and in that case the warning is swallowed entirely (verified: a passing test emitting logging.getLogger("ccdproc.array_escape").warning(...) produces no visible output under this repo's pytest config). As shipped, CCDPROC_LOG_ARRAY_ESCAPES=1 on the dask/jax tox envs would print nothing even if Bug 2 were fixed.

Options: emit warnings.warn(...) instead (shows up in pytest's warnings summary for passing tests), or document that the flag requires --log-cli-level=WARNING (and consider setting that in the relevant tox envs).

Minor notes

  • docs/array_api.rst describes array-api-strict as "performs no computation itself" — it's a thin wrapper over numpy and does compute; its distinguishing feature is strict API/device validation. Suggest rewording.
  • _escape_triage.pytest_runtest_makereport only records the call phase, so escapes that fail during fixture setup won't be triaged. Probably fine for this codebase (fixtures are mostly plain functions here), but worth a comment.
  • In the wrapper, obj.__array_namespace__() is invoked a second time just to build the log message; reusing the namespace from _is_foreign_array (e.g. returning it) would avoid re-calling arbitrary third-party code outside the guarded check.
  • The new CI job references ${{ matrix.toxposargs }} which is never defined in its matrix (harmless — evaluates to empty — and consistent with the main job, where some entries define it).
  • The ci-tests-expected-failures job will show a red ✗ on every PR until the ~109 strict failures are fixed. The comment says that's deliberate; just flagging that reviewers of unrelated PRs will see a failing check.

What checks out

  • Frame-classification anchoring on the package directory (fdf651e) correctly avoids the CI-checkout-named-ccdproc substring trap; verified sites resolve to real ccdproc/*.py locations in a strict-backend run.
  • Marker plumbing (pytest_collection_modifyitems, non-strict xfail, marker registration in pyproject.toml) works as advertised; py313-strict naming matches the existing py313-jax / py312-alldeps-dask pattern, and CCDPROC_ARRAY_DEVICE=default / device1 handling matches array_api_strict.Device's accepted names.
  • Hook re-export through conftest.py (pytest_runtest_makereport, pytest_terminal_summary) is picked up by pytest, and the hookwrapper yields exactly once on all paths.
  • Default numpy run unaffected: 331 passed, 5 skipped, matching main.

Bugs 1–3 share one root cause (frame/namespace detection in the logger) and are small fixes; the triage report and markers — the parts the strict CI job actually exercises — are solid.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Qri8rDgkWjCT7Yb7QKJrhf

Comment thread ccdproc/tests/_escape_triage.py
Comment thread ccdproc/tests/_escape_triage.py Outdated
Comment thread ccdproc/tests/_escape_triage.py
Comment thread ccdproc/conftest.py
Comment thread ccdproc/conftest.py
@mwcraig

mwcraig commented Jul 7, 2026

Copy link
Copy Markdown
Member Author

Re-verified all four escape-logger bugs independently on fdf651e — every one is real:

  • Bug 1 (wrapper reported as escape site): reproduced directly. Wrapping np.asarray via _make_escape_logging_wrapper and calling it from a plain function reports ccdproc/conftest.py:231 wrapper as the site, exactly as described. The root cause is as stated: extract_stack()[:-1] drops only _describe_escape_site itself, leaving the wrapper as the innermost frame, and the wrapper lives in ccdproc/conftest.py, which the classifiers count as a non-test ccdproc frame.
  • Bug 2 (dask never detected): confirmed with the py312-alldeps-dask tox env (dask 2025.7.0, array-api-compat 1.12.0): hasattr(a, "__array_namespace__") is False for a dask array built through array_api_compat.dask.array, while array_api_compat.array_namespace(a) correctly resolves it. The suggested array_namespace()-based detection is the right fix.
  • Bug 3 (triage report corruption when both flags are set): same root cause as Bug 1; excluding conftest.py in the frame classifiers fixes both.
  • Bug 4 (logger invisible on passing tests): confirmed under this repo's actual pytest config — a passing test emitting the warning produces zero visible output.

Two refinements to the proposed fixes:

  1. Bugs 1/3: agree that classifying ccdproc/conftest.py as test infrastructure (rather than extract_stack()[:-2]) is the way to go — it fixes both bugs at once and doesn't break if a frame is ever added or removed between the wrapper and the site helper. Concretely, _escape_triage.py will treat frames from os.path.join(_PACKAGE_ROOT, "conftest.py") the same as ccdproc/tests/ frames.
  2. Bug 4: of the two options given, warnings.warn(...) is not viable here — pyproject.toml sets filterwarnings = ["error", ...], so every escape would become a hard test failure on backends where the test otherwise passes, turning a logging-only flag into a behavior change. The other option works with almost no code: log_cli_level = "INFO" is already set in pyproject.toml, and verified that adding -o log_cli=true makes the warning visible on passing tests. Plan: enable log_cli in the tox envs (or document the flag) wherever CCDPROC_LOG_ARRAY_ESCAPES is set.

One small extra found while reproducing Bug 1: _is_foreign_array's final namespace not in _NUMPY_LIKE_NAMESPACES test sits outside the try/except and requires the namespace to be hashable. Real namespaces are modules (hashable), so it's theoretical, but the spec only promises "an object" — the planned _foreign_namespace() refactor (also requested in Copilot's inline comment about the double __array_namespace__() call) will move that test inside the guarded helper.

The minor notes all look right too; the docs/array_api.rst "performs no computation" wording and the missing comment on call-phase-only triage will be fixed in the same pass. The red ✗ on ci-tests-expected-failures remains deliberate until the ~109 strict failures are burned down.

Written by Claude at @mwcraig's direction.

- Classify ccdproc/conftest.py as test infrastructure in the escape-site
  frame classifiers, so the escape logger's own monkeypatch wrapper is
  never reported as the escape site and no longer corrupts the triage
  summary when both flags are enabled.
- Detect foreign arrays via array_api_compat.array_namespace() instead of
  the raw __array_namespace__ dunder, so dask arrays (which lack the
  dunder) are detected; return the namespace from the helper so the log
  message no longer re-invokes third-party code unguarded.
- Convert the triage hook to a new-style pluggy wrapper.
- Document the triage/escape-logger flags and backend markers in
  docs/array_api.rst, including the -o log_cli=true requirement for
  seeing escapes from passing tests, and correct the description of
  array-api-strict.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qri8rDgkWjCT7Yb7QKJrhf
@mwcraig

mwcraig commented Jul 8, 2026

Copy link
Copy Markdown
Member Author

All four escape-logger bugs are fixed in a9313a6 (pushed), along with the agreed minor notes. Each fix was verified end-to-end before committing:

  • Bug 1 (wrapper reported as escape site): ccdproc/conftest.py frames are now classified as test infrastructure in _escape_triage.py, so the monkeypatch wrapper can never win "innermost non-test ccdproc frame". A synthetic escape triggered from a frame compiled with a ccdproc/core.py filename now logs that frame, not conftest.py … wrapper.
  • Bug 2 (dask never detected): detection now goes through array_api_compat.array_namespace() via the new _foreign_namespace() helper. Both plain dask.array arrays and compat-built ones resolve to the compat dask namespace; numpy arrays, masked arrays, and non-arrays return None.
  • Bug 3 (triage corruption with both flags): same classifier fix. A strict-backend run of test_ccdproc.py + test_combiner.py with CCDPROC_TRIAGE_ESCAPES=1 CCDPROC_LOG_ARRAY_ESCAPES=1 now produces a triage summary with zero conftest.py entries, byte-identical to a logger-off run.
  • Bug 4 (logger invisible on passing tests): documented in docs/array_api.rst — the triage flags and backend markers now have a section there, including the -o log_cli=true requirement (warnings.warn stays ruled out because of filterwarnings = ["error"]). Verified on the dask backend: a fully passing test_combiner.py run (74 passed) with the flag and -o log_cli=true surfaced 125 escape warnings pointing at real sites (e.g. combiner.py:680 average_combine, core.py:1233 sigma_func).

Minor notes also addressed: the array-api-strict description in docs/array_api.rst no longer claims it "performs no computation", and the triage hook's docstring now states that fixture-setup failures are deliberately not recorded. The default numpy run is unchanged (331 passed, 5 skipped) and ruff/pre-commit pass. Thread-level replies posted alongside this comment; the open threads should be resolvable now.

Written by Claude at @mwcraig's direction.

mwcraig and others added 5 commits July 7, 2026 21:32
The jax CI job's failure cancels the rest of the test matrix via
fail-fast. Mark the seven tests that currently fail there (jax 0.10 /
astropy 8) as non-strict expected failures so the matrix completes:

- Six combiner tests fail because astropy's nddata arithmetic passes a
  jax array as dtype=, which raises jax's implicit-array-to-dtype
  DeprecationWarning as an error under the suite's warning filters.
- test_generator_ccds_without_unit does not raise the expected
  ValueError on the jax backend.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qri8rDgkWjCT7Yb7QKJrhf
The escapes factor sets CCDPROC_LOG_ARRAY_ESCAPES (warns on silent
conversion of foreign arrays to numpy, even in passing tests) and
CCDPROC_TRIAGE_ESCAPES (groups test failures by escape site), and turns
on pytest live logging via PYTEST_ADDOPTS so the escape warnings are
visible for passing tests. Enables environments like
py312-test-dask-escapes and py312-test-jax-escapes for local triage.
Not added to CI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMP3e8QVgzf932GvPP2mTJ
Extend the escape tooling so silent numpy coercions on non-numpy backends
(dask/jax) can be gated in CI, not just eyeballed in a log.

- The live escape logger now tallies every coercion and prints a
  deduplicated "escape log summary" (counts per file:line function) at the
  end of the session. The conftest wrapper passes the blamed FrameSummary to
  record_escape_log() so the summary and the ratchet share one source.

- New baseline ratchet: ccdproc/tests/array_escape_baseline.txt lists the
  known library escape sites keyed on (file, function, coercion) -- no line
  numbers, to avoid churn. CCDPROC_ENFORCE_ESCAPE_BASELINE=1 fails the
  session (via pytest_sessionfinish) if a new library escape appears;
  CCDPROC_WRITE_ESCAPE_BASELINE=1 (re)seeds the file, preserving hand-written
  reason tags. Test-frame escapes are excluded via _is_library_site.

- The 13 current dask sites are all third-party numpy-only boundaries
  (astropy CCDData/stats/nddata/modeling, scipy.ndimage), tagged BOUNDARY
  with reasons; no pure-ccdproc coercions remain, so the ratchet is a
  regression gate.

- tox: new `enforce` factor (compose e.g. py312-alldeps-dask-enforce) turns
  on the logger and enforcement. pyproject ships the baseline in the wheel so
  tox's installed-package run finds it. CI: new must-pass job
  ubuntu-py312-dask-escape-baseline runs the gate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GHqzFdpg3qqiBb29ETq35D
Job-level continue-on-error only greens the workflow run conclusion; the
job's own check run still reports failure, so the PR rollup stayed red.
Move continue-on-error to the test step (job and rollup go green), report
the real outcome as a warning annotation and step-summary line, and upload
it as an artifact that a new workflow_run workflow turns into a check run
(neutral when failing, success when passing). The follow-up workflow runs
from the default branch, so the extra check only appears once this merges.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G6gS7Qb6gnFCmo6n7BvkW8

@mwcraig mwcraig left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Critical review

Summary

The mechanism design is solid in the places people usually get wrong — outcome vs conclusion with continue-on-error, cross-workflow artifact download (run-id + github-token), the workflows: ["CI"] name match, checks: write/actions: read permissions, __file__-anchored baseline path surviving tox changedir, line-number-free baseline keys, and a missing-baseline-file that fails loud rather than silent. Those are all correct.

The real problems are in the tooling's failure modes and the CI plumbing's edge cases: the baseline regeneration mode can silently destroy the baseline file, enforcement silently stops enforcing if one env var is missing, and the strict-status pipeline conflates infrastructure failure with "expected test failure" (or reports nothing at all). Plus a changelog pointing at PRs that don't exist, a stale jax xfail that never fires, and the ratchet workflow — the heart of the PR — is undocumented in docs/array_api.rst.

All reproductions below were run against the PR head (b564834) in a fresh py3.12 venv (jax 0.10.2, dask 2026.7.1).


Bugs (confirmed, with reproductions)

B1. CCDPROC_WRITE_ESCAPE_BASELINE=1 silently wipes or truncates the baselineccdproc/tests/_escape_triage.py:412-414, 170-206

_write_escape_baseline() rewrites the file from _observed_library_sites(), which reads _ESCAPE_LOG_COUNTS — populated only by the _log_array_escapes fixture in conftest.py, which returns immediately unless CCDPROC_LOG_ARRAY_ESCAPES is truthy (conftest.py:266). Write mode neither enables the logger nor checks that it ran. The file header says only "Regenerate with CCDPROC_WRITE_ESCAPE_BASELINE=1 (preserves tags)."

Two failure modes:

  • Follow the header's instruction literally (no CCDPROC_LOG_ARRAY_ESCAPES, or on the default numpy backend): observed set is empty → the entire baseline is replaced with the header plus # (no library escapes observed this run) (_escape_triage.py:192-194).
  • Even with everything set correctly, running a subset of tests drops every baseline entry for code the subset didn't exercise — "preserves tags" preserves tags only for sites still observed.

Verified live:

CCDPROC_WRITE_ESCAPE_BASELINE=1 python -m pytest ccdproc/tests/test_ccdproc.py::test_create_deviation -q
# → 7 passed in 0.95s; array_escape_baseline.txt reduced from 13 entries to
#   "# (no library escapes observed this run)"  (git diff: 1 insertion, 13 deletions)

Suggested fix: refuse to write when the logger is inactive (if not _LOG_ARRAY_ESCAPES: raise UsageError(...)) and warn (or refuse) when the new site set is smaller than the existing baseline / empty.

B2. Enforcement silently no-ops without CCDPROC_LOG_ARRAY_ESCAPES_escape_triage.py:415-416, 371-377

CCDPROC_ENFORCE_ESCAPE_BASELINE and CCDPROC_LOG_ARRAY_ESCAPES are coupled only in the tox enforce factor, not in code. Set enforce without the logger and _observed_library_sites() is empty → _new_escapes() is empty → session passes, and the terminal summary prints the reassuring "No foreign-array escapes observed (numpy backend?); baseline not checked." A CI config that copies only the enforce var gets a permanently-green, non-enforcing gate.

Verified live (deleted the combiner.py average_combine baseline line to simulate a new escape):

CCDPROC_ENFORCE_ESCAPE_BASELINE=1 CCDPROC_ARRAY_LIBRARY=dask python -m pytest ccdproc/tests/test_combiner.py -q
# → 74 passed, exit 0: "No foreign-array escapes observed (numpy backend?); baseline not checked."
CCDPROC_LOG_ARRAY_ESCAPES=1 CCDPROC_ENFORCE_ESCAPE_BASELINE=1 CCDPROC_ARRAY_LIBRARY=dask python -m pytest ccdproc/tests/test_combiner.py -q
# → exit 1: "NEW escapes not in baseline (1) -- these fail the session: + combiner.py  average_combine  numpy.asarray"

Note the message even says "numpy backend?" while running on dask — the diagnostic itself misleads. Suggested fix: in pytest_sessionfinish (or at import), error out if ENFORCE_BASELINE and not _LOG_ARRAY_ESCAPES.

B3. Changelog cites nonexistent PRsCHANGES.rst:8,12

The two new entries are tagged [#937] and [#939]; neither PR exists. Both should be [#942]. Also, the new section heading is bare Unreleased, where this repo's convention is x.y.z (unreleased).

B4. Hardcoded artifact name breaks the moment the expected-failures matrix growsci_tests.yml:172-176, strict_status.yml:32,41

ci-tests-expected-failures is deliberately a matrix, but the artifact name is the constant strict-job-outcome. upload-artifact v4+ (v6.0.0 here) errors when an artifact of the same name already exists in the run — it is no longer last-write-wins. Add a second include: row and its upload fails with "an artifact with this name already exists". strict_status.yml likewise downloads the one fixed name and reads a single file, so the whole reporting path is structurally single-entry. Inert today (one row), guaranteed breakage on the first extension.

Suggested fix: name: strict-job-outcome-${{ matrix.name }}, download by pattern in strict_status.yml, iterate; or document that the job must stay single-entry.

B5. Infra failures are reported as "expected test failure" — or produce no check at allci_tests.yml:158-176, strict_status.yml:36

Two related holes:

  • Conflation: steps.tests.outcome == 'failure' for any nonzero tox exit — env-creation failure, array-api-strict>=2.0 failing to resolve, tox internal error — not just failing tests. All get labeled ":warning: tests failed (expected until the remaining array-API bugs are fixed)" and a grey neutral check. A genuinely broken strict environment is rebranded as routine.
  • Silent gap: "Record test outcome" and "Upload test outcome" have no if: always(). If an earlier step fails ("Install base dependencies"), they're skipped → no artifact → strict_status.yml's download fails (tolerated) → "Create check run" is skipped → no strict check is posted for that commit at all.

Note the naive fix interacts badly: with bare if: always(), a skipped tests step writes skipped to the file, which strict_status.yml maps to passed === false → "failing (expected)". The pipeline needs a third state ("infra error", e.g. conclusion: failure on the check run), or at minimum a comment acknowledging the limitation.

B6. Check-run name encodes the outcomestrict_status.yml:47

py313-strict: passing vs py313-strict: failing (expected) are two different checks to GitHub. Consequences: it can never be a branch-protection required check; the check "identity" flips names across pushes as the outcome changes; anything keyed on a stable check name breaks. If informational-only is the intent, say so in a comment — better, use a stable name: 'py313-strict' and vary only conclusion and output.title.

B7. The jax xfail on test_generator_ccds_without_unit is stale — the test XPASSesccdproc/tests/test_image_collection.py:~386

The reason says "ccds() on the jax backend does not raise ValueError when the files have no unit" — but that ValueError comes from fits_ccddata_reader at image_collection.py:960, before the backend conversion block (:963-973), so the backend cannot affect whether it raises. Verified empirically:

CCDPROC_ARRAY_LIBRARY=jax python -m pytest ccdproc/tests/test_image_collection.py -q -rX
# → 76 passed, 1 xpassed (this test) — same result in isolation and full-module runs

The marker is wrong (or was recorded against an environment quirk that no longer exists) and, because backend_xfail maps to strict=False, it XPASSes silently forever — a live demonstration of D5 below. Remove the marker; if it originally failed for someone, the failure was a different exception (e.g. a warning promoted to error), not the stated mechanism. (The other backend_xfails — astroscrappy/scipy/reproject/block_reduce — all sit on genuine numpy-only boundaries; this is the one outlier.)


Bugs (plausible — mechanism verified, occurrence depends on conditions)

P1. Duplicate strict checks on same-repo PRsci_tests.yml:3-13, strict_status.yml

on: push (all branches) + pull_request both fire for a branch pushed to the main repo; the concurrency group keys on github.ref, which differs (refs/heads/… vs refs/pull/N/merge), so neither run cancels the other. Two completed "CI" runs → two workflow_run events → two checks.create calls on the same head_sha → two (possibly contradictory, since the merge-ref run can differ) strict checks on the PR. Fix: gate strict_status.yml on github.event.workflow_run.event == 'pull_request' (which also stops noise checks from schedule/push-to-main runs).

P2. Baseline keys use frame function names → fragile across Python versions and comprehensions_escape_triage.py (record_escape_log keying on frame.name)

An escape inside a genexpr/lambda keys as <genexpr>/<lambda> on every version; list comprehensions key as <listcomp> on py<3.12 but as the enclosing function on 3.12+ (PEP 709). The enforce CI job is py312; the documented refresh is "locally", unpinned. A contributor regenerating on 3.11 produces keys that mismatch CI → spurious NEW-escape failure + spurious stale entry. Fix: document a Python floor for regeneration, or normalize <listcomp>-style names to the enclosing frame.

P3. Escape logging is not thread-safe; the reentrancy guard is process-globalconftest.py (_ReentrancyGuard, shared across all three wrappers)

Under dask's threaded scheduler, thread A inside a wrapped np.asarray sets the guard; a concurrent escape on thread B sees active and is skipped entirely; and the defaultdict[key] += 1 first-insert isn't atomic. Both drop real escapes (ratchet false-negatives), never false alarms. Fix: threading.local() guard + a lock around the tally.


Design limitations that should at least be documented

D1. The logger only sees Python-level np.asarray/np.asanyarray/np.ma.asanyarray module-attribute calls. C-level coercions inside scipy/astroscrappy/reproject (exactly the BOUNDARY deps the baseline enumerates) and any from numpy import asarray bound before the patch are invisible — the ratchet has structural false negatives. The module docstring ("catch silent conversion back to numpy") oversells it.

D2. xdist silently defeats the ratchet. _ESCAPE_LOG_COUNTS is a per-process global; with pytest -n, workers accumulate and the controller sees nothing → "baseline not checked", green. Not live today (no -n in tox.ini) — worth an assert or comment before someone adds it.

D3. Strict-job device mismatch noise. Fixture data is created on device1 (pytest_fixtures.py), but inline xp.ones(...)/xp.asarray(...) in test bodies land on the default device; array-api-strict raises on cross-device ops. Part of the 109 strict failures are harness artifacts, not ccdproc bugs — diluting the signal the job exists to produce. Worth a helper (or fixture-provided make_array) that pins the device.

D4. py313-strict installs only extras = test + array-api-strict — no alldeps (tox.ini), so numpy-boundary paths through scipy/reproject/astroscrappy aren't exercised there. Probably intentional; one comment line would prevent someone "fixing" it.

D5. strict=False backend_xfails never flag stale markers. Correct choice given xfail_strict = true globally, but a fixed backend bug becomes a silent XPASS forever (see B7). A note in the marker docs ("prune on XPASS") is enough.


Refactoring suggestions

R1. _escape_triage.py is three modules in a trenchcoat: (1) failure-triage grouping (pytest_runtest_makereport + _ESCAPE_SITES report), (2) live escape-log tally + reporting, (3) baseline load/write/enforce. Meanwhile the monkeypatching lives in conftest.py but its helpers (locate_escape_site, record_escape_log) live here, creating a conftest↔module ping-pong. Suggest _escape_log.py (patching + recording + summary) and _escape_ratchet.py (load/write/enforce/report); "triage" would then honestly name what's left.

R2. Hook registration via from ._escape_triage import pytest_sessionfinish # noqa (conftest.py:27-33) works and is commented, but pytest_plugins = ["ccdproc.tests._escape_triage"] is the idiomatic, less-fragile spelling (the noqa'd imports are load-bearing and an overzealous linter autofix could silently remove them).

R3. Implicit contract between the two workflows — the outcome file format (success/failure text) is shared between ci_tests.yml and strict_status.yml with no shared definition; a one-line schema comment in both places (or writing JSON with an explicit state: field, which also solves B5's third state) would harden it.

R4. tox factor coupling: enforce sets both CCDPROC_LOG_ARRAY_ESCAPES and CCDPROC_ENFORCE_ESCAPE_BASELINE — currently the only place the B2 invariant is maintained. After fixing B2 in code, keep the factor as the single source of the pairing. Also: nothing explains that the CI strict/enforce envs deliberately omit the test factor so they run from the source tree (not .tmp/{envname}); a future "normalization" to py312-test-… would silently change import paths.


Documentation

docs/array_api.rst documents the strict backend, CCDPROC_TRIAGE_ESCAPES, CCDPROC_LOG_ARRAY_ESCAPES, and the markers — but has zero mention of the ratchet (grep -iE 'baseline|enforce|ratchet' docs/array_api.rst → nothing). Missing, in rough priority order:

  1. Regenerating the baseline — that it requires all three of CCDPROC_WRITE_ESCAPE_BASELINE=1 + CCDPROC_LOG_ARRAY_ESCAPES=1 + a non-numpy CCDPROC_ARRAY_LIBRARY, over the full suite, in the source tree. Today that's split between a tox.ini comment and the baseline-file header, and the header's one-liner is the B1 footgun.
  2. What to do when the ratchet fires on your PR (py312-alldeps-dask-enforce goes red): read the "NEW escapes" section of the log, either migrate the call site or (deliberately) add a baseline line.
  3. How to read the strict CI signal: the ubuntu-py313-strict check is always green (carve-out); the real outcome is the grey py313-strict: failing (expected) / green py313-strict: passing check (only after this PR merges, since workflow_run executes from the default branch), plus the run's step summary. Nothing user-facing explains the grey check.
  4. CCDPROC_ENFORCE_ESCAPE_BASELINE and CCDPROC_WRITE_ESCAPE_BASELINE aren't listed alongside the other env vars at all.

Verified non-issues (so nobody re-litigates them)

Backend-name spellings in all backend_xfail/backend_skip marks match _normalize_backend_name (no silent never-applies typos); markers registered for --strict-markers; no filterwarnings loosening; no weakened assertions anywhere in the test diff; action SHAs/versions all real and current; cross-workflow artifact download inputs correct; outcome (not conclusion) correctly read under continue-on-error; session.exitstatus = 1 in pytest_sessionfinish genuinely fails the run; baseline path is __file__-anchored (changedir-safe); missing baseline file fails loud; _env_truthy handles unset/"0"/"true".


Suggested fix priority

  1. B1 + B2 (one-line guards; they undermine the tool's core promise) and B3 (trivial).
  2. B5 + B6 (strict-status robustness: third state, stable check name, if: always() with skipped-handling).
  3. B4 + P1 (artifact-per-matrix-name; gate workflow_run on pull_request).
  4. B7 (delete the stale jax marker).
  5. Docs section (regeneration + ratchet-fired + grey-check).
  6. P2/P3/D1–D5/R1–R4 as follow-ups or comments.

🤖 Generated with Claude Code

mwcraig and others added 3 commits July 14, 2026 10:43
Both CCDPROC_WRITE_ESCAPE_BASELINE and CCDPROC_ENFORCE_ESCAPE_BASELINE
read the sites tallied by the CCDPROC_LOG_ARRAY_ESCAPES logger, but
nothing coupled them in code: write mode with the logger off silently
truncated the baseline to an empty stub, and enforce mode vacuously
passed while printing a reassuring message. Fail the session with a
UsageError before any test runs when either mode is set without the
logger, refuse to rewrite the baseline when zero escapes were observed,
and warn loudly (listing the entries) when a rewrite drops baseline
entries, since a subset run drops sites its tests never exercised.

Also remove the stale jax backend_xfail on
test_generator_ccds_without_unit: the ValueError it references is
raised by fits_ccddata_reader before the backend conversion block, so
the backend cannot affect it, and the test xpasses on jax.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M5FNYqVHbKLbVSGJGftLrL
- Record the strict-job outcome as JSON with an explicit third state
  ("error") so an infrastructure failure (skipped/cancelled tests step,
  broken env creation) is reported as a red failure check instead of
  being conflated with "failing (expected)"; the record and upload
  steps run under if: always() so an artifact exists even when an
  earlier step fails.
- Name the outcome artifact per matrix entry and download by pattern:
  upload-artifact v4+ errors on duplicate names, so the previous fixed
  name would break the moment the expected-failures matrix grew.
- Use a stable check-run name (the matrix entry name) and carry the
  outcome in the conclusion and output instead of encoding it in the
  check name.
- Only report on workflow runs triggered by pull_request, so same-repo
  PR branches (which trigger both push and pull_request CI runs) do not
  get duplicate, possibly contradictory, checks on the same head SHA.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M5FNYqVHbKLbVSGJGftLrL
Add docs for the escape-baseline ratchet (what the baseline file is,
how to regenerate it, what to do when the enforce CI job fires) and for
reading the strict CI signal (the always-green carve-out job vs the
separate check run posted by the strict-status workflow), and list the
CCDPROC_ENFORCE_ESCAPE_BASELINE / CCDPROC_WRITE_ESCAPE_BASELINE env
vars alongside the other developer tooling.

Fix the changelog: both new entries belong to PR astropy#942 (not astropy#937/astropy#939),
and the section heading follows the x.y.z (unreleased) convention.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M5FNYqVHbKLbVSGJGftLrL
Removing this marker as stale broke the ubuntu-py313-jax CI job: the
test genuinely fails there (ccds() does not raise ValueError for
unit-less files), and the previous CI run confirms the marker was
xfailing in that environment all along. The staleness verification had
been done on macOS, where the test passes on jax with identical
astropy/jax/numpy versions, so the behavior is platform-dependent.
Restore the marker with a reason that records what is actually known;
the Linux-side root cause still needs investigation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M5FNYqVHbKLbVSGJGftLrL
Review follow-ups on PR astropy#942 (items D1, D2, D4, D5, R4):

- tox.ini: explain why the strict env installs no optional deps, why the
  CI strict/enforce envs omit the "test" factor, and why the enforce
  factor sets both escape-tooling variables.
- _escape_triage.py / conftest.py: note that only Python-level
  module-attribute calls to np.asarray/np.asanyarray/np.ma.asanyarray
  are visible to the escape logger; C-level coercions and pre-patch
  imports are structural false negatives.
- _escape_triage.py: reject baseline write/enforce modes under
  pytest-xdist -- the per-process escape tally never reaches the
  controller, so the baseline would be truncated or enforcement would
  pass vacuously.
- docs/array_api.rst: backend_xfail markers are non-strict, so fixed
  backend bugs become silent XPASSes; check for them with -rX and
  confirm against CI logs (not just locally) before pruning a marker.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M5FNYqVHbKLbVSGJGftLrL
Comment thread ccdproc/tests/_escape_triage.py Outdated
Comment thread ccdproc/conftest.py
Comment thread ccdproc/conftest.py
- _write_escape_baseline: collapse the three per-column max()
  comprehensions into one transpose over the site tuples.
- pytest_collection_modifyitems: add a docstring explaining how the
  backend_skip/backend_xfail markers are applied at collection time.
- _make_escape_logging_wrapper: document the wrapper's log-and-tally
  behavior and why the reentrancy guard is needed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M5FNYqVHbKLbVSGJGftLrL
@mwcraig mwcraig merged commit bada29d into astropy:main Jul 14, 2026
17 checks passed
@mwcraig mwcraig deleted the array-api-strict-triage-tooling branch July 14, 2026 20:04
mwcraig added a commit that referenced this pull request Jul 14, 2026
Trivial change to verify the post-merge strict-status check flow from
PR #942 (a single ubuntu-py313-strict check run with a neutral
conclusion is expected on this PR).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M5FNYqVHbKLbVSGJGftLrL
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants