G26 — NumPy C-API compatibility-envelope analysis¶
Registry: UC-TC-numpy-capi-envelope (partial)
Effort: L · Risk: medium
Origin: SciPy / Scientific-Python Roadmap §2.
Status note (delivered scope): empirical verification against a real
compiled NumPy 2.4 extension (see PR #564's follow-up discussion) confirmed
that NPY_TARGET_VERSION — recovered via the human-readable
NPY_FEATURE_VERSION_STRING NumPy's own generated _import_array() shim
embeds as a literal string — is reliably recoverable via a plain rodata
scan, surviving strip and independent of optimisation level. The raw
NPY_ABI_VERSION/NPY_API_VERSION hex constants are not: they are
passed as PyErr_Format varargs via a compiler-emitted immediate load, not
a string literal, so recovering them needs disassembly — a new heavy
dependency this project's no-heavy-deps policy rules out (same reasoning
that keeps G4 out of scope). Delivered: consumption detection
(_ARRAY_API/_UFUNC_API presence), the NPY_TARGET_VERSION string, a
two-snapshot delta detector wired into compare(), and a standalone
wheel-metadata cross-check (check_numpy_metadata_contract, mirroring
G10's parse_manylinux_glibc_floor — not auto-wired into the CLI compare
path, same rationale). Deferred, and why, in "Out of scope" below.
Problem¶
The NumPy C-API is one of the most important binary contracts in scientific
Python, and abicheck's existing native-symbol analysis does not adequately
describe it: the API is consumed largely through runtime capsule tables
(_ARRAY_API, _UFUNC_API, populated by import_array()/import_ufunc()),
not ordinary exported symbols, so a symbol_removed/symbol_added diff over
the dynamic symbol table sees nothing.
NumPy's own documented compatibility model gives abicheck a well-specified envelope to check against, but nothing currently extracts or verifies it:
NPY_ABI_VERSIONandNPY_API_VERSION(compiled-in constants) fix the ABI/API generation an extension was built against.NPY_TARGET_VERSIONlets a project pin an older target API deliberately (so the extension keeps working against older NumPy runtimes even when built with a newer NumPy).NPY_FEATURE_VERSIONandNPY_NO_DEPRECATED_APIgate which API slots are legal to call.- NumPy 2.0 changed the ABI: a wheel built against NumPy 1.x does not work against NumPy 2.x; a wheel built against NumPy 2.x may work against NumPy 1.x depending on the configured target — the "may" is exactly the compatibility question a scanner should answer instead of leaving it implicit.
- The NumPy version range declared in wheel metadata (
numpy>=1.23.5) can silently understate or overstate what the binary evidence actually requires.
Without this, a release comparison cannot say which NumPy environments were actually lost or gained between two builds of the same extension — the question a release manager actually needs answered.
Goal & acceptance criteria¶
- [x] Extract, per extension module, whether it consumes the NumPy C-API at
all (
_ARRAY_API/_UFUNC_APIreferenced) and, when it does, theNPY_TARGET_VERSIONit was compiled to target (the minimum NumPy runtime its C-API usage requires). The rawNPY_ABI_VERSION/NPY_API_VERSIONhex constants are not extracted — see the status note above;NPY_NO_DEPRECATED_APIis not a runtime-visible string/constant at all (it only gates which functions the source may call, compiled away with no trace) and is not extracted. - [x] Cross-reference the declared NumPy version range from wheel/package
metadata (
*.dist-info/METADATARequires-Dist: numpy) against the binary evidence and flag disagreement (package.parse_wheel_numpy_requirement+diff_numpy_capi.check_numpy_metadata_contract). - [ ] Compute and report a full support envelope string ("built with NumPy X, target floor Y, verified compatible NumPy Y through 2.x"). Not delivered as a rendered string — "built with NumPy X" specifically needs the un-recoverable raw build-time version (see status note); the underlying facts (target floor, declared range, and the RISK/BREAKING findings when they disagree) are computed and reported as ordinary findings instead.
- [x] Emit new
ChangeKinds, classified per the rootCLAUDE.mdfour-step procedure:numpy_abi_major_incompatible(BREAKING),numpy_target_floor_raised(RISK),numpy_metadata_understates_required_version(RISK— the declared floor is a lie relative to the binary evidence),numpy_capi_consumption_added(RISK— not originally listed, added during implementation: a module gaining NumPy C-API consumption is a new runtime dependency ordinary symbol diffing can't see, the same class of gap this whole plan exists to close),numpy_capi_consumption_removed(COMPATIBLE). Deferred (not implemented — see "Out of scope"):numpy_build_runtime_contract_mismatch,numpy_deprecated_c_api_reintroduced,numpy_api_used_above_declared_floor. - [x] Degrades honestly for extensions that don't consume the NumPy C-API at
all (no findings, not false positives —
extract_numpy_capi_surfacereturnsNone) and for builds where the target-version string isn't recoverable (capi_target_version=None; both detectors treat this as "can't check", not "no floor").
Design¶
abicheck/numpy_capi.py's extract_numpy_capi_surface(binary_path) scans a
binary's raw bytes (size-capped, format-agnostic — the same marker strings
appear regardless of ELF/PE/Mach-O, so no per-format section parsing is
needed) for NumPy's own generated _import_array()/_import_umath() shim
literals: _ARRAY_API/_UFUNC_API presence markers, and the
NPY_FEATURE_VERSION_STRING-derived "(NumPy X.Y)" target-version string.
Returns None (not an empty surface) when neither marker is present — an
ordinary, non-NumPy library produces no finding.
abicheck/package.py's parse_wheel_numpy_requirement/
parse_numpy_requirement_from_metadata extract the declared numpy
version-specifier range from a wheel's *.dist-info/METADATA
Requires-Dist: numpy... line (skipping marker-gated/optional-extra
entries) as the "declared" side.
abicheck/diff_numpy_capi.py has two independent functions:
diff_numpy_capi_surfaces(old, new)— a two-snapshot delta (consumption added/removed, target floor raised); wired intochecker.compare()unconditionally, since it needs only the two snapshots' ownnumpy_capifield.check_numpy_metadata_contract(surface, declared_numpy_requirement)— a single-artifact self-consistency check (declared range vs. binary target). Needs wheel-level metadatacompare()has no access to per library, so — like G10'spackage.parse_manylinux_glibc_floor— this is a standalone function for programmatic use, not auto-wired into the CLI compare path.
Files & surfaces¶
- New
abicheck/numpy_capi.py(surface model + binary extractor),abicheck/diff_numpy_capi.py(both detectors),abicheck/change_registry_numpy.py(new-kind registry entries, split out the same waychange_registry_coverage.pyis —change_registry.pyis at its 2000-line cap). abicheck/checker_policy.py(newChangeKindmembers) +abicheck/change_registry.py(splices inchange_registry_numpy.py).abicheck/model.py(numpy_capifield onAbiSnapshot),abicheck/serialization.py(persist/derive),abicheck/service.py(_try_attach_numpy_capi_surface, called for all three binary formats).abicheck/package.py(wheelRequires-Dist: numpyparsing).abicheck/checker.py(wiresdiff_numpy_capi_surfacesintocompare()).
Tests¶
tests/test_numpy_capi.py— binary-evidence extraction against synthetic byte fixtures reproducing NumPy's real generated shim strings (verified against a real compiled NumPy 2.4 extension during development), plusnumpy_capiserialization round-trip.tests/test_diff_numpy_capi.py— both detector functions (consumption added/removed, target floor raised/dropped/unchanged, metadata understatement, the 1.x/2.0 ABI-boundary case, malformed specifiers) and an end-to-end test through the realchecker.compare().tests/test_package.py— wheel/METADATA requirement parsing (versioned, bare/unconstrained, marker-gated, case-insensitive, multipleRequires-Distlines).- Not delivered: a compiled
examples/fixture pair (would need a real numpy install + C compiler in the example-fixture build matrix, unlike the fully synthetic unit coverage above) and a rendered "compatible from X through Y" support-envelope string (needs the un-recoverable raw build version — see the status note).
Effort & risk¶
L — one new evidence extractor (format-agnostic, so effectively one scan
rather than one per binary format) plus a metadata cross-check and a family
of new ChangeKinds. Medium risk realized as expected: the raw
NPY_ABI_VERSION/NPY_API_VERSION hex constants' on-disk representation
depends on compiler codegen (an immediate load, not a string literal) and
was not reliably recoverable without disassembly — confirmed by
compiling and inspecting a real NumPy 2.4 extension rather than guessing.
The NPY_TARGET_VERSION string, by contrast, turned out to be fully
reliable (a literal, compiler-preserved, strip-surviving string) — better
than the plan's original "best-effort, may need reduced coverage" framing
anticipated for that specific fact.
Out of scope¶
Deferred from the original acceptance criteria, given the hex-constant extraction limits above:
numpy_build_runtime_contract_mismatch,numpy_api_used_above_declared_floor— both need the rawNPY_ABI_VERSION/NPY_API_VERSIONhex constants or a per-API-slot call-site inventory, neither recoverable via a string scan.numpy_deprecated_c_api_reintroduced—NPY_NO_DEPRECATED_APIgates which functions the source may call at compile time; it leaves no runtime-visible trace (string, symbol, or otherwise) in the compiled binary at all.- The rendered support-envelope string ("built with NumPy X, target floor Y, verified compatible NumPy Y through 2.x") — "built with NumPy X" needs the same un-recoverable raw build-time version.
- A compiled
examples/fixture pair (synthetic unit fixtures cover the same cases; a real numpy+compiler build-matrix fixture is a reasonable follow-up, not required to close this pass).
Already out of scope per the original plan: ufunc/gufunc/dtype-loop surface
comparison (tracked separately, see the NumPy ufunc/dtype item in the
SciPy roadmap —
not yet gap-plan-ified); runtime import numpy; numpy.__version__ probing
(a hermetic-sandbox concern, deferred alongside G23's runtime-introspection
fallback); non-NumPy array-API implementations (CuPy, JAX, etc.); a
disassembly dependency to recover the raw hex constants (would need a new
heavy dependency this project's policy rules out, same reasoning as G4).