diff --git a/.github/ISSUE_TEMPLATE/numerical_issue.md b/.github/ISSUE_TEMPLATE/numerical_issue.md new file mode 100644 index 000000000..93acc922d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/numerical_issue.md @@ -0,0 +1,27 @@ +--- +name: Numerical issue +description: Report instability, invalid covariance, inconsistent likelihoods, or backend numerical divergence +title: "Numerical issue: " +labels: ["numerical", "bug"] +--- + +## Summary + +## Minimal reproducer + +```python +# Include imports, backend selection, seed, and data. +``` + +## Expected behavior + +## Observed behavior + +## Backend and environment + +Run `pyrecest info` and paste the output. + +## Notes + +Mention whether the issue involves covariance symmetry/PSD, Cholesky failures, +particle degeneracy, assignment ambiguity, or backend dtype/device behavior. diff --git a/.github/workflows/docs-examples.yml b/.github/workflows/docs-examples.yml new file mode 100644 index 000000000..f8e95d901 --- /dev/null +++ b/.github/workflows/docs-examples.yml @@ -0,0 +1,45 @@ +name: Documentation examples + +permissions: + contents: read + +on: + pull_request: + branches: + - "**" + workflow_dispatch: + +jobs: + doc-examples: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.13" + + - name: Install package for documentation examples + run: | + python -m pip install --upgrade pip + python -m pip install poetry + poetry env use python + poetry install --with dev --extras "healpy_support" + + - name: Run README Python examples + run: poetry run python scripts/run_doc_examples.py README.md --fail-fast + env: + PYTHONPATH: ${{ github.workspace }}/src + + - name: Generate compatibility dashboard preview + run: poetry run python scripts/generate_compatibility_dashboard.py --output compatibility-dashboard.md + env: + PYTHONPATH: ${{ github.workspace }}/src + + - name: Upload dashboard preview + uses: actions/upload-artifact@v7 + with: + name: compatibility-dashboard-preview + path: compatibility-dashboard.md diff --git a/.github/workflows/release-notes-preview.yml b/.github/workflows/release-notes-preview.yml new file mode 100644 index 000000000..07cd28e8e --- /dev/null +++ b/.github/workflows/release-notes-preview.yml @@ -0,0 +1,33 @@ +name: Release notes preview + +permissions: + contents: read + +on: + pull_request: + branches: + - "**" + workflow_dispatch: + +jobs: + release-notes-preview: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.13" + + - name: Generate release notes from PR branch commits + run: python scripts/generate_release_notes.py "origin/${{ github.base_ref || 'main' }}..HEAD" --output release-notes-preview.md + + - name: Upload release notes preview + uses: actions/upload-artifact@v7 + with: + name: release-notes-preview + path: release-notes-preview.md diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f2d00e9bb..9f7b01b3d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -33,10 +33,20 @@ jobs: - name: Install documentation dependencies run: | + retry() { + for attempt in 1 2 3; do + "$@" && return 0 + echo "Command failed on attempt ${attempt}; retrying..." + sleep $((attempt * 20)) + done + + "$@" + } + python -m pip install --upgrade pip - python -m pip install poetry + retry python -m pip install poetry poetry env use python - poetry install --only docs --no-root + retry poetry install --only docs --no-root - name: Build documentation run: poetry run mkdocs build --strict @@ -74,7 +84,18 @@ jobs: - name: Build distributions and validate local release metadata run: | - python -m pip install --upgrade pip build twine + retry() { + for attempt in 1 2 3; do + "$@" && return 0 + echo "Command failed on attempt ${attempt}; retrying..." + sleep $((attempt * 20)) + done + + "$@" + } + + python -m pip install --upgrade pip + retry python -m pip install build twine python scripts/check_release_consistency.py --local-only python -m build python -m twine check dist/* @@ -156,18 +177,32 @@ jobs: - name: Install dependencies run: | + retry() { + for attempt in 1 2 3; do + "$@" && return 0 + echo "Command failed on attempt ${attempt}; retrying..." + sleep $((attempt * 20)) + done + + "$@" + } + python -m pip install --upgrade pip - python -m pip install poetry + retry python -m pip install poetry poetry env use python case "${{ matrix.backend }}" in numpy) - poetry install --with dev --extras "healpy_support" + retry poetry install --with dev --extras "healpy_support" ;; pytorch) - poetry install --with dev --extras "healpy_support pytorch_support" + retry poetry install --with dev --extras "healpy_support" + retry poetry run python -m pip install \ + --index-url https://download.pytorch.org/whl/cpu \ + --extra-index-url https://pypi.org/simple \ + "torch>=2.4,<3.0" ;; jax) - poetry install --with dev --extras "healpy_support jax_support" + retry poetry install --with dev --extras "healpy_support jax_support" ;; *) echo "Unsupported backend: ${{ matrix.backend }}" >&2 @@ -246,18 +281,32 @@ jobs: - name: Install dependencies run: | + retry() { + for attempt in 1 2 3; do + "$@" && return 0 + echo "Command failed on attempt ${attempt}; retrying..." + sleep $((attempt * 20)) + done + + "$@" + } + python -m pip install --upgrade pip - python -m pip install poetry + retry python -m pip install poetry poetry env use python case "${{ matrix.backend }}" in numpy) - poetry install --with dev --extras "healpy_support" + retry poetry install --with dev --extras "healpy_support" ;; pytorch) - poetry install --with dev --extras "healpy_support pytorch_support" + retry poetry install --with dev --extras "healpy_support" + retry poetry run python -m pip install \ + --index-url https://download.pytorch.org/whl/cpu \ + --extra-index-url https://pypi.org/simple \ + "torch>=2.4,<3.0" ;; jax) - poetry install --with dev --extras "healpy_support jax_support" + retry poetry install --with dev --extras "healpy_support jax_support" ;; *) echo "Unsupported backend: ${{ matrix.backend }}" >&2 diff --git a/.secretlintignore b/.secretlintignore index 583940b3f..12fc26a6e 100644 --- a/.secretlintignore +++ b/.secretlintignore @@ -1,2 +1,4 @@ megalinter-reports -/github/workspace/github_conf/branch_protection_rules.json \ No newline at end of file +/github/workspace/github_conf/branch_protection_rules.json +*.isorted +**/*.isorted diff --git a/asv.conf.json b/asv.conf.json new file mode 100644 index 000000000..2d84a41c8 --- /dev/null +++ b/asv.conf.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "project": "PyRecEst", + "project_url": "https://github.com/FlorianPfaff/PyRecEst", + "repo": ".", + "branches": ["main"], + "pythons": ["3.11", "3.12", "3.13"], + "benchmark_dir": "benchmarks/asv_benchmarks", + "env_dir": ".asv/env", + "results_dir": ".asv/results", + "html_dir": ".asv/html" +} diff --git a/benchmarks/asv_benchmarks/bench_kalman.py b/benchmarks/asv_benchmarks/bench_kalman.py new file mode 100644 index 000000000..cfd4ea094 --- /dev/null +++ b/benchmarks/asv_benchmarks/bench_kalman.py @@ -0,0 +1,17 @@ +from pyrecest.backend import array, diag +from pyrecest.filters import KalmanFilter + + +class KalmanFilterBenchmarks: + def setup(self): + self.system_matrix = array([[1.0, 1.0], [0.0, 1.0]]) + self.measurement_matrix = array([[1.0, 0.0]]) + self.system_noise_cov = diag(array([0.05, 0.01])) + self.measurement_noise_cov = array([[0.25]]) + self.measurement = array([1.0]) + + def time_predict_update_loop(self): + filt = KalmanFilter((array([0.0, 1.0]), diag(array([1.0, 1.0])))) + for _ in range(100): + filt.predict_linear(self.system_matrix, self.system_noise_cov) + filt.update_linear(self.measurement, self.measurement_matrix, self.measurement_noise_cov) diff --git a/docs/backend-api-matrix.md b/docs/backend-api-matrix.md index e2f7704db..09a832ad2 100644 --- a/docs/backend-api-matrix.md +++ b/docs/backend-api-matrix.md @@ -17,28 +17,47 @@ python scripts/render_backend_api_matrix.py ## Support Levels -| Level | Meaning | -|---------------|-------------------------------------------------------------------------------------------------------------------------------| -| `supported` | Intended to preserve backend semantics for the listed API. | -| `partial` | Numerically useful, but with documented limitations such as SciPy bridges, CPU copies, or missing gradient/device guarantees. | -| `unsupported` | Should raise a clear `NotImplementedError` or be documented as unavailable for the backend. | +| Level | Meaning | +| ------------- | --------------------------------------------------------------------------------------------------------------------------- | +| `supported` | Intended to preserve backend semantics for the listed API. | +| `bridged` | Works by crossing into another numerical stack, usually NumPy/SciPy; do not assume device, dtype, or gradient preservation. | +| `partial` | Numerically useful, but with documented limitations such as missing modes or mixed native/bridged implementation. | +| `unsupported` | Should raise a clear `NotImplementedError` or be documented as unavailable for the backend. | ## Public API Rows -| API | NumPy | PyTorch | JAX | Notes | -|--------------------------------|-----------|-------------|-------------|--------------------------------------------------------------------------------------------------------------| -| `KalmanFilter` | supported | supported | supported | Linear Gaussian operations are the portable baseline. | -| `UKFOnManifolds` | supported | partial | unsupported | JAX exclusions are currently explicit. | -| `SphericalHarmonicsEOTTracker` | supported | unsupported | unsupported | Depends on spherical harmonics and SciPy-adjacent functionality. | -| `GaussianDistribution` | supported | supported | supported | Basic construction and portable operations should stay backend portable. | -| `LinearDiracDistribution` | supported | supported | supported | Used by conversion and particle-style workflows. | -| `UnscentedKalmanFilter` | supported | partial | partial | Portable for backend-compatible model functions; advanced paths may still bridge through NumPy/SciPy. | -| `EuclideanParticleFilter` | supported | partial | partial | Particle operations are portable where sampling and resampling helpers preserve backend semantics. | -| `DistributionConversion` | supported | partial | partial | Euclidean particle/Gaussian conversions are portable; grid, Fourier, and manifold routes are route-specific. | -| `MultiBernoulliTracker` | supported | partial | unsupported | Tracking workflows rely on assignment and measurement-set utilities that are currently NumPy-oriented. | -| `PointSetRegistration` | supported | partial | unsupported | Registration utilities may copy through NumPy/SciPy and should not be assumed differentiable. | -| `EvaluationUtilities` | supported | partial | partial | Plotting, assignment, and summaries remain partly NumPy/SciPy oriented. | +| API | NumPy | PyTorch | JAX | Notes | +| ------------------------------ | --------- | ----------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `BackendFacade` | supported | partial | partial | Facade names are importable across backends, but some functions are bridged or explicitly unsupported. | +| `DistributionConversion` | supported | partial | partial | Euclidean particle/Gaussian conversions are portable; grid, Fourier, and manifold routes are route-specific. | +| `EuclideanParticleFilter` | supported | partial | partial | Particle operations are portable where sampling and resampling helpers preserve backend semantics. | +| `EvaluationUtilities` | supported | bridged | bridged | Some plotting, assignment, and summary operations remain NumPy/SciPy oriented and may not preserve device or gradient semantics. | +| `GaussianDistribution` | supported | supported | supported | Basic construction, moment access, and portable operations should remain backend portable. | +| `KalmanFilter` | supported | supported | supported | Linear Gaussian operations are part of the portable baseline. | +| `LinearDiracDistribution` | supported | supported | supported | Used by representation conversion and particle-style workflows. | +| `MultiBernoulliTracker` | supported | partial | unsupported | Tracking workflows rely on assignment and measurement-set utilities that are currently NumPy-oriented. | +| `PointSetRegistration` | supported | partial | unsupported | Registration utilities may copy through NumPy/SciPy and should not be assumed differentiable. | +| `SphericalHarmonicsEOTTracker` | supported | unsupported | unsupported | Depends on spherical harmonics and SciPy-adjacent functionality. | +| `UKFOnManifolds` | supported | partial | unsupported | The current implementation documents explicit JAX exclusions for predict/update. | +| `UnscentedKalmanFilter` | supported | partial | partial | Portable for backend-compatible model functions; advanced paths may still bridge through NumPy/SciPy. | When adding a new public API, add a row to the matrix, update docs if the row is user-facing, and add a focused backend test if the API is expected to be portable. + +## Runtime Access + +Use the public helper when examples or downstream packages need to inspect +backend support without duplicating the table: + +```python +from pyrecest import get_backend_support + +assert get_backend_support("KalmanFilter", backend="jax") == "supported" +``` + +The CLI can also render the matrix: + +```bash +pyrecest backends --format markdown +``` diff --git a/docs/change-management.md b/docs/change-management.md new file mode 100644 index 000000000..d6d41e314 --- /dev/null +++ b/docs/change-management.md @@ -0,0 +1,15 @@ +# Change Management + +Broad improvements are easier to review and debug when split into independent +change sets. Prefer a series of focused PRs over one all-encompassing PR. + +## Review Checklist + +- The public API and stability category are clear. +- Backend behavior is declared as supported, bridged, partial, or unsupported. +- Numerical validation policy is explicit. +- User-facing examples or docs were updated. +- A small regression or scenario test protects the behavior. +- Release notes can describe the user-visible change in one sentence. + +When a change touches multiple rows in this checklist, consider splitting it. diff --git a/docs/compatibility-dashboard.md b/docs/compatibility-dashboard.md new file mode 100644 index 000000000..46d30c248 --- /dev/null +++ b/docs/compatibility-dashboard.md @@ -0,0 +1,22 @@ +# Compatibility Dashboard + +This page is a checked-in snapshot of the compatibility dimensions that should +also be generated in CI with `scripts/generate_compatibility_dashboard.py`. + +## Python Versions + +PyRecEst declares support for Python `>=3.11,<3.15`. + +## Backend Support + +Run the following command to generate the current public backend API matrix: + +```bash +pyrecest backends --format markdown +``` + +## Scenario Coverage + +At minimum, keep one linear-Gaussian scenario executable as a release smoke test. +As the scenario zoo grows, this page should list which scenarios run under each +backend and Python version. diff --git a/docs/flagship-example.md b/docs/flagship-example.md new file mode 100644 index 000000000..f810d1fe6 --- /dev/null +++ b/docs/flagship-example.md @@ -0,0 +1,25 @@ +# Flagship Example: Multi-target Tracking With Clutter + +A flagship example should demonstrate why PyRecEst is useful beyond a minimal +Kalman-filter snippet. The recommended flagship is a compact multi-target +tracking scenario with missed detections and clutter. + +## What It Should Show + +1. A clear generative model for target motion and measurements. +2. A reusable transition model and measurement model. +3. Gating and association diagnostics. +4. Track lifecycle behavior across several time steps. +5. A plot or tabular summary of estimates, missed detections, and false tracks. +6. Backend notes explaining whether the workflow is NumPy-only, bridged, or + portable. + +## Acceptance Criteria + +- The example runs from the repository root. +- It has a deterministic seed or golden expected output. +- It is small enough for CI smoke testing. +- It links to the backend API matrix and scenario-zoo entry. + +This page is intentionally a specification first. The example implementation can +be expanded incrementally without changing the acceptance criteria. diff --git a/docs/numerical-contracts.md b/docs/numerical-contracts.md new file mode 100644 index 000000000..ccd2d8db6 --- /dev/null +++ b/docs/numerical-contracts.md @@ -0,0 +1,27 @@ +# Numerical Contracts + +Numerical algorithms in PyRecEst should make their repair and validation policy +explicit. A covariance-like matrix should not be silently accepted when it is the +wrong shape, asymmetric beyond tolerance, or not positive semidefinite. + +Use `pyrecest.numerics` for shared checks: + +```python +from pyrecest.numerics import assert_covariance_matrix, jittered_cholesky + +cov = assert_covariance_matrix(cov, dim=2) +factor, jitter = jittered_cholesky(cov) +``` + +## Recommended Policy + +| Situation | Preferred behavior | +|--------------------------------|------------------------------------------------------------------| +| Bad shape | Raise `ShapeError` or `DimensionMismatchError`. | +| Asymmetric covariance | Raise unless the API explicitly documents symmetrization. | +| Slightly indefinite covariance | Raise, or use documented jitter/projection in a diagnostic path. | +| Ill-conditioned Cholesky | Return the jitter used, or raise `NumericalStabilityError`. | + +Repair helpers such as `nearest_symmetric_psd` are useful for diagnostics and +controlled experiments. They should not hide modeling errors in default filter +updates. diff --git a/docs/performance.md b/docs/performance.md index f2c324152..3e301646b 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -14,3 +14,17 @@ Backend-specific targets should be explicit: Avoid optimizing a backend-specific path until its dtype, device, and autodiff semantics are documented in the capability matrix. + +## Historical Benchmarks + +Use `asv` for trend tracking across commits and releases: + +```bash +poetry run asv run --quick +poetry run asv publish +poetry run asv preview +``` + +The lightweight JSON benchmark remains useful for CI smoke checks, while ASV is +better for longitudinal analysis of algorithmic changes. Add benchmarks for new +performance-sensitive APIs before optimizing them. diff --git a/docs/release-process.md b/docs/release-process.md new file mode 100644 index 000000000..3af02600d --- /dev/null +++ b/docs/release-process.md @@ -0,0 +1,30 @@ +# Release Process + +Use release changes as small, reviewable PRs whenever possible. + +## Recommended PR Split + +| PR type | Typical contents | +|--------------------|------------------------------------------------------------| +| Packaging | Version metadata, dependency ranges, wheel/sdist checks. | +| Backend contract | Capability matrix updates, backend-specific tests, docs. | +| Numerical behavior | Validation helpers, algorithmic changes, invariant tests. | +| Documentation | Tutorials, examples, compatibility dashboard, release notes. | +| Benchmarks | ASV or JSON benchmark updates. | + +## Release Notes + +Generate a first draft from commit subjects: + +```bash +python scripts/generate_release_notes.py v2.2.1..HEAD --output RELEASE_NOTES.md +``` + +Then edit the draft to emphasize user-facing changes, backend-support changes, +deprecations, dependency changes, and known limitations. + +## Post-release Checks + +After publishing, verify that the GitHub tag, GitHub release, PyPI version, and +`pyproject.toml` version all agree. Then install the published wheel in a fresh +environment and run the README example plus the scenario zoo smoke tests. diff --git a/docs/reproducible-experiments.md b/docs/reproducible-experiments.md index 25e27bd87..cc1013ebb 100644 --- a/docs/reproducible-experiments.md +++ b/docs/reproducible-experiments.md @@ -14,3 +14,16 @@ A strong artifact contains: Start from `reproducibility/templates/paper-artifact/` and keep generated files small enough to inspect in a pull request. + + +## Executable Documentation + +Treat public documentation as part of the reproducibility surface. Python code +blocks in README and tutorial pages should either be executable or explicitly +marked as skipped: + +```python +# pyrecest: skip +``` + +Use `scripts/run_doc_examples.py` before release to catch stale snippets. diff --git a/docs/scenario-zoo.md b/docs/scenario-zoo.md index 3560fd840..e2d80024a 100644 --- a/docs/scenario-zoo.md +++ b/docs/scenario-zoo.md @@ -32,3 +32,19 @@ Prefer deterministic scenarios where possible. For stochastic scenarios, record the seed, generator semantics, number of Monte Carlo runs, and tolerance bands. Use backend-portable operations if the scenario is intended to run under NumPy, PyTorch, and JAX. + +## Target Scenario Set + +The zoo should eventually cover at least these regression families: + +| Scenario family | Purpose | +|----------------------------------|--------------------------------------------------------------------------| +| Linear Gaussian Kalman filtering | Baseline end-to-end predict/update behavior. | +| Nonlinear UKF | Sigma-point and model-object coverage. | +| Particle filtering | Resampling, effective sample size, and seeded stochastic tolerance bands. | +| Directional filtering | Circular, spherical, or hyperspherical state behavior. | +| Multi-target tracking | Missed detections, clutter, association, and track lifecycle. | +| Backend portability | One compact scenario that is expected to pass on all supported backends. | + +Keep each scenario small enough to run in CI. Expensive examples should be +benchmarks or reproducibility artifacts instead. diff --git a/docs/stability-policy.md b/docs/stability-policy.md index ffe51bd99..6250c0de9 100644 --- a/docs/stability-policy.md +++ b/docs/stability-policy.md @@ -29,3 +29,26 @@ Recommended cadence: 1. introduce the replacement and warning in a minor release; 2. keep the warning for at least one additional minor release; 3. remove only in a major release unless the API was explicitly experimental. + +## Runtime Metadata + +Public APIs can expose stability metadata through `pyrecest.stability`: + +```python +from pyrecest.stability import get_public_api_status + +status = get_public_api_status("KalmanFilter") +``` + +Decorate new public functions, classes, and adapters with `@stability(...)` when +adding them to the package-level namespace. The decorator is intentionally small: +it stores metadata on the object and leaves import behavior unchanged. + +## Public Surface Convention + +- Objects imported from package-level modules such as `pyrecest.filters`, + `pyrecest.distributions`, `pyrecest.models`, and `pyrecest.sampling` are public + unless explicitly documented as experimental. +- Modules and attributes prefixed with `_` remain internal implementation detail. +- Backend-specific APIs are public only for the backends declared in the backend + API matrix. diff --git a/mkdocs.yml b/mkdocs.yml index fd02877f9..6d43ffdec 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -13,12 +13,17 @@ nav: - CLI: cli.md - Installation Footprint: install-footprint.md - Scenario Zoo: scenario-zoo.md + - Compatibility Dashboard: compatibility-dashboard.md + - Flagship Example: flagship-example.md + - Change Management: change-management.md - Diagnostics: diagnostics.md + - Numerical Contracts: numerical-contracts.md - Shapes and Conventions: conventions.md - Backend Compatibility: backend-compatibility.md - Backend Support Matrix: backend-support.md - Backend API Matrix: backend-api-matrix.md - Performance: performance.md + - Release Process: release-process.md - Failure Modes: failure-modes.md - Error Handling: error-handling.md - API Stability: stability-policy.md diff --git a/osv-scanner.toml b/osv-scanner.toml deleted file mode 100644 index 4f6146674..000000000 --- a/osv-scanner.toml +++ /dev/null @@ -1,7 +0,0 @@ -[[PackageOverrides]] -name = "idna" -version = "3.13" -ecosystem = "PyPI" -vulnerability.ignore = true -effectiveUntil = 2026-06-30 -reason = "Temporary suppression for stale poetry.lock entry; exported requirements are updated to idna 3.15. Regenerate poetry.lock with Poetry to remove this override." diff --git a/poetry.lock b/poetry.lock index 882399a5e..5eadfa91c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.3.4 and should not be changed by hand. [[package]] name = "argcomplete" @@ -69,6 +69,125 @@ files = [ docs = ["pytest"] test = ["hypothesis", "pytest", "pytest-remotedata"] +[[package]] +name = "asv" +version = "0.6.5" +description = "Airspeed Velocity: A simple Python history benchmarking tool" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "asv-0.6.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0174f8f1b8a0c8db4df44ae923f128f64951604489adca2282add143c4996d33"}, + {file = "asv-0.6.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5408856baf761e2520da08b40e854d33915a3d59c2b8187c9d510d570eee1df2"}, + {file = "asv-0.6.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a998342b9f8f74f10324dddcb90554872cf3458a7ce6c8c3e96267c087a3459"}, + {file = "asv-0.6.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4dad86244253438bffc8b1a8f941e48be1bf06e61fb51b3512102dd52dc6717b"}, + {file = "asv-0.6.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0a0068a12760e952741fd88050164658867258ccf5df5ee380e8b871cc6c6666"}, + {file = "asv-0.6.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:a1272609150c74144c86bc243a050eff63581fe906987b9b931abcaf26c65ca0"}, + {file = "asv-0.6.5-cp310-cp310-win_amd64.whl", hash = "sha256:ce0a6e834a4c30f2b567eb59c7189831bb0c2b345d5b92376b69b0def020f691"}, + {file = "asv-0.6.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cfc6e195f51d83060458f443155adf8b968d73520c63d0c28c72f30be8d58858"}, + {file = "asv-0.6.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:816a420666f39980e75b4dd58545a0702474811087b8c933dd14fb9f0cac5dd6"}, + {file = "asv-0.6.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5bba7568ca5c72e1d980746925c353ac9e76d46329fc324ed43778f91c5c00a1"}, + {file = "asv-0.6.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2d423f671f671f6ae5dd2a6912c64130ed374d01dfe2c3d05a6a5307cc47d5ed"}, + {file = "asv-0.6.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:4d82cc4e95530ce386c0280d84586b6358a7e73b6bb6d814b35734c2d49cc43a"}, + {file = "asv-0.6.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7911132aab8263751946ff79d9b75d1178ec278d2731f09d6d14ef4f26842c70"}, + {file = "asv-0.6.5-cp311-cp311-win_amd64.whl", hash = "sha256:48fba7264d348b932fd4d2f42b6128836347d46af3d61df0c9d641bc61678af8"}, + {file = "asv-0.6.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:375da7109fa160d41e4b86a5de7783e8c9bc9f1c930a1c02c29b652b15d46835"}, + {file = "asv-0.6.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:980cb8e9c3be5350621c85201bfb25f70c26695f69bd4e91b19f1b3c97f00ff3"}, + {file = "asv-0.6.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13f503d45077ba275d357a9712fe1506b98e507eb276ca01e981c9e5baf30b43"}, + {file = "asv-0.6.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:440aca773d254f6590f7a459bdc388441027bc2745eae644675665acc3809c2e"}, + {file = "asv-0.6.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bf63f7ee3d35ec8191543d82ca3a3be3a3c0cde8eb2d45a672f09f32ba5fbb37"}, + {file = "asv-0.6.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:58a0d2de09ebc67642b661d904d2e686e0f32bb5e8b4867523a15ff7561f061a"}, + {file = "asv-0.6.5-cp312-cp312-win_amd64.whl", hash = "sha256:8df71cd3c656680051e0d0b2834521f7ab6da3d4804c48354c0e5ca341a0a39e"}, + {file = "asv-0.6.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:bedfc7f0138ab136ccd67f42575dd4b2471c811239ad8c7b7aabc83f5eba79c3"}, + {file = "asv-0.6.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2f062e0658e568b98154afe11d91b5fb631f884678b7ad4a00ebcc0d6aa6b41f"}, + {file = "asv-0.6.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6caedcca3ec60602b907caeab54d1706abb12e088ce96650862c6fc117831cc0"}, + {file = "asv-0.6.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:861ae69bfdd8659f95a058891c98757d3a5f857bf0ddc5d810e0dabf3405042e"}, + {file = "asv-0.6.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:7344c0020c1ab1cb33af9626cf24e05c346b4fd521d4f866f35a7a9a276f8e16"}, + {file = "asv-0.6.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1279a92cd8a601d2be5430afe3dd9f942ab0e6003f33ff1914dd9f638b595a3d"}, + {file = "asv-0.6.5-cp313-cp313-win_amd64.whl", hash = "sha256:dbc3269464ec27d025d3b25e0e1f3d616035e05e01bcfceb2f4e965278d72197"}, + {file = "asv-0.6.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:7519c8f2c51de073f5c9ca04b520a457f54dc9fcb83bb716e5b239e3809b1d96"}, + {file = "asv-0.6.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a2b5ed725ecd24bdac55edd9a7c0c7c9b6791bf5b201edf84a07fa464815f3d"}, + {file = "asv-0.6.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:300ea5fd96bf5f791289ec47a8353feeacb68dd985470178224632315a0f4a47"}, + {file = "asv-0.6.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:3d5876155efad4a95ad20d70e681cef3a2c85335e250d72342518bde803e3847"}, + {file = "asv-0.6.5-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:8a517f580d87980fb1de6019d9e8680db7dace3654137e4dc2d991ea498ef26f"}, + {file = "asv-0.6.5-cp37-cp37m-win_amd64.whl", hash = "sha256:6bc1fb28f15eb51ec4f05c1ccf4a49d3b1b6b633051001cb54e348be82bd68de"}, + {file = "asv-0.6.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:bb11f60224dbf4da8a17fc4eb6229e549c51ba439760aefe69dcdcb67c93b8d8"}, + {file = "asv-0.6.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:702b7f22c1370095f7cbafacd806aed62e11a1f5211420dd72854b05ffc163d4"}, + {file = "asv-0.6.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dee25eac27c205c2a509b387227e9fd084c8a7e172320ecae5930dc05e5b954c"}, + {file = "asv-0.6.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f07fae4131dc2d77f1f784c1ed188dd6d6250c3b550a0cc384a5d9c2ff2b6ea"}, + {file = "asv-0.6.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:cc2655d3186997c1ab9e5307e9e499febf41603d9636ffc42b73a715048bcb4b"}, + {file = "asv-0.6.5-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:c516cc0acfdff1289e50b3a3edf360d48b5e701773ca2d3d59593fade6ca9c13"}, + {file = "asv-0.6.5-cp38-cp38-win_amd64.whl", hash = "sha256:a6e526954f4d9add4754e3105a650514f206f10b5d15923207cfe8d4be84366a"}, + {file = "asv-0.6.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b62f2024f072cb73db555c2eb545bb74b263d569c6bbadefd459bbea42c40de5"}, + {file = "asv-0.6.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2f185f38e5d4bccf255ccb55f592066f8c9e8d947bda46d3792e76112dbfbd0a"}, + {file = "asv-0.6.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:986cca3737e15bc33b0f1f41cedfea751f91664993578d71f4528430a0dc7ae2"}, + {file = "asv-0.6.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4fe3407f7a31e96de7d63e4a0eed3ba4eb2c571f98dc812a6392ead5bb7477b8"}, + {file = "asv-0.6.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:367a7869fb1f87d795b4c0c30e5ad3660feb0c9ebbbcb04c1fe98b3b8c1ad233"}, + {file = "asv-0.6.5-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:d79c015e493d787227d50a2ed6723d1b1be763af750f3387d12a660986ad207c"}, + {file = "asv-0.6.5-cp39-cp39-win_amd64.whl", hash = "sha256:2c8cf1f630a37f80cb6a82a815dbb80b096709a5f60845293ce0e3e2d6f93e95"}, + {file = "asv-0.6.5-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:bacb55e91562d5c8aa1ec63393db8cd5faac15be20133f9b5b538453341604a7"}, + {file = "asv-0.6.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bd9d0476ed9712252b933a27b29e0208b68c0909f0ff512f9c95cc1112def49"}, + {file = "asv-0.6.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dca4988cee5bdb2aa7552a123fe1e405574d1e67fa084bde25c32d93f329462a"}, + {file = "asv-0.6.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:43b25126ca1a8620887be80caa1e826e79224277fc637e31ad6cacd38b6a81a9"}, + {file = "asv-0.6.5-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5204d6e4c9d574a9f8dd9b3834c3385b8b118d48ce4fcb3bc10b61f60de287fd"}, + {file = "asv-0.6.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c419db85e40ca1cebefbe8bd5f338a16cbc5f7a2cbf2de0793f95d6bda9ede6c"}, + {file = "asv-0.6.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:80a76dc32330c97bfba861c547dd17bcc04811946f386e00c8c7dfbb12354280"}, + {file = "asv-0.6.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:0f26006545918b9a2fb78323823bc4f9fa8bad628fc2aae5bd77d41f58fa61ac"}, + {file = "asv-0.6.5-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:b1d0d6de347d99ccbfbe1e16b4c681d42f9145fc02981caa0ca5cd02577d1e53"}, + {file = "asv-0.6.5-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:91ed0762d4ee9099ba74d6017990d406892a323ac11b1d9a68cd4c0c71effad9"}, + {file = "asv-0.6.5-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:936b3fa5d2c155d7a04510758fa506308db7569d15ea153b7a202494a7080588"}, + {file = "asv-0.6.5-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:3693f32b5587a32f8349b52ec3baf22056aa57f1242b8c6a44bfda149c9a3a55"}, + {file = "asv-0.6.5-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:6d7b31d1b3b7e3a5ad2495897fd051bbf6b65f58149256aedbd0e21da5090bcc"}, + {file = "asv-0.6.5-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec059f433105dadf0c39eb8276a8632667b8e0a4e04ff70745a094b25e1bc5bd"}, + {file = "asv-0.6.5-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4fbfcb50189adf39ce8f4649bca67b61194bd6110789107d3d12b38c1e3f4fec"}, + {file = "asv-0.6.5-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:884d6f7a17f2e50008659a7cf8a58943742789628ff2b6b8c2bfdda058d7f348"}, + {file = "asv-0.6.5-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:75d02a286887b74d3aa2ba16953db9b4dbe5c5b3dda029fa1aa559280f18c417"}, + {file = "asv-0.6.5-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:73a2432f9a50517cb2b1e66af330bd4f5159c0f16b2c94be9b06d45290d309c4"}, + {file = "asv-0.6.5-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5f9f0fb72b9548cf9b90bfb0d59fff86c36740d027c1d91a3faae6cd9ee45a0"}, + {file = "asv-0.6.5-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:02ea1aeabdf8d042828cc1356c79782ca0b599a227a951984ca1bf4fd23d531f"}, + {file = "asv-0.6.5.tar.gz", hash = "sha256:a8eeb7c5037cd78c146bd727d27203132438d4d62f36e669eb0cd5d63da0cf39"}, +] + +[package.dependencies] +asv-runner = ">=0.2.1" +build = "*" +colorama = {version = "*", markers = "platform_system == \"Windows\""} +importlib-metadata = "*" +json5 = "*" +packaging = "*" +pympler = {version = "*", markers = "platform_python_implementation != \"PyPy\""} +pyyaml = {version = "*", markers = "platform_python_implementation != \"PyPy\""} +tabulate = "*" +virtualenv = "*" + +[package.extras] +all = ["asv[dev,doc,envs,hg]"] +dev = ["ruff"] +doc = ["furo", "setuptools", "sphinx", "sphinx-autoapi", "sphinx-collapse", "sphinxcontrib.bibtex", "sphinxcontrib.katex"] +envs = ["py-rattler"] +hg = ["python-hglib"] +plugs = ["asv-bench-memray"] +test = ["feedparser", "filelock", "flaky", "numpy", "pytest", "pytest-rerunfailures", "pytest-rerunfailures (>=10.0)", "pytest-timeout", "pytest-xdist", "python-hglib ; platform_system != \"Windows\"", "scipy ; platform_python_implementation != \"PyPy\"", "selenium"] +testr = ["rpy2 ; platform_system != \"Windows\" and platform_python_implementation != \"PyPy\""] + +[[package]] +name = "asv-runner" +version = "0.2.1" +description = "Core Python benchmark code for ASV" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "asv_runner-0.2.1-py3-none-any.whl", hash = "sha256:655d466208ce311768071f5003a61611481b24b3ad5ac41fb8a6374197e647e9"}, + {file = "asv_runner-0.2.1.tar.gz", hash = "sha256:945dd301a06fa9102f221b1e9ddd048f5ecd863796d4c8cd487f5577fe0db66d"}, +] + +[package.dependencies] +importlib-metadata = "*" + +[package.extras] +docs = ["furo", "myst-parser (>=2)", "sphinx", "sphinx-autobuild", "sphinx-autodoc2 (>=0.4.2)", "sphinx-contributors", "sphinx-copybutton", "sphinx-design", "sphinxcontrib-spelling"] + [[package]] name = "attrs" version = "26.1.0" @@ -153,6 +272,28 @@ attrs = ">=19.3.0" numpy = ">=1.19" scipy = ">=1.8.0" +[[package]] +name = "build" +version = "1.5.0" +description = "A simple, correct Python build frontend" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "build-1.5.0-py3-none-any.whl", hash = "sha256:13f3eecb844759ab66efec90ca17639bbf14dc06cb2fdf37a9010322d9c50a6f"}, + {file = "build-1.5.0.tar.gz", hash = "sha256:302c22c3ba2a0fd5f3911918651341ebb3896176cbdec15bd421f80b1afc7647"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "os_name == \"nt\""} +packaging = ">=24.0" +pyproject_hooks = "*" + +[package.extras] +keyring = ["keyring"] +uv = ["uv (>=0.1.18)"] +virtualenv = ["virtualenv (>=20.17) ; python_version >= \"3.10\" and python_version < \"3.14\"", "virtualenv (>=20.31) ; python_version >= \"3.14\""] + [[package]] name = "certifi" version = "2026.4.22" @@ -330,7 +471,7 @@ files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] -markers = {main = "platform_system == \"Windows\"", dev = "sys_platform == \"win32\"", docs = "platform_system == \"Windows\""} +markers = {main = "platform_system == \"Windows\"", dev = "sys_platform == \"win32\" or os_name == \"nt\" or platform_system == \"Windows\"", docs = "platform_system == \"Windows\""} [[package]] name = "colorlog" @@ -952,6 +1093,30 @@ files = [ [package.extras] all = ["mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] +[[package]] +name = "importlib-metadata" +version = "9.0.0" +description = "Read metadata from Python packages" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7"}, + {file = "importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc"}, +] + +[package.dependencies] +zipp = ">=3.20" + +[package.extras] +check = ["pytest-checkdocs (>=2.14)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=3.4)"] +perf = ["ipython"] +test = ["packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] +type = ["pytest-mypy (>=1.0.1) ; platform_python_implementation != \"PyPy\""] + [[package]] name = "iniconfig" version = "2.3.0" @@ -1054,6 +1219,18 @@ MarkupSafe = ">=2.0" [package.extras] i18n = ["Babel (>=2.7)"] +[[package]] +name = "json5" +version = "0.14.0" +description = "A Python implementation of the JSON5 data format." +optional = false +python-versions = ">=3.8.0" +groups = ["dev"] +files = [ + {file = "json5-0.14.0-py3-none-any.whl", hash = "sha256:56cf861bab076b1178eb8c92e1311d273a9b9acea2ccc82c276abf839ebaef3a"}, + {file = "json5-0.14.0.tar.gz", hash = "sha256:b3f492fad9f6cdbced8b7d40b28b9b1c9701c5f561bef0d33b81c2ff433fefcb"}, +] + [[package]] name = "kiwisolver" version = "1.5.0" @@ -1548,9 +1725,9 @@ files = [ [package.dependencies] numpy = [ - {version = ">=1.23.3", markers = "python_version == \"3.11\""}, - {version = ">=1.26.0", markers = "python_version == \"3.12\""}, {version = ">=2.1.0", markers = "python_version >= \"3.13\""}, + {version = ">=1.26.0", markers = "python_version == \"3.12\""}, + {version = ">=1.23.3", markers = "python_version == \"3.11\""}, ] [package.extras] @@ -2322,6 +2499,22 @@ pyyaml = "*" [package.extras] extra = ["pygments (>=2.19.1)"] +[[package]] +name = "pympler" +version = "1.1" +description = "A development tool to measure, monitor and analyze the memory behavior of Python objects." +optional = false +python-versions = ">=3.6" +groups = ["dev"] +markers = "platform_python_implementation != \"PyPy\"" +files = [ + {file = "Pympler-1.1-py3-none-any.whl", hash = "sha256:5b223d6027d0619584116a0cbc28e8d2e378f7a79c1e5e024f9ff3b673c58506"}, + {file = "pympler-1.1.tar.gz", hash = "sha256:1eaa867cb8992c218430f1708fdaccda53df064144d1c5656b1e6f1ee6000424"}, +] + +[package.dependencies] +pywin32 = {version = ">=226", markers = "platform_system == \"Windows\""} + [[package]] name = "pyparsing" version = "3.3.2" @@ -2337,6 +2530,18 @@ files = [ [package.extras] diagrams = ["jinja2", "railroad-diagrams"] +[[package]] +name = "pyproject-hooks" +version = "1.2.0" +description = "Wrappers to call pyproject.toml-based build backend hooks." +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913"}, + {file = "pyproject_hooks-1.2.0.tar.gz", hash = "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8"}, +] + [[package]] name = "pyshtools" version = "4.14.1" @@ -2487,13 +2692,44 @@ platformdirs = ">=4.3.6,<5" docs = ["furo (>=2025.12.19)", "sphinx (>=9.1)", "sphinx-autodoc-typehints (>=3.6.3)", "sphinxcontrib-mermaid (>=2)", "sphinxcontrib-towncrier (>=0.4)", "towncrier (>=25.8)"] testing = ["covdefaults (>=2.3)", "coverage (>=7.5.4)", "pytest (>=8.3.5)", "pytest-mock (>=3.14)", "setuptools (>=75.1)"] +[[package]] +name = "pywin32" +version = "311" +description = "Python for Window Extensions" +optional = false +python-versions = "*" +groups = ["dev"] +markers = "platform_system == \"Windows\" and platform_python_implementation != \"PyPy\"" +files = [ + {file = "pywin32-311-cp310-cp310-win32.whl", hash = "sha256:d03ff496d2a0cd4a5893504789d4a15399133fe82517455e78bad62efbb7f0a3"}, + {file = "pywin32-311-cp310-cp310-win_amd64.whl", hash = "sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b"}, + {file = "pywin32-311-cp310-cp310-win_arm64.whl", hash = "sha256:0502d1facf1fed4839a9a51ccbcc63d952cf318f78ffc00a7e78528ac27d7a2b"}, + {file = "pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151"}, + {file = "pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503"}, + {file = "pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2"}, + {file = "pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31"}, + {file = "pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067"}, + {file = "pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852"}, + {file = "pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d"}, + {file = "pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d"}, + {file = "pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a"}, + {file = "pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee"}, + {file = "pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87"}, + {file = "pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42"}, + {file = "pywin32-311-cp38-cp38-win32.whl", hash = "sha256:6c6f2969607b5023b0d9ce2541f8d2cbb01c4f46bc87456017cf63b73f1e2d8c"}, + {file = "pywin32-311-cp38-cp38-win_amd64.whl", hash = "sha256:c8015b09fb9a5e188f83b7b04de91ddca4658cee2ae6f3bc483f0b21a77ef6cd"}, + {file = "pywin32-311-cp39-cp39-win32.whl", hash = "sha256:aba8f82d551a942cb20d4a83413ccbac30790b50efb89a75e4f586ac0bb8056b"}, + {file = "pywin32-311-cp39-cp39-win_amd64.whl", hash = "sha256:e0c4cfb0621281fe40387df582097fd796e80430597cb9944f0ae70447bacd91"}, + {file = "pywin32-311-cp39-cp39-win_arm64.whl", hash = "sha256:62ea666235135fee79bb154e695f3ff67370afefd71bd7fea7512fc70ef31e3d"}, +] + [[package]] name = "pyyaml" version = "6.0.2" description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.8" -groups = ["main", "docs"] +groups = ["main", "dev", "docs"] files = [ {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, @@ -2549,6 +2785,7 @@ files = [ {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"}, {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, ] +markers = {dev = "platform_python_implementation != \"PyPy\""} [[package]] name = "pyyaml-env-tag" @@ -2793,6 +3030,21 @@ mpmath = ">=1.1.0,<1.4" [package.extras] dev = ["hypothesis (>=6.70.0)", "pytest (>=7.1.0)"] +[[package]] +name = "tabulate" +version = "0.10.0" +description = "Pretty-print tabular data" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "tabulate-0.10.0-py3-none-any.whl", hash = "sha256:f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3"}, + {file = "tabulate-0.10.0.tar.gz", hash = "sha256:e2cfde8f79420f6deeffdeda9aaec3b6bc5abce947655d17ac662b126e48a60d"}, +] + +[package.extras] +widechars = ["wcwidth"] + [[package]] name = "torch" version = "2.12.0" @@ -3037,6 +3289,26 @@ parallel = ["dask[complete]"] types = ["pandas-stubs", "scipy-stubs", "types-PyYAML", "types-Pygments", "types-colorama", "types-decorator", "types-defusedxml", "types-docutils", "types-networkx", "types-openpyxl", "types-pexpect", "types-psutil", "types-pycurl", "types-python-dateutil", "types-pytz", "types-requests", "types-setuptools", "types-xlrd"] viz = ["cartopy (>=0.24)", "matplotlib (>=3.10)", "nc-time-axis", "seaborn"] +[[package]] +name = "zipp" +version = "4.1.0" +description = "Backport of pathlib-compatible object wrapper for zip files" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f"}, + {file = "zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602"}, +] + +[package.extras] +check = ["pytest-checkdocs (>=2.14)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=3.4)"] +test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more_itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] +type = ["pytest-mypy (>=1.0.1) ; platform_python_implementation != \"PyPy\""] + [extras] all-support = ["autograd", "healpy", "jax", "jaxlib", "torch"] healpy-support = ["healpy"] @@ -3046,4 +3318,4 @@ pytorch-support = ["torch"] [metadata] lock-version = "2.1" python-versions = ">=3.11,<3.15" -content-hash = "e859fc5deefca16987f618af010d779116b92d26fa57f482a51675e879127163" +content-hash = "a15e7d9391a0fc32d0c8ef3c8b94cfcd45f648cf00438382406d392d0ec81310" diff --git a/pyproject.toml b/pyproject.toml index 23b2fb80f..c552b8430 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,6 +68,7 @@ pytest-cov = ">=5,<8" parameterized = ">=0.9,<1.0" nox = ">=2024.10,<2027.0" pytest-benchmark = ">=4.0,<6.0" +asv = ">=0.6,<1.0" [tool.poetry.group.docs.dependencies] mkdocs = "^1.6.1" @@ -100,6 +101,9 @@ filterwarnings = [ markers = [ "numerical_stress: slower numerical stability and ill-conditioning checks", "benchmark: optional performance benchmark tests", + "backend_portable: subprocess-based checks that verify import-time backend portability", + "doc_example: executable documentation examples", + "validation: closed-form or reference-result algorithm validation tests", ] [tool.coverage.run] diff --git a/scripts/generate_compatibility_dashboard.py b/scripts/generate_compatibility_dashboard.py new file mode 100644 index 000000000..f3d350023 --- /dev/null +++ b/scripts/generate_compatibility_dashboard.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python +"""Generate a compact compatibility dashboard in Markdown.""" + +from __future__ import annotations + +import argparse +from pathlib import Path +import tomllib + +from pyrecest.backend_support import format_backend_support_markdown + + +def _python_range(pyproject_path: Path) -> str: + data = tomllib.loads(pyproject_path.read_text(encoding="utf-8")) + return data["tool"]["poetry"]["dependencies"]["python"] + + +def _scenario_names(root: Path) -> list[str]: + scenarios = root / "scenarios" + if not scenarios.exists(): + return [] + return sorted(path.name for path in scenarios.iterdir() if path.is_dir()) + + +def render_dashboard(root: Path) -> str: + lines = [ + "# Compatibility Dashboard", + "", + f"Python support declared in `pyproject.toml`: `{_python_range(root / 'pyproject.toml')}`.", + "", + "## Public Backend API Matrix", + "", + format_backend_support_markdown(), + "", + "## Scenario Zoo", + "", + ] + scenarios = _scenario_names(root) + if scenarios: + lines.extend(f"- `{name}`" for name in scenarios) + else: + lines.append("No scenarios found.") + lines.append("") + return "\n".join(lines) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, default=Path.cwd()) + parser.add_argument("--output", type=Path, default=Path("docs/compatibility-dashboard.md")) + args = parser.parse_args(argv) + + text = render_dashboard(args.root) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(text, encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/generate_release_notes.py b/scripts/generate_release_notes.py new file mode 100644 index 000000000..246223b50 --- /dev/null +++ b/scripts/generate_release_notes.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python +"""Generate lightweight grouped release notes from git history.""" + +from __future__ import annotations + +import argparse +import subprocess +from collections import defaultdict + +GROUPS = { + "feat": "Features", + "fix": "Fixes", + "docs": "Documentation", + "test": "Tests", + "perf": "Performance", + "build": "Build and packaging", + "ci": "Continuous integration", + "refactor": "Refactoring", + "chore": "Maintenance", +} + + +def _git_log(revision_range: str) -> list[str]: + completed = subprocess.run( + ["git", "log", "--format=%s", revision_range], + check=True, + capture_output=True, + text=True, + ) + return [line.strip() for line in completed.stdout.splitlines() if line.strip()] + + +def _group_subject(subject: str) -> str: + prefix = subject.split(":", 1)[0].split("(", 1)[0].lower() + return GROUPS.get(prefix, "Other changes") + + +def render_release_notes(subjects: list[str]) -> str: + grouped: dict[str, list[str]] = defaultdict(list) + for subject in subjects: + grouped[_group_subject(subject)].append(subject) + + lines = ["# Release Notes", ""] + for group in [*GROUPS.values(), "Other changes"]: + entries = grouped.get(group, []) + if not entries: + continue + lines.extend([f"## {group}", ""]) + lines.extend(f"- {entry}" for entry in entries) + lines.append("") + return "\n".join(lines).rstrip() + "\n" + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("revision_range", help="Git revision range, for example v2.2.1..HEAD") + parser.add_argument("--output", help="Optional output Markdown path") + args = parser.parse_args(argv) + + rendered = render_release_notes(_git_log(args.revision_range)) + if args.output: + with open(args.output, "w", encoding="utf-8") as handle: + handle.write(rendered) + else: + print(rendered, end="") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_doc_examples.py b/scripts/run_doc_examples.py new file mode 100644 index 000000000..5538b8aa8 --- /dev/null +++ b/scripts/run_doc_examples.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python +"""Execute Python fenced code blocks from selected Markdown files.""" + +from __future__ import annotations + +import argparse +import os +import re +import subprocess +import sys +import tempfile +from dataclasses import dataclass +from pathlib import Path + +PYTHON_FENCE_RE = re.compile(r"```(?:python|py)\n(?P.*?)\n```", re.DOTALL) +SKIP_MARKERS = ("# pyrecest: skip", "# doctest: +SKIP") + + +@dataclass(frozen=True) +class CodeBlock: + path: Path + index: int + code: str + + +def iter_python_blocks(path: Path): + text = path.read_text(encoding="utf-8") + for index, match in enumerate(PYTHON_FENCE_RE.finditer(text), start=1): + code = match.group("code").strip() + if not code or any(marker in code for marker in SKIP_MARKERS): + continue + if "..." in code: + continue + yield CodeBlock(path=path, index=index, code=code) + + +def run_block(block: CodeBlock, *, env: dict[str, str]) -> int: + with tempfile.NamedTemporaryFile("w", suffix=".py", encoding="utf-8", delete=False) as handle: + handle.write(block.code) + script_path = Path(handle.name) + try: + completed = subprocess.run([sys.executable, str(script_path)], env=env, text=True) + return int(completed.returncode) + finally: + script_path.unlink(missing_ok=True) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("paths", nargs="+", type=Path) + parser.add_argument("--collect-only", action="store_true", help="List runnable blocks without executing them.") + parser.add_argument("--fail-fast", action="store_true", help="Stop at the first failing block.") + args = parser.parse_args(argv) + + env = os.environ.copy() + src_path = str(Path.cwd() / "src") + env["PYTHONPATH"] = src_path + os.pathsep + env.get("PYTHONPATH", "") + + failures = 0 + for path in args.paths: + for block in iter_python_blocks(path): + label = f"{block.path}:{block.index}" + if args.collect_only: + print(label) + continue + print(f"Running {label}") + if run_block(block, env=env) != 0: + failures += 1 + if args.fail_fast: + return 1 + return 1 if failures else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/pyrecest/__init__.py b/src/pyrecest/__init__.py index dbdddd580..679c636fe 100644 --- a/src/pyrecest/__init__.py +++ b/src/pyrecest/__init__.py @@ -2,6 +2,11 @@ import pyrecest._backend # noqa from pyrecest.backend import copy # noqa: F401 +from pyrecest.backend_support import ( # noqa: F401 + backend_support, + format_backend_support_markdown, + get_backend_support, +) from pyrecest.backend_tools import ( # noqa: F401 assert_backend, get_backend_name, @@ -10,10 +15,18 @@ ) from pyrecest.exceptions import ( # noqa: F401 BackendNotSupportedError, + BackendSupportError, DimensionMismatchError, NumericalStabilityError, + OptionalDependencyError, PyRecEstError, ShapeError, + ValidationError, +) +from pyrecest.stability import ( # noqa: F401 + get_public_api_status, + iter_public_api_status, + stability, ) try: @@ -22,15 +35,24 @@ __version__ = "0+unknown" __all__ = [ - "__version__", "BackendNotSupportedError", + "BackendSupportError", "DimensionMismatchError", "NumericalStabilityError", + "OptionalDependencyError", "PyRecEstError", "ShapeError", + "ValidationError", + "__version__", "assert_backend", + "backend_support", "copy", + "format_backend_support_markdown", "get_backend_name", + "get_backend_support", + "get_public_api_status", "is_backend", + "iter_public_api_status", + "stability", "warn_if_backend_env_changed", ] diff --git a/src/pyrecest/_backend/capabilities.py b/src/pyrecest/_backend/capabilities.py index 95d2e9299..02a414a24 100644 --- a/src/pyrecest/_backend/capabilities.py +++ b/src/pyrecest/_backend/capabilities.py @@ -1,9 +1,9 @@ """Backend capability declarations used by documentation and tests. The dynamic backend facade intentionally exposes the same attribute names for -all backends. Some attributes are partial or explicitly unsupported on a given -backend. Keeping those declarations in one lightweight module gives tests and -documentation a single source of truth. +all backends. Some attributes are native, bridged through NumPy/SciPy, partial, or explicitly +unsupported on a given backend. Keeping those declarations in one lightweight +module gives tests and documentation a single source of truth. """ from __future__ import annotations @@ -22,12 +22,16 @@ "": ("searchsorted",), "signal": ("fftconvolve",), }, - "partial": { + "bridged": { "linalg": { "sqrtm": "SciPy bridge; not differentiable through the bridge.", "fractional_matrix_power": "SciPy bridge; not differentiable through the bridge.", "polar": "SciPy bridge; not differentiable through the bridge.", "quadratic_assignment": "SciPy bridge; returns Python indices.", + }, + }, + "partial": { + "linalg": { "solve_sylvester": "Uses native fast paths and falls back to SciPy.", }, "random": { @@ -58,6 +62,7 @@ "solve_sylvester", ), }, + "bridged": {}, "partial": { "random": { "module": "Global PRNG state is provided for facade compatibility; explicit state passing is preferred for JAX workflows.", @@ -129,34 +134,44 @@ "notes": "Registration utilities may copy through NumPy/SciPy and should not be assumed differentiable.", }, "EvaluationUtilities": { + "numpy": "supported", + "pytorch": "bridged", + "jax": "bridged", + "notes": "Some plotting, assignment, and summary operations remain NumPy/SciPy oriented and may not preserve device or gradient semantics.", + }, + "BackendFacade": { "numpy": "supported", "pytorch": "partial", "jax": "partial", - "notes": "Some plotting, assignment, and summary operations remain NumPy/SciPy oriented.", + "notes": "Facade names are importable across backends, but some functions are bridged or explicitly unsupported.", }, } -BACKEND_SUPPORT_LEVELS: Final = ("supported", "partial", "unsupported") +BACKEND_SUPPORT_LEVELS: Final = ("supported", "bridged", "partial", "unsupported") -def get_unsupported_functions( - backend_name: str, module_name: str = "" -) -> tuple[str, ...]: +def get_unsupported_functions(backend_name: str, module_name: str = "") -> tuple[str, ...]: """Return unsupported facade functions for a backend module.""" backend = BACKEND_CAPABILITIES.get(backend_name, {}) unsupported = backend.get("unsupported", {}) return tuple(unsupported.get(module_name, ())) -def get_partial_capabilities( - backend_name: str, module_name: str = "" -) -> dict[str, str]: +def get_partial_capabilities(backend_name: str, module_name: str = "") -> dict[str, str]: """Return partial-support notes for a backend module.""" backend = BACKEND_CAPABILITIES.get(backend_name, {}) partial = backend.get("partial", {}) return dict(partial.get(module_name, {})) +def get_bridged_capabilities(backend_name: str, module_name: str = "") -> dict[str, str]: + """Return operations that work by crossing into another numerical stack.""" + backend = BACKEND_CAPABILITIES.get(backend_name, {}) + bridged = backend.get("bridged", {}) + return dict(bridged.get(module_name, {})) + + + def get_api_backend_support(api_name: str) -> dict[str, str]: """Return backend support metadata for a public API name.""" return dict(API_BACKEND_CAPABILITIES.get(api_name, {})) diff --git a/src/pyrecest/_backend/jax/__init__.py b/src/pyrecest/_backend/jax/__init__.py index 40f416a47..86c1b34ac 100644 --- a/src/pyrecest/_backend/jax/__init__.py +++ b/src/pyrecest/_backend/jax/__init__.py @@ -2,130 +2,67 @@ based on implementation by Emile Mathieu for Riemannian Score-based SDE """ - import jax.numpy as _jnp -from jax import vmap -from jax.numpy import ( # For pyrecest; For Riemannian score-based SDE - abs, +from jax.numpy import ( all, allclose, amax, amin, - angle, any, - apply_along_axis, - arange, - arccos, - arccosh, - arcsin, - arctan, - arctan2, - arctanh, argmax, argmin, - argsort, - array_equal, asarray, - atleast_1d, - atleast_2d, broadcast_arrays, broadcast_to, - ceil, clip, - column_stack, complex64, complex128, concatenate, conj, - copy, - cos, - cosh, - count_nonzero, - cov, cross, cumprod, cumsum, - deg2rad, - diag, diag_indices, diagonal, - diff, - divide, - dot, - dstack, einsum, - empty, empty_like, equal, - exp, expand_dims, - eye, flip, float32, float64, - floor, - full, - full_like, greater, hsplit, hstack, - imag, int32, int64, isclose, - isfinite, - isinf, isnan, - isreal, kron, less, less_equal, - linspace, - log, - log1p, logical_and, logical_or, - matmul, - max, maximum, mean, meshgrid, - min, minimum, - mod, moveaxis, - ndim, - nonzero, - ones, ones_like, - outer, pad, - power, prod, quantile, - rad2deg, - real, repeat, reshape, - roll, - round, searchsorted, shape, - sign, - sin, - sinh, sort, split, - sqrt, - squeeze, stack, std, sum, take, - tan, - tanh, tile, - trace, transpose, tril, tril_indices, @@ -133,13 +70,83 @@ triu_indices, uint8, unique, - vectorize, vstack, where, - zeros, zeros_like, + # For pyrecest + diag, + diff, + apply_along_axis, + nonzero, + column_stack, + conj, + atleast_1d, + atleast_2d, + dstack, + full, + isreal, + triu, + kron, + angle, + arctan, + cov, + count_nonzero, + full_like, + isinf, + isfinite, + deg2rad, + rad2deg, + argsort, + max, + min, + roll, + dstack, + abs, + arange, + abs, + angle, + arange, + arccos, + arccosh, + arcsin, + arctan2, + arctanh, + ceil, + copy, + cos, + cosh, + divide, + dot, + exp, + floor, + imag, + log, + matmul, + mod, + ndim, + outer, + power, + real, + sign, + sin, + sinh, + sqrt, + squeeze, + tan, + tanh, + trace, + vectorize, + empty, + eye, + zeros, + linspace, + ones, + round, + array_equal, + # For Riemannian score-based SDE + log1p, ) - +from jax import vmap def has_autodiff(): """If allows for automatic differentiation. @@ -154,40 +161,43 @@ def has_autodiff(): def isscalar(x): return _jnp.isscalar(x) and not isinstance(x, _jnp.ndarray) +from ._dtype import ( + set_default_dtype, as_dtype +) from jax import device_get as to_numpy -from jax.numpy import array -from jax.numpy import asarray as from_numpy -from jax.numpy import ravel as flatten -from jax.scipy.integrate import trapezoid + +from jax.scipy.special import erf, gamma, polygamma, gammaln from jax.scipy.integrate import trapezoid as trapz -from jax.scipy.special import erf, gamma, gammaln, polygamma +from jax.scipy.integrate import trapezoid + +from jax.numpy import ravel as flatten +from jax.numpy import asarray as from_numpy from .._backend_config import jax_atol as atol from .._backend_config import jax_rtol as rtol -from . import fft # For PyRecEst -from . import signal # For PyRecEst + + +from . import autodiff +from . import linalg +from . import random +from . import fft # For PyRecEst from . import spatial # For PyRecEst -from . import autodiff, linalg, random -from ._dtype import as_dtype, set_default_dtype +from . import signal # For PyRecEst + +from jax.numpy import array def convert_to_wider_dtype(*args, **kwargs): - raise NotImplementedError( - "The function convert_to_wider_dtype is not supported in this JAX backend." - ) + raise NotImplementedError("The function convert_to_wider_dtype is not supported in this JAX backend.") def get_default_dtype(*args, **kwargs): - raise NotImplementedError( - "The function get_default_dtype is not supported in this JAX backend." - ) + raise NotImplementedError("The function get_default_dtype is not supported in this JAX backend.") def get_default_cdtype(*args, **kwargs): - raise NotImplementedError( - "The function get_default_cdtype is not supported in this JAX backend." - ) + raise NotImplementedError("The function get_default_cdtype is not supported in this JAX backend.") def to_ndarray(x, to_ndim, axis=0): @@ -347,16 +357,19 @@ def cast(array, dtype): return _jnp.asarray(array, dtype=dtype) -def ravel_tril_indices(n): - return _jnp.tril_indices(n) +def ravel_tril_indices(n, k=0, m=None): + if m is None: + m = n + rows, cols = _jnp.tril_indices(n, k=k, m=m) + return _jnp.ravel_multi_index((rows, cols), (n, m)) def is_array(obj): return isinstance(obj, _jnp.ndarray) -def get_slice(array, start, end): - return array[start:end] +def get_slice(array, indices): + return array[indices] def as_dtype(array): @@ -392,9 +405,28 @@ def one_hot(indices, depth): return _jnp.eye(depth)[indices] -# Scatter-add operation -def scatter_add(array, indices, updates): - return _jnp.zeros_like(array).at[indices].add(updates) +def scatter_add(input, dim, index, src): + """Add ``src`` into ``input`` at ``index`` along ``dim``. + + This mirrors the NumPy/PyTorch backend contract instead of constructing a + zero tensor and losing the existing input values. + """ + input = _jnp.asarray(input) + index = _jnp.asarray(index) + src = _jnp.asarray(src, dtype=input.dtype) + + if dim < 0: + dim += input.ndim + if dim == 0: + return input.at[index].add(src) + if dim == 1: + if input.ndim < 2: + raise ValueError("dim=1 scatter_add requires an array with at least two dimensions") + row_indices = _jnp.arange(input.shape[0]) + while row_indices.ndim < index.ndim: + row_indices = _jnp.expand_dims(row_indices, axis=-1) + return input.at[row_indices, index].add(src) + raise NotImplementedError("scatter_add is implemented only for dim 0 and dim 1 in the JAX backend") # Set diagonal elements of a matrix diff --git a/src/pyrecest/_backend/pytorch/__init__.py b/src/pyrecest/_backend/pytorch/__init__.py index 968254d5f..1309f8d5a 100644 --- a/src/pyrecest/_backend/pytorch/__init__.py +++ b/src/pyrecest/_backend/pytorch/__init__.py @@ -4,89 +4,92 @@ import numpy as _np import torch as _torch -from torch import ( # The ones below are for pyrecest; For Riemannian score-based SDE - angle, +from torch import ( arange, - arctan, argmin, - argsort, asarray, - atleast_1d, - atleast_2d, -) -from torch import broadcast_tensors as broadcast_arrays -from torch import ( # The ones below are for pyrecest; For Riemannian score-based SDE clip, - column_stack, complex64, complex128, conj, - count_nonzero, - deg2rad, - diag, - diff, - dstack, empty, empty_like, -) -from torch import equal as array_equal # For PyRecEst -from torch import ( # The ones below are for pyrecest; For Riemannian score-based SDE erf, eye, flatten, float32, float64, - full, - full_like, greater, hstack, int32, int64, - isfinite, - isinf, isnan, - isreal, kron, less, - log1p, logical_or, mean, meshgrid, moveaxis, - nonzero, ones, ones_like, polygamma, quantile, - rad2deg, -) -from torch import repeat_interleave as repeat -from torch import ( # The ones below are for pyrecest; For Riemannian score-based SDE reshape, - roll, - round, scatter_add, searchsorted, stack, trapezoid, - triu, uint8, - vmap, vstack, zeros, zeros_like, + # The ones below are for pyrecest + diag, + diff, + nonzero, + column_stack, + conj, + atleast_1d, + atleast_2d, + dstack, + full, + isreal, + triu, + kron, + angle, + arctan, + count_nonzero, + full_like, + isinf, + isfinite, + deg2rad, + rad2deg, + argsort, + roll, + dstack, + vmap, + round, + # For Riemannian score-based SDE + log1p, ) -from torch.special import gammaln +from torch import equal as array_equal # For PyRecEst + +from torch import broadcast_tensors as broadcast_arrays +from torch import repeat_interleave as repeat from torch.special import gammaln as _gammaln +from torch.special import gammaln from .._backend_config import pytorch_atol as atol from .._backend_config import pytorch_rtol as rtol -from . import autodiff # NOQA -from . import fft # NOQA -from . import linalg # NOQA -from . import random # NOQA -from . import signal # NOQA -from . import spatial # for pyrecest; NOQA +from . import ( + autodiff, # NOQA + linalg, # NOQA + random, # NOQA + # for pyrecest + fft, # NOQA + spatial, # NOQA + signal, # NOQA +) from ._common import array, cast, from_numpy from ._dtype import ( _add_default_dtype_by_casting, @@ -368,24 +371,24 @@ def allclose(a, b, atol=atol, rtol=rtol): def apply_along_axis(func, axis, tensor): # Create a list to hold the output results output_list = [] - + # Loop through the tensor along the specified axis for index in range(tensor.shape[axis]): # Create a slice object that selects `index` along the specified axis slice_obj = [slice(None)] * tensor.ndim slice_obj[axis] = index - + # Extract the slice and apply the function tensor_slice = tensor[slice_obj] result_slice = func(tensor_slice) - + # Convert the result to a tensor and append to the list result_tensor = array(result_slice) output_list.append(result_tensor) - + # Stack the output tensors along the same axis output_tensor = stack(output_list, dim=axis) - + return output_tensor @@ -400,9 +403,7 @@ def max(a, axis=None): return _torch.max(array(a)) return _torch.max(array(a), dim=axis).values - -amax = max - +amax=max def maximum(a, b): return _torch.max(array(a), array(b)) @@ -604,7 +605,7 @@ def set_diag(x, new_diag): def prod(x, axis=None): if axis is None: - axis = 0 + return _torch.prod(x) return _torch.prod(x, axis) @@ -853,14 +854,14 @@ def sort(a, axis=-1): return sorted_a -def min(a, axis=-1): +def min(a, axis=None): + if axis is None: + return _torch.min(a) values, _ = _torch.min(a, dim=axis) return values - amin = min - def take(a, indices, axis=0): if not _torch.is_tensor(indices): indices = _torch.as_tensor(indices) @@ -932,8 +933,8 @@ def cross(a, b): elif a.shape[0] == 2 and b.shape[0] == 2: result = a[0] * b[1] - a[1] * b[0] else: - raise NotImplementedError("Not implemented for this dimension.") - + raise NotImplementedError('Not implemented for this dimension.') + return result diff --git a/src/pyrecest/backend_support.py b/src/pyrecest/backend_support.py new file mode 100644 index 000000000..7b7bebf9a --- /dev/null +++ b/src/pyrecest/backend_support.py @@ -0,0 +1,54 @@ +"""Public accessors for backend support metadata.""" + +from __future__ import annotations + +from pyrecest._backend.capabilities import ( + API_BACKEND_CAPABILITIES, + BACKEND_SUPPORT_LEVELS, + iter_api_backend_capabilities, +) + + +def get_backend_support(api_name: str, *, backend: str | None = None) -> dict[str, str] | str | None: + """Return backend support metadata for a public API. + + Parameters + ---------- + api_name: + Name as listed in the backend API matrix. + backend: + Optional backend name. When supplied, return only that backend's support + level. Otherwise return the complete row. + """ + row = API_BACKEND_CAPABILITIES.get(api_name) + if row is None: + return None + if backend is not None: + return row.get(backend) + return dict(row) + + +def backend_support(api_name: str, backend: str | None = None) -> dict[str, str] | str | None: + """Alias for :func:`get_backend_support` for concise user code.""" + return get_backend_support(api_name, backend=backend) + + +def format_backend_support_markdown() -> str: + """Render the public backend API matrix as a Markdown table.""" + lines = [ + "| API | NumPy | PyTorch | JAX | Notes |", + "|-----|-------|---------|-----|-------|", + ] + for api_name, row in iter_api_backend_capabilities(): + lines.append( + f"| `{api_name}` | {row['numpy']} | {row['pytorch']} | {row['jax']} | {row.get('notes', '')} |" + ) + return "\n".join(lines) + + +__all__ = [ + "BACKEND_SUPPORT_LEVELS", + "backend_support", + "format_backend_support_markdown", + "get_backend_support", +] diff --git a/src/pyrecest/cli.py b/src/pyrecest/cli.py index c574d2013..d688bea1a 100644 --- a/src/pyrecest/cli.py +++ b/src/pyrecest/cli.py @@ -40,41 +40,18 @@ def _cmd_info(_args: argparse.Namespace) -> int: return 0 -def _render_api_backend_matrix(rows: dict[str, dict[str, str]]) -> str: - """Render API backend support rows as a Markdown table.""" - lines = [ - "| API | NumPy | PyTorch | JAX | Notes |", - "|-----|-------|---------|-----|-------|", - ] - for api_name, row in sorted(rows.items()): - lines.append( - "| `{api}` | {numpy} | {pytorch} | {jax} | {notes} |".format( - api=api_name, - numpy=row.get("numpy", "unknown"), - pytorch=row.get("pytorch", "unknown"), - jax=row.get("jax", "unknown"), - notes=row.get("notes", ""), - ) - ) - return "\n".join(lines) - - def _cmd_backends(args: argparse.Namespace) -> int: from pyrecest._backend.capabilities import ( API_BACKEND_CAPABILITIES, BACKEND_CAPABILITIES, ) + from pyrecest.backend_support import format_backend_support_markdown + payload = {"facade": BACKEND_CAPABILITIES, "api": API_BACKEND_CAPABILITIES} if args.format == "markdown": - print(_render_api_backend_matrix(API_BACKEND_CAPABILITIES)) + print(format_backend_support_markdown()) else: - print( - json.dumps( - {"facade": BACKEND_CAPABILITIES, "api": API_BACKEND_CAPABILITIES}, - indent=2, - sort_keys=True, - ) - ) + print(json.dumps(payload, indent=2, sort_keys=True)) return 0 @@ -157,39 +134,43 @@ def _cmd_run_scenario(args: argparse.Namespace) -> int: def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( - prog="pyrecest", description="PyRecEst command line utilities" + prog="pyrecest", + description="PyRecEst command line utilities", ) subparsers = parser.add_subparsers(dest="command", required=True) info_parser = subparsers.add_parser( - "info", help="Print version, backend, and dependency information as JSON." + "info", + help="Print version, backend, and dependency information as JSON.", ) info_parser.set_defaults(func=_cmd_info) backends_parser = subparsers.add_parser( - "backends", help="Print backend capability metadata as JSON." + "backends", + help="Print backend capability metadata.", ) backends_parser.add_argument( "--format", choices=("json", "markdown"), default="json", - help="Output format.", + help="Output format for backend support metadata.", ) backends_parser.set_defaults(func=_cmd_backends) scenario_parser = subparsers.add_parser( - "run-scenario", help="Run a TOML scenario and print a JSON result." - ) - scenario_parser.add_argument( - "config", type=Path, help="Path to scenario config.toml" + "run-scenario", + help="Run a TOML scenario and print a JSON result.", ) + scenario_parser.add_argument("config", type=Path, help="Path to scenario config.toml") scenario_parser.add_argument( - "--expected", type=Path, help="Optional expected-results JSON file" + "--expected", + type=Path, + help="Optional expected-results JSON file", ) scenario_parser.add_argument( "--tolerance", type=float, - help="Tolerance for expected final estimate checks; defaults to expected JSON tolerance or 1e-8", + help=("Tolerance for expected final estimate checks; defaults to expected JSON tolerance or 1e-8"), ) scenario_parser.set_defaults(func=_cmd_run_scenario) diff --git a/src/pyrecest/distributions/nonperiodic/abstract_hyperrectangular_distribution.py b/src/pyrecest/distributions/nonperiodic/abstract_hyperrectangular_distribution.py index b147305cb..ef7adb969 100644 --- a/src/pyrecest/distributions/nonperiodic/abstract_hyperrectangular_distribution.py +++ b/src/pyrecest/distributions/nonperiodic/abstract_hyperrectangular_distribution.py @@ -1,5 +1,5 @@ # pylint: disable=no-name-in-module,no-member -from pyrecest.backend import array, diff, prod, reshape +from pyrecest.backend import array, diff, prod, reshape, to_numpy from scipy.integrate import nquad from ..abstract_bounded_nonperiodic_distribution import ( @@ -9,7 +9,12 @@ class AbstractHyperrectangularDistribution(AbstractBoundedNonPeriodicDistribution): def __init__(self, bounds): - AbstractBoundedNonPeriodicDistribution.__init__(self, bounds.shape[1]) + bounds = array(bounds) + if bounds.ndim == 1: + bounds = reshape(bounds, (1, 2)) + if bounds.ndim != 2 or bounds.shape[1] != 2: + raise ValueError("bounds must have shape (dim, 2)") + AbstractBoundedNonPeriodicDistribution.__init__(self, int(bounds.shape[0])) self.bounds = bounds def get_manifold_size(self): @@ -36,8 +41,18 @@ def integrate(self, integration_boundaries=None) -> float: if integration_boundaries is None: integration_boundaries = self.bounds - integration_boundaries = reshape(integration_boundaries, (2, -1)) - left, right = integration_boundaries - - integration_boundaries = zip(left, right) - return nquad(lambda *args: self.pdf(array(args)), integration_boundaries)[0] + integration_boundaries = reshape(array(integration_boundaries), (-1, 2)) + if integration_boundaries.shape[0] != self.dim: + raise ValueError(f"integration_boundaries must have shape ({self.dim}, 2)") + left = integration_boundaries[:, 0] + right = integration_boundaries[:, 1] + ranges = [ + (float(lower), float(upper)) + for lower, upper in zip(to_numpy(left), to_numpy(right)) + ] + + def integrand(*args): + values = self.pdf(reshape(array(args), (1, self.dim))) + return float(to_numpy(values).reshape(-1)[0]) + + return nquad(integrand, ranges)[0] diff --git a/src/pyrecest/exceptions.py b/src/pyrecest/exceptions.py index c2ef722aa..c088f4e0d 100644 --- a/src/pyrecest/exceptions.py +++ b/src/pyrecest/exceptions.py @@ -1,8 +1,8 @@ """Shared exception classes for PyRecEst. -These classes are intentionally small and dependency-free. They provide a +These classes are intentionally small and dependency-free. They provide a stable vocabulary for user-facing failures without forcing every internal -validation helper to change at once. Existing ``ValueError`` and +validation helper to change at once. Existing ``ValueError`` and ``NotImplementedError`` call sites can be migrated incrementally. """ @@ -15,18 +15,17 @@ class PyRecEstError(Exception): """Base class for PyRecEst-specific exceptions.""" -class BackendNotSupportedError(NotImplementedError, PyRecEstError): - """Raised when an API is unavailable for the active numerical backend. +class BackendSupportError(PyRecEstError): + """Base class for backend-selection and backend-capability errors.""" - The class subclasses :class:`NotImplementedError` so existing tests and - callers that check for unavailable operations continue to work while the - message becomes more structured and actionable. - """ + +class BackendNotSupportedError(BackendSupportError, NotImplementedError): + """Raised when an API is unavailable for the active numerical backend.""" def __init__( self, api: str, - backend: str, + backend: str | None = None, *, supported_backends: Iterable[str] | None = None, reason: str | None = None, @@ -36,22 +35,33 @@ def __init__( self.supported_backends = tuple(supported_backends or ()) self.reason = reason - message = f"{api} is not supported for backend '{backend}'" - if self.supported_backends: - supported = ", ".join(self.supported_backends) - message += f"; supported backends: {supported}" - if reason: - message += f"; reason: {reason}" + if backend is None: + message = api + else: + message = f"{api} is not supported for backend '{backend}'" + if self.supported_backends: + supported = ", ".join(self.supported_backends) + message += f"; supported backends: {supported}" + if reason: + message += f"; reason: {reason}" super().__init__(message) -class ShapeError(ValueError, PyRecEstError): +class OptionalDependencyError(PyRecEstError, ImportError): + """Raised when an optional extra is required for a feature.""" + + +class ValidationError(PyRecEstError, ValueError): + """Base class for PyRecEst input validation errors.""" + + +class ShapeError(ValidationError): """Raised when an array, vector, matrix, or measurement set has bad shape.""" def __init__( self, name: str, - actual_shape: object, + actual_shape: object | None = None, *, expected: str | None = None, reason: str | None = None, @@ -61,11 +71,14 @@ def __init__( self.expected = expected self.reason = reason - message = f"{name} has invalid shape {actual_shape!r}" - if expected: - message += f"; expected {expected}" - if reason: - message += f"; reason: {reason}" + if actual_shape is None: + message = name + else: + message = f"{name} has invalid shape {actual_shape!r}" + if expected: + message += f"; expected {expected}" + if reason: + message += f"; reason: {reason}" super().__init__(message) @@ -73,27 +86,46 @@ class DimensionMismatchError(ShapeError): """Raised when two or more objects have inconsistent dimensions.""" def __init__( - self, left_name: str, left_dim: int, right_name: str, right_dim: int + self, + left_name: str, + left_dim: int | None = None, + right_name: str | None = None, + right_dim: int | None = None, ) -> None: self.left_name = left_name self.left_dim = left_dim self.right_name = right_name self.right_dim = right_dim + + if left_dim is None or right_name is None or right_dim is None: + super().__init__(left_name) + return + super().__init__( f"{left_name}/{right_name}", (left_dim, right_dim), expected="matching dimensions", - reason=f"{left_name} has dimension {left_dim}, but {right_name} has dimension {right_dim}", + reason=(f"{left_name} has dimension {left_dim}, but {right_name} has dimension {right_dim}"), ) -class NumericalStabilityError(PyRecEstError): +class NumericalStabilityError(ValidationError): """Raised when a numerically unstable operation cannot be completed safely.""" def __init__(self, operation: str, *, reason: str | None = None) -> None: self.operation = operation self.reason = reason - message = f"Numerical stability failure in {operation}" - if reason: - message += f": {reason}" + message = operation if reason is None else f"Numerical stability failure in {operation}: {reason}" super().__init__(message) + + +__all__ = [ + "BackendNotSupportedError", + "BackendSupportError", + "DimensionMismatchError", + "NumericalStabilityError", + "OptionalDependencyError", + "PyRecEstError", + "ShapeError", + "ValidationError", +] diff --git a/src/pyrecest/numerics.py b/src/pyrecest/numerics.py new file mode 100644 index 000000000..da566730a --- /dev/null +++ b/src/pyrecest/numerics.py @@ -0,0 +1,112 @@ +"""Numerical-contract helpers for covariance-like matrices.""" + +from __future__ import annotations + +import numpy as np + +from pyrecest.exceptions import DimensionMismatchError, NumericalStabilityError, ShapeError + + +def _to_numpy_array(value) -> np.ndarray: + try: + import pyrecest.backend as backend + + return np.asarray(backend.to_numpy(value), dtype=float) + except Exception: # pragma: no cover - fallback for source-tree bootstrap or unusual array objects + return np.asarray(value, dtype=float) + + +def _from_numpy_array(value: np.ndarray): + try: + import pyrecest.backend as backend + + return backend.array(value) + except Exception: # pragma: no cover + return value + + +def symmetrize_matrix(matrix): + """Return ``0.5 * (matrix + matrix.T)`` in the active backend representation.""" + arr = _to_numpy_array(matrix) + if arr.ndim != 2: + raise ShapeError(f"Expected a matrix, got shape {arr.shape}.") + return _from_numpy_array(0.5 * (arr + arr.T)) + + +def is_symmetric(matrix, *, atol: float = 1e-10) -> bool: + """Return whether a matrix is symmetric within an absolute tolerance.""" + arr = _to_numpy_array(matrix) + return bool(arr.ndim == 2 and arr.shape[0] == arr.shape[1] and np.allclose(arr, arr.T, atol=atol, rtol=0.0)) + + +def is_positive_semidefinite(matrix, *, atol: float = 1e-10) -> bool: + """Return whether a symmetric matrix is positive semidefinite within tolerance.""" + arr = _to_numpy_array(matrix) + if arr.ndim != 2 or arr.shape[0] != arr.shape[1] or not is_symmetric(arr, atol=atol): + return False + return bool(np.min(np.linalg.eigvalsh(arr)) >= -atol) + + +def nearest_symmetric_psd(matrix, *, min_eigenvalue: float = 0.0): + """Project a symmetric matrix to the nearest eigenvalue-clipped PSD matrix. + + This helper is intended for diagnostics and controlled numerical repair. It + should not silently replace validation in algorithms where invalid covariance + matrices indicate a modeling error. + """ + arr = _to_numpy_array(matrix) + if arr.ndim != 2 or arr.shape[0] != arr.shape[1]: + raise ShapeError(f"Expected a square matrix, got shape {arr.shape}.") + sym = 0.5 * (arr + arr.T) + eigvals, eigvecs = np.linalg.eigh(sym) + clipped = np.maximum(eigvals, min_eigenvalue) + repaired = (eigvecs * clipped) @ eigvecs.T + return _from_numpy_array(0.5 * (repaired + repaired.T)) + + +def jittered_cholesky(matrix, *, initial_jitter: float = 1e-12, max_attempts: int = 8): + """Return a Cholesky factor and the jitter used to obtain it. + + The function tries the raw matrix first, then repeatedly adds diagonal + jitter. It raises :class:`NumericalStabilityError` if no factorization is + found within ``max_attempts``. + """ + arr = _to_numpy_array(matrix) + if arr.ndim != 2 or arr.shape[0] != arr.shape[1]: + raise ShapeError(f"Expected a square matrix, got shape {arr.shape}.") + sym = 0.5 * (arr + arr.T) + eye = np.eye(sym.shape[0]) + jitter = 0.0 + for attempt in range(max_attempts + 1): + try: + factor = np.linalg.cholesky(sym + jitter * eye) + return _from_numpy_array(factor), jitter + except np.linalg.LinAlgError: + jitter = initial_jitter if attempt == 0 else jitter * 10.0 + raise NumericalStabilityError( + f"Cholesky factorization failed after {max_attempts} jitter attempts." + ) + + +def assert_covariance_matrix(matrix, *, name: str = "covariance", dim: int | None = None, atol: float = 1e-10): + """Validate a covariance matrix and return it in the active backend representation.""" + arr = _to_numpy_array(matrix) + if arr.ndim != 2 or arr.shape[0] != arr.shape[1]: + raise ShapeError(f"{name} must be a square matrix, got shape {arr.shape}.") + if dim is not None and arr.shape[0] != dim: + raise DimensionMismatchError(f"{name} dimension {arr.shape[0]} does not match expected dimension {dim}.") + if not is_symmetric(arr, atol=atol): + raise NumericalStabilityError(f"{name} must be symmetric within atol={atol}.") + if not is_positive_semidefinite(arr, atol=atol): + raise NumericalStabilityError(f"{name} must be positive semidefinite within atol={atol}.") + return _from_numpy_array(arr) + + +__all__ = [ + "assert_covariance_matrix", + "is_positive_semidefinite", + "is_symmetric", + "jittered_cholesky", + "nearest_symmetric_psd", + "symmetrize_matrix", +] diff --git a/src/pyrecest/optional_dependencies.py b/src/pyrecest/optional_dependencies.py new file mode 100644 index 000000000..caf2bc0e4 --- /dev/null +++ b/src/pyrecest/optional_dependencies.py @@ -0,0 +1,33 @@ +"""Helpers for lazy optional dependencies.""" + +from __future__ import annotations + +import importlib +from types import ModuleType + +from pyrecest.exceptions import OptionalDependencyError + + +def require_optional_dependency(package: str, extra: str, *, feature: str | None = None) -> ModuleType: + """Import an optional dependency or raise a standardized error. + + Parameters + ---------- + package: + Import name, for example ``"matplotlib"`` or ``"healpy"``. + extra: + PyRecEst extra that installs the dependency. + feature: + Optional user-facing feature name for the error message. + """ + try: + return importlib.import_module(package) + except ImportError as exc: # pragma: no cover - exercised through tests with a missing sentinel package + subject = f" for {feature}" if feature else "" + raise OptionalDependencyError( + f"Optional dependency {package!r} is required{subject}. " + f"Install it with `python -m pip install 'pyrecest[{extra}]'`." + ) from exc + + +__all__ = ["require_optional_dependency"] diff --git a/src/pyrecest/stability.py b/src/pyrecest/stability.py new file mode 100644 index 000000000..2b8ae9235 --- /dev/null +++ b/src/pyrecest/stability.py @@ -0,0 +1,84 @@ +"""Runtime metadata helpers for public API stability.""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable +from dataclasses import asdict, dataclass +from typing import Final, Literal, ParamSpec, TypeVar + +P = ParamSpec("P") +R = TypeVar("R") + +StabilityLevel = Literal["stable", "experimental", "deprecated", "backend-specific", "internal"] +STABILITY_LEVELS: Final = ("stable", "experimental", "deprecated", "backend-specific", "internal") + + +@dataclass(frozen=True) +class PublicAPIStatus: + """Stability metadata for a public API entry.""" + + name: str + level: StabilityLevel + since: str | None = None + remove_in: str | None = None + replacement: str | None = None + notes: str = "" + + def to_dict(self) -> dict[str, str | None]: + """Return a JSON-serializable representation.""" + return asdict(self) + + +_PUBLIC_API_STATUS: Final[dict[str, PublicAPIStatus]] = { + "KalmanFilter": PublicAPIStatus("KalmanFilter", "stable", since="2.2.0", notes="Core linear Gaussian filter."), + "GaussianDistribution": PublicAPIStatus("GaussianDistribution", "stable", since="2.2.0", notes="Core Euclidean distribution."), + "pyrecest.backend": PublicAPIStatus("pyrecest.backend", "backend-specific", since="2.2.0", notes="Support depends on the backend capability matrix."), + "UKFOnManifolds": PublicAPIStatus("UKFOnManifolds", "backend-specific", since="2.2.0", notes="Backend exclusions are documented in the backend API matrix."), +} + + +def stability( + level: StabilityLevel, + *, + since: str | None = None, + remove_in: str | None = None, + replacement: str | None = None, + notes: str = "", +) -> Callable[[Callable[P, R]], Callable[P, R]]: + """Attach stability metadata to a function, method, or class.""" + if level not in STABILITY_LEVELS: + raise ValueError(f"Unknown stability level: {level!r}") + + def decorator(obj: Callable[P, R]) -> Callable[P, R]: + status = PublicAPIStatus( + name=f"{obj.__module__}.{obj.__qualname__}", + level=level, + since=since, + remove_in=remove_in, + replacement=replacement, + notes=notes, + ) + setattr(obj, "__pyrecest_stability__", status) + return obj + + return decorator + + +def get_public_api_status(name: str) -> PublicAPIStatus | None: + """Return registered stability metadata for a public API name.""" + return _PUBLIC_API_STATUS.get(name) + + +def iter_public_api_status() -> Iterable[PublicAPIStatus]: + """Iterate registered public API stability rows in stable name order.""" + return tuple(_PUBLIC_API_STATUS[name] for name in sorted(_PUBLIC_API_STATUS)) + + +__all__ = [ + "PublicAPIStatus", + "STABILITY_LEVELS", + "StabilityLevel", + "get_public_api_status", + "iter_public_api_status", + "stability", +] diff --git a/src/pyrecest/types.py b/src/pyrecest/types.py new file mode 100644 index 000000000..0d19bc372 --- /dev/null +++ b/src/pyrecest/types.py @@ -0,0 +1,32 @@ +"""Lightweight public type aliases used in docs, protocols, and signatures. + +The aliases intentionally stay runtime-light. They document PyRecEst's shape and +semantic conventions without forcing a particular array library or shape-typing +package on users. +""" + +from __future__ import annotations + +from typing import Any, TypeAlias + +ArrayLike: TypeAlias = Any +BackendArray: TypeAlias = Any +ScalarLike: TypeAlias = Any +StateVector: TypeAlias = Any +MeasurementVector: TypeAlias = Any +MeasurementSet: TypeAlias = Any +CovarianceMatrix: TypeAlias = Any +PrecisionMatrix: TypeAlias = Any +WeightVector: TypeAlias = Any + +__all__ = [ + "ArrayLike", + "BackendArray", + "CovarianceMatrix", + "MeasurementSet", + "MeasurementVector", + "PrecisionMatrix", + "ScalarLike", + "StateVector", + "WeightVector", +] diff --git a/tests/backend_support/test_backend_portability_harness.py b/tests/backend_support/test_backend_portability_harness.py new file mode 100644 index 000000000..45e00ad66 --- /dev/null +++ b/tests/backend_support/test_backend_portability_harness.py @@ -0,0 +1,14 @@ +import pytest + +import pyrecest.backend as backend +from tests.support.backend_runner import run_backend_code + + +@pytest.mark.backend_portable +def test_backend_runner_uses_import_time_backend(): + result = run_backend_code( + backend.__backend_name__, + "import pyrecest.backend as backend; print(backend.__backend_name__)", + ) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == backend.__backend_name__ diff --git a/tests/distributions/test_custom_hyperrectangular_distribution.py b/tests/distributions/test_custom_hyperrectangular_distribution.py index b3f0b86b6..68d929a74 100644 --- a/tests/distributions/test_custom_hyperrectangular_distribution.py +++ b/tests/distributions/test_custom_hyperrectangular_distribution.py @@ -36,6 +36,27 @@ def test_pdf_method(self): "PDF calculated values do not match the expected values.", ) + def test_manifold_size_uses_one_row_per_dimension(self): + self.assertEqual(self.hud.dim, 2) + self.assertTrue(allclose(self.hud.get_manifold_size(), 6.0)) + + def test_integrate_defaults_to_full_rectangular_bounds(self): + self.assertAlmostEqual(float(self.hud.integrate()), 1.0, places=10) + + def test_three_dimensional_bounds_set_dim_and_volume(self): + dist = HyperrectangularUniformDistribution( + array( + [ + [0.0, 2.0], + [1.0, 4.0], + [-2.0, 2.0], + ] + ) + ) + + self.assertEqual(dist.dim, 3) + self.assertTrue(allclose(dist.get_manifold_size(), 24.0)) + if __name__ == "__main__": unittest.main() diff --git a/tests/support/backend_runner.py b/tests/support/backend_runner.py new file mode 100644 index 000000000..33f58a90b --- /dev/null +++ b/tests/support/backend_runner.py @@ -0,0 +1,34 @@ +"""Subprocess helpers for import-time backend portability tests.""" + +from __future__ import annotations + +import os +import subprocess +import sys +from dataclasses import dataclass + + +@dataclass(frozen=True) +class BackendRunResult: + backend: str + returncode: int + stdout: str + stderr: str + + +def run_backend_code(backend: str, code: str, *, timeout: float = 30.0) -> BackendRunResult: + env = os.environ.copy() + env["PYRECEST_BACKEND"] = backend + completed = subprocess.run( + [sys.executable, "-c", code], + capture_output=True, + text=True, + env=env, + timeout=timeout, + ) + return BackendRunResult( + backend=backend, + returncode=int(completed.returncode), + stdout=completed.stdout, + stderr=completed.stderr, + ) diff --git a/tests/test_backend_interface.py b/tests/test_backend_interface.py index 8efdd8216..734a9ef80 100644 --- a/tests/test_backend_interface.py +++ b/tests/test_backend_interface.py @@ -2,10 +2,26 @@ import numpy.testing as npt import pyrecest.backend -from pyrecest.backend import array, isscalar, random +from pyrecest.backend import ( + amin, + array, + isscalar, + prod, + random, + ravel_tril_indices, + scatter_add, + to_numpy, +) class TestBackendInterface(unittest.TestCase): + @staticmethod + def _scalar(value): + converted = to_numpy(value) + if hasattr(converted, "item"): + return converted.item() + return converted + def test_isscalar_matches_numpy_semantics(self): self.assertTrue(isscalar(1)) self.assertTrue(isscalar(1.5)) @@ -13,6 +29,24 @@ def test_isscalar_matches_numpy_semantics(self): self.assertFalse(isscalar(array([1]))) self.assertFalse(isscalar([1])) + def test_prod_without_axis_reduces_all_elements(self): + values = array([[2, 3], [4, 5]]) + + self.assertEqual(self._scalar(prod(values)), 120) + + def test_amin_without_axis_reduces_all_elements(self): + values = array([[2, 3], [4, 5]]) + + self.assertEqual(self._scalar(amin(values)), 2) + + def test_ravel_tril_indices_returns_flat_indices(self): + npt.assert_array_equal(to_numpy(ravel_tril_indices(3)), [0, 3, 4, 6, 7, 8]) + + def test_scatter_add_preserves_existing_values(self): + result = scatter_add(array([10, 20, 30]), 0, array([0, 2]), array([1, 2])) + + npt.assert_array_equal(to_numpy(result), [11, 20, 32]) + @unittest.skipUnless( pyrecest.backend.__backend_name__ == "numpy", # pylint: disable=no-member reason="NumPy-specific backend behavior", diff --git a/tests/test_backend_support_public.py b/tests/test_backend_support_public.py new file mode 100644 index 000000000..fa3daf5cf --- /dev/null +++ b/tests/test_backend_support_public.py @@ -0,0 +1,14 @@ +from pyrecest import backend_support, format_backend_support_markdown, get_backend_support +from pyrecest._backend.capabilities import BACKEND_SUPPORT_LEVELS + + +def test_public_backend_support_lookup(): + assert get_backend_support("KalmanFilter", backend="numpy") == "supported" + assert backend_support("EvaluationUtilities", backend="pytorch") in BACKEND_SUPPORT_LEVELS + assert get_backend_support("missing-api") is None + + +def test_backend_support_markdown_contains_expected_rows(): + rendered = format_backend_support_markdown() + assert "KalmanFilter" in rendered + assert "BackendFacade" in rendered diff --git a/tests/test_doc_example_runner.py b/tests/test_doc_example_runner.py new file mode 100644 index 000000000..430a949fd --- /dev/null +++ b/tests/test_doc_example_runner.py @@ -0,0 +1,14 @@ +from pathlib import Path + +from scripts.run_doc_examples import iter_python_blocks + + +def test_doc_example_runner_skips_marked_blocks(tmp_path: Path): + doc = tmp_path / "doc.md" + doc.write_text( + "```python\nprint('run')\n```\n\n```python\n# pyrecest: skip\nprint('skip')\n```\n", + encoding="utf-8", + ) + blocks = list(iter_python_blocks(doc)) + assert len(blocks) == 1 + assert "run" in blocks[0].code diff --git a/tests/test_numerics.py b/tests/test_numerics.py new file mode 100644 index 000000000..88f815852 --- /dev/null +++ b/tests/test_numerics.py @@ -0,0 +1,34 @@ +import numpy as np +import pytest + +from pyrecest.exceptions import NumericalStabilityError +from pyrecest.numerics import ( + assert_covariance_matrix, + is_positive_semidefinite, + is_symmetric, + jittered_cholesky, + nearest_symmetric_psd, + symmetrize_matrix, +) + + +def test_symmetrize_matrix_and_psd_projection(): + matrix = np.array([[1.0, 2.0], [0.0, -0.1]]) + symmetric = np.asarray(symmetrize_matrix(matrix)) + assert np.allclose(symmetric, symmetric.T) + + repaired = np.asarray(nearest_symmetric_psd(matrix)) + assert is_symmetric(repaired) + assert is_positive_semidefinite(repaired) + + +def test_jittered_cholesky_reports_jitter(): + matrix = np.array([[1.0, 0.0], [0.0, 0.0]]) + factor, jitter = jittered_cholesky(matrix) + assert np.asarray(factor).shape == (2, 2) + assert jitter > 0.0 + + +def test_assert_covariance_matrix_rejects_non_psd(): + with pytest.raises(NumericalStabilityError): + assert_covariance_matrix(np.array([[1.0, 0.0], [0.0, -1.0]])) diff --git a/tests/test_optional_dependencies.py b/tests/test_optional_dependencies.py new file mode 100644 index 000000000..677d12e79 --- /dev/null +++ b/tests/test_optional_dependencies.py @@ -0,0 +1,14 @@ +import pytest + +from pyrecest.exceptions import OptionalDependencyError +from pyrecest.optional_dependencies import require_optional_dependency + + +def test_require_optional_dependency_imports_existing_module(): + assert require_optional_dependency("math", "test").sqrt(4.0) == 2.0 + + +def test_require_optional_dependency_reports_extra_for_missing_module(): + with pytest.raises(OptionalDependencyError) as exc_info: + require_optional_dependency("definitely_missing_pyrecest_dependency", "plot", feature="plotting") + assert "pyrecest[plot]" in str(exc_info.value) diff --git a/tests/test_release_notes_script.py b/tests/test_release_notes_script.py new file mode 100644 index 000000000..de2aada13 --- /dev/null +++ b/tests/test_release_notes_script.py @@ -0,0 +1,8 @@ +from scripts.generate_release_notes import render_release_notes + + +def test_release_notes_grouping(): + notes = render_release_notes(["feat: add tracker", "fix: handle shape", "unprefixed change"]) + assert "## Features" in notes + assert "## Fixes" in notes + assert "## Other changes" in notes diff --git a/tests/test_stability.py b/tests/test_stability.py new file mode 100644 index 000000000..065e1da9d --- /dev/null +++ b/tests/test_stability.py @@ -0,0 +1,17 @@ +from pyrecest.stability import get_public_api_status, iter_public_api_status, stability + + +def test_registered_public_api_status(): + status = get_public_api_status("KalmanFilter") + assert status is not None + assert status.level == "stable" + assert list(iter_public_api_status()) + + +def test_stability_decorator_attaches_metadata(): + @stability("experimental", since="2.3.0", notes="test helper") + def sample(): + return 1 + + assert sample() == 1 + assert sample.__pyrecest_stability__.level == "experimental" diff --git a/tests/validation/test_gaussian_product_validation.py b/tests/validation/test_gaussian_product_validation.py new file mode 100644 index 000000000..65c0fbd9a --- /dev/null +++ b/tests/validation/test_gaussian_product_validation.py @@ -0,0 +1,11 @@ +import pytest + +from pyrecest.backend import allclose +from examples.basic.gaussian_multiplication import run_example + + +@pytest.mark.validation +def test_gaussian_product_matches_closed_form_information_result(): + _factors, product, reference_product = run_example() + assert bool(allclose(product.mu, reference_product.mu)) + assert bool(allclose(product.C, reference_product.C))