diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml deleted file mode 100644 index 458fd5b08..000000000 --- a/.github/workflows/static-analysis.yml +++ /dev/null @@ -1,86 +0,0 @@ -name: Static analysis - -permissions: - contents: read - -on: - push: - branches: - - main - pull_request: - branches: - - "**" - workflow_dispatch: - -concurrency: - group: ${{ github.ref }}-${{ github.workflow }} - cancel-in-progress: true - -jobs: - static: - 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 static analysis tools - run: | - python -m pip install --upgrade pip - python -m pip install "ruff>=0.8,<1.0" - - - name: Ruff allowlist - run: | - python -m ruff check \ - scripts/check_benchmark_regression.py \ - scripts/check_minimal_imports.py \ - scripts/check_public_api_registry.py \ - scripts/render_backend_api_matrix.py \ - src/pyrecest/api_registry.py \ - tests/test_api_registry.py \ - tests/test_capability_matrix.py - python -m ruff format --check \ - scripts/check_benchmark_regression.py \ - scripts/check_minimal_imports.py \ - scripts/check_public_api_registry.py \ - scripts/render_backend_api_matrix.py \ - src/pyrecest/api_registry.py \ - tests/test_api_registry.py \ - tests/test_capability_matrix.py - - - name: Compile Python files - run: python -m compileall -q src scripts tests - - - name: Check generated backend API matrix docs - run: python scripts/render_backend_api_matrix.py --check docs/backend-api-matrix.md - - - name: Check generated public API registry docs - run: python scripts/check_public_api_registry.py --check docs/public-api-registry.md - - ruff-ratchet: - 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: Run targeted Ruff ratchet - run: | - python -m pip install --upgrade pip - python -m pip install "ruff>=0.11,<0.15" - python -m ruff check \ - src/pyrecest/_backend/capabilities.py \ - src/pyrecest/cli.py \ - src/pyrecest/filters/__init__.py \ - scripts/generate_backend_api_matrix.py \ - scripts/check_benchmark_results.py \ - tests/test_backend_capabilities.py \ - tests/test_filters_lazy_exports.py diff --git a/benchmarks/baselines/basic_regressions-linux-py313.json b/benchmarks/baselines/basic_regressions-linux-py313.json index 04a1580e9..f92e60af6 100644 --- a/benchmarks/baselines/basic_regressions-linux-py313.json +++ b/benchmarks/baselines/basic_regressions-linux-py313.json @@ -4,10 +4,7 @@ "name": "linear_kalman", "iterations": 200, "max_elapsed_seconds": 30.0, - "final_estimate": [ - 200.0, - 1.0 - ] + "final_estimate": [200.0, 1.0] } ] } diff --git a/benchmarks/baselines/basic_regressions.json b/benchmarks/baselines/basic_regressions.json index 8eab962a9..f9b43fad7 100644 --- a/benchmarks/baselines/basic_regressions.json +++ b/benchmarks/baselines/basic_regressions.json @@ -3,10 +3,7 @@ { "name": "linear_kalman", "iterations": 200, - "final_estimate": [ - 200.0, - 1.0 - ] + "final_estimate": [200.0, 1.0] } ] } diff --git a/docs/backend-contracts.md b/docs/backend-contracts.md index 03ba5f03b..647bff7fb 100644 --- a/docs/backend-contracts.md +++ b/docs/backend-contracts.md @@ -3,12 +3,12 @@ PyRecEst has a dynamic backend facade, so backend support needs executable contracts in addition to prose documentation. -| Contract surface | Check | -|------------------|-------| -| Facade metadata | Every declared unsupported or partial function must exist on the active facade module. | -| Public API matrix | Every API row must name NumPy, PyTorch, JAX, and explanatory notes. | -| Portable examples | Core examples should run under each backend they claim to support. | -| Unsupported paths | Unsupported backend paths should raise clear backend-named errors. | +| Contract surface | Check | +|-------------------|----------------------------------------------------------------------------------------| +| Facade metadata | Every declared unsupported or partial function must exist on the active facade module. | +| Public API matrix | Every API row must name NumPy, PyTorch, JAX, and explanatory notes. | +| Portable examples | Core examples should run under each backend they claim to support. | +| Unsupported paths | Unsupported backend paths should raise clear backend-named errors. | When adding a backend-specific restriction, update `src/pyrecest/_backend/capabilities.py`, the backend API matrix, and a focused diff --git a/docs/public-api-registry.md b/docs/public-api-registry.md index 3feb72ebc..2542d5514 100644 --- a/docs/public-api-registry.md +++ b/docs/public-api-registry.md @@ -34,18 +34,18 @@ mapped to the same implementation module as their canonical form. New examples and documentation should use the canonical spelling. -| API | Module | Category | Backend contract | Notes | -|-----|--------|----------|------------------|-------| -| `BackendFacade` | `pyrecest.backend` | backend-specific | `BackendFacade` | Facade names are importable across backends, with bridged or unsupported functions documented in the backend matrix. | -| `DistributionConversion` | `pyrecest.distributions.conversion` | backend-specific | `DistributionConversion` | Euclidean Gaussian/particle routes are portable; grid, Fourier, and manifold routes are route-specific. | -| `EuclideanParticleFilter` | `pyrecest.filters` | backend-specific | `EuclideanParticleFilter` | Particle behavior depends on sampler and resampling support in the active backend. | -| `EvaluationUtilities` | `pyrecest.evaluation` | backend-specific | `EvaluationUtilities` | Plotting, assignment, summaries, and result helpers are only partly backend-portable. | -| `GaussianDistribution` | `pyrecest.distributions` | stable | `GaussianDistribution` | Basic construction, moment access, and portable operations are part of the core distribution API. | -| `KalmanFilter` | `pyrecest.filters` | stable | `KalmanFilter` | Linear Gaussian filtering is part of the portable baseline. | -| `LinearDiracDistribution` | `pyrecest.distributions` | stable | `LinearDiracDistribution` | Core particle-style representation used by conversion and filtering workflows. | -| `MultiBernoulliTracker` | `pyrecest.filters` | backend-specific | `MultiBernoulliTracker` | Tracking workflows rely on assignment and measurement-set utilities with NumPy-oriented paths. | -| `PointSetRegistration` | `pyrecest.utils` | backend-specific | `PointSetRegistration` | Registration helpers may bridge through NumPy/SciPy and are not guaranteed differentiable. | -| `SphericalHarmonicsEOTTracker` | `pyrecest.filters` | backend-specific | `SphericalHarmonicsEOTTracker` | Depends on spherical-harmonics and SciPy-adjacent functionality. | -| `UKFOnManifolds` | `pyrecest.filters` | backend-specific | `UKFOnManifolds` | Current predict/update paths explicitly exclude JAX. | -| `UnscentedKalmanFilter` | `pyrecest.filters` | backend-specific | `UnscentedKalmanFilter` | Portable for backend-compatible model functions; advanced paths may bridge through NumPy/SciPy. | +| API | Module | Category | Backend contract | Notes | +|--------------------------------|-------------------------------------|------------------|--------------------------------|----------------------------------------------------------------------------------------------------------------------| +| `BackendFacade` | `pyrecest.backend` | backend-specific | `BackendFacade` | Facade names are importable across backends, with bridged or unsupported functions documented in the backend matrix. | +| `DistributionConversion` | `pyrecest.distributions.conversion` | backend-specific | `DistributionConversion` | Euclidean Gaussian/particle routes are portable; grid, Fourier, and manifold routes are route-specific. | +| `EuclideanParticleFilter` | `pyrecest.filters` | backend-specific | `EuclideanParticleFilter` | Particle behavior depends on sampler and resampling support in the active backend. | +| `EvaluationUtilities` | `pyrecest.evaluation` | backend-specific | `EvaluationUtilities` | Plotting, assignment, summaries, and result helpers are only partly backend-portable. | +| `GaussianDistribution` | `pyrecest.distributions` | stable | `GaussianDistribution` | Basic construction, moment access, and portable operations are part of the core distribution API. | +| `KalmanFilter` | `pyrecest.filters` | stable | `KalmanFilter` | Linear Gaussian filtering is part of the portable baseline. | +| `LinearDiracDistribution` | `pyrecest.distributions` | stable | `LinearDiracDistribution` | Core particle-style representation used by conversion and filtering workflows. | +| `MultiBernoulliTracker` | `pyrecest.filters` | backend-specific | `MultiBernoulliTracker` | Tracking workflows rely on assignment and measurement-set utilities with NumPy-oriented paths. | +| `PointSetRegistration` | `pyrecest.utils` | backend-specific | `PointSetRegistration` | Registration helpers may bridge through NumPy/SciPy and are not guaranteed differentiable. | +| `SphericalHarmonicsEOTTracker` | `pyrecest.filters` | backend-specific | `SphericalHarmonicsEOTTracker` | Depends on spherical-harmonics and SciPy-adjacent functionality. | +| `UKFOnManifolds` | `pyrecest.filters` | backend-specific | `UKFOnManifolds` | Current predict/update paths explicitly exclude JAX. | +| `UnscentedKalmanFilter` | `pyrecest.filters` | backend-specific | `UnscentedKalmanFilter` | Portable for backend-compatible model functions; advanced paths may bridge through NumPy/SciPy. | diff --git a/docs/quality-gates.md b/docs/quality-gates.md index cbb8033de..8117b0bb4 100644 --- a/docs/quality-gates.md +++ b/docs/quality-gates.md @@ -9,14 +9,14 @@ should be safe to require on every pull request. Recommended required checks for protected branches are: -| Check | Purpose | -|-------|---------| -| `Static analysis` | Runs the static baseline, compile checks, and generated-doc checks. | -| `Test workflow / docs` | Builds documentation with `mkdocs build --strict`. | -| `Test workflow / package` | Builds distributions, installs the wheel, and runs smoke examples. | -| `Test workflow / test` | Runs the backend matrix for NumPy, PyTorch, and JAX. | -| `CodeQL` | Scans the Python codebase for security issues. | -| `Dependency review` | Fails pull requests that introduce high-severity dependency advisories. | +| Check | Purpose | +|---------------------------|-------------------------------------------------------------------------| +| `Static analysis` | Runs the static baseline, compile checks, and generated-doc checks. | +| `Test workflow / docs` | Builds documentation with `mkdocs build --strict`. | +| `Test workflow / package` | Builds distributions, installs the wheel, and runs smoke examples. | +| `Test workflow / test` | Runs the backend matrix for NumPy, PyTorch, and JAX. | +| `CodeQL` | Scans the Python codebase for security issues. | +| `Dependency review` | Fails pull requests that introduce high-severity dependency advisories. | Scheduled jobs may run larger or slower matrices, but the required checks should remain small enough that contributors can iterate quickly. diff --git a/docs/scientific-validation.md b/docs/scientific-validation.md index 95b2ee7aa..2cea3d393 100644 --- a/docs/scientific-validation.md +++ b/docs/scientific-validation.md @@ -13,25 +13,25 @@ intentionally deterministic and has a documented golden output. ## Validation Layers -| Layer | Purpose | Examples | -|-------|---------|----------| -| API smoke tests | Confirm public entry points exist and have stable capabilities. | Protocol capability matrices, import checks, CLI smoke tests. | -| Deterministic algebraic checks | Verify identities that should hold without randomness. | Gaussian multiplication, Kalman covariance symmetry, normalized innovation squared consistency. | -| Numerical invariant checks | Catch invalid estimates even when exact values are not known. | Positive semidefinite covariances, nonnegative probabilities, normalized weights, unit-norm directional states. | -| Monte Carlo checks | Verify statistical behavior across repeated randomized runs. | NEES/NIS coverage, sampling moment convergence, resampling effective sample size behavior. | -| Scenario regression checks | Preserve known behavior for complete workflows. | Scenario zoo expected outputs, benchmark regressions, tracker association edge cases. | +| Layer | Purpose | Examples | +|--------------------------------|-----------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------| +| API smoke tests | Confirm public entry points exist and have stable capabilities. | Protocol capability matrices, import checks, CLI smoke tests. | +| Deterministic algebraic checks | Verify identities that should hold without randomness. | Gaussian multiplication, Kalman covariance symmetry, normalized innovation squared consistency. | +| Numerical invariant checks | Catch invalid estimates even when exact values are not known. | Positive semidefinite covariances, nonnegative probabilities, normalized weights, unit-norm directional states. | +| Monte Carlo checks | Verify statistical behavior across repeated randomized runs. | NEES/NIS coverage, sampling moment convergence, resampling effective sample size behavior. | +| Scenario regression checks | Preserve known behavior for complete workflows. | Scenario zoo expected outputs, benchmark regressions, tracker association edge cases. | ## Core Invariants -| Component | Invariant | -|-----------|-----------| -| Probability distributions | Densities integrate or sum to one on their manifold. | -| Circular and toroidal distributions | Wrapping by the period preserves density. | -| Hyperspherical distributions | Samples and support points remain unit norm. | -| Gaussian filters | Covariances stay symmetric positive definite after updates. | -| Particle filters | Weights remain finite, non-negative, and normalized after resampling. | -| Representation conversion | Moment-matching routes preserve mean/covariance within tolerance. | -| Trackers | Cardinality, association, and gating diagnostics remain internally consistent. | +| Component | Invariant | +|-------------------------------------|--------------------------------------------------------------------------------| +| Probability distributions | Densities integrate or sum to one on their manifold. | +| Circular and toroidal distributions | Wrapping by the period preserves density. | +| Hyperspherical distributions | Samples and support points remain unit norm. | +| Gaussian filters | Covariances stay symmetric positive definite after updates. | +| Particle filters | Weights remain finite, non-negative, and normalized after resampling. | +| Representation conversion | Moment-matching routes preserve mean/covariance within tolerance. | +| Trackers | Cardinality, association, and gating diagnostics remain internally consistent. | When a change affects a Kalman-style Gaussian estimator, check at least: diff --git a/docs/tutorials/backend-portable-workflows.md b/docs/tutorials/backend-portable-workflows.md index 32583b920..2c530dbd2 100644 --- a/docs/tutorials/backend-portable-workflows.md +++ b/docs/tutorials/backend-portable-workflows.md @@ -58,13 +58,13 @@ unless the API is intentionally backend-specific. Backend differences usually appear first as shape, dtype, or scalar-conversion issues. Prefer explicit one-dimensional vectors and two-dimensional matrices: -| Quantity | Recommended shape | -|----------|-------------------| -| State mean | `(n,)` | -| State covariance | `(n, n)` | -| Measurement vector | `(m,)` | -| Measurement matrix | `(m, n)` | -| Measurement covariance | `(m, m)` | +| Quantity | Recommended shape | +|------------------------|-------------------| +| State mean | `(n,)` | +| State covariance | `(n, n)` | +| Measurement vector | `(m,)` | +| Measurement matrix | `(m, n)` | +| Measurement covariance | `(m, m)` | For a one-dimensional measurement, use `array([z])` rather than a scalar and `array([[r]])` rather than `array([r])`. diff --git a/scripts/audit_install_footprint.py b/scripts/audit_install_footprint.py index f985abff4..8f8065418 100644 --- a/scripts/audit_install_footprint.py +++ b/scripts/audit_install_footprint.py @@ -21,7 +21,9 @@ def _dependency_name(raw_name: str) -> str: def main() -> int: parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("pyproject", nargs="?", type=Path, default=Path("pyproject.toml")) + parser.add_argument( + "pyproject", nargs="?", type=Path, default=Path("pyproject.toml") + ) parser.add_argument( "--fail-on-heavy-defaults", action="store_true", @@ -42,7 +44,9 @@ def main() -> int: print("Default dependencies:") for name in sorted(default_names): - note = HEAVY_DEFAULT_DEPENDENCIES.get(name.replace("-", "_")) or HEAVY_DEFAULT_DEPENDENCIES.get(name) + note = HEAVY_DEFAULT_DEPENDENCIES.get( + name.replace("-", "_") + ) or HEAVY_DEFAULT_DEPENDENCIES.get(name) suffix = f" # candidate extra: {note}" if note else "" print(f"- {name}{suffix}") @@ -58,7 +62,8 @@ def main() -> int: ) if heavy_defaults and args.fail_on_heavy_defaults: print( - "Heavy dependencies remain in the default install: " + ", ".join(heavy_defaults), + "Heavy dependencies remain in the default install: " + + ", ".join(heavy_defaults), file=sys.stderr, ) return 1 diff --git a/scripts/check_backend_api_matrix.py b/scripts/check_backend_api_matrix.py index 3c8c1e3bc..e7b4b73c1 100644 --- a/scripts/check_backend_api_matrix.py +++ b/scripts/check_backend_api_matrix.py @@ -14,7 +14,6 @@ from pathlib import Path from types import ModuleType - BACKEND_COLUMNS = ("numpy", "pytorch", "jax") @@ -24,10 +23,17 @@ def _repo_root() -> Path: def load_capability_module(source_path: Path | None = None) -> ModuleType: """Load the backend capability module without importing the package.""" - capabilities_path = source_path or _repo_root() / "src" / "pyrecest" / "_backend" / "capabilities.py" - spec = importlib.util.spec_from_file_location("_pyrecest_backend_capabilities", capabilities_path) + capabilities_path = ( + source_path + or _repo_root() / "src" / "pyrecest" / "_backend" / "capabilities.py" + ) + spec = importlib.util.spec_from_file_location( + "_pyrecest_backend_capabilities", capabilities_path + ) if spec is None or spec.loader is None: - raise RuntimeError(f"Cannot load backend capability metadata from {capabilities_path}") + raise RuntimeError( + f"Cannot load backend capability metadata from {capabilities_path}" + ) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) @@ -99,7 +105,9 @@ def validate_documented_matrix( expected = expected_row[backend_name] observed = documented_row[backend_name] if expected not in support_levels: - errors.append(f"metadata row `{api_name}` has invalid {backend_name} support level `{expected}`") + errors.append( + f"metadata row `{api_name}` has invalid {backend_name} support level `{expected}`" + ) if observed != expected: errors.append( f"docs/backend-api-matrix.md row `{api_name}` has {backend_name}={observed!r}; expected {expected!r}" diff --git a/scripts/check_benchmark_regression.py b/scripts/check_benchmark_regression.py index a9553be94..b46bfef03 100644 --- a/scripts/check_benchmark_regression.py +++ b/scripts/check_benchmark_regression.py @@ -10,7 +10,6 @@ from pathlib import Path from typing import Any - JsonObject = Mapping[str, Any] @@ -66,14 +65,24 @@ def _check_numeric_sequence( rel_tol: float, ) -> list[str]: actual_values = _flatten_numbers(actual, path=f"{benchmark_name}.{field_name}") - expected_values = _flatten_numbers(expected, path=f"baseline.{benchmark_name}.{field_name}") + expected_values = _flatten_numbers( + expected, path=f"baseline.{benchmark_name}.{field_name}" + ) if len(actual_values) != len(expected_values): - return [f"{benchmark_name}: {field_name} length changed from {len(expected_values)} to {len(actual_values)}"] + return [ + f"{benchmark_name}: {field_name} length changed from {len(expected_values)} to {len(actual_values)}" + ] failures = [] - for index, (actual_value, expected_value) in enumerate(zip(actual_values, expected_values)): - if not math.isclose(actual_value, expected_value, abs_tol=abs_tol, rel_tol=rel_tol): - failures.append(f"{benchmark_name}: {field_name}[{index}] expected {expected_value!r}, got {actual_value!r}") + for index, (actual_value, expected_value) in enumerate( + zip(actual_values, expected_values) + ): + if not math.isclose( + actual_value, expected_value, abs_tol=abs_tol, rel_tol=rel_tol + ): + failures.append( + f"{benchmark_name}: {field_name}[{index}] expected {expected_value!r}, got {actual_value!r}" + ) return failures @@ -95,13 +104,17 @@ def _check_elapsed( if "max_elapsed_seconds" in baseline_entry: max_elapsed = float(baseline_entry["max_elapsed_seconds"]) if elapsed > max_elapsed: - failures.append(f"{benchmark_name}: elapsed_seconds {elapsed:.6g} exceeded absolute limit {max_elapsed:.6g}") + failures.append( + f"{benchmark_name}: elapsed_seconds {elapsed:.6g} exceeded absolute limit {max_elapsed:.6g}" + ) if "elapsed_seconds" in baseline_entry: baseline_elapsed = float(baseline_entry["elapsed_seconds"]) limit = baseline_elapsed * max_slowdown if elapsed > limit: - failures.append(f"{benchmark_name}: elapsed_seconds {elapsed:.6g} exceeded baseline {baseline_elapsed:.6g} * {max_slowdown:.6g}") + failures.append( + f"{benchmark_name}: elapsed_seconds {elapsed:.6g} exceeded baseline {baseline_elapsed:.6g} * {max_slowdown:.6g}" + ) return failures @@ -129,7 +142,9 @@ def check_benchmarks( expected_iterations = int(baseline_entry["iterations"]) actual_iterations = int(actual_entry.get("iterations", -1)) if actual_iterations != expected_iterations: - failures.append(f"{benchmark_name}: iterations changed from {expected_iterations} to {actual_iterations}") + failures.append( + f"{benchmark_name}: iterations changed from {expected_iterations} to {actual_iterations}" + ) failures.extend( _check_elapsed( diff --git a/scripts/check_benchmark_results.py b/scripts/check_benchmark_results.py index 901edc347..2373a539d 100644 --- a/scripts/check_benchmark_results.py +++ b/scripts/check_benchmark_results.py @@ -19,7 +19,9 @@ def _load_json(path: Path) -> dict[str, Any]: raise SystemExit(f"Could not parse {path}: {exc}") from exc -def _index_benchmarks(payload: Mapping[str, Any], source: Path) -> dict[str, Mapping[str, Any]]: +def _index_benchmarks( + payload: Mapping[str, Any], source: Path +) -> dict[str, Mapping[str, Any]]: benchmarks = payload.get("benchmarks") if not isinstance(benchmarks, list): raise SystemExit(f"{source} must contain a top-level 'benchmarks' list.") @@ -27,10 +29,14 @@ def _index_benchmarks(payload: Mapping[str, Any], source: Path) -> dict[str, Map indexed: dict[str, Mapping[str, Any]] = {} for entry in benchmarks: if not isinstance(entry, Mapping): - raise SystemExit(f"{source} contains a benchmark entry that is not an object: {entry!r}") + raise SystemExit( + f"{source} contains a benchmark entry that is not an object: {entry!r}" + ) name = entry.get("name") if not isinstance(name, str) or not name: - raise SystemExit(f"{source} contains a benchmark entry without a non-empty name: {entry!r}") + raise SystemExit( + f"{source} contains a benchmark entry without a non-empty name: {entry!r}" + ) if name in indexed: raise SystemExit(f"{source} contains duplicate benchmark entry {name!r}.") indexed[name] = entry @@ -53,7 +59,9 @@ def _compare_nested_numbers( if not _is_number(actual): return [f"{path}: expected numeric value {expected!r}, got {actual!r}."] if not math.isclose(float(actual), float(expected), rel_tol=rtol, abs_tol=atol): - return [f"{path}: expected {float(expected)!r}, got {float(actual)!r} with tolerances rtol={rtol}, atol={atol}."] + return [ + f"{path}: expected {float(expected)!r}, got {float(actual)!r} with tolerances rtol={rtol}, atol={atol}." + ] return [] if isinstance(expected, Sequence) and not isinstance(expected, str): @@ -63,7 +71,9 @@ def _compare_nested_numbers( return [f"{path}: expected length {len(expected)}, got {len(actual)}."] errors: list[str] = [] - for index, (expected_item, actual_item) in enumerate(zip(expected, actual, strict=True)): + for index, (expected_item, actual_item) in enumerate( + zip(expected, actual, strict=True) + ): errors.extend( _compare_nested_numbers( expected_item, @@ -92,16 +102,24 @@ def _runtime_errors( max_elapsed = baseline_entry.get("max_elapsed_seconds") if max_elapsed is not None: if not _is_number(elapsed): - errors.append("elapsed_seconds is missing or not numeric in the result entry.") + errors.append( + "elapsed_seconds is missing or not numeric in the result entry." + ) elif float(elapsed) > float(max_elapsed): - errors.append(f"elapsed_seconds={float(elapsed):.6g} exceeds max_elapsed_seconds={float(max_elapsed):.6g}.") + errors.append( + f"elapsed_seconds={float(elapsed):.6g} exceeds max_elapsed_seconds={float(max_elapsed):.6g}." + ) baseline_elapsed = baseline_entry.get("elapsed_seconds") if max_runtime_ratio is not None and baseline_elapsed is not None: if not _is_number(elapsed): - errors.append("elapsed_seconds is missing or not numeric in the result entry.") + errors.append( + "elapsed_seconds is missing or not numeric in the result entry." + ) elif float(elapsed) > float(baseline_elapsed) * max_runtime_ratio: - errors.append(f"elapsed_seconds={float(elapsed):.6g} exceeds baseline elapsed_seconds {float(baseline_elapsed):.6g} by more than ratio {max_runtime_ratio:.6g}.") + errors.append( + f"elapsed_seconds={float(elapsed):.6g} exceeds baseline elapsed_seconds {float(baseline_elapsed):.6g} by more than ratio {max_runtime_ratio:.6g}." + ) return errors @@ -113,9 +131,21 @@ def main() -> None: type=Path, help="Benchmark result JSON produced by benchmarks/basic_regressions.py.", ) - parser.add_argument("--baseline", type=Path, required=True, help="Baseline JSON to compare against.") - parser.add_argument("--rtol", type=float, default=1e-8, help="Relative tolerance for numeric outputs.") - parser.add_argument("--atol", type=float, default=1e-8, help="Absolute tolerance for numeric outputs.") + parser.add_argument( + "--baseline", type=Path, required=True, help="Baseline JSON to compare against." + ) + parser.add_argument( + "--rtol", + type=float, + default=1e-8, + help="Relative tolerance for numeric outputs.", + ) + parser.add_argument( + "--atol", + type=float, + default=1e-8, + help="Absolute tolerance for numeric outputs.", + ) parser.add_argument( "--max-runtime-ratio", type=float, @@ -139,8 +169,13 @@ def main() -> None: errors.append(f"Missing benchmark result {name!r}.") continue - if "iterations" in expected_entry and actual_entry.get("iterations") != expected_entry["iterations"]: - errors.append(f"{name}: expected iterations={expected_entry['iterations']!r}, got {actual_entry.get('iterations')!r}.") + if ( + "iterations" in expected_entry + and actual_entry.get("iterations") != expected_entry["iterations"] + ): + errors.append( + f"{name}: expected iterations={expected_entry['iterations']!r}, got {actual_entry.get('iterations')!r}." + ) if "final_estimate" in expected_entry: errors.extend( @@ -159,7 +194,9 @@ def main() -> None: max_runtime_ratio=args.max_runtime_ratio, ) if args.warn_only_runtime: - runtime_warnings.extend(f"{name}: {message}" for message in runtime_messages) + runtime_warnings.extend( + f"{name}: {message}" for message in runtime_messages + ) else: errors.extend(f"{name}: {message}" for message in runtime_messages) @@ -171,7 +208,9 @@ def main() -> None: print(f"::error::{error}") raise SystemExit(1) - print(f"Validated {len(baseline)} benchmark baseline entr{'y' if len(baseline) == 1 else 'ies'}.") + print( + f"Validated {len(baseline)} benchmark baseline entr{'y' if len(baseline) == 1 else 'ies'}." + ) if __name__ == "__main__": diff --git a/scripts/check_public_api_registry.py b/scripts/check_public_api_registry.py index 0f29a320a..2f87502d6 100644 --- a/scripts/check_public_api_registry.py +++ b/scripts/check_public_api_registry.py @@ -70,20 +70,28 @@ def validate_registry() -> list[str]: def render_markdown() -> str: registry, _ = _load_registry() - lines = [ - "| API | Module | Category | Backend contract | Notes |", - "|-----|--------|----------|------------------|-------|", - ] + headers = ["API", "Module", "Category", "Backend contract", "Notes"] + rows = [] for api_name, row in sorted(registry.items()): - lines.append( - "| `{api}` | `{module}` | {category} | `{contract}` | {notes} |".format( - api=api_name, - module=row["module"], - category=row["category"], - contract=row.get("backend_contract", ""), - notes=row.get("notes", ""), - ) + rows.append( + [ + f"`{api_name}`", + f"`{row['module']}`", + row["category"], + f"`{row.get('backend_contract', '')}`", + row.get("notes", ""), + ] ) + + widths = [max(len(values[index]) for values in [headers, *rows]) for index in range(len(headers))] + + def format_row(values: list[str]) -> str: + cells = [f" {value.ljust(widths[index])} " for index, value in enumerate(values)] + return "|" + "|".join(cells) + "|" + + separator = "|" + "|".join("-" * (width + 2) for width in widths) + "|" + lines = [format_row(headers), separator] + lines.extend(format_row(row) for row in rows) return "\n".join(lines) + "\n" diff --git a/scripts/generate_backend_api_matrix.py b/scripts/generate_backend_api_matrix.py index 0ff0e3549..22fe617ef 100644 --- a/scripts/generate_backend_api_matrix.py +++ b/scripts/generate_backend_api_matrix.py @@ -13,12 +13,22 @@ def _format_table(headers: list[str], rows: list[list[str]]) -> list[str]: - widths = [max(len(row[index]) for row in [headers, *rows]) for index in range(len(headers))] + widths = [ + max(len(row[index]) for row in [headers, *rows]) + for index in range(len(headers)) + ] lines = [ - "| " + " | ".join(cell.ljust(widths[index]) for index, cell in enumerate(headers)) + " |", + "| " + + " | ".join(cell.ljust(widths[index]) for index, cell in enumerate(headers)) + + " |", "|" + "|".join("-" * (width + 2) for width in widths) + "|", ] - lines.extend("| " + " | ".join(cell.ljust(widths[index]) for index, cell in enumerate(row)) + " |" for row in rows) + lines.extend( + "| " + + " | ".join(cell.ljust(widths[index]) for index, cell in enumerate(row)) + + " |" + for row in rows + ) return lines @@ -117,7 +127,9 @@ def render_backend_api_matrix() -> str: def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--output", type=Path, help="Write generated Markdown to this path.") + parser.add_argument( + "--output", type=Path, help="Write generated Markdown to this path." + ) args = parser.parse_args(argv) rendered = render_backend_api_matrix() diff --git a/scripts/render_backend_api_matrix.py b/scripts/render_backend_api_matrix.py index 33460d201..ce9e05301 100644 --- a/scripts/render_backend_api_matrix.py +++ b/scripts/render_backend_api_matrix.py @@ -15,9 +15,10 @@ from types import ModuleType from typing import Any - REPOSITORY_ROOT = Path(__file__).resolve().parents[1] -CAPABILITIES_PATH = REPOSITORY_ROOT / "src" / "pyrecest" / "_backend" / "capabilities.py" +CAPABILITIES_PATH = ( + REPOSITORY_ROOT / "src" / "pyrecest" / "_backend" / "capabilities.py" +) def _load_capabilities_module() -> ModuleType: @@ -41,12 +42,22 @@ def _load_api_backend_capabilities() -> dict[str, dict[str, str]]: def _format_table(headers: list[str], rows: list[list[str]]) -> list[str]: - widths = [max(len(row[index]) for row in [headers, *rows]) for index in range(len(headers))] + widths = [ + max(len(row[index]) for row in [headers, *rows]) + for index in range(len(headers)) + ] lines = [ - "| " + " | ".join(cell.ljust(widths[index]) for index, cell in enumerate(headers)) + " |", + "| " + + " | ".join(cell.ljust(widths[index]) for index, cell in enumerate(headers)) + + " |", "|" + "|".join("-" * (width + 2) for width in widths) + "|", ] - lines.extend("| " + " | ".join(cell.ljust(widths[index]) for index, cell in enumerate(row)) + " |" for row in rows) + lines.extend( + "| " + + " | ".join(cell.ljust(widths[index]) for index, cell in enumerate(row)) + + " |" + for row in rows + ) return lines diff --git a/src/pyrecest/_backend/capabilities.py b/src/pyrecest/_backend/capabilities.py index 3dc1b9d1b..7b8dcb0e9 100644 --- a/src/pyrecest/_backend/capabilities.py +++ b/src/pyrecest/_backend/capabilities.py @@ -151,21 +151,27 @@ REQUIRED_BACKENDS: Final = ("numpy", "pytorch", "jax") -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]: +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", {}) @@ -189,14 +195,20 @@ def validate_api_backend_capabilities() -> tuple[str, ...]: if not api_name: errors.append("Capability row has an empty API name.") - missing_backends = [backend for backend in REQUIRED_BACKENDS if backend not in row] + missing_backends = [ + backend for backend in REQUIRED_BACKENDS if backend not in row + ] if missing_backends: - errors.append(f"{api_name}: missing backend support entries for {', '.join(missing_backends)}.") + errors.append( + f"{api_name}: missing backend support entries for {', '.join(missing_backends)}." + ) for backend_name in REQUIRED_BACKENDS: support_level = row.get(backend_name) if support_level not in BACKEND_SUPPORT_LEVELS: - errors.append(f"{api_name}: unsupported support level {support_level!r} for {backend_name}.") + errors.append( + f"{api_name}: unsupported support level {support_level!r} for {backend_name}." + ) if not row.get("notes"): errors.append(f"{api_name}: missing explanatory notes.") diff --git a/src/pyrecest/_backend/jax/__init__.py b/src/pyrecest/_backend/jax/__init__.py index 5b31a2c95..96ffd4d97 100644 --- a/src/pyrecest/_backend/jax/__init__.py +++ b/src/pyrecest/_backend/jax/__init__.py @@ -172,15 +172,21 @@ def isscalar(x): 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): @@ -422,7 +428,9 @@ def scatter_add(input, dim, index, src): row_indices = _jnp.arange(input.shape[0]) else: row_shape = (input.shape[0],) + (1,) * (index.ndim - 1) - row_indices = _jnp.broadcast_to(_jnp.arange(input.shape[0]).reshape(row_shape), index.shape) + row_indices = _jnp.broadcast_to( + _jnp.arange(input.shape[0]).reshape(row_shape), index.shape + ) return input.at[row_indices, index].add(src) raise NotImplementedError("scatter_add is implemented for dim 0 and dim 1.") diff --git a/src/pyrecest/_backend/numpy/__init__.py b/src/pyrecest/_backend/numpy/__init__.py index 23409c74a..a0e8b9db7 100644 --- a/src/pyrecest/_backend/numpy/__init__.py +++ b/src/pyrecest/_backend/numpy/__init__.py @@ -211,7 +211,9 @@ def vmapped_fun(*args): # numpy.all as ``all``; NumPy treats a bare generator as one truthy # object instead of iterating over its yielded booleans. if not all([arg.shape[0] == args[0].shape[0] for arg in args]): - raise ValueError("All arguments must have the same size in the first dimension") + raise ValueError( + "All arguments must have the same size in the first dimension" + ) # Prepare the output array (assuming the output of pyfunc is a scalar or numpy array) first_output = pyfunc(*(arg[0, ...] for arg in args)) diff --git a/src/pyrecest/_backend/pytorch/__init__.py b/src/pyrecest/_backend/pytorch/__init__.py index 0eef93561..277bf63d2 100644 --- a/src/pyrecest/_backend/pytorch/__init__.py +++ b/src/pyrecest/_backend/pytorch/__init__.py @@ -172,7 +172,9 @@ def std( def cov(input, correction=1, fweights=None, aweights=None, bias=False): # for pyrecest if not bias: - return _torch.cov(input, correction=correction, fweights=fweights, aweights=aweights) + return _torch.cov( + input, correction=correction, fweights=fweights, aweights=aweights + ) assert fweights is None if aweights is None: @@ -189,7 +191,9 @@ def cov(input, correction=1, fweights=None, aweights=None, bias=False): deviation_centered = input - means # Calculate weighted biased covariance - cov_matrix = _torch.einsum("ij,kj,j->ik", deviation_centered, deviation_centered, aweights) + cov_matrix = _torch.einsum( + "ij,kj,j->ik", deviation_centered, deviation_centered, aweights + ) return cov_matrix @@ -494,7 +498,12 @@ def linspace(start, stop, num=50, endpoint=True, dtype=None): stop = _torch.flatten(stop) if endpoint: - result = _torch.vstack([_torch.linspace(start=start[i], end=stop[i], steps=num, dtype=dtype) for i in range(start.shape[0])]).T + result = _torch.vstack( + [ + _torch.linspace(start=start[i], end=stop[i], steps=num, dtype=dtype) + for i in range(start.shape[0]) + ] + ).T else: result = _torch.vstack( [ @@ -884,7 +893,9 @@ def _unnest_iterable(ls): def pad(a, pad_width, mode="constant", constant_values=0.0): - return _torch.nn.functional.pad(a, _unnest_iterable(reversed(pad_width)), mode=mode, value=constant_values) + return _torch.nn.functional.pad( + a, _unnest_iterable(reversed(pad_width)), mode=mode, value=constant_values + ) def is_array(x): diff --git a/src/pyrecest/distributions/nonperiodic/abstract_hyperrectangular_distribution.py b/src/pyrecest/distributions/nonperiodic/abstract_hyperrectangular_distribution.py index d4f76acc2..c133e9d01 100644 --- a/src/pyrecest/distributions/nonperiodic/abstract_hyperrectangular_distribution.py +++ b/src/pyrecest/distributions/nonperiodic/abstract_hyperrectangular_distribution.py @@ -48,7 +48,10 @@ def integrate(self, integration_boundaries=None) -> float: 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))] + 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))) diff --git a/src/pyrecest/evaluation/configure_for_filter.py b/src/pyrecest/evaluation/configure_for_filter.py index e3c75dfbc..323f039d9 100644 --- a/src/pyrecest/evaluation/configure_for_filter.py +++ b/src/pyrecest/evaluation/configure_for_filter.py @@ -211,4 +211,4 @@ def prediction_routine(): # type: ignore[misc] prediction_routine, likelihood_for_filter, meas_noise_for_filter, - ) \ No newline at end of file + ) diff --git a/src/pyrecest/evaluation/get_distance_function.py b/src/pyrecest/evaluation/get_distance_function.py index fd5595739..ffff00d76 100644 --- a/src/pyrecest/evaluation/get_distance_function.py +++ b/src/pyrecest/evaluation/get_distance_function.py @@ -177,4 +177,4 @@ def distance_function(x1, x2): "available_distance_functions", "get_distance_function", "register_distance_function", -] \ No newline at end of file +] diff --git a/src/pyrecest/evaluation/perform_predict_update_cycles.py b/src/pyrecest/evaluation/perform_predict_update_cycles.py index b6bd4d0f0..9927493c1 100644 --- a/src/pyrecest/evaluation/perform_predict_update_cycles.py +++ b/src/pyrecest/evaluation/perform_predict_update_cycles.py @@ -2,7 +2,6 @@ import warnings import numpy as _np - from pyrecest.backend import any, array, atleast_2d, empty_like, squeeze from .configure_for_filter import configure_for_filter diff --git a/tests/distributions/test_custom_hyperrectangular_distribution.py b/tests/distributions/test_custom_hyperrectangular_distribution.py index ab5f5c4cd..f3e5e4707 100644 --- a/tests/distributions/test_custom_hyperrectangular_distribution.py +++ b/tests/distributions/test_custom_hyperrectangular_distribution.py @@ -27,7 +27,9 @@ def test_object_creation(self): def test_pdf_method(self): """Test that the pdf method returns correct values.""" - x_mesh, y_mesh = meshgrid(linspace(1.0, 3.0, 50), linspace(2.0, 5.0, 50), indexing="ij") + x_mesh, y_mesh = meshgrid( + linspace(1.0, 3.0, 50), linspace(2.0, 5.0, 50), indexing="ij" + ) expected_pdf = 1.0 / 6.0 * ones(50**2) calculated_pdf = self.cd.pdf(column_stack((x_mesh.ravel(), y_mesh.ravel()))) self.assertTrue( @@ -78,7 +80,9 @@ def test_normalize_verify_handles_scalar_integral(self): if backend.__backend_name__ != "numpy": # pylint: disable=no-member self.skipTest("normalize currently supports the NumPy backend only") - dist = CustomHyperrectangularDistribution(lambda xs: 2.0 * ones(xs.shape[0]), self.bounds) + dist = CustomHyperrectangularDistribution( + lambda xs: 2.0 * ones(xs.shape[0]), self.bounds + ) normalized = dist.normalize(verify=True) self.assertAlmostEqual(float(normalized.integrate()), 1.0, places=10) diff --git a/tests/evaluation/test_evaluation_registries.py b/tests/evaluation/test_evaluation_registries.py index b62801e0d..18f398c66 100644 --- a/tests/evaluation/test_evaluation_registries.py +++ b/tests/evaluation/test_evaluation_registries.py @@ -1,5 +1,4 @@ import pytest - from pyrecest.backend import array, pi, to_numpy from pyrecest.evaluation.get_distance_function import ( get_distance_function, @@ -31,7 +30,9 @@ def test_euclidean_mtt_distance_uses_assignment_with_cutoff(): def test_underscored_symmetric_hypersphere_distance_is_antipodal_invariant(): distance = get_distance_function("hypersphere_symmetric") - assert _as_float(distance(array([1.0, 0.0]), array([-1.0, 0.0]))) == pytest.approx(0.0) + assert _as_float(distance(array([1.0, 0.0]), array([-1.0, 0.0]))) == pytest.approx( + 0.0 + ) def test_se2bounded_distance_uses_angular_component_before_linear_dispatch(): diff --git a/tests/filters/test_linear_gaussian_invariants.py b/tests/filters/test_linear_gaussian_invariants.py index e964466cb..4bc7a4135 100644 --- a/tests/filters/test_linear_gaussian_invariants.py +++ b/tests/filters/test_linear_gaussian_invariants.py @@ -3,7 +3,6 @@ from __future__ import annotations import numpy as np - from pyrecest.backend import array from pyrecest.distributions import GaussianDistribution from pyrecest.filters import KalmanFilter @@ -72,7 +71,9 @@ def test_normalized_innovation_squared_matches_manual_solve(): innovation_np = _to_numpy(innovation) innovation_covariance_np = _to_numpy(innovation_covariance) - expected = innovation_np.T @ np.linalg.solve(innovation_covariance_np, innovation_np) + expected = innovation_np.T @ np.linalg.solve( + innovation_covariance_np, innovation_np + ) assert np.allclose(_as_float(observed), expected, atol=1e-10) diff --git a/tests/test_backend_api_matrix_contract.py b/tests/test_backend_api_matrix_contract.py index a280cdc3e..97fea9eef 100644 --- a/tests/test_backend_api_matrix_contract.py +++ b/tests/test_backend_api_matrix_contract.py @@ -10,12 +10,13 @@ validate_documented_matrix, ) - REPO_ROOT = Path(__file__).resolve().parents[1] def test_backend_api_matrix_documentation_matches_capability_metadata(): - module = load_capability_module(REPO_ROOT / "src" / "pyrecest" / "_backend" / "capabilities.py") + module = load_capability_module( + REPO_ROOT / "src" / "pyrecest" / "_backend" / "capabilities.py" + ) documented = parse_documented_matrix(REPO_ROOT / "docs" / "backend-api-matrix.md") errors = validate_documented_matrix( @@ -28,7 +29,9 @@ def test_backend_api_matrix_documentation_matches_capability_metadata(): def test_backend_api_capability_rows_use_declared_support_levels(): - module = load_capability_module(REPO_ROOT / "src" / "pyrecest" / "_backend" / "capabilities.py") + module = load_capability_module( + REPO_ROOT / "src" / "pyrecest" / "_backend" / "capabilities.py" + ) support_levels = set(module.BACKEND_SUPPORT_LEVELS) for api_name, row in module.API_BACKEND_CAPABILITIES.items(): diff --git a/tests/test_public_api_contract.py b/tests/test_public_api_contract.py index 96679b33f..fb169039d 100644 --- a/tests/test_public_api_contract.py +++ b/tests/test_public_api_contract.py @@ -2,7 +2,6 @@ import pytest - PUBLIC_MODULES_WITH_ALL = ( "pyrecest", "pyrecest.filters", diff --git a/tests/test_scientific_invariants.py b/tests/test_scientific_invariants.py index 106090f40..f0edc817b 100644 --- a/tests/test_scientific_invariants.py +++ b/tests/test_scientific_invariants.py @@ -1,5 +1,4 @@ import numpy as np - from pyrecest.backend import array, diag, pi, to_numpy from pyrecest.distributions import CircularUniformDistribution, GaussianDistribution