V0.30 Hygiene Audit#

Status: AUDIT-ONLY in v0.30a. Phase 1 IMPLEMENTED in v0.30e: ruff, mypy (strict scope narrowed to pdelie.contracts, pdelie._boundary, pdelie.derivatives.*), and pytest-cov are configured in pyproject.toml and wired into three new non-blocking CI jobs (lint, typecheck, coverage). Coverage baseline at v0.30e HEAD: 86% on src/pdelie/. Non-blocking gates: a red run reports findings but does not fail the workflow. Numpy <2 cap and Python 3.11-only matrix remain unchanged; broadening those is Phase 3+ work. Release-gate consolidation IMPLEMENTED narrowly in v0.30f: configs/release_gate_manifest.json and the parameterized tests/test_release_gates.py replay declarative assertions for 18 releases; all 26 per-version files are retained (functional smoke tests intentionally remain explicit Python tests). See “Release-gate consolidation” below.

This document audits the current state of cross-cutting code-quality infrastructure (lint, type-checking, coverage, Python matrix, NumPy upper bound, release-gate proliferation, optional-dependency import policy, JSON-strict reporting policy) and proposes staged enforcement.

Lint status#

No [tool.ruff], [tool.black], or [tool.flake8] configuration is present in pyproject.toml. The current file contains only [build-system], [project], [project.urls], [project.optional-dependencies], [tool.setuptools], [tool.setuptools.packages.find], and [tool.pytest.ini_options].

There is no .pre-commit-config.yaml at the repository root.

The project’s documented manual lint step is the whitespace check git diff --check (referenced in CONTRIBUTING.md:88 under “Check whitespace”). This is run by hand at PR time.

Codebase shape: a quick scan of src/pdelie/ shows consistent from __future__ import annotations and inline PEP-604 unions (str | None). A default ruff configuration with selections ["E", "W", "F", "B", "I", "UP", "RUF", "NPY"] is plausible without large refactor cost.

Type-checker status#

No [tool.mypy], [tool.pyright], or equivalent configuration in pyproject.toml. No mypy.ini, .mypy.ini, pyrightconfig.json, or equivalent at the repository root.

The codebase uses typed parameters and return annotations throughout (e.g. src/pdelie/derivatives/spectral_fd.py carries full annotations, src/pdelie/symmetry/fitting/translation_baseline.py annotates SVD results). A strict-on-src/pdelie/contracts.py and src/pdelie/derivatives/ configuration is plausible immediately; broader strictness will require some tightening (untyped dictionaries in diagnostics: dict[str, object] fields where the values are heterogeneous).

Coverage status#

No [tool.coverage] in pyproject.toml. No coverage or pytest-cov in the test optional-dependency group (verified in pyproject.toml lines 55-62, which lists build, matplotlib, pysindy, scikit-learn, pytest, xarray). CI emits no coverage.xml artifact.

The test suite is large — 689 test functions across 106 test files. Coverage measurement is feasible once pytest-cov is added; the absolute coverage number on src/pdelie/ is likely high given the inventory of release-gate, public-API audit, and per-module tests.

Python version matrix#

CI runs only python-version: "3.11", verified across all four jobs in .github/workflows/ci.yml:

  • v0_29-release-gate — Python 3.11

  • docs-build — Python 3.11

  • editable-tests — Python 3.11

  • package-smoke — Python 3.11

pyproject.toml declares requires-python = ">=3.11". No upper bound is declared. There is no validation against Python 3.12 or 3.13.

NumPy upper bound#

pyproject.toml line 32: numpy>=1.24,<2. This excludes the entire NumPy 2.x line.

The scientific Python ecosystem completed the NumPy 2.x migration during 2024. Major downstream consumers (SciPy, scikit-learn, xarray, PyTorch) are all on 2.x-compatible code paths. The numpy<2 cap is now an adoption blocker rather than a safety measure.

A NumPy 2.x compatibility validation side job is feasible; the codebase uses NumPy in mainstream ways (FFT, gradient, SVD, linspace, basic broadcasting), all of which have stable APIs across the 1.x/2.x boundary. Likely-affected sites are limited to:

  • type-hint usage of np.bool_ / np.integer / np.generic (compatible across versions)

  • np.fft calls (compatible)

  • np.random.default_rng (compatible, used as np.random.default_rng(seed))

Estimated 1–2 days to validate and lift the cap, but this work is not in scope for v0.30 proper.

Release-gate proliferation#

Counted at v0.30a baseline: 26 per-version release-gate test files in tests/:

  • tests/test_v0_4_release_gate.py through tests/test_v0_9_release_gate.py (6 files)

  • tests/test_v0_10_release_gate.py through tests/test_v0_29_release_gate.py (20 files)

Releases v0.1, v0.2, v0.3 did not produce dedicated release-gate test files; the modern pattern began at v0.4.

Each file follows the same structural pattern: read several documents by path, assert specific phrases are present, assert forbidden names are not on pdelie or any submodule, validate JSON manifests for strict JSON compatibility. The structure is mechanical; the duplication is significant.

This audit recommends consolidation in v0.30 proper. See “Release-gate consolidation” below.

Optional-dependency import pattern#

The current pattern is correct. Reference implementation at src/pdelie/discovery/pysindy_adapter.py:12-20:

def _require_discovery_dependencies():
    try:
        pysindy = importlib.import_module("pysindy")
        importlib.import_module("sklearn")
    except (ModuleNotFoundError, ImportError, ValueError) as exc:
        raise ImportError(
            "PySINDy discovery adapter requires pdelie[downstream] or pdelie[test]."
        ) from exc
    return pysindy

Key properties:

  • Optional dependencies are imported on first use, not at module load time.

  • The import error is raised with an actionable message naming the optional-extras install target.

  • The function is private (leading underscore) and called only from inside fit_pysindy_discovery, never at package-level import.

The viz layer follows the same pattern. tests/test_public_api.py:462-487 verifies this by mocking out matplotlib and confirming that import pdelie.viz still succeeds; only at first call to plot_* does the ImportError surface.

This policy must remain unchanged through v0.30 and beyond. New optional extras (e.g., a future pdelie[generative] for LieGAN-style methods) must follow this exact pattern.

Strict JSON / no-NaN policy#

The current policy is correct. Reference helpers exist in the reporting layer:

  • _validate_strict_json_compatible(value, *, name) is called by summarize_* helpers throughout src/pdelie/reporting/summaries.py and elsewhere

  • The pattern json.loads(json.dumps(matrix, allow_nan=False)) == matrix is the gate (verified in tests/test_v0_29_release_gate.py:93)

  • _json_safe(value) (in src/pdelie/verification/__init__.py:28-37) normalizes numpy arrays and scalars to Python natives before serialization

Any new reporting output landed in v0.30 proper must follow this discipline:

  • No float("nan") in serialized output. Use None or an explicit unavailable-status string.

  • No numpy.ndarray, numpy.generic, or pandas objects in serialized output. Convert to nested Python lists/dicts.

  • No tensor objects, file paths, or model handles in serialized output.

This policy is non-negotiable for new APIs.

Staged enforcement#

No pyproject.toml or CI change in v0.30a. The audit recommends:

Phase 0 — v0.30a (this release)#

Audit-only. This document records the baseline. No pyproject.toml change, no CI change. The negative tests in tests/test_v0_30_hygiene_audit.py confirm that no premature enforcement has landed.

Phase 1 — v0.30 proper#

  • Add [tool.ruff] with target-version = "py311", selections ["E", "W", "F", "B", "I", "UP", "RUF", "NPY"], and the project’s whitespace-conscious line length (likely 100 or 120). Add a lint CI job that runs ruff check . as warning-only / non-blocking.

  • Add [tool.mypy] with strict = true initially scoped to:

    • src/pdelie/contracts.py

    • src/pdelie/derivatives/

    • src/pdelie/data/

    • src/pdelie/residuals/ Leave the rest lenient. Add a typecheck CI job as warning-only / non-blocking.

  • Add [tool.coverage.run] with source = ["src/pdelie"] and branch = true; add [tool.coverage.report] with an initial floor fail_under = 80 (conservative; raise later). Add pytest-cov to the test optional-dependency group. Coverage job runs but does not gate merges initially.

Phase 1 is opt-in by CI: the existing editable-tests, package-smoke, docs-build, and v0_29-release-gate (which becomes v0_30-release-gate) jobs continue unchanged and remain the merge gates.

Phase 2 — v0.30.1a through v0.30.1k (split per-axis)#

Phase 2 is split into small dedicated maintenance PRs, each promoting exactly one advisory job or broadening exactly one mypy strict scope. This is deliberately the opposite of “one big Phase 2 PR”: each promotion is boring, mergeable in a day, and cannot fight rebase-hell against v0.31.

  • v0.30.1a — lint promotion (this PR). Removes continue-on-error: true from the lint CI job and its Ruff lint step. python -m ruff check . now gates merges. typecheck and coverage remain advisory.

  • v0.30.1b — coverage promotion. Removes continue-on-error: true from the coverage CI job and its pytest --cov step. Raises [tool.coverage.report].fail_under from 80 to 85. Current baseline is 86% on src/pdelie/; the raise-with-headroom prevents silent regression on new code that lands in v0.31+.

  • v0.30.1c through v0.30.1k — mypy strict-scope broadening, one subpackage per PR. Order: pdelie.data.*pdelie.residuals.*pdelie.reporting.*pdelie.discovery.*pdelie.symmetry.*pdelie.invariants.*pdelie.portability.*pdelie.verification.*pdelie.viz.*. After the last, promote the typecheck CI job to blocking.

Rationale for the per-axis split (recorded in the workflow review that authored the v0.31 arc plan):

  • Phase 2 sits between two feature releases (v0.30 close and v0.31). Bundling it with either creates review noise and rebase risk.

  • A soak period without new code produces zero new signal — v0.30.0 introduced no code change since v0.30e already ran the advisory jobs. Ordering Phase 2 promotions before v0.31 exposes any regression at PR review time on v0.31 itself, rather than after merge.

  • Coverage regression masking is a live risk: continue-on-error: true on the coverage job plus fail_under = 80 against an 86% baseline gives ~6 points of slack. New pdelie.tasks.discovery code with thin tests could silently drag coverage toward 80% while CI stays advisory-green. v0.30.1b closes that gap before v0.31a lands.

Phase 3 — v0.32#

  • Expand CI matrix to python-version: ["3.11", "3.12", "3.13"] for editable-tests and package-smoke.

  • Add a NumPy 2.x side job: editable-tests-numpy2x that installs numpy>=2,<3 and runs the test suite. Initially warning-only.

Phase 4 — v1.0#

  • Lift the numpy<2 upper bound to numpy<3 once the 2.x side job has soaked for at least two releases without regressions.

Release-gate consolidation#

Landed in v0.30f — narrowly. The consolidation is by manifest addition, not by file deletion. The design goal — one parameterized test driven by a JSON manifest — was implemented, but the “replace 26 files” outcome that v0.30a proposed is scoped down: only declarative assertions that fit the 11 supported classes migrate to the manifest. Functional smoke and numeric behavior tests continue as explicit Python tests. Files are retained regardless.

What v0.30f ships#

  • configs/release_gate_manifest.json — strict-JSON manifest with summary_type = "pdelie_declarative_release_gate_manifest". 18 release rows migrated, spanning v0.10, v0.13v0.25, v0.27v0.29, and a self-check row for v0.30f. release_gate_manifest.json is the sole source of truth for declarative assertions moving forward.

  • tests/test_release_gates.py — single parameterized test file that dispatches over the 11 supported assertion classes:

    • required_phrases_in_scope_doc, required_phrases_in_api_stability, required_phrases_in_roadmap, required_phrases_in_plan, required_phrases_in_readiness_doc

    • forbidden_root_attributes, forbidden_submodule_attributes, required_root_attributes, required_submodule_attributes

    • strict_json_manifests, notebook_structural_checks

  • CI: the v0_29-release-gate job is renamed to v0_30f-release-gate. Its invocation invokes tests/test_current_release_gate.py, the new tests/test_release_gates.py, and every retained tests/test_v0_NN_release_gate.py (v0.4 through v0.29).

Classification (Explore audit, v0.30f)#

  • functional_only (2 files): test_v0_4_release_gate.py, test_v0_7_release_gate.py. No declarative content to replay; retained unchanged.

  • mixed (23 files): test_v0_6_release_gate.py, test_v0_8_release_gate.py, test_v0_9_release_gate.py, test_v0_10_release_gate.py, and every file test_v0_11_release_gate.py through test_v0_29_release_gate.py. Declarative content that fits the 11 supported classes is replayed via the manifest; functional/numeric assertions stay in the source file.

  • declarative_only: zero files. This is why no file is deleted in v0.30f.

Files excluded from the manifest and why#

Some mixed files use assertion patterns outside the 11 supported classes. The manifest does not partially convert these; their declarative content stays in the source file, and the file is listed under excluded_functional_release_gate_files in the manifest with a per-file reason. These are the excluded files:

  • v0.4 (tests/test_v0_4_release_gate.py) — reason: pure_numeric_vertical_slice. No declarative content at all.

  • v0.6 (tests/test_v0_6_release_gate.py) — reason: declarative_block_embedded_in_functional_test_tail. Declarative attribute checks are nested inside a functional pipeline test.

  • v0.7 (tests/test_v0_7_release_gate.py) — reason: pure_numeric_bridge_parity. No declarative content at all.

  • v0.8 (tests/test_v0_8_release_gate.py) — reason: schema_gap_forbidden_phrases_in_api_stability. Uses ... not in api_stability on specific phrases; the manifest does not yet support forbidden_phrases_in_*.

  • v0.9 (tests/test_v0_9_release_gate.py) — same reason as v0.8.

  • v0.11 (tests/test_v0_11_release_gate.py) — reason: interleaved_runtime_and_declarative_in_same_test. Test 1 mixes compute_spectral_fd_derivatives calls with declarative assertions inside one function.

  • v0.12 (tests/test_v0_12_release_gate.py) — reason: schema_gap_changelog_readme_roadmap_history_phrase_checks. Uses CHANGELOG.md, README.md, and docs/planning/archive/ROADMAP_HISTORY.md phrase checks; the manifest does not yet support those doc-path classes.

  • v0.26 (tests/test_v0_26_release_gate.py) — same reason as v0.8 and v0.9.

What did not get consolidated#

  • 26 per-version tests/test_v0_NN_release_gate.py files. All retained. No file deleted in v0.30f.

  • Every mixed file’s functional/numeric assertions. These remain explicit Python tests.

  • The v0.29 SUPPORT_MATRIX.md phrase checks, docs/tutorials/*.nblink link checks, docs/workflows/*.md disjunctive+forbidden per-page phrase rules, and required_json_fields structural checks on the support-matrix manifest — all schema gaps for the current 11-class manifest. They stay in tests/test_v0_29_release_gate.py.

Why this is still worth doing#

Even with zero deletions, v0.30f delivers a durable pattern:

  1. Future release gates (v0.30 proper, v0.31+) that fit the 11 classes can skip a Python file entirely — add a row to the manifest instead.

  2. The manifest is a strict-JSON, machine-readable audit surface: what phrases must appear in which docs, what attributes must (not) exist on which modules. Downstream tooling can consume it.

  3. Schema gaps are named explicitly (excluded_functional_release_gate_files), so future manifest-schema extensions (e.g. forbidden_phrases_in_api_stability, required_phrases_in_readme) can be planned rather than accidental.

Summary table#

Concern

Current state

v0.30 proper target

Final target

Lint

none

ruff non-blocking

ruff blocking (v0.30.1+)

Type-checker

none

mypy strict on core, non-blocking

mypy strict everywhere, blocking (v0.30.1+)

Coverage

none

pytest-cov non-blocking, floor 80

gate at 85 (v0.30.1+)

Python matrix

3.11 only

3.11 only (no change)

3.11/3.12/3.13 (v0.32)

numpy upper bound

<2

<2 (no change)

<3 (v1.0)

Release-gate files

26 hand-maintained

1 parameterized + manifest

1 (stable)

Optional imports

lazy via importlib.import_module

unchanged

unchanged

JSON strictness

allow_nan=False enforced

unchanged

unchanged