From 668daed6f5d83a03fc95a7872a0a302d097e3fc4 Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Fri, 10 Jul 2026 21:54:14 -0700 Subject: [PATCH 1/3] feat(api)!: make numerical invariants and errors explicit - add structured error reasons, locations, origins, and factorization context - make determinant, exact-conversion, LU, and LDLT paths range-safe and mathematically explicit - validate benchmark inputs independently and require reproducible, provenance-backed performance evidence - centralize tool versions, adopt nextest profiles, and replace Codacy with repository-owned checks and SARIF reporting BREAKING CHANGE: Rename `Tolerance::new` to `Tolerance::try_new` and `Matrix::get_checked` to `Matrix::try_get`; remove `Matrix::set_checked` in favor of `Matrix::set`. `Vector::dot` and `Vector::norm2_sq` now borrow their operands. `det_sign_exact` is now infallible and returns `DeterminantSign` instead of `Result`. `LaError` variants now use typed reason, location, and origin fields and require `..` in downstream matches. LDLT now requires exact symmetry, determinant error bounds may be unavailable under gradual underflow, and `ERR_COEFF_2`, `ERR_COEFF_3`, and `ERR_COEFF_4` are no longer exported by the prelude. --- .codacy.yml | 156 -- .config/nextest.toml | 35 + .github/workflows/audit.yml | 58 +- .github/workflows/benchmarks.yml | 42 +- .github/workflows/ci.yml | 68 +- .github/workflows/codacy.yml | 170 -- .github/workflows/codecov.yml | 57 +- .github/workflows/codeql.yml | 11 +- .github/workflows/release-benchmarks.yml | 11 +- .github/workflows/rust-clippy.yml | 6 +- .github/workflows/semgrep-sarif.yml | 17 +- .markdownlint.json | 13 - .python-version | 2 +- .yamllint | 4 +- AGENTS.md | 216 +- CONTRIBUTING.md | 169 +- Cargo.lock | 326 +-- Cargo.toml | 38 +- README.md | 251 +- REFERENCES.md | 38 +- SECURITY.md | 87 +- benches/common/exact.rs | 537 ++++- benches/common/vs_linalg.rs | 110 +- benches/exact.rs | 643 ++--- benches/vs_linalg.rs | 1043 +++++--- clippy.toml | 6 + docs/BENCHMARKING.md | 160 +- docs/COVERAGE.md | 39 +- docs/PERFORMANCE.md | 105 +- docs/RELEASING.md | 71 +- .../vs_linalg_lu_solve_median.provenance.json | 36 + docs/roadmap.md | 29 +- dprint.json | 22 +- examples/const_det_4x4.rs | 10 +- examples/det_5x5.rs | 2 + examples/exact_det_3x3.rs | 21 +- examples/exact_sign_3x3.rs | 14 +- examples/exact_solve_3x3.rs | 16 +- examples/ldlt_solve_3x3.rs | 10 +- examples/solve_5x5.rs | 2 + justfile | 583 +++-- pyproject.toml | 48 +- rust-toolchain.toml | 21 +- scripts/README.md | 71 +- scripts/archive_changelog.py | 51 +- scripts/archive_performance.py | 479 +++- scripts/bench_compare.py | 851 ++++++- scripts/check_docs_version_sync.py | 242 +- scripts/check_semgrep_fixtures.py | 43 +- scripts/criterion_dim_plot.py | 522 +++- scripts/postprocess_changelog.py | 6 +- scripts/subprocess_utils.py | 4 +- scripts/tests/__init__.py | 2 +- scripts/tests/test_archive_performance.py | 284 ++- scripts/tests/test_bench_compare.py | 527 +++- scripts/tests/test_check_docs_version_sync.py | 153 +- scripts/tests/test_check_semgrep_fixtures.py | 10 +- scripts/tests/test_criterion_dim_plot.py | 434 +++- scripts/tests/test_subprocess_utils.py | 33 +- scripts/tests/test_tag_release.py | 29 +- semgrep.yaml | 725 +++++- src/error.rs | 1166 +++++---- src/exact.rs | 2146 +++++++++-------- src/ldlt.rs | 639 +++-- src/lib.rs | 260 +- src/lu.rs | 538 +++-- src/matrix.rs | 1421 +++++++---- src/scaled_product.rs | 352 +++ src/tolerance.rs | 84 +- src/vector.rs | 140 +- tests/exact_bench_config.rs | 108 +- tests/exact_conversion_boundaries.rs | 237 ++ tests/prelude_exports.rs | 90 + tests/proptest_exact.rs | 481 ++-- tests/proptest_factorizations.rs | 43 +- tests/proptest_matrix.rs | 173 +- tests/proptest_vector.rs | 33 +- tests/regressions.rs | 40 +- tests/scaled_product_determinants.rs | 57 + .../.github/workflows/action_policy.yml | 76 + tests/semgrep/docs/public_examples.md | 11 + .../scripts/tests/python_exceptions.py | 78 + .../src/project_rules/finite_api_contract.rs | 100 +- .../src/project_rules/portable_policy.rs | 88 + tests/vs_linalg_inputs.rs | 467 ++-- ty.toml | 4 - uv.lock | 311 ++- 87 files changed, 13216 insertions(+), 5696 deletions(-) delete mode 100644 .codacy.yml create mode 100644 .config/nextest.toml delete mode 100644 .github/workflows/codacy.yml delete mode 100644 .markdownlint.json create mode 100644 clippy.toml create mode 100644 docs/assets/bench/vs_linalg_lu_solve_median.provenance.json create mode 100644 src/scaled_product.rs create mode 100644 tests/exact_conversion_boundaries.rs create mode 100644 tests/prelude_exports.rs create mode 100644 tests/scaled_product_determinants.rs create mode 100644 tests/semgrep/.github/workflows/action_policy.yml create mode 100644 tests/semgrep/docs/public_examples.md create mode 100644 tests/semgrep/scripts/tests/python_exceptions.py create mode 100644 tests/semgrep/src/project_rules/portable_policy.rs diff --git a/.codacy.yml b/.codacy.yml deleted file mode 100644 index a3fde82..0000000 --- a/.codacy.yml +++ /dev/null @@ -1,156 +0,0 @@ ---- -# Codacy configuration for la-stack project -# -# This repository is primarily Rust. -# Note: clippy and rustfmt are not supported by Codacy and are handled by GitHub Actions CI. -# cspell:ignore pyproject - -engines: - # === DOCUMENTATION / SCRIPTS === - markdownlint: - enabled: true - include_paths: - - "**/*.md" - config: - file: ".markdownlint.json" - - shellcheck: - enabled: true - include_paths: - - "**/*.sh" - config: - shell: bash - severity: warning - include_code: true - - # === PYTHON / SECURITY === - # Ruff for Python linting and formatting (reads from pyproject.toml) - ruff: - enabled: true - include_paths: - - "scripts/**/*.py" - - "**/*.py" - config: - file: "pyproject.toml" - - # Bandit for Python security analysis - bandit: - enabled: true - include_paths: - - "scripts/**/*.py" - - "**/*.py" - config: - severity: high - confidence: high - skips: ["B101", "B102", "B103", "B108", "B110", "B404", "B603", "B607"] - exclude_info: true - exclude_dirs: ["tests"] - - # === RUST / SECURITY === - lizard: - enabled: true - include_paths: - - "src/**/*.rs" - - "tests/**/*.rs" - - "examples/**/*.rs" - - "benches/**/*.rs" - - "scripts/**/*.py" - config: - languages: ["rust", "python"] - threshold: - cyclomatic_complexity: 15 - token_count: 300 - nesting_depth: 5 - parameter_count: 5 - length: 1000 - - semgrep: - enabled: true - include_paths: - - "src/**/*.rs" - - "tests/**/*.rs" - - "examples/**/*.rs" - - "benches/**/*.rs" - - "scripts/**/*.py" - - trivy: - enabled: true - config: - severity: ["HIGH", "CRITICAL"] - skip_dev_dependencies: true - enable_secret_scanning: true - - # === DUPLICATION DETECTION === - duplication: - enabled: true - config: - minimum_mass: 60 - minimum_tokens: 80 - exclude_paths: - - "target/**" - - "coverage/**" - - "benches/**" - - "examples/**" - - "tests/**" - -# === GLOBAL EXCLUSIONS === -exclude_paths: - - "target/**" - - "coverage/**" - - "Cargo.lock" - - ".git/**" - - ".cspellcache" - - ".DS_Store" - # Python artifacts - - "__pycache__/**" - - "*.pyc" - - ".pytest_cache/**" - - ".ruff_cache/**" - - ".mypy_cache/**" - - "venv/**" - - ".venv/**" - - "uv.lock" - -# Focus analysis on source, docs, and CI configuration -include_paths: - - "src/**" - - "benches/**" - - "examples/**" - - "tests/**" - - "scripts/**" - - "*.py" - - "Cargo.toml" - - "pyproject.toml" - - "rust-toolchain.toml" - - "rustfmt.toml" - - "justfile" - - ".github/**" - - "*.md" - - "*.yml" - - "*.yaml" - - "*.json" - - ".markdownlint.json" - - "cspell.json" - - ".codecov.yml" - -# Custom file extensions per language (Codacy schema compliant) -languages: - rust: - extensions: - - ".rs" - python: - extensions: - - ".py" - markdown: - extensions: - - ".md" - yaml: - extensions: - - ".yml" - - ".yaml" - json: - extensions: - - ".json" - shell: - extensions: - - ".sh" diff --git a/.config/nextest.toml b/.config/nextest.toml new file mode 100644 index 0000000..73bc86a --- /dev/null +++ b/.config/nextest.toml @@ -0,0 +1,35 @@ +# nextest configuration for la-stack +# See: https://nexte.st/book/configuration.html + +[profile.default] +test-threads = "num-cpus" +failure-output = "immediate-final" +success-output = "never" +fail-fast = false +retries = 0 + +# Default-suite tests are intentionally small. Keep a finite watchdog so an +# accidental hang fails locally instead of consuming the runner indefinitely. +slow-timeout = { period = "10s", terminate-after = 1 } + +[profile.ci] +failure-output = "immediate-final" +success-output = "never" +fail-fast = false + +# Match the shared repository baseline for transient runner failures while +# keeping coverage deterministic below. +retries = 1 +slow-timeout = { period = "10s", terminate-after = 1 } + +[profile.ci.junit] +path = "test-results/junit.xml" +store-success-output = false +store-failure-output = true + +[profile.coverage] +# LLVM-instrumented tests need more headroom than ordinary CI while retaining +# a finite watchdog for genuine hangs. +inherits = "ci" +retries = 0 +slow-timeout = { period = "300s", terminate-after = 1 } diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml index 600e0ca..6a28319 100644 --- a/.github/workflows/audit.yml +++ b/.github/workflows/audit.yml @@ -2,38 +2,52 @@ name: "Audit dependencies" on: push: + branches: ["main"] paths: - .github/workflows/audit.yml - "**/Cargo.toml" - "**/Cargo.lock" pull_request: paths: + - .github/workflows/audit.yml - "**/Cargo.toml" - "**/Cargo.lock" schedule: - cron: "0 6 * * 1" # Monday at 6 AM UTC workflow_dispatch: +concurrency: + group: > + audit-${{ github.workflow }}-${{ + github.event_name == 'pull_request' && + github.event.pull_request.number || + github.ref + }} + cancel-in-progress: true + permissions: + actions: read contents: read + security-events: write jobs: audit: runs-on: ubuntu-latest env: - CARGO_AUDIT_VERSION: "0.22.1" + CARGO_AUDIT_VERSION: "0.22.2" steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Install Rust toolchain - uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1.16.1 + uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0 with: cache: true # toolchain/components are specified in rust-toolchain.toml + cache-bin: false - - name: Cache advisory database - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + - name: Cache audit database + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cargo/advisory-db key: advisory-db-${{ github.ref_name }}-v1 @@ -45,13 +59,47 @@ jobs: tool: cargo-audit@${{ env.CARGO_AUDIT_VERSION }} - name: Run cargo audit + id: audit run: | + set +e + cargo audit --format sarif > audit-results.sarif + sarif_status=$? cargo audit --json > audit-results.json + json_status=$? cargo audit + text_status=$? + set -e + + exit_code=0 + if ((sarif_status != 0 || json_status != 0 || text_status != 0)); then + exit_code=1 + fi + echo "exit_code=$exit_code" >> "$GITHUB_OUTPUT" + exit 0 + + - name: Upload audit SARIF results + if: >- + always() && + hashFiles('audit-results.sarif') != '' && + ( + github.event_name != 'pull_request' || + github.event.pull_request.head.repo.full_name == github.repository + ) + uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + with: + sarif_file: audit-results.sarif + category: cargo-audit + wait-for-processing: true - name: Upload audit results uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: always() with: name: audit-results - path: audit-results.json + path: | + audit-results.json + audit-results.sarif + + - name: Fail on audit findings + if: steps.audit.outputs.exit_code != '0' + run: exit 1 diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index fe737a4..553db7e 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -16,16 +16,26 @@ on: paths: - "src/**" - "benches/**" + - "tests/exact_bench_config.rs" + - "tests/vs_linalg_inputs.rs" - "Cargo.toml" - "Cargo.lock" + - "justfile" + - "rust-toolchain.toml" + - ".github/workflows/benchmarks.yml" pull_request: branches: - main paths: - "src/**" - "benches/**" + - "tests/exact_bench_config.rs" + - "tests/vs_linalg_inputs.rs" - "Cargo.toml" - "Cargo.lock" + - "justfile" + - "rust-toolchain.toml" + - ".github/workflows/benchmarks.yml" workflow_dispatch: concurrency: @@ -52,9 +62,15 @@ jobs: persist-credentials: false - name: Install Rust toolchain - uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1.16.1 + uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0 with: cache: true + cache-bin: false + + - name: Validate benchmark inputs + run: > + cargo test --locked --features bench,exact + --test vs_linalg_inputs --test exact_bench_config # ── PR: find and download the latest main baseline ────────────── - name: Find latest main baseline @@ -116,14 +132,16 @@ jobs: if [ -d target/criterion/exact_d2/det/main ]; then echo "::notice::Baseline found — comparing against main" + echo "comparison_available=true" >> "$GITHUB_OUTPUT" # --baseline-lenient rather than --baseline: benches added on the # PR branch that don't yet exist in the main baseline get a # "no baseline data" notice instead of aborting the whole run. - cargo bench --features bench,exact --bench exact \ + cargo bench --locked --features bench,exact --bench exact \ -- --baseline-lenient main 2>&1 | tee bench-output.txt else echo "::notice::No baseline found — running without comparison" - cargo bench --features bench,exact --bench exact \ + echo "comparison_available=false" >> "$GITHUB_OUTPUT" + cargo bench --locked --features bench,exact --bench exact \ 2>&1 | tee bench-output.txt fi @@ -139,9 +157,13 @@ jobs: (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/main' run: > - cargo bench --features bench,exact --bench exact + cargo bench --locked --features bench,exact --bench exact -- --save-baseline main + - name: Run benchmarks (manual ref) + if: github.event_name == 'workflow_dispatch' && github.ref != 'refs/heads/main' + run: cargo bench --locked --features bench,exact --bench exact + - name: Upload baseline artifact if: > (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && @@ -150,7 +172,7 @@ jobs: with: name: bench-baseline-main path: target/criterion - retention-days: 30 + retention-days: 90 if-no-files-found: error # ── PR: report results ────────────────────────────────────────── @@ -159,16 +181,17 @@ jobs: run: | set -euo pipefail + comparison_available="${BENCH_COMPARISON_AVAILABLE:-}" regression="${BENCH_REGRESSION:-}" - if [ -z "$regression" ]; then + if [ "$comparison_available" != "true" ] || [ -z "$regression" ]; then { echo "### ❓ Benchmark Comparison Unavailable" echo "" - echo "The benchmark step did not produce a result." - echo "This may indicate a build failure or missing baseline." + echo "No usable comparison against the main baseline was produced." + echo "The benchmark still ran, but no regression claim can be made." } >> "$GITHUB_STEP_SUMMARY" - echo "::warning::Benchmark comparison produced no output" + echo "::warning::Benchmark comparison unavailable" elif [ "$regression" = "true" ]; then { echo "### ⚠️ Performance Regression Detected" @@ -189,4 +212,5 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" fi env: + BENCH_COMPARISON_AVAILABLE: ${{ steps.bench-compare.outputs.comparison_available }} BENCH_REGRESSION: ${{ steps.bench-compare.outputs.regression }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 035c95c..bfe890b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,14 +24,6 @@ on: env: CARGO_TERM_COLOR: always RUST_BACKTRACE: 1 - CARGO_NEXTEST_VERSION: "0.9.137" - DPRINT_VERSION: "0.54.0" - JUST_VERSION: "1.51.0" - RUMDL_VERSION: "0.2.9" - TAPLO_VERSION: "0.10.0" - TYPOS_VERSION: "1.47.2" - UV_VERSION: "0.11.19" - ZIZMOR_VERSION: "1.25.2" jobs: build: @@ -64,67 +56,97 @@ jobs: persist-credentials: false - name: Install Rust toolchain - uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1.16.1 + uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0 with: target: ${{ matrix.target }} cache: true + cache-bin: false # toolchain, components, etc. are specified in rust-toolchain.toml + - name: Read just version + id: just_version + shell: bash + run: | + version="$(grep '^just_version :=' justfile | cut -d '"' -f 2)" + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Install just + uses: taiki-e/cache-cargo-install-action@417450f3c33ee20393705369577571770643d4c7 # v3.0.7 + with: + tool: just@${{ steps.just_version.outputs.version }} + + - name: Export tool versions + id: tool_versions + shell: bash + run: | + { + echo "CARGO_MACHETE_VERSION=$(just --evaluate cargo_machete_version)" + echo "CARGO_NEXTEST_VERSION=$(just --evaluate cargo_nextest_version)" + echo "DPRINT_VERSION=$(just --evaluate dprint_version)" + echo "RUMDL_VERSION=$(just --evaluate rumdl_version)" + echo "TAPLO_VERSION=$(just --evaluate taplo_version)" + echo "TYPOS_VERSION=$(just --evaluate typos_version)" + echo "UV_VERSION=$(just --evaluate uv_version)" + echo "ZIZMOR_VERSION=$(just --evaluate zizmor_version)" + } >> "$GITHUB_OUTPUT" + - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version-file: ".python-version" - name: Install uv - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: - version: ${{ env.UV_VERSION }} + version: ${{ steps.tool_versions.outputs.UV_VERSION }} enable-cache: true - name: Sync Python tooling run: uv sync --locked --group dev - - name: Install just - uses: taiki-e/cache-cargo-install-action@417450f3c33ee20393705369577571770643d4c7 # v3.0.7 - with: - tool: just@${{ env.JUST_VERSION }} - - name: Install dprint uses: taiki-e/cache-cargo-install-action@417450f3c33ee20393705369577571770643d4c7 # v3.0.7 with: - tool: dprint@${{ env.DPRINT_VERSION }} + tool: dprint@${{ steps.tool_versions.outputs.DPRINT_VERSION }} - name: Install rumdl uses: taiki-e/cache-cargo-install-action@417450f3c33ee20393705369577571770643d4c7 # v3.0.7 with: - tool: rumdl@${{ env.RUMDL_VERSION }} + tool: rumdl@${{ steps.tool_versions.outputs.RUMDL_VERSION }} - name: Install taplo id: install-taplo continue-on-error: ${{ matrix.os == 'windows-latest' }} uses: taiki-e/cache-cargo-install-action@417450f3c33ee20393705369577571770643d4c7 # v3.0.7 with: - tool: taplo-cli@${{ env.TAPLO_VERSION }} + tool: taplo-cli@${{ steps.tool_versions.outputs.TAPLO_VERSION }} - name: Install taplo on Windows after cached install failure if: matrix.os == 'windows-latest' && steps.install-taplo.outcome == 'failure' shell: pwsh run: cargo install --locked taplo-cli --version $env:TAPLO_VERSION + env: + TAPLO_VERSION: ${{ steps.tool_versions.outputs.TAPLO_VERSION }} - name: Install typos uses: taiki-e/cache-cargo-install-action@417450f3c33ee20393705369577571770643d4c7 # v3.0.7 with: - tool: typos-cli@${{ env.TYPOS_VERSION }} + tool: typos-cli@${{ steps.tool_versions.outputs.TYPOS_VERSION }} - name: Install zizmor uses: taiki-e/cache-cargo-install-action@417450f3c33ee20393705369577571770643d4c7 # v3.0.7 with: - tool: zizmor@${{ env.ZIZMOR_VERSION }} + tool: zizmor@${{ steps.tool_versions.outputs.ZIZMOR_VERSION }} + + - name: Install cargo-machete + uses: taiki-e/cache-cargo-install-action@417450f3c33ee20393705369577571770643d4c7 # v3.0.7 + with: + tool: cargo-machete@${{ steps.tool_versions.outputs.CARGO_MACHETE_VERSION }} - name: Install cargo-nextest uses: taiki-e/cache-cargo-install-action@417450f3c33ee20393705369577571770643d4c7 # v3.0.7 with: - tool: cargo-nextest@${{ env.CARGO_NEXTEST_VERSION }} + tool: cargo-nextest@${{ steps.tool_versions.outputs.CARGO_NEXTEST_VERSION }} - name: Run CI checks run: just ci diff --git a/.github/workflows/codacy.yml b/.github/workflows/codacy.yml deleted file mode 100644 index e511824..0000000 --- a/.github/workflows/codacy.yml +++ /dev/null @@ -1,170 +0,0 @@ -# This workflow uses actions that are not certified by GitHub. -# They are provided by a third-party and are governed by -# separate terms of service, privacy policy, and support -# documentation. - -# This workflow checks out code, performs a Codacy security scan -# and integrates the results with the -# GitHub Advanced Security code scanning feature. -# For more information on the Codacy security scan action usage and -# parameters, see https://github.com/codacy/codacy-analysis-cli-action. -# For more information on Codacy Analysis CLI in general, see -# https://github.com/codacy/codacy-analysis-cli. - -name: Codacy Security Scan - -concurrency: - # This concurrency group ensures that only one Codacy analysis runs at a time - group: codacy-${{ github.ref_name }} - cancel-in-progress: true - -on: - push: - branches: ["main"] - pull_request: - # The branches below must be a subset of the branches above - branches: ["main"] - schedule: - - cron: "42 0 * * 1" - workflow_dispatch: - -permissions: - contents: read - -jobs: - codacy-security-scan: - permissions: - # for actions/checkout to fetch code - contents: read - # for github/codeql-action/upload-sarif to upload SARIF results - security-events: write - # only required for a private repository by - # github/codeql-action/upload-sarif to get the Action run status - actions: read - env: - CODACY_PROJECT_TOKEN: ${{ secrets.CODACY_PROJECT_TOKEN }} - name: Codacy Security Scan - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - # Checkout the repository to the GitHub Actions runner - - name: Checkout code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - persist-credentials: false - - - name: Set Codacy paths - run: | - set -euo pipefail - echo "CODACY_WORKDIR=$RUNNER_TEMP/codacy-src" >> "$GITHUB_ENV" - echo "CODACY_SARIF=$RUNNER_TEMP/results.sarif" >> "$GITHUB_ENV" - - - name: Prepare workspace copy without .git - run: | - set -euo pipefail - mkdir -p "$CODACY_WORKDIR" - rsync -a --delete --exclude '.git' ./ "$CODACY_WORKDIR/" - - - name: Verify Codacy config includes Python security tooling - run: | - set -euo pipefail - config="$CODACY_WORKDIR/.codacy.yml" - if [ ! -f "$config" ]; then - echo "::error::.codacy.yml not found in workspace copy ($config)" - exit 1 - fi - if ! grep -qE '^[[:space:]]*bandit:' "$config"; then - echo "::error::Bandit engine not configured in .codacy.yml; Python security scanning will be skipped." - exit 1 - fi - - # Execute Codacy Analysis CLI and generate a SARIF output with - # the security issues identified during the analysis - - name: Run Codacy Analysis CLI - if: ${{ env.CODACY_PROJECT_TOKEN != '' }} - id: codacy_analysis - uses: codacy/codacy-analysis-cli-action@562ee3e92b8e92df8b67e0a5ff8aa8e261919c08 # v4.4.7 - with: - # Check https://github.com/codacy/codacy-analysis-cli#project-token - # to get your project token from your Codacy repository. - project-token: ${{ env.CODACY_PROJECT_TOKEN }} - verbose: true - directory: ${{ env.CODACY_WORKDIR }} - output: ${{ env.CODACY_SARIF }} - format: sarif - skip-uncommitted-files-check: true - # Adjust severity of non-security issues - gh-code-scanning-compat: true - # Force 0 exit code to allow SARIF file generation - # This will handover control about PR rejection to the GitHub side - max-allowed-issues: 2147483647 - # Codacy can fail transiently on PRs (e.g. remote config/tools service outages). - # Keep PR checks non-blocking and continue to SARIF fallback/upload. - continue-on-error: ${{ github.event_name == 'pull_request' }} - - - name: Warn when Codacy token is unavailable on PR - if: ${{ github.event_name == 'pull_request' && env.CODACY_PROJECT_TOKEN == '' }} - run: | - echo "::warning::CODACY_PROJECT_TOKEN is unavailable for this pull_request." - echo "::warning::Skipping Codacy Analysis CLI and using SARIF fallback." - - - name: Warn when Codacy analysis fails on PR - if: ${{ always() && github.event_name == 'pull_request' && steps.codacy_analysis.outcome == 'failure' }} - run: | - echo "::warning::Codacy Analysis CLI failed on this pull_request run; continuing with SARIF fallback." - - # Validate SARIF output or create an empty fallback for upload - - name: Validate or create SARIF - if: always() - run: | - # Fail fast and surface errors clearly - set -euo pipefail - if [ -f "$CODACY_SARIF" ] && [ -s "$CODACY_SARIF" ]; then - echo "$CODACY_SARIF present; preselecting for upload and skipping split." - echo "SARIF_FILE=$CODACY_SARIF" >> "$GITHUB_ENV" - exit 0 - else - echo "No SARIF file found or file is empty: $CODACY_SARIF" - echo "Creating empty SARIF file to prevent workflow failure" - # Create empty SARIF file with proper schema - schema_url="https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json" - empty_sarif="$RUNNER_TEMP/sarif_empty.sarif" - { - echo '{' - echo " \"\$schema\": \"$schema_url\"," - echo ' "version": "2.1.0",' - echo ' "runs": []' - echo '}' - } > "$empty_sarif" - # Mark the empty SARIF for upload - echo "SARIF_FILE=$empty_sarif" >> "$GITHUB_ENV" - exit 0 - fi - - # Select SARIF file for upload - - name: Select SARIF file for upload - if: always() - run: | - set -euo pipefail - # Honor preselected SARIF_FILE from earlier steps (e.g., empty SARIF case) - if [ -n "${SARIF_FILE:-}" ]; then - echo "Preselected SARIF_FILE=$SARIF_FILE; not overriding." - exit 0 - fi - # First, try to upload the original SARIF file if it exists - if [ -f "$CODACY_SARIF" ] && [ -s "$CODACY_SARIF" ]; then - echo "Found $CODACY_SARIF, attempting upload..." - echo "SARIF_FILE=$CODACY_SARIF" >> "$GITHUB_ENV" - else - echo "No valid SARIF files found" - echo "SARIF_FILE=" >> "$GITHUB_ENV" - fi - continue-on-error: true - - # Upload the identified SARIF file - - name: Upload identified SARIF file - if: always() && env.SARIF_FILE != '' - uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 - with: - sarif_file: ${{ env.SARIF_FILE }} - continue-on-error: true diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index fae7222..da58973 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -13,16 +13,11 @@ on: # Least-privilege permissions permissions: contents: read - checks: write - pull-requests: write jobs: coverage: name: Code Coverage runs-on: ubuntu-latest - env: - CARGO_LLVM_COV_VERSION: "0.8.7" - JUST_VERSION: "1.51.0" steps: - name: Checkout repository uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -31,9 +26,29 @@ jobs: persist-credentials: false - name: Install Rust toolchain - uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1.16.1 + uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0 with: cache: true # toolchain/components are specified in rust-toolchain.toml + cache-bin: false + + - name: Read just version + id: just_version + run: | + version="$(grep '^just_version :=' justfile | cut -d '"' -f 2)" + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Install just + uses: taiki-e/cache-cargo-install-action@417450f3c33ee20393705369577571770643d4c7 # v3.0.7 + with: + tool: just@${{ steps.just_version.outputs.version }} + + - name: Export coverage tool versions + id: tool_versions + run: | + { + echo "CARGO_LLVM_COV_VERSION=$(just --evaluate cargo_llvm_cov_version)" + echo "CARGO_NEXTEST_VERSION=$(just --evaluate cargo_nextest_version)" + } >> "$GITHUB_OUTPUT" - name: Install LLVM coverage tools run: rustup component add llvm-tools-preview @@ -41,12 +56,12 @@ jobs: - name: Install cargo-llvm-cov uses: taiki-e/cache-cargo-install-action@417450f3c33ee20393705369577571770643d4c7 # v3.0.7 with: - tool: cargo-llvm-cov@${{ env.CARGO_LLVM_COV_VERSION }} + tool: cargo-llvm-cov@${{ steps.tool_versions.outputs.CARGO_LLVM_COV_VERSION }} - - name: Install just + - name: Install cargo-nextest uses: taiki-e/cache-cargo-install-action@417450f3c33ee20393705369577571770643d4c7 # v3.0.7 with: - tool: just@${{ env.JUST_VERSION }} + tool: cargo-nextest@${{ steps.tool_versions.outputs.CARGO_NEXTEST_VERSION }} - name: Run coverage run: | @@ -66,6 +81,11 @@ jobs: exit 2 fi echo "::notice::Coverage report generated successfully: $(wc -l < coverage/cobertura.xml) lines" + if [ ! -f target/nextest/coverage/test-results/junit.xml ]; then + echo "::error::nextest JUnit report was not generated." + exit 2 + fi + echo "::notice::Test results generated successfully." echo "::endgroup::" env: RUST_BACKTRACE: 1 @@ -81,9 +101,28 @@ jobs: env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + - name: Upload test results to Codecov + if: ${{ success() && hashFiles('target/nextest/coverage/test-results/junit.xml') != '' }} + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + with: + files: target/nextest/coverage/test-results/junit.xml + flags: unittests + name: nextest-results + report_type: test_results + fail_ci_if_error: false + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + - name: Archive coverage results uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: always() with: name: coverage-report path: coverage/ + + - name: Archive test results + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: always() + with: + name: test-results + path: target/nextest/coverage/test-results/ diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index fe37920..de677eb 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -25,7 +25,7 @@ jobs: strategy: fail-fast: false matrix: - language: ["actions", "rust"] + language: ["actions", "python", "rust"] steps: - name: Checkout repository @@ -35,27 +35,28 @@ jobs: - name: Install Rust toolchain if: matrix.language == 'rust' - uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1.16.1 + uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0 with: cache: true + cache-bin: false # toolchain, components, etc. are specified in rust-toolchain.toml - name: Initialize CodeQL if: matrix.language != 'rust' - uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 + uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 with: languages: ${{ matrix.language }} queries: security-extended - name: Initialize CodeQL (Rust) if: matrix.language == 'rust' - uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 + uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 with: languages: ${{ matrix.language }} build-mode: none queries: security-extended - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 + uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 with: category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/release-benchmarks.yml b/.github/workflows/release-benchmarks.yml index 2c211c0..71f235a 100644 --- a/.github/workflows/release-benchmarks.yml +++ b/.github/workflows/release-benchmarks.yml @@ -32,18 +32,23 @@ jobs: persist-credentials: false - name: Install Rust toolchain - uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1.16.1 + uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0 with: cache: false + - name: Validate benchmark inputs + run: > + cargo test --locked --features bench,exact + --test vs_linalg_inputs --test exact_bench_config + - name: Save release Criterion baseline env: RELEASE_TAG: ${{ github.event.release.tag_name }} run: | set -euo pipefail - cargo bench --features bench --bench vs_linalg -- --save-baseline "$RELEASE_TAG" - cargo bench --features bench,exact --bench exact -- --save-baseline "$RELEASE_TAG" + cargo bench --locked --features bench --bench vs_linalg -- --save-baseline "$RELEASE_TAG" + cargo bench --locked --features bench,exact --bench exact -- --save-baseline "$RELEASE_TAG" - name: Package release Criterion baseline id: package-baseline diff --git a/.github/workflows/rust-clippy.yml b/.github/workflows/rust-clippy.yml index a03c38a..1b3d3ac 100644 --- a/.github/workflows/rust-clippy.yml +++ b/.github/workflows/rust-clippy.yml @@ -33,9 +33,10 @@ jobs: persist-credentials: false - name: Install Rust toolchain - uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1.16.1 + uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0 with: cache: true # toolchain/components are specified in rust-toolchain.toml + cache-bin: false - name: Install clippy-sarif uses: taiki-e/cache-cargo-install-action@417450f3c33ee20393705369577571770643d4c7 # v3.0.7 @@ -59,7 +60,6 @@ jobs: clippy-sarif | \ tee rust-clippy-results.sarif | \ sarif-fmt - continue-on-error: true - name: Upload SARIF results if: >- @@ -69,7 +69,7 @@ jobs: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository ) - uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 with: sarif_file: rust-clippy-results.sarif category: "clippy" diff --git a/.github/workflows/semgrep-sarif.yml b/.github/workflows/semgrep-sarif.yml index a04981e..31f6418 100644 --- a/.github/workflows/semgrep-sarif.yml +++ b/.github/workflows/semgrep-sarif.yml @@ -23,9 +23,6 @@ permissions: security-events: write actions: read -env: - UV_VERSION: "0.11.19" - jobs: semgrep-sarif: name: Repository Rule SARIF Analysis @@ -37,16 +34,22 @@ jobs: with: persist-credentials: false + - name: Read uv version + id: uv_version + run: | + version="$(grep '^uv_version :=' justfile | cut -d '"' -f 2)" + echo "version=$version" >> "$GITHUB_OUTPUT" + - name: Install uv - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: - version: ${{ env.UV_VERSION }} + version: ${{ steps.uv_version.outputs.version }} - name: Run repository Semgrep rules id: semgrep run: | set +e - uv run semgrep \ + uv run --locked semgrep \ --metrics off \ --error \ --strict \ @@ -67,7 +70,7 @@ jobs: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository ) - uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 with: sarif_file: semgrep-results.sarif category: semgrep-repository-rules diff --git a/.markdownlint.json b/.markdownlint.json deleted file mode 100644 index 1bac847..0000000 --- a/.markdownlint.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "MD013": {"line_length": 160}, - "MD018": false, - "MD024": false, - "MD029": false, - "MD033": false, - "MD038": false, - "MD041": false, - "MD060": false, - "_comment": { - "note": "MD029 disabled - we prefer manual numbering over lazy numbering (1. 1. 1.) for better readability; MD060 disabled to avoid auto-reformatting tables" - } -} diff --git a/.python-version b/.python-version index 24ee5b1..6324d40 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.13 +3.14 diff --git a/.yamllint b/.yamllint index 4884a52..6e8af46 100644 --- a/.yamllint +++ b/.yamllint @@ -8,10 +8,10 @@ ignore: | rules: line-length: - max: 120 + max: 160 truthy: allowed-values: ['true', 'false', 'on', 'off', 'yes', 'no'] - # Prettier uses a single space before inline comments ("foo: bar # comment"). + # pretty_yaml uses a single space before inline comments ("foo: bar # comment"). comments: min-spaces-from-content: 1 comments-indentation: disable diff --git a/AGENTS.md b/AGENTS.md index ed213ac..85ec808 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,9 +6,10 @@ Essential guidance for AI assistants working in this repository. When making changes in this repo, prioritize (in order): -- Correctness -- Speed -- Coverage (but keep the code idiomatic Rust) +- Mathematical correctness and invariant preservation +- API stability and composability +- Idiomatic, well-tested Rust +- Performance within the documented scope ## Design Principles @@ -20,28 +21,48 @@ invariant over the convenient edit. ### Mathematical correctness as an invariant -- Exact paths (`*_exact`) never silently lose precision. When f64 output - is required, a separate `*_exact_f64` method returns - [`LaError::Overflow`] on unrepresentability — not a truncation. -- Any f64 operation that can accumulate rounding error either documents - its absolute bound (`det_errbound`, `ERR_COEFF_*`) or explicitly states +- Arbitrary-precision paths (`det_exact`, `solve_exact`) never silently lose + precision. Strict exact-to-`f64` methods (`det_exact_f64`, + `solve_exact_f64`) return [`LaError::Unrepresentable`] rather than rounding: + [`UnrepresentableReason::RequiresRounding`] means a finite `f64` is available + only after rounding, while [`UnrepresentableReason::NotFinite`] means no + finite `f64` can represent the result. The explicit `*_exact_rounded_f64` + methods opt into rounding but still return `NotFinite` when rounding cannot + produce a finite value. +- New or changed f64 operations that can accumulate rounding error document + their absolute bound (`det_errbound`, `ERR_COEFF_*`) or explicitly state that no bound is provided. -- Non-finite values (NaN, ±∞) always surface as - `LaError::NonFinite { row, col }` with source-location metadata. No - silent NaN propagation, no `unwrap_or(f64::NAN)`. +- Non-finite matrix/vector inputs and arithmetic intermediates surface as + `LaError::NonFinite` with typed `NonFiniteOrigin` and `NonFiniteLocation` + metadata; do not silently propagate NaN or use `unwrap_or(f64::NAN)`. + Computed failures name their `ArithmeticOperation`, while raw matrix/vector + values use input origins and exact source locations. +- Exact singularity and tolerance-based rejection remain distinguishable through + `SingularityReason`; numerical failures preserve the factorization, observed + pivot magnitude, and tolerance. +- Parse raw tolerances through `Tolerance::try_new`; failures use typed + `InvalidToleranceReason`. Exact-to-f64 output failures use + `LaError::Unrepresentable`. - Algorithms cite their source (Shewchuk, Bareiss, Goldberg, …) via `REFERENCES.md` and document their conditioning behaviour. +- `Matrix::det()` uses closed forms through D=4. Its D≥5 zero-tolerance LU + fallback preserves `LaError::Singular` when elimination cannot produce a + non-zero pivot; floating-point factorization must not relabel that numerical + failure as an exact `0.0`. Use the exact determinant APIs when exact + singularity classification is required. ### Public-API stability -- Error enums are `#[non_exhaustive]`; public wrapper types are +- Public error enums and struct-style error variants are `#[non_exhaustive]`; + downstream matches include wildcard arms and `..`. Public wrapper types are `#[must_use]`. - New functionality is additive by default: use the prelude for ergonomic re-exports, and avoid churn for its own sake. - Pre-1.0 semver: any `0.x.y` release may include breaking API changes when - they materially improve correctness, orthogonality, or long-term API clarity. - Do not keep compatibility aliases that weaken the public model; document - intentional breaks clearly in release notes and commit messages. + they materially improve correctness, orthogonality, performance, or + long-term API clarity. Do not keep compatibility aliases that weaken the + public model; document intentional breaks clearly in release notes and commit + messages. - Do **not** automatically update the library version in `Cargo.toml`, `Cargo.lock`, README dependency snippets, or related docs during ordinary feature, fix, review, or hygiene work. Version bumps are maintainer-driven @@ -61,9 +82,13 @@ invariant over the convenient edit. - `const fn` wherever possible — not for micro-optimisation, but because compile-time evaluation forces a pure function of inputs. -- `Result<_, LaError>` for all fallible operations. Panics are reserved - for debug-only precondition violations (e.g. LDLT symmetry check) and - documented on the method. +- Use `Result<_, LaError>` for all fallible operations. Public library code + must not panic on user input. +- Panics are reserved for truly unreachable internal invariant violations and + must be documented when callers could observe them. +- Validation belongs to the lowest type or module that owns the invariant. + Higher-level APIs preserve and propagate typed `LaError` values rather than + stringifying them. - Public APIs that return plain values must be genuinely infallible for all representable inputs. If callers can observe failure, return `Result` or `Option` instead of relying on `panic!`, `assert!`, `unwrap`, or `expect`. @@ -93,14 +118,24 @@ invariant over the convenient edit. `det_errbound`). Problems outside this scope — large or dynamic dimensions, sparse matrices, parallelism — belong to `nalgebra` or `faer` (see anti-goals in `README.md`). -- Within scope, prefer allocation-free paths, `const fn` wherever the - inputs allow, and FMA where applicable. Validate any performance - claim against the `bench-vs-linalg` (vs nalgebra / faer) or - `bench-exact` (exact-arithmetic) suites before relying on it. +- Within scope, prefer allocation-free paths, `const fn` wherever the inputs + allow, and FMA where applicable. +- Performance-sensitive changes require comparable before-and-after evidence + from the same representative benchmark command, inputs, features, and + environment. Use `bench-vs-linalg` (vs nalgebra / faer) or `bench-exact` + (exact arithmetic), as appropriate. +- Preserve benchmark provenance and distinguish descriptive point-estimate + ratios from statistically supported performance claims. Marginal Criterion + interval separation is not a paired confidence interval for the change. +- Measurements from runs that violate documented invariants are invalid + performance evidence. ### Testing mirrors the principles - Unit tests cover known values, error paths, and dimension-generic +- Error-path tests match the exact variant, typed reason/origin/location, and + structured fields; do not replace an unexpected error with a numeric sentinel + or assert only `is_err()`. correctness across D=2..=5 (see **Dimension Coverage** below). - Proptests under `tests/proptest_*.rs` cover algebraic invariants (round-trip, residual, sign agreement) — not just "does it not panic". @@ -120,6 +155,8 @@ invariant over the convenient edit. `git --no-pager log`, `git --no-pager show`, `git --no-pager blame`) to inspect changes/history - **ALWAYS** use `git --no-pager` when reading git output - Suggest git commands that modify version control state for the user to run manually +- Do not revert user changes. The worktree may be dirty; preserve unrelated + changes and work around overlapping edits. - When suggesting branch names, prefer `{type}/{issue}-descriptor-or-two`, e.g. `fix/307-topology-validation`, `perf/315-bench-profile`, or `doc/329-branch-guidance`. If an environment requires an owner/tool prefix, keep this structure after the prefix, e.g. `codex/fix/307-topology-validation`. @@ -136,21 +173,30 @@ When user requests commit message generation: ### Code Quality +- **Unsafe Rust is forbidden.** Keep the manifest-level `unsafe_code = "forbid"` + lint and crate/module `#![forbid(unsafe_code)]` enforcement intact. - **ALLOWED**: Run formatters/linters: `cargo fmt`, `cargo clippy`, `cargo doc`, `taplo fmt`, `taplo lint`, - `uv run ruff check --fix`, `uv run ruff format`, `shfmt -w`, `shellcheck -x`, `rumdl`, `dprint`, + `uv run --locked ruff check --fix`, `uv run --locked ruff format`, `rumdl`, `dprint`, `typos`, `actionlint` - **NEVER**: Use `sed`, `awk`, `perl` for code edits -- **ALWAYS**: Use `edit_files` tool for edits (and `create_file` for new files) +- **ALWAYS**: Use the provided structured patch/edit tool for manual edits. +- **FALLBACK**: Direct patch rejects and backup files outside the repository + and clean them immediately. - **EXCEPTION**: Shell text tools OK for read-only analysis only ### Validation +- Select validators proportionally to the changed surfaces. Use focused recipes + for documentation, configuration, Python, test-only, benchmark-only, or + example-only changes; compose each relevant validator once when a patch spans + multiple surfaces. Core Rust or public-behavior changes require final + `just ci`. - **JSON**: Validate with `jq empty .json` after editing (or `just validate-json`) - **TOML**: Lint/format with taplo: `just toml-lint`, `just toml-fmt-check`, `just toml-fmt` - **GitHub Actions**: Validate workflows with `just action-lint` (uses `actionlint`) - **Spell check**: Run `just spell-check` after editing; add legitimate technical terms to `typos.toml` under `[default.extend-words]` -- **Shell scripts**: Run `shfmt -w scripts/*.sh` and `shellcheck -x scripts/*.sh` after editing +- **Shell scripts**: Run `just shell-fix` and `just shell-check` after editing - **YAML**: Use `just yaml-lint` and `just yaml-fix` - **Markdown**: Use `just markdown-check` and `just markdown-fix` @@ -210,9 +256,9 @@ testable. #### Reference examples -- `src/matrix.rs` — `gen_public_api_matrix_tests!` -- `src/lu.rs` — `gen_public_api_pivoting_solve_and_det_tests!`, `gen_public_api_tridiagonal_smoke_solve_and_det_tests!` -- `src/ldlt.rs` — `gen_public_api_ldlt_identity_tests!`, `gen_public_api_ldlt_diagonal_tests!` +- `src/matrix.rs` — `gen_matrix_tests!` +- `src/lu.rs` — `gen_pivoting_solve_and_det_tests!`, `gen_tridiagonal_smoke_solve_and_det_tests!` +- `src/ldlt.rs` — `gen_ldlt_identity_tests!`, `gen_ldlt_diagonal_tests!` - `src/exact.rs` — `gen_det_exact_tests!`, `gen_det_exact_f64_tests!`, `gen_solve_exact_tests!`, `gen_solve_exact_f64_tests!` #### When single-dimension tests are acceptable @@ -223,7 +269,8 @@ macro-ification. ### Python -- Use `uv run` for all Python scripts (never `python3` or `python` directly) +- Python support tooling targets Python 3.14. +- Use `uv run --locked` for all Python scripts (never `python3` or `python` directly) - Use pytest for tests (not unittest) - **Type checking**: `just python-check` includes type checking (blocking - all code must pass type checks) - Add type hints to new code @@ -235,22 +282,24 @@ just check # Lint/validators (non-mutating) just fix # Apply formatters/auto-fixes (mutating) just ci # Full CI simulation (checks + tests + examples + bench compile) just test # Lib + doc tests (fast) -just test-all # All tests (Rust + Python) +just test-all # All tests (Rust, benchmark inputs, and Python) just examples # Run all examples ``` ### Detailed Command Reference -- All tests (Rust + Python): `just test-all` -- Benchmark comparison (generate `docs/PERFORMANCE.md`): `just bench-compare` (snapshot) or `just bench-compare v0.4.1` (vs baseline) -- Benchmarks: `cargo bench` (or `just bench`) +- All tests (Rust, exact-feature doctests, benchmark-input smoke tests, and Python): `just test-all` +- Benchmark comparison (local report): `just bench-compare [baseline] [suite] [scope]` +- Benchmarks: `cargo bench --locked --features bench` (or `just bench`) - Benchmarks (exact arithmetic): `just bench-exact` - Benchmarks (la-stack vs nalgebra/faer): `just bench-vs-linalg [filter]` (full run) or `just bench-vs-linalg-quick [filter]` (reduced) -- Benchmarks (plot vs_linalg CSV/SVG): `just plot-vs-linalg [metric] [stat] [sample] [update_readme]` / `just plot-vs-linalg-readme [metric] [stat] [sample] [update_readme]` +- Benchmarks (plot vs_linalg CSV/SVG/JSON provenance): `just plot-vs-linalg [metric] [stat] [sample] [log_y]`; + publish a freshly gated full run to README with + `just plot-vs-linalg-readme [metric] [stat] [sample] [log_y]` - Benchmarks (save baseline): `just bench-save-baseline v0.4.1` - Build (debug): `cargo build` (or `just build`) - Build (release): `cargo build --release` (or `just build-release`) -- Changelog (generate full): `just changelog` (runs `git-cliff -o CHANGELOG.md` + post-processing) +- Changelog (generate full): `just changelog` (generates, post-processes, archives, and formats changelog files) - Changelog (prepend unreleased): `just changelog-unreleased v0.4.1` - Coverage (CI XML): `just coverage-ci` - Coverage (HTML): `just coverage` @@ -259,17 +308,19 @@ just examples # Run all examples - Fast Rust tests (lib + doc): `just test` - Format: `cargo fmt` (or `just fmt`) - Integration tests: `just test-integration` +- Benchmark-input smoke tests: `just test-bench-inputs` - Lint (Clippy): `cargo clippy --all-targets --all-features -- -D warnings` (or `just clippy`) - Lint (Clippy, exact feature): `cargo clippy --features exact --all-targets -- -D warnings` (or `just clippy-exact`) - Lint/validate: `just check` +- Cargo manifest/lockfile synchronization: `just cargo-lock-check` +- Unused dependency check: `just unused-deps` (uses `cargo-machete`) - Pre-commit validation / CI simulation: `just ci` (lint + tests + examples + bench compile) -- Python setup: `uv sync --group dev` (or `just python-sync`) +- Python setup from the lockfile: `uv sync --locked --group dev` (or `just python-sync`) - Python tests: `just test-python` -- Run a single test (by name filter): `cargo test solve_2x2_basic` (or the full path: `cargo test lu::tests::solve_2x2_basic`) - Cargo accepts only one positional test filter. To run multiple focused - filters, run separate `cargo test ` commands rather than passing - multiple filter arguments. -- Run exact-feature tests: `cargo test --features exact --verbose` (or `just test-exact`) +- Run one runnable test by substring: `cargo nextest run solve_2x2_basic` + - For an exact full-path match, use `cargo nextest run -- --exact lu::tests::solve_2x2_basic`. +- Run exact-feature tests: `cargo nextest run --profile ci --features exact --verbose` + (or `just test-exact`, which also runs exact-feature doctests) - Run examples: `just examples` (or `cargo run --example det_5x5` / `cargo run --example solve_5x5` / `cargo run --example ldlt_solve_3x3` / `cargo run --example const_det_4x4` / `cargo run --features exact --example exact_det_3x3` / @@ -318,7 +369,9 @@ When using `gh` to view issues, PRs, or other GitHub objects: Use the `gh` CLI to read, create, and edit issues: - **Read**: `gh issue view --json title,body,labels,milestone | cat` -- **List**: `gh issue list --json number,title,labels --jq '.[] | "#\(.number) \(.title)"' | cat` (add `--label enhancement`, `--milestone v0.4.1`, etc. to filter) +- **List**: + `gh issue list --json number,title,labels --jq '.[] | "#\(.number) \(.title)"' | cat` + (add `--label enhancement`, `--milestone v0.4.1`, etc. to filter) - **Create**: `gh issue create --title "..." --body "..." --label enhancement --label rust` - **Edit**: `gh issue edit --add-label "..."`, `--milestone "..."`, `--title "..."` - **Comment**: `gh issue comment --body "..."` @@ -343,52 +396,91 @@ When creating or updating issues: ## Feature flags - `exact` — enables exact arithmetic methods via `BigRational`: - `det_exact()`, `det_exact_f64()`, `det_sign_exact()`, `solve_exact()`, and `solve_exact_f64()`. - Re-exports `BigInt`, `BigRational`, and the commonly needed `num-traits` - items (`FromPrimitive`, `ToPrimitive`, and `Signed`) from the crate root and prelude - (so consumers get usable `from_f64` / `to_f64` / `is_positive` etc. without adding - `num-bigint` / `num-rational` / `num-traits` as their own deps). - Gates `src/exact.rs`, additional tests, and the `exact_det_3x3`/`exact_sign_3x3`/`exact_solve_3x3` examples. - Clippy, doc builds, and test commands have dedicated `--features exact` variants. + `det_exact()`, `det_exact_f64()`, `det_exact_rounded_f64()`, + `det_sign_exact()`, `solve_exact()`, `solve_exact_f64()`, and + `solve_exact_rounded_f64()`. `det_sign_exact()` is infallible for every + finite-by-construction `Matrix`; the exact-value, conversion, and solve APIs + remain fallible for their genuine scale, representation, and singularity + failures. `ExactF64Conversion` converts an already-computed + exact determinant or solution under the strict or rounded contract without + rerunning exact elimination. Feature-gated re-exports include + `DeterminantSign`, `ExactF64Conversion`, `BigInt`, `BigRational`, and the + commonly needed `num-traits` items (`FromPrimitive`, `ToPrimitive`, and `Signed`). + `UnrepresentableReason` and the other typed `LaError` category enums remain + available without `exact`; callers should not need optional arithmetic + dependencies merely to match errors. + Gates `src/exact.rs`, additional tests, and the exact-arithmetic examples. + Clippy, doc builds, and test commands have dedicated `--features exact` + variants. +- `bench` — cfg-only gate required by the benchmark targets and + `tests/vs_linalg_inputs.rs`. Benchmark libraries remain dev-dependencies. ## Code structure (big picture) - This is a single Rust *library crate* (no `src/main.rs`). The crate root is `src/lib.rs`. - The linear algebra implementation is split across: - - `src/lib.rs`: crate root + shared items (`LaError`, `DEFAULT_SINGULAR_TOL`) + re-exports + - `src/lib.rs`: crate root, public module wiring, and re-exports + - `src/error.rs`: `LaError` plus typed singularity, non-finite, + positive-semidefinite, tolerance, factorization, arithmetic-operation, and + exact-conversion categories + - `src/tolerance.rs`: validated singular-tolerance policy - `src/vector.rs`: `Vector` (`[f64; D]`) - - `src/matrix.rs`: `Matrix` (`[[f64; D]; D]`) + helpers (`get`, `set`, `inf_norm`, `det`, `det_direct`) + - `src/matrix.rs`: `Matrix` (`[[f64; D]; D]`) + helpers (`get`, `try_get`, `set`, `inf_norm`, `det`, `det_direct`) - `src/lu.rs`: `Lu` factorization with partial pivoting (`solve`, `det`) - `src/ldlt.rs`: `Ldlt` factorization without pivoting for symmetric SPD/PSD matrices (`solve`, `det`) - `src/exact.rs`: exact arithmetic behind `features = ["exact"]`: - - Determinants: `det_exact()`, `det_exact_f64()`, `det_sign_exact()` via integer-only - Bareiss in `BigInt` (`bareiss_det_int`); `det_sign_exact()` adds a Shewchuk-style - f64 filter for fast sign resolution - - Linear system solve: `solve_exact()`, `solve_exact_f64()` via Gaussian elimination - with first-non-zero pivoting in `BigRational` + - Determinants: `det_exact()`, strict `det_exact_f64()`, rounded + `det_exact_rounded_f64()`, and `det_sign_exact()` via a scaled `BigInt` + determinant core (`exact_det_int_finite`): direct expansions for D≤4 and + fraction-free Bareiss elimination for D≥5. `det_sign_exact()` infallibly + returns a `DeterminantSign` and adds a Shewchuk-style f64 filter for fast + sign resolution in D≤4 + - Exact-to-`f64` conversion failures retain an `UnrepresentableReason` so + callers can distinguish required rounding from non-finite output + - Linear system solve: `solve_exact()`, strict `solve_exact_f64()`, and + rounded `solve_exact_rounded_f64()` use fraction-free Bareiss forward + elimination in `BigInt` with first-non-zero pivoting, followed by + `BigRational` back-substitution - Rust unit tests are inline `#[cfg(test)]` modules in each `src/*.rs` file. - Property-based tests live under `tests/proptest_*.rs` (uses the `proptest` dev-dependency): `proptest_matrix.rs`, `proptest_vector.rs`, `proptest_factorizations.rs`, and `proptest_exact.rs` (the last gated on the `exact` feature). They run as integration tests via `just test-integration` or `just test-all`. -- Python tests live in `scripts/tests/` and run via `just test-python` (`uv run pytest`). +- Python tests live in `scripts/tests/` and run via `just test-python` (`uv run --locked pytest`). - The public API re-exports these items from `src/lib.rs`. - The `justfile` defines all dev workflows (see `just --list`). - Dev-only benchmarks live in `benches/vs_linalg.rs` (Criterion + nalgebra/faer comparison) and `benches/exact.rs` (exact arithmetic across D=2–5, plus adversarial-input groups `exact_near_singular_3x3`, `exact_large_entries_3x3`, `exact_hilbert_4x4`, `exact_hilbert_5x5`). -- Python scripts under `scripts/`: - - `bench_compare.py`: exact-arithmetic benchmark comparison across releases (generates `docs/PERFORMANCE.md`) - - `criterion_dim_plot.py`: benchmark plotting (CSV + SVG + README table update) + Exact Criterion helpers accept only `ValidatedExactInput`, so independent + oracle validation is a type-checked prerequisite outside timed closures. +- Key Python scripts under `scripts/`: + - `bench_compare.py`: exact and vs-linalg Criterion comparison reports under + `target/bench-reports/` + - `archive_performance.py`: promote and archive curated release performance reports + - `criterion_dim_plot.py`: benchmark plotting and fail-closed README publication + (CSV + SVG + JSON provenance + README table) - `tag_release.py`: annotated tag creation from CHANGELOG.md sections - - `postprocess_changelog.py`: strips trailing blank lines from git-cliff output + - `archive_changelog.py`: archive completed changelog minor series + - `postprocess_changelog.py`: inject summaries, reflow and normalize Markdown, + and strip trailing blank lines from git-cliff output - `subprocess_utils.py`: safe subprocess wrappers for git commands - Release workflow is documented in `docs/RELEASING.md`. +## Agent Expectations + +- Prefer small, focused patches and the simplest maintainable correct solution. +- Search existing documentation and nearby code before inventing conventions. +- Fix small, clearly related issues discovered in a touched area when doing so + improves correctness, clarity, tests, or maintainability. +- Avoid broad mechanical churn; separate repository-wide cleanup from focused + work. + ## Publishing note -- If you publish this crate to crates.io, prefer updating documentation *before* publishing a new version (doc-only changes still require a version bump on crates.io). +- If you publish this crate to crates.io, prefer updating documentation + *before* publishing a new version (doc-only changes still require a version bump on crates.io). ## Editing tools policy diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 611d9fa..4a77b73 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,41 +4,143 @@ Thanks for helping improve `la-stack`. This crate is intentionally small and invariant-heavy, so changes should preserve mathematical correctness, API clarity, and the fixed-dimension stack-allocation model. -## Workflow +## Getting Started + +Install Rust through [rustup](https://rustup.rs/), Git, Python 3.14, +[uv](https://docs.astral.sh/uv/), and `just`. Install `just` from its locked +dependency graph: + +```bash +cargo install --locked just +``` + +Set up the remaining development tools and validate the checkout: ```bash -cargo install just -just setup # install/verify dev tools + sync Python deps -just check # lint/validate (non-mutating) -just fix # apply auto-fixes (mutating) -just ci # lint + tests + examples + bench compile +just setup # install or verify dev tools and sync Python dependencies +just check # lint and validate without changing files +just ci # run the comprehensive local CI path ``` -The repository uses Rust-native tooling for documentation and config checks: -`rumdl` for Markdown, `dprint` with `pretty_yaml` for YAML, `taplo` for TOML, -and `typos` for spelling. GitHub Actions references are SHA-pinned, restricted -to an explicit allowlist, and kept with readable version comments for review. +Use `just fix` when you intentionally want formatters and automatic fixes to +change files. Run `just --list` for the full command surface. + +The repository uses `cargo-nextest` for runnable Rust tests, `cargo-machete` +for unused-dependency checks, and `just cargo-lock-check` to verify that the +committed Cargo lockfile matches the manifest. `rumdl` checks Markdown, +`dprint` plus `yamllint` check YAML and CFF, `taplo` checks TOML, and `typos` +checks spelling. Python support tooling is locked with `uv` and checked by +Ruff, Ty, and Semgrep. GitHub Actions references are SHA-pinned, restricted to +an explicit allowlist, and kept with readable version comments for review. CI runs `just ci` on Ubuntu, macOS, and Windows to keep platform coverage aligned with the local comprehensive validation path. -## Performance checks +## Contributor Workflow + +Before starting work, check existing GitHub issues for related bug reports, +feature requests, or design discussions. Open an issue before substantial API, +algorithm, invariant, dependency, or performance changes so the expected +behavior and mathematical context can be agreed on first. A focused typo or +similarly mechanical correction does not require advance discussion. + +Human contributors should create focused branches. Prefer +`{type}/{issue}-descriptor-or-two`, using the issue number when one exists and +a concise type such as `fix`, `feat`, `perf`, `doc`, `test`, `refactor`, `ci`, +`build`, `chore`, or `style`: + +```bash +git switch -c fix/307-exact-conversion +git switch -c perf/315-lu-solve +git switch -c doc/329-branch-guidance +``` + +Keep each change scoped to one coherent purpose. Update tests and documentation +with the behavior they support, avoid unrelated formatting churn, and cite +relevant literature for numerical or algorithmic work. Automation and AI +assistants must stop before version-control mutations and release operations; a +human contributor performs and reviews commits, pushes, tags, and releases. + +## Project References + +Use the existing canonical documents instead of duplicating their guidance: + +| Topic | Canonical reference | +|-------|---------------------| +| Agent rules and repository invariants | [`AGENTS.md`](AGENTS.md) | +| User-facing API, examples, and project scope | [`README.md`](README.md) | +| Package metadata, features, and dependencies | [`Cargo.toml`](Cargo.toml) | +| Commands and validation workflow | [`justfile`](justfile), `just --list` | +| Python support tooling | [`scripts/README.md`](scripts/README.md) | +| Benchmark methodology and baselines | [`docs/BENCHMARKING.md`](docs/BENCHMARKING.md) | +| Coverage workflow and reports | [`docs/COVERAGE.md`](docs/COVERAGE.md) | +| Citations and bibliography | [`CITATION.cff`](CITATION.cff), [`REFERENCES.md`](REFERENCES.md) | +| Security reporting and support | [`SECURITY.md`](SECURITY.md) | +| Releases and changelog generation | [`docs/RELEASING.md`](docs/RELEASING.md), [`CHANGELOG.md`](CHANGELOG.md) | + +## Commit Message Format + +Use conventional commits so the release tooling can generate useful changelog +entries: + +```text +type(scope): short description -Performance-sensitive changes should compare the current tree against the -latest published release: +- Explain the important behavior or maintenance change. +- Include issue or pull-request references when useful. +``` + +Common types are `feat`, `fix`, `perf`, `refactor`, `build`, `ci`, `docs`, +`test`, `style`, and `chore`. Mark incompatible public API or behavior changes +explicitly: + +```text +feat!: redesign exact conversion API + +BREAKING CHANGE: strict conversion now returns a typed unrepresentable error. +``` + +Pull-request titles should use the same conventional format because merge +commits feed the generated changelog. + +## Submitting Changes + +Open a pull request with a descriptive conventional title and a concise +summary covering: + +- **Problem:** the issue, behavior, or invariant the change addresses. +- **Solution:** how the implementation addresses it and the important design + choices. +- **Testing:** the validators, tests, and feature combinations that were run. +- **Performance:** comparable before-and-after measurements for + performance-sensitive work, or why measurement is not applicable. + +Use the same machine, toolchain, features, inputs, and benchmark configuration +for before-and-after measurements. The local release comparison is: ```bash just performance-local ``` -This writes `target/bench-reports/performance.md` without changing committed -release docs. Regressions are worth treating as design feedback: if a slowdown -is intentional, document the correctness, API clarity, or composability benefit +It writes `target/bench-reports/performance.md` without changing committed +release documentation. Treat regressions as design feedback. If a slowdown is +intentional, explain the correctness, API clarity, or composability benefit that justifies it. -For coverage commands and report locations, see [`docs/COVERAGE.md`](docs/COVERAGE.md). -For benchmark methodology, see [`docs/BENCHMARKING.md`](docs/BENCHMARKING.md). -For the full set of developer commands, run `just --list`. +Core Rust, Cargo, or public-behavior changes must pass `just ci` before a pull +request is ready. Documentation, configuration, Python, test-only, +benchmark-only, and example-only changes use the matching focused validators +documented in [`AGENTS.md`](AGENTS.md). Pull requests are reviewed for correctness, +mathematical accuracy, tests, documentation, style, dependency impact, and +performance. Non-substantive whitespace or formatting churn may be declined +unless it is part of an intentional tooling cleanup. + +## Types of Contributions + +Bug fixes, new features, documentation, tests, benchmarks, performance work, +and infrastructure improvements are welcome. For algorithmic or numerical +work, update [`REFERENCES.md`](REFERENCES.md) as needed and document the +assumptions, invariants, conditioning behavior, and known limitations. ## AI-Assisted Development @@ -58,8 +160,35 @@ Portions of this library were developed with the assistance of these tools: - [KiloCode](https://kilocode.ai/) - [WARP](https://www.warp.dev) -All code was written and/or reviewed and validated by the author. +All AI-assisted work must be reviewed and validated by a human maintainer +before it is merged. For full tool citation metadata, see the [AI-Assisted Development Tools](REFERENCES.md#ai-assisted-development-tools) section of [`REFERENCES.md`](REFERENCES.md). + +## Release Process + +Releases are deliberate, maintainer-driven work. Ordinary feature, fix, +review, and hygiene changes must not update package versions, version-pinned +dependency snippets, citation release dates, generated changelogs, checked-in +release benchmark reports, tags, or other release artifacts. The maintainer +performs every version bump and release manually by +following [`docs/RELEASING.md`](docs/RELEASING.md). Do not substitute an +automated or abbreviated release path. + +## Getting Help + +Use GitHub Issues for bug reports, feature requests, design questions, and +general project help. Search existing issues before opening a new one. For a +bug, include: + +- The crate version or commit and enabled features. +- Rust version, operating system, and relevant development-tool versions. +- A minimal matrix, vector, or code reproduction when possible. +- Expected and actual behavior, including the complete error or panic output. +- The validation commands already run. +- Performance measurements and benchmark configuration when relevant. + +Report suspected vulnerabilities privately through the process in +[`SECURITY.md`](SECURITY.md), not in a public issue. diff --git a/Cargo.lock b/Cargo.lock index b0dcf0c..ed5ce58 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -28,9 +28,9 @@ checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" [[package]] name = "anstyle" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "approx" @@ -43,9 +43,9 @@ dependencies = [ [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "bit-set" @@ -64,34 +64,34 @@ checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" [[package]] name = "bitflags" -version = "2.10.0" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "bumpalo" -version = "3.19.0" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytemuck" -version = "1.24.0" +version = "1.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" +checksum = "d6aedf8ae72766347502cf3cb4f41cf5e9cc37d28bee90f1fdaaae15f9cf9424" dependencies = [ "bytemuck_derive", ] [[package]] name = "bytemuck_derive" -version = "1.10.2" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +checksum = "f65693059b6b9c588b9f62fed1cedbf0a8b805631457ea162d68f0de186f3de5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.118", ] [[package]] @@ -108,9 +108,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.2.49" +version = "1.2.66" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215" +checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" dependencies = [ "find-msvc-tools", "shlex", @@ -151,18 +151,18 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.53" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", ] [[package]] name = "clap_builder" -version = "4.5.53" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ "anstyle", "clap_lex", @@ -170,9 +170,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.6" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "criterion" @@ -211,9 +211,9 @@ dependencies = [ [[package]] name = "crossbeam-deque" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" dependencies = [ "crossbeam-epoch", "crossbeam-utils", @@ -221,18 +221,18 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] name = "crunchy" @@ -264,9 +264,9 @@ checksum = "e1d926b4d407d372f141f93bb444696142c29d32962ccbd3531117cf3aa0bfa9" [[package]] name = "either" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] name = "enum-as-inner" @@ -277,7 +277,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.118", ] [[package]] @@ -306,7 +306,7 @@ checksum = "3bf679796c0322556351f287a51b49e48f7c4986e727b5dd78c972d30e2e16cc" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.118", ] [[package]] @@ -327,9 +327,9 @@ dependencies = [ [[package]] name = "faer" -version = "0.24.0" +version = "0.24.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02d2ecfb80b6f8b0c569e36988a052e64b14d8def9d372390b014e8bf79f299a" +checksum = "5ab6df3dd147fe8d702a288b95bcd8fcc499ab572fc80da6828f60cd4d524d67" dependencies = [ "bytemuck", "dyn-stack", @@ -365,15 +365,15 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "find-msvc-tools" -version = "0.1.5" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "fnv" @@ -381,6 +381,30 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + [[package]] name = "gemm" version = "0.19.0" @@ -500,9 +524,9 @@ dependencies = [ [[package]] name = "generativity" -version = "1.1.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5881e4c3c2433fe4905bb19cfd2b5d49d4248274862b68c27c33d9ba4e13f9ec" +checksum = "d2c81fb5260e37854d09d5c87183309fd8c555b75289427884b25660bc87a85e" [[package]] name = "getrandom" @@ -512,15 +536,26 @@ checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + [[package]] name = "glam" -version = "0.30.9" +version = "0.30.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd47b05dddf0005d850e5644cae7f2b14ac3df487979dbfff3b56f20b1a6ae46" +checksum = "19fc433e8437a212d1b6f1e68c7824af3aed907da60afa994e7f542d18d12aa9" [[package]] name = "glam" @@ -536,9 +571,9 @@ checksum = "f70749695b063ecbf6b62949ccccde2e733ec3ecbbd71d467dca4e5c6c97cca0" [[package]] name = "glam" -version = "0.33.0" +version = "0.33.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fb167719045debebe9f532320accc7b5c993c5a3b813f5696a11d5ca7bdc57b" +checksum = "7f22fb22f065b308be0d8724e3706c7fa3fc2a6c7d6899df4cad7860e7a75436" [[package]] name = "half" @@ -587,17 +622,18 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.15" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.83" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ - "once_cell", + "cfg-if", + "futures-util", "wasm-bindgen", ] @@ -618,21 +654,21 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.178" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libm" -version = "0.2.15" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "linux-raw-sys" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "matrixmultiply" @@ -646,9 +682,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.7.6" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "nalgebra" @@ -657,12 +693,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adc43a60c217b0c6ff46e47f26911015ad8d2e5a8be1af668c67e370d99a4346" dependencies = [ "approx", - "glam 0.30.9", + "glam 0.30.10", "glam 0.31.1", "glam 0.32.1", - "glam 0.33.0", + "glam 0.33.2", "matrixmultiply", - "nalgebra-macros", "num-complex", "num-rational", "num-traits", @@ -670,17 +705,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "nalgebra-macros" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "973e7178a678cfd059ccec50887658d482ce16b0aa9da3888ddeab5cd5eb4889" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.111", -] - [[package]] name = "nano-gemm" version = "0.2.2" @@ -753,9 +777,9 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", @@ -813,9 +837,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "oorandom" @@ -845,6 +869,12 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + [[package]] name = "plotters" version = "0.3.7" @@ -896,9 +926,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.103" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] @@ -924,9 +954,9 @@ dependencies = [ [[package]] name = "pulp" -version = "0.22.2" +version = "0.22.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e205bb30d5b916c55e584c22201771bcf2bad9aabd5d4127f38387140c38632" +checksum = "046aa45b989642ec2e4717c8e72d677b13edd831a4d3b6cf37d9a3e54912496a" dependencies = [ "bytemuck", "cfg-if", @@ -941,9 +971,9 @@ dependencies = [ [[package]] name = "pulp-wasm-simd-flag" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40e24eee682d89fb193496edf918a7f407d30175b2e785fe057e4392dfd182e0" +checksum = "1d8f70e07b9c3962945a74e59ca1c511bba65b6419468acc217c457d93f3c740" [[package]] name = "qd" @@ -965,9 +995,9 @@ checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" [[package]] name = "quote" -version = "1.0.42" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] @@ -978,6 +1008,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.9.4" @@ -1000,11 +1036,11 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.9.3" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ - "getrandom", + "getrandom 0.3.4", ] [[package]] @@ -1033,9 +1069,9 @@ checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" [[package]] name = "rayon" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" dependencies = [ "either", "rayon-core", @@ -1059,9 +1095,9 @@ checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430" [[package]] name = "regex" -version = "1.12.2" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +checksum = "2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2" dependencies = [ "aho-corasick", "memchr", @@ -1071,9 +1107,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.13" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db" dependencies = [ "aho-corasick", "memchr", @@ -1082,15 +1118,15 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.8" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "rustix" -version = "1.1.2" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ "bitflags", "errno", @@ -1101,9 +1137,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "rusty-fork" @@ -1117,12 +1153,6 @@ dependencies = [ "wait-timeout", ] -[[package]] -name = "ryu" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" - [[package]] name = "safe_arch" version = "1.0.0" @@ -1174,27 +1204,27 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.118", ] [[package]] name = "serde_json" -version = "1.0.145" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", - "ryu", "serde", "serde_core", + "zmij", ] [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "simba" @@ -1208,6 +1238,12 @@ dependencies = [ "wide", ] +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "syn" version = "1.0.109" @@ -1221,9 +1257,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.111" +version = "2.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" dependencies = [ "proc-macro2", "quote", @@ -1246,12 +1282,12 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.23.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys", @@ -1274,7 +1310,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.118", ] [[package]] @@ -1289,9 +1325,9 @@ dependencies = [ [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unarray" @@ -1301,9 +1337,9 @@ checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" [[package]] name = "unicode-ident" -version = "1.0.22" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "version_check" @@ -1332,18 +1368,18 @@ dependencies = [ [[package]] name = "wasip2" -version = "1.0.1+wasi-0.2.4" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.106" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" dependencies = [ "cfg-if", "once_cell", @@ -1354,9 +1390,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.106" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1364,31 +1400,31 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.106" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.118", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.106" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" dependencies = [ "unicode-ident", ] [[package]] name = "web-sys" -version = "0.3.83" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" dependencies = [ "js-sys", "wasm-bindgen", @@ -1396,9 +1432,9 @@ dependencies = [ [[package]] name = "wide" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a7714cd0430a663154667c74da5d09325c2387695bee18b3f7f72825aa3693a" +checksum = "dfdfe6a32973f2d1b268b8895845a8a96cac2f0191e72c27cc929036060dbf89" dependencies = [ "bytemuck", "safe_arch", @@ -1452,26 +1488,32 @@ dependencies = [ [[package]] name = "wit-bindgen" -version = "0.46.0" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "zerocopy" -version = "0.8.31" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.31" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.118", ] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index b64550f..0ff387f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,24 +11,46 @@ repository = "https://github.com/acgetchell/la-stack" homepage = "https://github.com/acgetchell/la-stack" categories = [ "mathematics", "science" ] keywords = [ "linear-algebra", "geometry", "const-generics", "exact-arithmetic", "robust-predicates" ] +include = [ + "/Cargo.toml", + "/CHANGELOG.md", + "/CITATION.cff", + "/CONTRIBUTING.md", + "/LICENSE", + "/README.md", + "/REFERENCES.md", + "/SECURITY.md", + "/benches/**/*.rs", + "/docs/**/*.md", + "/docs/assets/**/*.csv", + "/docs/assets/**/*.jpg", + "/docs/assets/**/*.png", + "/docs/assets/**/*.svg", + "/examples/**/*.rs", + "/src/**/*.rs", + "/tests/*.proptest-regressions", + "/tests/*.rs", +] [dependencies] # All runtime deps are optional; see [features] below. -criterion = { version = "0.8.2", features = [ "html_reports" ], optional = true } -faer = { version = "0.24.0", default-features = false, features = [ "std", "linalg" ], optional = true } -nalgebra = { version = "0.35.0", optional = true } +# Must stay in sync with num-rational num-bigint = { version = "0.4.6", optional = true } num-rational = { version = "0.4.2", features = [ "num-bigint-std" ], optional = true } num-traits = { version = "0.2.19", optional = true } [dev-dependencies] approx = "0.5.1" +criterion = { version = "0.8.2", features = [ "html_reports" ] } +faer = { version = "0.24.4", default-features = false, features = [ "std", "linalg" ] } +nalgebra = { version = "0.35.0", default-features = false, features = [ "std" ] } pastey = "0.2.3" proptest = "1.11.0" [features] default = [ ] -bench = [ "dep:criterion", "dep:faer", "dep:nalgebra" ] +# cfg-only feature gate for benchmark-only fixtures. +bench = [ ] exact = [ "dep:num-bigint", "dep:num-rational", "dep:num-traits" ] [[example]] @@ -61,12 +83,16 @@ codegen-units = 1 features = [ "exact" ] [lints.rust] +warnings = { level = "deny", priority = -1 } unsafe_code = "forbid" -missing_docs = "deny" -dead_code = "deny" +missing_docs = { level = "deny", priority = 0 } +dead_code = { level = "deny", priority = 0 } +unreachable_pub = { level = "deny", priority = 0 } [lints.rustdoc] +bare_urls = "deny" broken_intra_doc_links = "deny" [lints.clippy] +extra_unused_type_parameters = "warn" pedantic = { level = "warn", priority = -1 } diff --git a/README.md b/README.md index 0825a9c..2e4e17c 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,9 @@ [![License](https://img.shields.io/crates/l/la-stack.svg)](./LICENSE) [![Docs.rs](https://docs.rs/la-stack/badge.svg)](https://docs.rs/la-stack) [![CI](https://github.com/acgetchell/la-stack/actions/workflows/ci.yml/badge.svg)](https://github.com/acgetchell/la-stack/actions/workflows/ci.yml) -[![rust-clippy analyze](https://github.com/acgetchell/la-stack/actions/workflows/rust-clippy.yml/badge.svg)](https://github.com/acgetchell/la-stack/actions/workflows/rust-clippy.yml) +[![rust-clippy analyze][clippy-badge]][clippy-workflow] [![codecov](https://codecov.io/gh/acgetchell/la-stack/graph/badge.svg?token=4eKXa5QjuZ)](https://codecov.io/gh/acgetchell/la-stack) -[![Audit dependencies](https://github.com/acgetchell/la-stack/actions/workflows/audit.yml/badge.svg)](https://github.com/acgetchell/la-stack/actions/workflows/audit.yml) -[![Codacy Security Scan](https://github.com/acgetchell/la-stack/actions/workflows/codacy.yml/badge.svg)](https://github.com/acgetchell/la-stack/actions/workflows/codacy.yml) +[![Audit dependencies][audit-badge]][audit-workflow] ![la-stack](https://raw.githubusercontent.com/acgetchell/la-stack/main/docs/assets/la-stack.jpg) @@ -46,7 +45,8 @@ for current release planning. ## 🚫 Anti-goals -- Bare-metal performance: see [`blas-src`](https://crates.io/crates/blas-src), [`lapack-src`](https://crates.io/crates/lapack-src), [`openblas-src`](https://crates.io/crates/openblas-src) +- Bare-metal performance: see [`blas-src`](https://crates.io/crates/blas-src), + [`lapack-src`](https://crates.io/crates/lapack-src), or [`openblas-src`](https://crates.io/crates/openblas-src) - Comprehensive: use [`nalgebra`](https://crates.io/crates/nalgebra) if you need a full-featured library - Large matrices/dimensions with parallelism: use [`faer`](https://crates.io/crates/faer) if you need this - Alternate floating-point scalar families: `la-stack` supports `f64` and optional exact arithmetic, not `f32` / `f16` APIs @@ -87,7 +87,8 @@ la-stack = "0.4.3" - `default`: no runtime dependencies - `exact`: `BigRational` exact determinant and solve APIs -- `bench`: Criterion, nalgebra, and faer for internal benchmarks +- `bench`: cfg-only gate for benchmark fixtures and benchmark-input tests; + benchmark libraries remain development dependencies Solve a 5×5 system via LU: @@ -140,8 +141,17 @@ fn main() -> Result<(), LaError> { let ldlt = match a.ldlt(DEFAULT_SINGULAR_TOL) { Ok(ldlt) => ldlt, - Err(err @ LaError::Asymmetric { row, col, .. }) => { - eprintln!("LDLT requires symmetry; first mismatch at ({row}, {col})"); + Err(err @ LaError::Asymmetric { + row, + col, + upper, + lower, + allowed_abs_diff, + .. + }) => { + eprintln!( + "LDLT mismatch at ({row}, {col}): {upper} vs {lower} (allowed {allowed_abs_diff})" + ); return Err(err); } Err(err) => return Err(err), @@ -154,15 +164,20 @@ fn main() -> Result<(), LaError> { } ``` -> ⚠️ **LDLT invariant:** The input matrix must be **symmetric**. Asymmetric +> ⚠️ **LDLT invariant:** The input matrix must be **exactly symmetric**: every +> mirrored pair must compare equal (`+0.0 == -0.0` is accepted). Asymmetric > inputs passed to > [`Matrix::ldlt`](https://docs.rs/la-stack/latest/la_stack/struct.Matrix.html#method.ldlt) -> return a typed `LaError::Asymmetric` before factorization starts. Use +> return a typed `LaError::Asymmetric` containing both observed values and the +> required allowed difference of zero. The tolerance-based > [`Matrix::first_asymmetry`](https://docs.rs/la-stack/latest/la_stack/struct.Matrix.html#method.first_asymmetry) -> to locate the offending pair, or fall back to `lu()` if your matrices may not -> be symmetric at all. Symmetric inputs with a negative LDLT diagonal return -> `LaError::NotPositiveSemidefinite`; zero or too-small non-negative diagonals -> return `LaError::Singular`. +> and `Matrix::is_symmetric` methods remain useful diagnostics, but do not prove +> the exact precondition required by LDLT. Fall back to `lu()` if your matrices +> may not be symmetric at all. A negative LDLT diagonal or a zero diagonal with nonzero +> remaining coupling returns `LaError::NotPositiveSemidefinite` with a typed +> `PositiveSemidefiniteViolation`. An uncoupled zero or other non-negative pivot +> at or below the caller's tolerance returns `LaError::Singular` with a +> numerical `SingularityReason`. ## ⚡ Compile-time determinants (D ≤ 4) @@ -193,11 +208,11 @@ fn main() -> Result<(), LaError> { ``` The public `det()` method automatically dispatches through the closed-form path -for D ≤ 4 and falls back to LU for D ≥ 5. Finite inputs return a floating-point -determinant estimate in every dimension; `det()` does not surface -`LaError::Singular`. Tiny nonzero determinants are not flattened by a pivot -tolerance. Use `lu()` directly when you need tolerance-aware singularity -detection or the pivot-column diagnostic from the factorization, and use the +for D ≤ 4 and falls back to zero-tolerance LU for D ≥ 5. Tiny nonzero +determinants are not flattened by a configured pivot tolerance. The LU fallback +returns `LaError::Singular` when floating-point elimination cannot produce a +non-zero pivot; it does not misreport that numerical failure as an exact zero. +Use `lu()` directly when you need a different tolerance policy, and use the exact determinant APIs when exact singularity classification matters. ## 🔬 Exact arithmetic (`"exact"` feature) @@ -218,8 +233,9 @@ la-stack = { version = "0.4.3", features = ["exact"] } - **`det_exact_f64()`** — returns the exact determinant as `f64` only when it is exactly representable (or `LaError::Unrepresentable` otherwise) - **`det_exact_rounded_f64()`** — returns the exact determinant rounded to a - finite `f64` -- **`det_sign_exact()`** — returns the provably correct sign (−1, 0, or +1) + finite `f64` using IEEE 754 round-to-nearest, ties-to-even +- **`det_sign_exact()`** — infallibly returns the provably correct + `DeterminantSign` variant (`Negative`, `Zero`, or `Positive`) **Linear system solve:** @@ -227,7 +243,10 @@ la-stack = { version = "0.4.3", features = ["exact"] } - **`solve_exact_f64(b)`** — solves `Ax = b` exactly, returning `Vector` only when every component is exactly representable as `f64` - **`solve_exact_rounded_f64(b)`** — solves `Ax = b` exactly, returning each - component rounded to finite `f64` + component rounded to finite `f64` using IEEE 754 round-to-nearest, + ties-to-even +- **`ExactF64Conversion`** — converts an existing exact determinant or solution + under the strict or rounded contract without repeating exact elimination ```rust,ignore use la_stack::prelude::*; @@ -239,11 +258,11 @@ fn main() -> Result<(), LaError> { [4.0, 5.0, 6.0], [7.0, 8.0, 9.0], ])?; - assert_eq!(m.det_sign_exact()?, 0); // exactly singular + assert_eq!(m.det_sign_exact(), DeterminantSign::Zero); // exactly singular let det = m.det_exact()?; assert_eq!(det, BigRational::from_integer(0.into())); // exact zero - let det_f64 = m.det_exact_f64()?; + let det_f64 = det.try_to_f64()?; assert_eq!(det_f64, 0.0); // If strict exact-to-f64 conversion would require rounding, opt in @@ -252,9 +271,10 @@ fn main() -> Result<(), LaError> { [1.0 + f64::EPSILON, 0.0], [0.0, 1.0 - f64::EPSILON], ])?; - let rounded_det = match inexact.det_exact_f64() { + let exact_det = inexact.det_exact()?; + let rounded_det = match exact_det.try_to_f64() { Ok(det) => det, - Err(err) if err.requires_rounding() => inexact.det_exact_rounded_f64()?, + Err(err) if err.requires_rounding() => exact_det.to_rounded_f64()?, Err(err) => return Err(err), }; assert_eq!(rounded_det.to_bits(), 1.0f64.to_bits()); @@ -268,7 +288,8 @@ fn main() -> Result<(), LaError> { ])?; let huge_det = huge.det_exact()?; assert_eq!( - huge.det_exact_f64() + huge_det + .try_to_f64() .err() .and_then(|err| err.unrepresentable_reason()), Some(UnrepresentableReason::NotFinite) @@ -278,7 +299,8 @@ fn main() -> Result<(), LaError> { // Exact linear system solve let a = Matrix::<2>::try_from_rows([[1.0, 2.0], [3.0, 4.0]])?; let b = Vector::<2>::try_new([5.0, 11.0])?; - let x = a.solve_exact_f64(b)?.into_array(); + let exact_x = a.solve_exact(b)?; + let x = exact_x.try_to_f64()?.into_array(); assert!((x[0] - 1.0).abs() <= f64::EPSILON); assert!((x[1] - 2.0).abs() <= f64::EPSILON); @@ -286,45 +308,85 @@ fn main() -> Result<(), LaError> { } ``` -With the `exact` feature enabled, `BigInt` and `BigRational` are re-exported -from the crate root and prelude, alongside the most commonly needed -`num-traits` items (`FromPrimitive`, `ToPrimitive`, `Signed`). This lets -consumers construct exact values (`BigRational::from_f64`, `from_i64`), query -sign (`is_positive` / `is_negative`), and convert back to `f64` (`to_f64`) -with a single `use la_stack::prelude::*;` — no need to add `num-bigint`, -`num-rational`, or `num-traits` to their own `Cargo.toml`. - -For `det_sign_exact()`, D ≤ 4 matrices use a fast f64 filter (error-bounded -`det_direct()`) that resolves the sign without allocating. Only near-degenerate -or large (D ≥ 5) matrices fall through to the exact Bareiss algorithm. +With the `exact` feature enabled, `DeterminantSign`, `ExactF64Conversion`, +`BigInt`, and `BigRational` are re-exported from the crate root and prelude, +alongside the most commonly needed `num-traits` items (`FromPrimitive`, +`ToPrimitive`, `Signed`). This lets consumers construct exact values +(`BigRational::from_f64`, `from_i64`), query sign (`is_positive` / +`is_negative`), and convert back to `f64` (`try_to_f64`, `to_rounded_f64`, or +the raw `to_f64`) with a single +`use la_stack::prelude::*;` — no need to add `num-bigint`, `num-rational`, +or `num-traits` to their own `Cargo.toml`. Use +`DeterminantSign::as_i8()` only when numeric −1/0/+1 interoperability is +required. + +For `det_sign_exact()`, D ≤ 4 matrices first use a fast f64 filter +(error-bounded `det_direct()`) when its rounded intermediates stay in the normal +range or are exact structural zeros. An inconclusive filter falls back to the +same direct determinant expansion in `BigInt`. D ≥ 5 skips the closed-form +filter and uses fraction-free Bareiss elimination in `BigInt`. +Because `Matrix` stores only finite entries, arithmetic range failures in the +filter are inconclusive rather than errors and the exact fallback is total. ### Adaptive precision with `det_errbound()` `det_errbound()` returns the conservative absolute error bound used by the fast -filter. This method does NOT require the `exact` feature — it uses pure f64 -arithmetic and is available by default. This enables building custom -adaptive-precision logic for geometric predicates: +filter when the relative-error analysis is valid. It returns `None` when a +D ≤ 4 computation may be affected by gradual underflow, as well as for +unsupported D ≥ 5 dimensions. This method does NOT require the `exact` feature +— it uses pure f64 arithmetic and is available by default. This enables +building custom adaptive-precision logic for geometric predicates: ```rust,ignore use la_stack::prelude::*; -fn main() -> Result<(), LaError> { - let m = Matrix::<3>::identity(); - if let Some(bound) = m.det_errbound()? { - if let Some(det) = m.det_direct()? { - if det.abs() > bound { - // f64 sign is guaranteed correct - let sign = det.signum() as i8; +fn adaptive_det_sign( + matrix: &Matrix, +) -> DeterminantSign { + if let (Ok(Some(bound)), Ok(Some(det))) = + (matrix.det_errbound(), matrix.det_direct()) + { + if det.abs() > bound { + return if det > 0.0 { + DeterminantSign::Positive } else { - // Fall back to exact arithmetic (requires `exact` feature) - let sign = m.det_sign_exact()?; - } + DeterminantSign::Negative + }; } - } else { - // D ≥ 5: no fast filter, use exact directly (requires `exact` feature) - let sign = m.det_sign_exact()?; } + matrix.det_sign_exact() +} + +fn main() -> Result<(), LaError> { + let identity = Matrix::<3>::identity(); + assert_eq!( + adaptive_det_sign(&identity), + DeterminantSign::Positive + ); + + // A zero determinant cannot pass the f64 sign filter, so this exercises + // the exact fallback. + let singular = Matrix::<3>::try_from_rows([ + [1.0, 2.0, 3.0], + [4.0, 5.0, 6.0], + [7.0, 8.0, 9.0], + ])?; + assert_eq!(adaptive_det_sign(&singular), DeterminantSign::Zero); + + // The f64 filter overflows for this finite matrix, but the exact fallback + // still resolves its positive determinant sign. + let big = f64::MAX / 2.0; + let overflowing = Matrix::<3>::try_from_rows([ + [0.0, 0.0, 1.0], + [big, 0.0, 1.0], + [0.0, big, 1.0], + ])?; + assert_eq!( + adaptive_det_sign(&overflowing), + DeterminantSign::Positive + ); + Ok(()) } ``` @@ -334,7 +396,7 @@ dimension-specific constants behind that bound. In plain terms, they answer: "how many machine-epsilon-sized rounding mistakes can this closed-form determinant formula accumulate?" To get an absolute error bound, `det_errbound()` multiplies the coefficient by a size measure of the matrix entries, the -**absolute Leibniz sum**: +**absolute Leibniz sum**, equivalently the permanent of `|A|`: ```text p(|A|) = sum over determinant terms of product of absolute values @@ -348,41 +410,86 @@ For a 2×2 matrix `[[a, b], [c, d]]`, that scale is `|a*d| + |b*c|`, so: The coefficients are not tolerances and are not meant to be tuned by callers; they are conservative constants derived from the fixed D ≤ 4 formulas and their -floating-point rounding chains. They are exposed for advanced users who want to -compose the same bound themselves. +floating-point rounding chains when gradual underflow is absent. They are +explicit crate-root exports for +advanced users who want to compose the same bound themselves: +`use la_stack::{ERR_COEFF_2, ERR_COEFF_3, ERR_COEFF_4};`. They intentionally stay +out of the common prelude. ## 🧩 API at a glance | Type | Storage | Purpose | Key methods | |---|---|---|---| -| `Vector` | `[f64; D]` | Finite fixed-length vector for input and computation | `try_new`, `zero`, `dot`, `norm2_sq` | +| `Vector` | `[f64; D]` | Finite fixed-length vector for input and computation | `try_new`, `as_array`, `into_array`, `dot`, `norm2_sq` | | `Matrix` | `[[f64; D]; D]` | Finite square matrix for input and computation | See below | | `Lu` | `Matrix` + pivot array | Factorization for solves/det | `solve`, `det` | | `Ldlt` | `Matrix` | Factorization for symmetric SPD/PSD solves/det | `solve`, `det` | +| `Tolerance` | finite non-negative `f64` | Validated numerical threshold | `try_new`, `get` | +| `LaError` | typed variants and reasons | Structured, actionable failure reporting | See error enums below | +| `DeterminantSign`¹ | enum | Exact determinant sign | `as_i8` | Storage shown above reflects the intentional `f64` scalar model. -`Matrix` key methods: `lu`, `ldlt`, `det`, `det_direct`, `det_errbound`, +`Matrix` key methods: `as_rows`, `into_rows`, `lu`, `ldlt`, `det`, +`det_direct`, `det_errbound`, `det_exact`¹, `det_exact_f64`¹, `det_exact_rounded_f64`¹, `det_sign_exact`¹, `solve_exact`¹, `solve_exact_f64`¹, `solve_exact_rounded_f64`¹. Matrix and vector constructors validate non-finite inputs at public API boundaries. After construction, `Matrix` and `Vector` carry that -finite-storage invariant directly, so kernels do not revalidate stored entries. +finite-storage invariant directly, so factorization kernels do not repeat an +O(D²) input scan. Computed factor matrices are still checked before they become +observable results. + +`Matrix::as_rows` and `Vector::as_array` borrow their validated backing arrays; +`Matrix::into_rows` and `Vector::into_array` consume the value and return the +owned fixed-size arrays. + +`Matrix::get` returns `Option` for bounds-only access; `Matrix::try_get` +preserves invalid coordinates in `LaError`. The single fallible `Matrix::set` +validates both bounds and finiteness before mutating the matrix. + +`LaError` and its reason/location enums are non-exhaustive. Numerical +singularity records the [`FactorizationKind`](https://docs.rs/la-stack/latest/la_stack/enum.FactorizationKind.html), +observed pivot magnitude, and tolerance, while exact-arithmetic singularity is +identified separately. `LaError::NonFinite` retains the crate-wide non-finite +contract but uses `NonFiniteOrigin`, `NonFiniteLocation`, and +`ArithmeticOperation` to distinguish invalid inputs from computed overflow. +`InvalidToleranceReason` distinguishes negative from non-finite tolerances, and +`PositiveSemidefiniteViolation` distinguishes negative LDLT pivots from a zero +pivot with nonzero coupling. Match these public enums with a wildcard and use +`..` for struct-style variants so future error context can be added without +breaking callers. ¹ Requires `features = ["exact"]`. ## 📊 Benchmarks (vs nalgebra/faer) -![LU solve (factor + solve): median time vs dimension](https://raw.githubusercontent.com/acgetchell/la-stack/v0.4.3/docs/assets/bench/vs_linalg_lu_solve_median.svg) +![LU solve (factor + solve): median time vs dimension][lu-solve-benchmark] Raw data: [docs/assets/bench/vs_linalg_lu_solve_median.csv](https://github.com/acgetchell/la-stack/blob/v0.4.3/docs/assets/bench/vs_linalg_lu_solve_median.csv) +Historical provenance status: +[docs/assets/bench/vs_linalg_lu_solve_median.provenance.json](docs/assets/bench/vs_linalg_lu_solve_median.provenance.json) Representative benchmark: `lu_solve` factors the matrix and solves one right-hand side. Median time is lower-is-better, and the “la-stack vs nalgebra/faer” columns show the % time reduction relative to each baseline -(positive = la-stack faster). This is not an aggregate score across all -operations. +(positive means the recorded la-stack median is lower). These are descriptive +point-estimate ratios, not statistical significance claims or an aggregate score +across operations. + +Timings count only when the implementation preserves the documented +correctness guarantees and invariants. Performance claims require comparable +before-and-after evidence using the same inputs, configuration, and environment. +This v0.4.3 snapshot predates deterministic measurement-provenance capture, so +its CPU, operating system, Rust toolchain, exact measured source state, +dependency lock digest, and Criterion configuration are unavailable. The CSV +preserves confidence bounds, but without the missing configuration and +environment they do not make the result reproducible across environments. Treat +it as a historical snapshot, not reproducible cross-environment evidence. Future +`just plot-vs-linalg-readme` publications run the benchmark-input correctness +gate, require complete canonical-dimension coverage, and write deterministic +JSON provenance beside the CSV and SVG. For the full per-kernel comparison methodology, input construction, and release-comparison workflow details, see @@ -434,17 +541,19 @@ cargo run --features exact --example exact_solve_3x3 A short contributor workflow: ```bash -cargo install just +cargo install --locked just just setup # install/verify dev tools + sync Python deps just check # lint/validate (non-mutating) just fix # apply auto-fixes (mutating) just ci # lint + tests + examples + bench compile ``` -The repository uses Rust-native tooling for documentation and config checks: -`rumdl` for Markdown, `dprint` with `pretty_yaml` for YAML, `taplo` for TOML, -and `typos` for spelling. GitHub Actions references are SHA-pinned, restricted -to an explicit allowlist, and kept with readable version comments for review. +The repository uses `cargo-nextest` for runnable Rust tests, `cargo-machete` +for unused-dependency checks, `rumdl` for Markdown, `dprint` plus `yamllint` +for YAML/CFF, `taplo` for TOML, and `typos` for spelling. Python 3.14 support +tooling is locked with `uv` and checked by Ruff, Ty, and Semgrep. GitHub Actions +references are SHA-pinned, restricted to an explicit allowlist, and kept with +readable version comments for review. CI runs `just ci` on Ubuntu, macOS, and Windows to keep platform coverage aligned with the local comprehensive validation path. @@ -477,3 +586,9 @@ for the repository's AI-assisted development note. ## 📄 License BSD 3-Clause License. See [LICENSE](./LICENSE). + +[audit-badge]: https://github.com/acgetchell/la-stack/actions/workflows/audit.yml/badge.svg +[audit-workflow]: https://github.com/acgetchell/la-stack/actions/workflows/audit.yml +[clippy-badge]: https://github.com/acgetchell/la-stack/actions/workflows/rust-clippy.yml/badge.svg +[clippy-workflow]: https://github.com/acgetchell/la-stack/actions/workflows/rust-clippy.yml +[lu-solve-benchmark]: https://raw.githubusercontent.com/acgetchell/la-stack/v0.4.3/docs/assets/bench/vs_linalg_lu_solve_median.svg diff --git a/REFERENCES.md b/REFERENCES.md index 09fb973..8fb08ec 100644 --- a/REFERENCES.md +++ b/REFERENCES.md @@ -24,41 +24,47 @@ No generated content was used without human oversight. ### Absolute error bound for closed-form determinants `Matrix::det_errbound()` returns a conservative Shewchuk-style absolute error bound [8] -for `Matrix::det_direct()` in dimensions 2–4. The bound has the form -`ERR_COEFF_D · p(|A|)`, where `p(|A|)` is the absolute Leibniz sum (the cofactor-expansion -tree with `|·|` at every leaf) and `ERR_COEFF_D ∈ {ERR_COEFF_2, ERR_COEFF_3, ERR_COEFF_4}` -is a dimension-specific constant derived from the rounding-event count of `det_direct`. +for `Matrix::det_direct()` in dimensions 2–4 when every rounded intermediate is normal +or an exact structural zero. The bound has the form +`ERR_COEFF_D · p(|A|)`, where `p(|A|) = perm(|A|)` is the absolute Leibniz sum—the +combinatorial permanent of the entrywise-absolute matrix—and +`ERR_COEFF_D ∈ {ERR_COEFF_2, ERR_COEFF_3, ERR_COEFF_4}` is a dimension-specific constant +derived from the rounding-event count of `det_direct`. +The method returns `None` when gradual underflow could violate the relative-error model. The same bound is used internally by `det_sign_exact()`'s fast filter, but `det_errbound()` itself is available without the `exact` feature, so downstream crates can build custom adaptive-precision logic with pure f64 arithmetic. -### Exact determinant sign (adaptive-precision Bareiss) +### Exact determinant sign (adaptive-precision integer arithmetic) `det_sign_exact()` uses a Shewchuk-style f64 error-bound filter [8] (the same bound exposed -by `det_errbound()` above) backed by integer-only Bareiss elimination [7] in `BigInt`. Each -f64 entry is decomposed into `mantissa × 2^exponent`, scaled to a common integer base, and -eliminated without any `BigRational` or GCD overhead. +by `det_errbound()` above) backed by exact `BigInt` arithmetic. Each f64 entry is decomposed +into `mantissa × 2^exponent` and scaled to a common integer base. Dimensions 0–4 use direct +integer determinant expansions; D ≥ 5 uses integer-only Bareiss elimination [7]. Neither +path constructs `BigRational` values or performs GCD normalization. See `src/exact.rs` for the full architecture description. ### Exact linear system solve (hybrid Bareiss / BigRational) -`solve_exact()` and `solve_exact_f64()` share the BigInt core used for determinants. Matrix -and RHS entries are decomposed via IEEE 754 bit extraction [9] and scaled to a shared base -`2^e_min` so the augmented system `(A | b)` becomes a `BigInt` matrix. Forward elimination -runs in `BigInt` using Bareiss fraction-free updates [7] — no `BigRational` and no GCD +`solve_exact()` and `solve_exact_f64()` share the determinant path's exact f64 decomposition +and integer scaling. Matrix and RHS entries are decomposed via IEEE 754 bit extraction [9] +and scaled to a shared base `2^e_min` so the augmented system `(A | b)` becomes a `BigInt` +matrix. Forward elimination runs in `BigInt` using Bareiss fraction-free updates [7]—no +`BigRational` and no GCD normalisation in the `O(D³)` phase. The upper-triangular result is then lifted into `BigRational` for back-substitution, where fractions are inherent and the cost is only `O(D²)`. Row swaps from first-non-zero pivoting are applied to both the matrix and the RHS; because power-of-two scaling is applied uniformly to both sides of `A x = b`, the solution is unchanged by the scale factor. -### f64 → integer decomposition (`f64_decompose`) +### f64 → integer decomposition (`decompose_f64`) -Both the determinant and solve paths convert f64 entries via `f64_decompose`, which extracts +Both the determinant and solve paths convert f64 entries via `decompose_f64`, which extracts the IEEE 754 binary64 sign, unbiased exponent, and significand [9] and strips trailing zeros from the significand so `|x| = m · 2^e` with `m` odd. The integer matrix is then assembled -by shifting each mantissa left by `exp − e_min`, giving a GCD-free, Bareiss-ready starting -point. A one-shot wrapper `f64_to_bigrational` (used only in tests) packages the same +by shifting each mantissa left by `exp − e_min`, giving a GCD-free exact-integer starting +point. Solves and D ≥ 5 determinants then apply Bareiss elimination; D ≤ 4 determinants use +direct expansions. A one-shot wrapper `f64_to_big_rational` (used only in tests) packages the same decomposition into a single `BigRational`. See Goldberg [10] for background on IEEE 754 representation and exact rational reconstruction. diff --git a/SECURITY.md b/SECURITY.md index 8dadb8b..a365ccc 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,13 +2,16 @@ ## Supported Versions -Use the latest released crate version or the default branch. Security fixes are not backported to older versions unless noted in a release. +Use the latest released crate version or the default branch. Security fixes +are not backported to older versions unless noted in a release. -This crate is pre-1.0 and under active development, so API compatibility and security support are tied to the current release line. +This crate is pre-1.0 and under active development, so API compatibility and +security support are tied to the current release line. ## Reporting a Vulnerability -Please report vulnerabilities privately using GitHub private vulnerability reporting: +Please report vulnerabilities privately using GitHub private vulnerability +reporting: @@ -20,11 +23,81 @@ Include: - Enabled Cargo features, especially `exact` if exact arithmetic is involved. - Steps to reproduce, ideally with a minimal Rust example or test. - Expected and observed behavior. -- Any relevant matrix, vector, benchmark input, or serialized artifact shape, with sensitive data removed. +- Security impact, such as a panic, denial of service, or incorrect result. +- Any relevant matrix, vector, or benchmark input shape, with sensitive data + removed. +- A suggested fix or mitigation, if available. -For numerical correctness issues that are not security-sensitive, open a normal GitHub issue with a minimal reproduction. +For numerical correctness issues that are not security-sensitive, open a +normal GitHub issue with a minimal reproduction. + +## Disclosure Process + +- Reports are acknowledged as maintainer availability allows. +- The issue is triaged and its severity assessed on a best-effort basis. +- Accepted reports receive updates when there is meaningful progress or a + material change in the assessment. +- For an accepted vulnerability, the project prepares a fix, publishes a + GitHub Security Advisory, releases the fix, and requests a RustSec advisory + when appropriate. +- If a report is declined, the reporter receives an explanation. + +Please follow coordinated disclosure and avoid public disclosure until a fix +or mitigation is available. + +## Scope + +The crate uses `#![forbid(unsafe_code)]`, which reduces memory-safety risk. +Security-relevant correctness and availability issues can still exist. In +scope are: + +- Panics or crashes triggered by malformed or adversarial matrices or vectors. +- CPU or memory denial of service caused by crafted inputs, including inputs + to optional exact-arithmetic paths. +- Incorrect numerical results that affect security, data integrity, or + availability when processing untrusted input. +- Violations of documented exact-arithmetic guarantees, such as silent + precision loss, when they have a security impact. + +Out of scope are: + +- Documented floating-point limitations, conditioning behavior, or rounding + bounds that do not create a security impact. +- Performance limitations that are not exploitable as denial of service. +- Issues caused by use outside the documented API contracts or supported + problem scope. + +## Patch and Advisory Policy + +- Fixes are released on the latest supported release line. Older releases + receive fixes only when explicitly noted. +- Releases are published to crates.io with corresponding GitHub releases. +- Accepted vulnerabilities are documented with GitHub Security Advisories and, + when appropriate, RustSec advisories. +- Public technical detail may be limited until users have had a reasonable + opportunity to update. + +## RustSec + +Applicable vulnerabilities may be disclosed through the +[RustSec Advisory Database](https://github.com/RustSec/advisory-db), enabling +detection with `cargo audit`. + +## Safe Harbor + +Good-faith security research is welcome. Avoid privacy violations, data +destruction, persistence, service disruption, and public disclosure before a +fix or mitigation is available. Reports that follow coordinated disclosure +and make a reasonable effort to avoid harm are treated as helpful +contributions. + +## Acknowledgements + +Responsible disclosure is appreciated. Reporters may be credited in +advisories or release notes unless anonymity is requested. ## Security Checks -This project uses GitHub CodeQL, Dependabot security updates, secret scanning with push protection, `cargo audit`, zizmor, Clippy SARIF analysis, -and repository-owned Semgrep rules. +This project uses GitHub CodeQL, Dependabot security updates, secret scanning +with push protection, `cargo audit`, zizmor, Clippy SARIF analysis, and +repository-owned Semgrep rules. diff --git a/benches/common/exact.rs b/benches/common/exact.rs index 2d44f24..f04a135 100644 --- a/benches/common/exact.rs +++ b/benches/common/exact.rs @@ -2,23 +2,24 @@ //! Shared helpers for exact-arithmetic benchmark input generation and tests. +use core::array::{self, from_fn}; +use core::cmp::Ordering; use std::fmt::{self, Display}; use std::num::NonZeroU64; +use la_stack::{DeterminantSign, LaError, Matrix, UnrepresentableReason, Vector}; +use num_bigint::BigInt; +use num_rational::BigRational; +use num_traits::{FromPrimitive, Signed, ToPrimitive, Zero}; + +/// Number of matrices in each deterministic random benchmark corpus. +pub const RANDOM_INPUT_ARRAY_LEN: usize = 50; +/// Stable global seed used to derive one random corpus per dimension. +pub const RANDOM_SEED: [u8; 32] = [0; 32]; + /// Configuration errors for exact-arithmetic benchmark input generation. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ExactBenchConfigError { - /// The random input corpus length was zero. - EmptyCorpus, - /// An ordered inclusive range produced an invalid non-zero sampling width. - InvalidRangeWidth { - /// Inclusive lower bound. - min: i16, - /// Inclusive upper bound. - max: i16, - /// Computed inclusive width before conversion to the cached sampling width. - width: i32, - }, /// The inclusive lower bound was greater than the inclusive upper bound. UnorderedRange { /// Inclusive lower bound. @@ -31,13 +32,6 @@ pub enum ExactBenchConfigError { impl Display for ExactBenchConfigError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match *self { - Self::EmptyCorpus => f.write_str("random input corpus must be nonempty"), - Self::InvalidRangeWidth { min, max, width } => { - write!( - f, - "random integer range {min}..={max} produced invalid sampling width {width}" - ) - } Self::UnorderedRange { min, max } => { write!(f, "random integer range must be ordered: {min}..={max}") } @@ -60,26 +54,19 @@ impl I16Range { /// /// # Errors /// - /// Returns [`ExactBenchConfigError::UnorderedRange`] when `min > max`, or - /// [`ExactBenchConfigError::InvalidRangeWidth`] if the inclusive range width - /// cannot be represented as a non-zero sampling width. - pub fn new(min: i16, max: i16) -> Result { + /// Returns [`ExactBenchConfigError::UnorderedRange`] when `min > max`. + pub fn try_new(min: i16, max: i16) -> Result { if min > max { return Err(ExactBenchConfigError::UnorderedRange { min, max }); } - let raw_width = i32::from(max) - i32::from(min) + 1; - let width = - u64::try_from(raw_width).map_err(|_| ExactBenchConfigError::InvalidRangeWidth { - min, - max, - width: raw_width, - })?; - let width = NonZeroU64::new(width).ok_or(ExactBenchConfigError::InvalidRangeWidth { - min, - max, - width: raw_width, - })?; + let raw_width = u64::from(max.abs_diff(min)) + 1; + // For ordered i16 bounds, the inclusive width is always 1..=65,536. + // The `None` arm is therefore an internal invariant violation, not a + // caller-reachable configuration error. + let Some(width) = NonZeroU64::new(raw_width) else { + unreachable!("an ordered inclusive i16 range has positive width"); + }; Ok(Self { min, width }) } } @@ -105,12 +92,488 @@ impl SplitMix64 { z ^ (z >> 31) } - #[allow(clippy::cast_possible_truncation)] /// Draw a random `i16` inside a validated inclusive range. #[must_use] pub fn next_i16(&mut self, range: I16Range) -> i16 { + #[expect( + clippy::cast_possible_truncation, + reason = "an inclusive i16 range has width at most 65,536, so its modulo offset fits i32" + )] let offset = (self.next_u64() % range.width.get()) as i32; - let value = i32::from(range.min) + offset; - value as i16 + let value_i32 = i32::from(range.min) + offset; + #[expect( + clippy::cast_possible_truncation, + reason = "the validated inclusive range guarantees min plus its modulo offset stays within i16" + )] + let value = value_i32 as i16; + value + } +} + +/// Matrix/RHS pair used by exact-arithmetic benchmarks and smoke tests. +#[derive(Clone, Copy)] +#[must_use] +pub struct ExactInput { + /// Finite matrix under test. + pub matrix: Matrix, + /// Finite right-hand side under test. + pub rhs: Vector, +} + +/// Exact-arithmetic benchmark input whose results have been checked against +/// independent mathematical oracles. +/// +/// Values of this type can only be produced by [`validate_exact_fixture`]. +/// Keeping its fields private prevents Criterion helpers from accidentally +/// accepting a raw fixture whose preconditions have not been checked. +#[derive(Clone, Copy)] +#[must_use] +pub struct ValidatedExactInput { + matrix: Matrix, + rhs: Vector, +} + +impl ValidatedExactInput { + /// Borrow the independently validated benchmark matrix. + pub const fn matrix(&self) -> &Matrix { + &self.matrix + } + + /// Return the independently validated benchmark right-hand side. + pub const fn rhs(&self) -> Vector { + self.rhs + } +} + +/// Return a successful fixture-construction result or panic with context. +fn require_ok(result: Result, operation: &str) -> T { + match result { + Ok(value) => value, + Err(err) => panic!("{operation} failed: {err}"), + } +} + +/// Return a deterministic, strictly diagonally-dominant matrix entry. +#[inline] +#[expect( + clippy::cast_precision_loss, + reason = "benchmark dimensions and indices are small enough to be represented exactly as f64" +)] +const fn matrix_entry(r: usize, c: usize) -> f64 { + if r == c { + (r as f64).mul_add(1.0e-3, (D as f64) + 1.0) + } else { + 0.1 / ((r + c + 1) as f64) + } +} + +/// Build the deterministic baseline matrix rows for dimension `D`. +#[inline] +#[must_use] +pub const fn make_matrix_rows() -> [[f64; D]; D] { + let mut rows = [[0.0; D]; D]; + let mut r = 0; + while r < D { + let mut c = 0; + while c < D { + rows[r][c] = matrix_entry::(r, c); + c += 1; + } + r += 1; + } + rows +} + +/// Build the deterministic baseline right-hand-side vector for dimension `D`. +#[inline] +#[expect( + clippy::cast_precision_loss, + reason = "benchmark vector indices are small enough to be represented exactly as f64" +)] +#[must_use] +pub fn make_vector_array() -> [f64; D] { + from_fn(|i| (i as f64) + 1.0) +} + +/// Derive a stable per-dimension seed from the global random benchmark seed. +fn random_seed_for_dim() -> u64 { + let mut seed = + 0xC0DE_CAFE_D15C_A11Au64 ^ require_ok(u64::try_from(D), "dimension seed conversion"); + for (i, byte) in RANDOM_SEED.iter().copied().enumerate() { + let shift = require_ok(u32::try_from((i % 8) * 8), "seed shift conversion"); + seed ^= u64::from(byte) << shift; + seed = seed.rotate_left(7) ^ require_ok(u64::try_from(i), "seed index conversion"); + } + seed +} + +/// Build a fixed random corpus of finite, strictly diagonally-dominant inputs. +pub fn make_random_input_corpus() -> [ExactInput; RANDOM_INPUT_ARRAY_LEN] { + let mut rng = SplitMix64::new(random_seed_for_dim::()); + let entry_range = require_ok(I16Range::try_new(-10, 10), "random integer range"); + array::from_fn(|_| { + let mut rows = [[0.0; D]; D]; + let mut diag = [0_i16; D]; + + for (r, row) in rows.iter_mut().enumerate() { + for (c, entry) in row.iter_mut().enumerate() { + if r == c { + diag[r] = rng.next_i16(entry_range); + } else { + *entry = f64::from(rng.next_i16(entry_range)); + } + } + } + + let shift = + f64::from(require_ok(u8::try_from(D), "dimension shift conversion")).mul_add(10.0, 1.0); + for (i, row) in rows.iter_mut().enumerate() { + row[i] = if diag[i] >= 0 { + f64::from(diag[i]) + shift + } else { + f64::from(diag[i]) - shift + }; + } + + let rhs = from_fn(|_| f64::from(rng.next_i16(entry_range))); + + ExactInput { + matrix: require_ok( + Matrix::::try_from_rows(rows), + "random matrix construction", + ), + rhs: require_ok(Vector::::try_new(rhs), "random RHS vector construction"), + } + }) +} + +/// Build the fixed near-singular 3×3 benchmark input. +pub fn near_singular_3x3_input() -> ExactInput<3> { + let perturbation = f64::from_bits(0x3CD0_0000_0000_0000); // 2^-50 + ExactInput { + matrix: require_ok( + Matrix::<3>::try_from_rows([ + [1.0 + perturbation, 2.0, 3.0], + [4.0, 5.0, 6.0], + [7.0, 8.0, 9.0], + ]), + "near-singular matrix construction", + ), + rhs: require_ok( + Vector::<3>::try_new([1.0, 2.0, 3.0]), + "near-singular RHS vector construction", + ), + } +} + +/// Build the fixed extreme-magnitude 3×3 benchmark input. +pub fn large_entries_3x3_input() -> ExactInput<3> { + let big = f64::MAX / 2.0; + ExactInput { + matrix: require_ok( + Matrix::<3>::try_from_rows([[big, 1.0, 1.0], [1.0, big, 1.0], [1.0, 1.0, big]]), + "large-entry matrix construction", + ), + rhs: require_ok( + Vector::<3>::try_new([1.0, 1.0, 1.0]), + "large-entry RHS vector construction", + ), + } +} + +/// Build a Hilbert-matrix benchmark input with an all-ones RHS. +#[expect( + clippy::cast_precision_loss, + reason = "Hilbert benchmark dimensions and indices are small enough to be represented exactly as f64" +)] +pub fn hilbert_input() -> ExactInput { + let rows = from_fn(|r| from_fn(|c| 1.0 / ((r + c + 1) as f64))); + ExactInput { + matrix: require_ok( + Matrix::::try_from_rows(rows), + "Hilbert matrix construction", + ), + rhs: require_ok( + Vector::::try_new([1.0; D]), + "Hilbert RHS vector construction", + ), + } +} + +/// Convert one finite binary64 value to its exact rational value independently. +fn rational_from_f64(value: f64) -> BigRational { + let Some(exact) = BigRational::from_f64(value) else { + panic!("finite binary64 fixture {value:?} must convert exactly"); + }; + exact +} + +/// Return whether one permutation has even parity. +fn permutation_is_even(perm: &[usize]) -> bool { + let mut inversions = 0usize; + for i in 0..perm.len() { + for j in (i + 1)..perm.len() { + if perm[i] > perm[j] { + inversions += 1; + } + } + } + inversions.is_multiple_of(2) +} + +/// Advance a slice to its next lexicographic permutation. +fn next_permutation(values: &mut [usize]) -> bool { + if values.len() < 2 { + return false; + } + + let mut pivot = values.len() - 2; + loop { + if values[pivot] < values[pivot + 1] { + break; + } + if pivot == 0 { + return false; + } + pivot -= 1; + } + + let mut successor = values.len() - 1; + while values[successor] <= values[pivot] { + successor -= 1; + } + values.swap(pivot, successor); + values[(pivot + 1)..].reverse(); + true +} + +/// Compute a determinant with the independent factorial-time Leibniz formula. +fn determinant_leibniz(matrix: &Matrix) -> BigRational { + let rows = matrix.as_rows(); + let mut determinant = BigRational::zero(); + let mut permutation: [usize; D] = from_fn(|index| index); + + loop { + let mut term = BigRational::from_integer(BigInt::from(1)); + for (row, &col) in permutation.iter().enumerate() { + term *= rational_from_f64(rows[row][col]); + } + if permutation_is_even(&permutation) { + determinant += term; + } else { + determinant -= term; + } + if !next_permutation(&mut permutation) { + break; + } + } + + determinant +} + +/// Return the exact determinant sign implied by an independent rational value. +fn determinant_sign(exact: &BigRational) -> DeterminantSign { + match exact.cmp(&BigRational::zero()) { + Ordering::Less => DeterminantSign::Negative, + Ordering::Equal => DeterminantSign::Zero, + Ordering::Greater => DeterminantSign::Positive, + } +} + +/// Derive the strict finite-binary64 outcome from an independently checked rational. +fn expected_strict_f64(exact: &BigRational) -> Result { + let Some(rounded) = exact.to_f64() else { + return Err(UnrepresentableReason::NotFinite); + }; + if !rounded.is_finite() { + return Err(UnrepresentableReason::NotFinite); + } + if BigRational::from_f64(rounded).as_ref() == Some(exact) { + Ok(rounded) + } else { + Err(UnrepresentableReason::RequiresRounding) + } +} + +/// Return the exact overflow midpoint for binary64 round-to-nearest. +/// +/// Magnitudes strictly below this value round to a finite binary64 value. The +/// midpoint itself rounds to infinity because `f64::MAX` has an odd least +/// significand bit while the hypothetical `2^1024` endpoint is even. +fn finite_rounding_limit() -> BigRational { + let half_max_ulp = BigRational::from_integer(BigInt::from(1) << 970usize); + rational_from_f64(f64::MAX) + half_max_ulp +} + +/// Verify directly that a finite binary64 result is the nearest-even value. +fn assert_nearest_even_f64(actual: f64, exact: &BigRational) { + assert!(actual.is_finite()); + if actual == 0.0 { + assert_eq!(actual.is_sign_negative(), exact.is_negative()); + } + + let actual_exact = rational_from_f64(actual); + let actual_distance = (&actual_exact - exact).abs(); + for neighbor in [actual.next_down(), actual.next_up()] { + if !neighbor.is_finite() { + continue; + } + let neighbor_distance = (rational_from_f64(neighbor) - exact).abs(); + assert!( + actual_distance <= neighbor_distance, + "rounded value {actual:?} is farther from {exact} than adjacent value {neighbor:?}", + ); + if actual_distance == neighbor_distance { + assert_eq!( + actual.to_bits() & 1, + 0, + "halfway value must select the even binary64 significand" + ); + } + } +} + +/// Check one strict scalar conversion against an independently derived outcome. +fn assert_strict_scalar( + actual: Result, + exact: &BigRational, + expected_index: Option, +) { + match (actual, expected_strict_f64(exact)) { + (Ok(actual), Ok(expected)) => assert_eq!(actual.to_bits(), expected.to_bits()), + (Err(LaError::Unrepresentable { index, reason, .. }), Err(expected_reason)) => { + assert_eq!(index, expected_index); + assert_eq!(reason, expected_reason); + } + (actual, expected) => { + panic!("strict conversion mismatch: actual={actual:?}, expected={expected:?}") + } + } +} + +/// Check one rounded scalar conversion against the exact rational oracle. +fn assert_rounded_scalar(actual: Result, exact: &BigRational) { + if exact.abs() < finite_rounding_limit() { + match actual { + Ok(actual) => assert_nearest_even_f64(actual, exact), + Err(error) => panic!("finite nearest-even conversion failed: {error}"), + } + } else { + assert!(matches!( + actual, + Err(LaError::Unrepresentable { + reason: UnrepresentableReason::NotFinite, + .. + }) + )); + } +} + +/// Verify `A · x = b` exactly using independently reconstructed binary64 inputs. +fn assert_exact_residual(input: &ExactInput, solution: &[BigRational; D]) { + for row in 0..D { + let mut observed = BigRational::zero(); + for (col, value) in solution.iter().enumerate() { + observed += rational_from_f64(input.matrix.as_rows()[row][col]) * value; + } + assert_eq!(observed, rational_from_f64(input.rhs.as_array()[row])); + } +} + +/// Validate every exact benchmark operation against independent mathematical evidence. +/// +/// The returned proof-bearing fixture is the only input accepted by Criterion +/// registration and timed-operation helpers. +/// +/// This runs only during benchmark setup and smoke tests, never inside a timed +/// Criterion closure. +/// +/// # Panics +/// +/// Panics when any benchmark operation disagrees with the independent exact +/// oracle or when a fixture unexpectedly violates an operation precondition. +pub fn validate_exact_fixture(input: ExactInput) -> ValidatedExactInput { + let determinant = determinant_leibniz(&input.matrix); + assert_eq!( + require_ok(input.matrix.det_exact(), "exact determinant oracle check"), + determinant + ); + assert_eq!( + input.matrix.det_sign_exact(), + determinant_sign(&determinant) + ); + assert_strict_scalar(input.matrix.det_exact_f64(), &determinant, None); + assert_rounded_scalar(input.matrix.det_exact_rounded_f64(), &determinant); + + let solution = require_ok( + input.matrix.solve_exact(input.rhs), + "exact solve oracle check", + ); + assert_exact_residual(&input, &solution); + + let strict_solution = input.matrix.solve_exact_f64(input.rhs); + let first_failure = solution.iter().enumerate().find_map(|(index, value)| { + expected_strict_f64(value) + .err() + .map(|reason| (index, reason)) + }); + match (strict_solution, first_failure) { + (Ok(actual), None) => { + for (index, exact) in solution.iter().enumerate() { + let Ok(expected) = expected_strict_f64(exact) else { + panic!("strict solution component {index} unexpectedly requires rounding"); + }; + assert_eq!(actual.as_array()[index].to_bits(), expected.to_bits()); + } + } + ( + Err(LaError::Unrepresentable { + index: Some(index), + reason, + .. + }), + Some((expected_index, expected_reason)), + ) => { + assert_eq!(index, expected_index); + assert_eq!(reason, expected_reason); + } + (actual, expected) => { + panic!( + "strict exact-solve conversion mismatch: actual={actual:?}, expected={expected:?}" + ) + } + } + + let rounding_limit = finite_rounding_limit(); + let first_rounded_failure = solution + .iter() + .position(|exact| exact.abs() >= rounding_limit); + match ( + input.matrix.solve_exact_rounded_f64(input.rhs), + first_rounded_failure, + ) { + (Ok(rounded), None) => { + for (actual, exact) in rounded.as_array().iter().copied().zip(&solution) { + assert_nearest_even_f64(actual, exact); + } + } + ( + Err(LaError::Unrepresentable { + index: Some(index), + reason: UnrepresentableReason::NotFinite, + .. + }), + Some(expected_index), + ) => assert_eq!(index, expected_index), + (actual, expected) => { + panic!( + "rounded exact-solve conversion mismatch: actual={actual:?}, expected failing index={expected:?}" + ) + } + } + + ValidatedExactInput { + matrix: input.matrix, + rhs: input.rhs, } } diff --git a/benches/common/vs_linalg.rs b/benches/common/vs_linalg.rs index ccdb443..20f8b67 100644 --- a/benches/common/vs_linalg.rs +++ b/benches/common/vs_linalg.rs @@ -9,35 +9,57 @@ use nalgebra::SMatrix; /// Return `det(P)` for faer's permutation representation. /// /// Sign(det(P)) is +1 for even permutations and -1 for odd. Parity is computed -/// from the number of cycles: `sign = (-1)^(n - cycles)`. +/// from the number of cycles: `sign = (-1)^(n - cycles)`. The benchmark +/// dimensions use an allocation-free bit mask; larger permutations use an +/// allocation-free inversion count so the function remains total. +#[must_use] pub fn faer_perm_sign(p: PermRef<'_, usize>) -> f64 { let (forward, _inverse) = p.arrays(); - let n = forward.len(); + let is_odd = if forward.len() <= u128::BITS as usize { + permutation_is_odd_by_cycles(forward) + } else { + permutation_is_odd_by_inversions(forward) + }; + + if is_odd { -1.0 } else { 1.0 } +} - let mut seen = vec![false; n]; +/// Return whether a permutation of at most 128 elements is odd. +fn permutation_is_odd_by_cycles(forward: &[usize]) -> bool { + let mut seen = 0u128; let mut cycles = 0usize; - for start in 0..n { - if seen[start] { + for start in 0..forward.len() { + if seen & (1u128 << start) != 0 { continue; } cycles += 1; - let mut i = start; - while !seen[i] { - seen[i] = true; - i = forward[i]; + let mut index = start; + while seen & (1u128 << index) == 0 { + seen |= 1u128 << index; + index = forward[index]; } } - if (n - cycles).is_multiple_of(2) { - 1.0 - } else { - -1.0 + !(forward.len() - cycles).is_multiple_of(2) +} + +/// Return whether a permutation is odd using an allocation-free fallback. +fn permutation_is_odd_by_inversions(forward: &[usize]) -> bool { + let mut is_odd = false; + + for (index, &left) in forward.iter().enumerate() { + for &right in &forward[index + 1..] { + is_odd ^= left > right; + } } + + is_odd } /// Compute a determinant from a faer partial-pivot LU factorization. +#[must_use] pub fn faer_det_from_partial_piv_lu(lu: &PartialPivLu) -> f64 { // For PA = LU with unit-lower L, det(A) = det(P) * det(U). let u = lu.U(); @@ -49,6 +71,7 @@ pub fn faer_det_from_partial_piv_lu(lu: &PartialPivLu) -> f64 { } /// Compute a determinant from a faer LDLT factorization. +#[must_use] pub fn faer_det_from_ldlt(ldlt: &FaerLdlt) -> f64 { let d = ldlt.D().column_vector(); let mut det = 1.0; @@ -60,7 +83,11 @@ pub fn faer_det_from_ldlt(ldlt: &FaerLdlt) -> f64 { /// Return a deterministic, strictly diagonally-dominant benchmark matrix entry. #[inline] -#[allow(clippy::cast_precision_loss)] // D, r, c are small integers, precision loss is not an issue. +#[expect( + clippy::cast_precision_loss, + reason = "benchmark dimensions and indices are small enough to be represented exactly as f64" +)] +#[must_use] pub fn matrix_entry(r: usize, c: usize) -> f64 { if r == c { // Strict diagonal dominance for stability. @@ -73,6 +100,7 @@ pub fn matrix_entry(r: usize, c: usize) -> f64 { /// Build the shared matrix rows used by all crates for a dimension. #[inline] +#[must_use] pub fn make_matrix_rows() -> [[f64; D]; D] { let mut rows = [[0.0; D]; D]; @@ -85,15 +113,66 @@ pub fn make_matrix_rows() -> [[f64; D]; D] { rows } +/// Build a well-conditioned matrix whose first LU column requires a row swap. +#[inline] +#[must_use] +pub fn make_pivoting_matrix_rows() -> [[f64; D]; D] { + let mut rows = make_matrix_rows(); + if D > 1 { + rows.swap(0, 1); + } + rows +} + +/// Build a positive-definite diagonal matrix spanning 112 binary exponents at D=8. +/// +/// Each successive pivot is `2^-16` times the previous one. Benchmarks use a +/// zero tolerance so the complete, finite factorization remains in scope. +#[inline] +#[must_use] +pub fn make_ill_conditioned_matrix_rows() -> [[f64; D]; D] { + let mut rows = [[0.0; D]; D]; + let mut diagonal = 1.0; + for (index, row) in rows.iter_mut().enumerate() { + row[index] = diagonal; + diagonal *= 1.0 / 65_536.0; + } + rows +} + +/// Build a positive diagonal matrix whose factors underflow in sequential order +/// but whose balanced exact product is one when `D` is a multiple of four. +#[inline] +#[must_use] +pub fn make_balanced_dynamic_range_rows() -> [[f64; D]; D] { + const TWO_NEG_800: f64 = f64::from_bits(223_u64 << 52); + const TWO_POS_800: f64 = f64::from_bits(1823_u64 << 52); + + let mut rows = [[0.0; D]; D]; + for (index, row) in rows.iter_mut().enumerate() { + row[index] = if index % 4 < 2 { + TWO_NEG_800 + } else { + TWO_POS_800 + }; + } + rows +} + /// Return a deterministic benchmark vector entry. #[inline] -#[allow(clippy::cast_precision_loss)] // i is a small integer, precision loss is not an issue. +#[expect( + clippy::cast_precision_loss, + reason = "benchmark vector indices are small enough to be represented exactly as f64" +)] +#[must_use] pub fn vector_entry(i: usize, offset: f64) -> f64 { (i as f64) + 1.0 + offset } /// Build the shared vector input used by all crates for a dimension. #[inline] +#[must_use] pub fn make_vector_array(offset: f64) -> [f64; D] { let mut data = [0.0; D]; @@ -106,6 +185,7 @@ pub fn make_vector_array(offset: f64) -> [f64; D] { /// Compute nalgebra's matrix infinity norm using la-stack's row-sum convention. #[inline] +#[must_use] pub fn nalgebra_inf_norm(m: &SMatrix) -> f64 { // Infinity norm = max absolute row sum. let mut max_row_sum = 0.0; diff --git a/benches/exact.rs b/benches/exact.rs index 139f0db..5e4534c 100644 --- a/benches/exact.rs +++ b/benches/exact.rs @@ -11,48 +11,36 @@ //! reproducible input. //! 2. **Adversarial / extreme-input benches** — matrices chosen to //! stress specific corners of the exact-arithmetic pipeline: -//! near-singularity (forces the Bareiss fallback), large f64 entries +//! near-singularity (forces the exact integer fallback), large f64 entries //! (stresses intermediate `BigInt` growth), and Hilbert-style //! ill-conditioning (wide range of `(mantissa, exponent)` pairs in -//! the `f64_decompose → BigInt` path). These measure tail behaviour +//! the `decompose_f64 → BigInt` path). These measure tail behaviour //! that fixed well-conditioned inputs miss and provide stronger //! empirical evidence for `docs/PERFORMANCE.md`. -//! 3. **Random percentile benches** (`exact_random_percentile_d{2..5}`) — -//! a fixed-seed corpus of diagonally-dominant random matrices per -//! dimension. Each operation is pre-timed across the corpus to select -//! p50/p95/p99 cumulative input subsets, then measured with Criterion. +//! 3. **Random corpus benches** (`exact_random_corpus_d{2..5}`) — a +//! fixed-seed corpus of diagonally-dominant random matrices per dimension. +//! Every measured iteration executes the full corpus in its stable order, +//! so current and baseline revisions receive identical workloads. //! //! Fallible exact-to-f64 conversions use a `_result` suffix. Those rows measure //! the full `Result` path, including valid `Err(Unrepresentable)` outcomes for //! inputs whose exact answer cannot be represented as finite binary64. -use std::array; -use std::cell::Cell; use std::fmt::Display; use std::hint::black_box; -use std::num::NonZeroUsize; -use std::time::Instant; -use criterion::{BatchSize, BenchmarkGroup, Criterion, measurement::WallTime}; -use pastey::paste; +use criterion::{BenchmarkGroup, Criterion, Throughput, measurement::WallTime}; use la_stack::{Matrix, Vector}; -mod common { - pub mod exact; -} - -use common::exact::{ExactBenchConfigError, I16Range, SplitMix64}; +#[path = "common/exact.rs"] +pub mod exact_bench; -const RANDOM_INPUTS_PER_DIM: SampleCount = SampleCount::new_unchecked(50); -const RANDOM_INPUT_ARRAY_LEN: usize = RANDOM_INPUTS_PER_DIM.get(); -const RANDOM_TIMING_PASSES: SampleCount = SampleCount::new_unchecked(5); -const RANDOM_SEED: [u8; 32] = [0; 32]; -const RANDOM_PERCENTILES: [RandomPercentile; 3] = [ - RandomPercentile::P50, - RandomPercentile::P95, - RandomPercentile::P99, -]; +use exact_bench::{ + ExactInput, RANDOM_INPUT_ARRAY_LEN, ValidatedExactInput, hilbert_input, + large_entries_3x3_input, make_matrix_rows, make_random_input_corpus, make_vector_array, + near_singular_3x3_input, validate_exact_fixture, +}; /// Return a successful benchmark operation result or panic with the named operation. fn require_ok(result: Result, operation: &str) -> T { @@ -62,114 +50,9 @@ fn require_ok(result: Result, operation: &str) -> T { } } -/// Non-zero sample count used when selecting percentile benchmark inputs. -#[derive(Clone, Copy)] -struct SampleCount { - len: NonZeroUsize, -} - -impl SampleCount { - /// Construct a sample count for compile-time constants with visible nonzero values. - const fn new_unchecked(len: usize) -> Self { - match NonZeroUsize::new(len) { - Some(len) => Self { len }, - None => panic!("random input corpus must be nonempty"), - } - } - - /// Validate a runtime sample count before percentile calculations use it. - const fn new(len: usize) -> Result { - if let Some(len) = NonZeroUsize::new(len) { - Ok(Self { len }) - } else { - Err(ExactBenchConfigError::EmptyCorpus) - } - } - - /// Return the proven nonzero sample count as a raw `usize`. - const fn get(self) -> usize { - self.len.get() - } -} - -/// Percentiles selected from a pre-timed random-input corpus. -#[derive(Clone, Copy)] -enum RandomPercentile { - P50, - P95, - P99, -} - -impl RandomPercentile { - /// Return the percentile value as an integer percentage. - const fn value(self) -> usize { - match self { - Self::P50 => 50, - Self::P95 => 95, - Self::P99 => 99, - } - } - - /// Return the benchmark-name suffix for this percentile. - const fn name(self) -> &'static str { - match self { - Self::P50 => "p50", - Self::P95 => "p95", - Self::P99 => "p99", - } - } -} - -/// Return a deterministic, strictly diagonally-dominant benchmark matrix entry. -#[inline] -#[allow(clippy::cast_precision_loss)] -const fn matrix_entry(r: usize, c: usize) -> f64 { - if r == c { - (r as f64).mul_add(1.0e-3, (D as f64) + 1.0) - } else { - 0.1 / ((r + c + 1) as f64) - } -} - -/// Build the deterministic baseline matrix rows for dimension `D`. -#[inline] -const fn make_matrix_rows() -> [[f64; D]; D] { - let mut rows = [[0.0; D]; D]; - let mut r = 0; - while r < D { - let mut c = 0; - while c < D { - rows[r][c] = matrix_entry::(r, c); - c += 1; - } - r += 1; - } - rows -} - -/// Build the deterministic baseline right-hand-side vector for dimension `D`. -#[inline] -#[allow(clippy::cast_precision_loss)] -fn make_vector_array() -> [f64; D] { - let mut data = [0.0; D]; - let mut i = 0; - while i < D { - data[i] = (i as f64) + 1.0; - i += 1; - } - data -} - -/// Matrix/RHS pair used by random percentile exact-arithmetic benchmarks. +/// Exact operation measured by a benchmark group. #[derive(Clone, Copy)] -struct ExactRandomInput { - matrix: Matrix, - rhs: Vector, -} - -/// Exact operation timed when selecting representative random inputs. -#[derive(Clone, Copy)] -enum ExactRandomOperation { +enum ExactOperation { DetSignExact, DetExact, DetExactF64Result, @@ -179,7 +62,7 @@ enum ExactRandomOperation { SolveExactRoundedF64, } -impl ExactRandomOperation { +impl ExactOperation { /// Return the benchmark-name stem for this exact operation. const fn name(self) -> &'static str { match self { @@ -194,101 +77,60 @@ impl ExactRandomOperation { } } -/// Derive a stable per-dimension seed from the global random benchmark seed. -#[allow(clippy::cast_possible_truncation)] -fn random_seed_for_dim() -> u64 { - let mut seed = - 0xC0DE_CAFE_D15C_A11Au64 ^ require_ok(u64::try_from(D), "dimension seed conversion"); - for (i, byte) in RANDOM_SEED.iter().copied().enumerate() { - let shift = require_ok(u32::try_from((i % 8) * 8), "seed shift conversion"); - seed ^= u64::from(byte) << shift; - seed = seed.rotate_left(7) ^ require_ok(u64::try_from(i), "seed index conversion"); - } - seed -} - -/// Build a fixed random corpus of finite, strictly diagonally-dominant inputs. -fn make_random_input_corpus() -> [ExactRandomInput; RANDOM_INPUT_ARRAY_LEN] { - let mut rng = SplitMix64::new(random_seed_for_dim::()); - let entry_range = require_ok(I16Range::new(-10, 10), "random integer range"); - array::from_fn(|_| { - let mut rows = [[0.0; D]; D]; - let mut diag = [0_i16; D]; - - for (r, row) in rows.iter_mut().enumerate() { - for (c, entry) in row.iter_mut().enumerate() { - if r == c { - diag[r] = rng.next_i16(entry_range); - } else { - *entry = f64::from(rng.next_i16(entry_range)); - } - } - } - - let shift = - f64::from(require_ok(u8::try_from(D), "dimension shift conversion")).mul_add(10.0, 1.0); - for (i, row) in rows.iter_mut().enumerate() { - row[i] = if diag[i] >= 0 { - f64::from(diag[i]) + shift - } else { - f64::from(diag[i]) - shift - }; - } - - let rhs = array::from_fn(|_| f64::from(rng.next_i16(entry_range))); +const GENERAL_OPERATIONS: &[ExactOperation] = &[ + ExactOperation::DetExact, + ExactOperation::DetExactF64Result, + ExactOperation::DetExactRoundedF64, + ExactOperation::DetSignExact, + ExactOperation::SolveExact, + ExactOperation::SolveExactF64Result, + ExactOperation::SolveExactRoundedF64, +]; - ExactRandomInput { - matrix: require_ok( - Matrix::::try_from_rows(rows), - "random matrix construction", - ), - rhs: require_ok(Vector::::try_new(rhs), "random RHS vector construction"), - } - }) -} +const CORPUS_AND_EXTREME_OPERATIONS: &[ExactOperation] = &[ + ExactOperation::DetSignExact, + ExactOperation::DetExact, + ExactOperation::SolveExact, + ExactOperation::SolveExactF64Result, + ExactOperation::SolveExactRoundedF64, +]; -/// Execute one exact operation on a random benchmark input. -fn run_random_operation( - operation: ExactRandomOperation, - input: ExactRandomInput, -) { +/// Execute one exact operation on a borrowed, independently validated input. +fn run_exact_operation(operation: ExactOperation, input: &ValidatedExactInput) { match operation { - ExactRandomOperation::DetSignExact => { - let sign = require_ok( - black_box(input.matrix).det_sign_exact(), - "exact determinant sign", - ); - black_box(sign); + ExactOperation::DetSignExact => { + let sign = black_box(input.matrix()).det_sign_exact(); + let _ = black_box(sign); } - ExactRandomOperation::DetExact => { - let det = require_ok(black_box(input.matrix).det_exact(), "exact determinant"); + ExactOperation::DetExact => { + let det = require_ok(black_box(input.matrix()).det_exact(), "exact determinant"); black_box(det); } - ExactRandomOperation::DetExactF64Result => { - let det = black_box(input.matrix).det_exact_f64(); + ExactOperation::DetExactF64Result => { + let det = black_box(input.matrix()).det_exact_f64(); let _ = black_box(det); } - ExactRandomOperation::DetExactRoundedF64 => { + ExactOperation::DetExactRoundedF64 => { let det = require_ok( - black_box(input.matrix).det_exact_rounded_f64(), + black_box(input.matrix()).det_exact_rounded_f64(), "exact determinant rounded to f64", ); let _ = black_box(det); } - ExactRandomOperation::SolveExact => { + ExactOperation::SolveExact => { let x = require_ok( - black_box(input.matrix).solve_exact(black_box(input.rhs)), + black_box(input.matrix()).solve_exact(black_box(input.rhs())), "exact linear solve", ); let _ = black_box(x); } - ExactRandomOperation::SolveExactF64Result => { - let x = black_box(input.matrix).solve_exact_f64(black_box(input.rhs)); + ExactOperation::SolveExactF64Result => { + let x = black_box(input.matrix()).solve_exact_f64(black_box(input.rhs())); let _ = black_box(x); } - ExactRandomOperation::SolveExactRoundedF64 => { + ExactOperation::SolveExactRoundedF64 => { let x = require_ok( - black_box(input.matrix).solve_exact_rounded_f64(black_box(input.rhs)), + black_box(input.matrix()).solve_exact_rounded_f64(black_box(input.rhs())), "exact linear solve rounded to f64", ); let _ = black_box(x); @@ -296,171 +138,32 @@ fn run_random_operation( } } -/// Add one exact-arithmetic operation benchmark over a fixed input pair. +/// Add one exact-arithmetic operation benchmark over a validated fixed input pair. fn bench_exact_operation( group: &mut BenchmarkGroup<'_, WallTime>, - operation: ExactRandomOperation, - matrix: Matrix, - rhs: Vector, + operation: ExactOperation, + input: &ValidatedExactInput, ) { group.bench_function(operation.name(), |bencher| { bencher.iter(|| { - run_random_operation(operation, ExactRandomInput { matrix, rhs }); + run_exact_operation(operation, input); }); }); } -/// Time one exact operation on one random input in nanoseconds. -fn time_random_operation( - operation: ExactRandomOperation, - input: ExactRandomInput, -) -> u128 { - let start = Instant::now(); - run_random_operation(operation, input); - start.elapsed().as_nanos() -} - -/// Time one exact operation repeatedly on one random input. -fn time_random_operation_repeated( - operation: ExactRandomOperation, - input: ExactRandomInput, -) -> u128 { - let mut elapsed = 0; - for _ in 0..RANDOM_TIMING_PASSES.get() { - elapsed += time_random_operation(operation, input); - } - elapsed -} - -/// Convert a percentile request into an index in a sorted timing corpus. -const fn percentile_index(count: SampleCount, percentile: RandomPercentile) -> usize { - ((count.get() - 1) * percentile.value() + 50) / 100 -} - -/// Select cumulative corpus index sets by pre-timing every input for one operation. -fn percentile_input_indices( - corpus: &[ExactRandomInput; RANDOM_INPUT_ARRAY_LEN], - operation: ExactRandomOperation, -) -> [Vec; RANDOM_PERCENTILES.len()] { - let input_count = require_ok(SampleCount::new(corpus.len()), "random input corpus size"); - let mut timings = [(0_u128, 0_usize); RANDOM_INPUT_ARRAY_LEN]; - for (i, input) in corpus.iter().copied().enumerate() { - timings[i] = (time_random_operation_repeated(operation, input), i); - } - timings.sort_unstable(); - - RANDOM_PERCENTILES.map(|percentile| { - let timing_idx = percentile_index(input_count, percentile); - let threshold = timings[timing_idx].0; - let selected_len = timings.partition_point(|&(elapsed, _)| elapsed <= threshold); - timings[..selected_len] - .iter() - .map(|&(_, input_idx)| input_idx) - .collect() - }) -} - -/// Add p50/p95/p99 Criterion benches over percentile input sets. -fn bench_random_percentile_operation( +/// Add one Criterion benchmark that executes the complete validated random corpus. +fn bench_random_corpus_operation( group: &mut BenchmarkGroup<'_, WallTime>, - corpus: &[ExactRandomInput; RANDOM_INPUT_ARRAY_LEN], - operation: ExactRandomOperation, + corpus: &[ValidatedExactInput; RANDOM_INPUT_ARRAY_LEN], + operation: ExactOperation, ) { - let index_sets = percentile_input_indices(corpus, operation); - - for (percentile, input_indices) in RANDOM_PERCENTILES.into_iter().zip(index_sets) { - let input_count = require_ok( - SampleCount::new(input_indices.len()), - "percentile input set size", - ); - let cursor = Cell::new(0); - group.bench_function( - format!("{}_{}", operation.name(), percentile.name()), - move |bencher| { - bencher.iter_batched( - || { - let cursor_pos = cursor.get(); - cursor.set((cursor_pos + 1) % input_count.get()); - corpus[input_indices[cursor_pos]] - }, - |sample| run_random_operation(operation, sample), - BatchSize::SmallInput, - ); - }, - ); - } -} - -/// Near-singular matrix: base singular matrix + tiny perturbation. -/// -/// The base `[[1,2,3],[4,5,6],[7,8,9]]` is exactly singular; adding -/// `2^-50` to entry (0,0) makes `det = -3 × 2^-50 ≠ 0`. The f64 filter -/// in `det_sign_exact` cannot resolve this sign, so Bareiss is forced; -/// `solve_exact` is the primary use case for near-degenerate inputs -/// (exact circumcenter etc.) and exercises the largest intermediate -/// `BigInt` values in the hybrid solve. -#[inline] -fn near_singular_3x3() -> Matrix<3> { - let perturbation = f64::from_bits(0x3CD0_0000_0000_0000); // 2^-50 - require_ok( - Matrix::<3>::try_from_rows([ - [1.0 + perturbation, 2.0, 3.0], - [4.0, 5.0, 6.0], - [7.0, 8.0, 9.0], - ]), - "near-singular matrix construction", - ) -} - -/// Large-entry 3×3: strictly diagonally-dominant matrix with diagonal -/// entries near `f64::MAX / 2` and ones elsewhere. -/// -/// Each big entry decomposes into a 53-bit mantissa with exponent `~970`; -/// the unit off-diagonals have exponent `0`, so the shared `e_min = 0` -/// shift in `component_to_bigint` produces `BigInt`s of `~1023` bits for -/// the diagonal and small integers elsewhere. Bareiss fraction-free -/// updates then multiply these together, stressing the big-integer -/// multiply and allocator along the full `O(D³)` elimination phase. The -/// matrix is non-singular (det ≈ `big³`) so both `det_*` and `solve_*` -/// paths complete. -#[inline] -fn large_entries_3x3() -> Matrix<3> { - let big = f64::MAX / 2.0; - require_ok( - Matrix::<3>::try_from_rows([[big, 1.0, 1.0], [1.0, big, 1.0], [1.0, 1.0, big]]), - "large-entry matrix construction", - ) -} - -/// Hilbert matrix `H[i][j] = 1 / (i + j + 1)`. -/// -/// Most entries (`1/3`, `1/5`, `1/6`, `1/7`, …) are non-terminating in -/// binary, so every cell has a distinct 53-bit mantissa and a small -/// negative exponent. `f64_decompose` therefore produces a wide mix of -/// `(mantissa, exponent)` pairs with no shared power-of-two factors, -/// and the scaling shift to the common `e_min` yields `BigInt` values -/// of varied bit-lengths — a different kind of adversarial input from -/// the large-entries case. Hilbert matrices are also classically -/// ill-conditioned (condition number grows exponentially with D), so -/// they are a realistic stand-in for the near-degenerate geometric -/// predicate inputs that motivate exact arithmetic. -#[inline] -#[allow(clippy::cast_precision_loss)] -fn hilbert() -> Matrix { - let mut rows = [[0.0; D]; D]; - let mut r = 0; - while r < D { - let mut c = 0; - while c < D { - rows[r][c] = 1.0 / ((r + c + 1) as f64); - c += 1; - } - r += 1; - } - require_ok( - Matrix::::try_from_rows(rows), - "Hilbert matrix construction", - ) + group.bench_function(operation.name(), |bencher| { + bencher.iter(|| { + for input in corpus { + run_exact_operation(operation, input); + } + }); + }); } /// Populate a Criterion group with the five headline exact-arithmetic @@ -472,119 +175,114 @@ fn hilbert() -> Matrix { /// operations, making the resulting tables directly comparable. fn bench_extreme_group( group: &mut BenchmarkGroup<'_, WallTime>, - m: Matrix, - rhs: Vector, + input: &ValidatedExactInput, +) { + for &operation in CORPUS_AND_EXTREME_OPERATIONS { + bench_exact_operation(group, operation, input); + } +} + +/// Add the direct-determinant baseline for a dimension that supports it. +fn bench_det_direct( + group: &mut BenchmarkGroup<'_, WallTime>, + input: &ValidatedExactInput, ) { - bench_exact_operation(group, ExactRandomOperation::DetSignExact, m, rhs); - bench_exact_operation(group, ExactRandomOperation::DetExact, m, rhs); - bench_exact_operation(group, ExactRandomOperation::SolveExact, m, rhs); - bench_exact_operation(group, ExactRandomOperation::SolveExactF64Result, m, rhs); - bench_exact_operation(group, ExactRandomOperation::SolveExactRoundedF64, m, rhs); + let Some(_) = require_ok(input.matrix().det_direct(), "direct determinant setup") else { + panic!("det_direct must support this benchmark dimension"); + }; + group.bench_function("det_direct", |bencher| { + bencher.iter(|| { + let det = require_ok( + black_box(input.matrix()).det_direct(), + "direct f64 determinant", + ); + let Some(det) = det else { + panic!("det_direct support changed after benchmark setup"); + }; + black_box(det); + }); + }); +} + +macro_rules! register_det_direct_benchmark { + ($group:expr, $input:expr, supported) => {{ + bench_det_direct(&mut $group, &$input); + }}; + ($group:expr, $matrix:expr, unsupported) => {}; } macro_rules! gen_exact_benches_for_dim { - ($c:expr, $d:literal) => { - paste! {{ - let a = require_ok( + ($c:expr, $d:literal, $direct:ident) => {{ + let input = validate_exact_fixture(ExactInput { + matrix: require_ok( Matrix::<$d>::try_from_rows(make_matrix_rows::<$d>()), "benchmark matrix construction", - ); - let rhs = require_ok( + ), + rhs: require_ok( Vector::<$d>::try_new(make_vector_array::<$d>()), "benchmark RHS vector construction", - ); + ), + }); - let mut [] = ($c).benchmark_group(concat!("exact_d", stringify!($d))); + let mut group = ($c).benchmark_group(concat!("exact_d", stringify!($d))); - // === f64 baselines === - [].bench_function("det", |bencher| { - bencher.iter(|| { - let det = require_ok(black_box(a).det(), "f64 determinant"); - black_box(det); - }); + // === f64 baselines === + group.bench_function("det", |bencher| { + bencher.iter(|| { + let det = require_ok(black_box(input.matrix()).det(), "f64 determinant"); + black_box(det); }); + }); - [].bench_function("det_direct", |bencher| { - bencher.iter(|| { - let det = black_box(a).det_direct(); - black_box(det); - }); - }); + register_det_direct_benchmark!(group, input, $direct); - bench_exact_operation(&mut [], ExactRandomOperation::DetExact, a, rhs); - bench_exact_operation(&mut [], ExactRandomOperation::DetExactF64Result, a, rhs); - bench_exact_operation(&mut [], ExactRandomOperation::DetExactRoundedF64, a, rhs); - bench_exact_operation(&mut [], ExactRandomOperation::DetSignExact, a, rhs); - bench_exact_operation(&mut [], ExactRandomOperation::SolveExact, a, rhs); - bench_exact_operation(&mut [], ExactRandomOperation::SolveExactF64Result, a, rhs); - bench_exact_operation(&mut [], ExactRandomOperation::SolveExactRoundedF64, a, rhs); + for &operation in GENERAL_OPERATIONS { + bench_exact_operation(&mut group, operation, &input); + } - [].finish(); - }}; - }; + group.finish(); + }}; } -macro_rules! gen_random_percentile_benches_for_dim { - ($c:expr, $d:literal) => { - paste! {{ - let corpus = make_random_input_corpus::<$d>(); - let mut [] = - ($c).benchmark_group(concat!("exact_random_percentile_d", stringify!($d))); - - bench_random_percentile_operation( - &mut [], - &corpus, - ExactRandomOperation::DetSignExact, - ); - bench_random_percentile_operation( - &mut [], - &corpus, - ExactRandomOperation::DetExact, - ); - bench_random_percentile_operation( - &mut [], - &corpus, - ExactRandomOperation::SolveExact, - ); - bench_random_percentile_operation( - &mut [], - &corpus, - ExactRandomOperation::SolveExactF64Result, - ); - bench_random_percentile_operation( - &mut [], - &corpus, - ExactRandomOperation::SolveExactRoundedF64, - ); +macro_rules! gen_random_corpus_benches_for_dim { + ($c:expr, $d:literal) => {{ + let corpus = make_random_input_corpus::<$d>().map(validate_exact_fixture); - [].finish(); - }}; - }; + let mut group = ($c).benchmark_group(concat!("exact_random_corpus_d", stringify!($d))); + let input_count = require_ok( + u64::try_from(corpus.len()), + "random corpus throughput conversion", + ); + group.throughput(Throughput::Elements(input_count)); + + for &operation in CORPUS_AND_EXTREME_OPERATIONS { + bench_random_corpus_operation(&mut group, &corpus, operation); + } + + group.finish(); + }}; } fn main() { let mut c = Criterion::default().configure_from_args(); - #[allow(unused_must_use)] { - gen_exact_benches_for_dim!(&mut c, 2); - gen_exact_benches_for_dim!(&mut c, 3); - gen_exact_benches_for_dim!(&mut c, 4); - gen_exact_benches_for_dim!(&mut c, 5); + gen_exact_benches_for_dim!(&mut c, 2, supported); + gen_exact_benches_for_dim!(&mut c, 3, supported); + gen_exact_benches_for_dim!(&mut c, 4, supported); + gen_exact_benches_for_dim!(&mut c, 5, unsupported); } - // === Random percentile groups === + // === Fixed random-corpus groups === // - // Each dimension uses a fixed-seed corpus of strictly - // diagonally-dominant integer matrices. For each operation, the corpus - // is pre-timed repeatedly to select cumulative p50/p95/p99 input sets, - // then Criterion cycles through each set with normal sampling. - #[allow(unused_must_use)] + // Each measured iteration executes all 50 strictly diagonally-dominant + // integer inputs in their fixed-seed order. Baseline and current revisions + // therefore receive exactly the same workload. { - gen_random_percentile_benches_for_dim!(&mut c, 2); - gen_random_percentile_benches_for_dim!(&mut c, 3); - gen_random_percentile_benches_for_dim!(&mut c, 4); - gen_random_percentile_benches_for_dim!(&mut c, 5); + gen_random_corpus_benches_for_dim!(&mut c, 2); + gen_random_corpus_benches_for_dim!(&mut c, 3); + gen_random_corpus_benches_for_dim!(&mut c, 4); + gen_random_corpus_benches_for_dim!(&mut c, 5); } // === Adversarial / extreme-input groups === @@ -595,63 +293,38 @@ fn main() { // via `bench_extreme_group`, so the resulting tables are directly // comparable across input classes. - // Near-singular 3×3: forces Bareiss fallback in det_sign_exact and - // exercises the largest intermediate BigInt values in solve_exact - // (the primary motivating use case for exact solve). + // Near-singular 3×3: forces the direct BigInt fallback in det_sign_exact + // and exercises an ill-conditioned exact solve. { + let input = validate_exact_fixture(near_singular_3x3_input()); let mut group = c.benchmark_group("exact_near_singular_3x3"); - bench_extreme_group( - &mut group, - near_singular_3x3(), - require_ok( - Vector::<3>::try_new([1.0, 2.0, 3.0]), - "near-singular RHS vector construction", - ), - ); + bench_extreme_group(&mut group, &input); group.finish(); } // Large-entry 3×3: diagonal entries near `f64::MAX / 2` stress // BigInt growth during Bareiss forward elimination. { + let input = validate_exact_fixture(large_entries_3x3_input()); let mut group = c.benchmark_group("exact_large_entries_3x3"); - bench_extreme_group( - &mut group, - large_entries_3x3(), - require_ok( - Vector::<3>::try_new([1.0, 1.0, 1.0]), - "large-entry RHS vector construction", - ), - ); + bench_extreme_group(&mut group, &input); group.finish(); } // Hilbert 4×4 and 5×5: classically ill-conditioned matrices whose - // entries span many orders of magnitude in `(mantissa, exponent)` - // space, exercising the f64 → BigInt scaling path. + // entries have varied binary mantissas and exponents, exercising the + // f64 → BigInt scaling path. { + let input = validate_exact_fixture(hilbert_input::<4>()); let mut group = c.benchmark_group("exact_hilbert_4x4"); - bench_extreme_group( - &mut group, - hilbert::<4>(), - require_ok( - Vector::<4>::try_new([1.0; 4]), - "Hilbert RHS vector construction", - ), - ); + bench_extreme_group(&mut group, &input); group.finish(); } { + let input = validate_exact_fixture(hilbert_input::<5>()); let mut group = c.benchmark_group("exact_hilbert_5x5"); - bench_extreme_group( - &mut group, - hilbert::<5>(), - require_ok( - Vector::<5>::try_new([1.0; 5]), - "Hilbert RHS vector construction", - ), - ); + bench_extreme_group(&mut group, &input); group.finish(); } diff --git a/benches/vs_linalg.rs b/benches/vs_linalg.rs index 9b129a4..3b0f2f7 100644 --- a/benches/vs_linalg.rs +++ b/benches/vs_linalg.rs @@ -12,21 +12,22 @@ use std::fmt::Display; use std::hint::black_box; -use criterion::Criterion; +use criterion::measurement::WallTime; +use criterion::{BatchSize, BenchmarkGroup, Criterion}; use faer::linalg::solvers::Solve; +use faer::mat::AsMatRef; use faer::{Mat, Side}; -use nalgebra::{SMatrix, SVector}; -use pastey::paste; +use nalgebra::{Const, DimMin, SMatrix, SVector}; -use la_stack::{DEFAULT_SINGULAR_TOL, Matrix, Vector}; +use la_stack::{DEFAULT_SINGULAR_TOL, Matrix, Tolerance, Vector}; -mod common { - pub mod vs_linalg; -} +#[path = "common/vs_linalg.rs"] +pub mod vs_linalg_common; -use common::vs_linalg::{ - faer_det_from_ldlt, faer_det_from_partial_piv_lu, make_matrix_rows, make_vector_array, - matrix_entry, nalgebra_inf_norm, vector_entry, +use vs_linalg_common::{ + faer_det_from_ldlt, faer_det_from_partial_piv_lu, make_balanced_dynamic_range_rows, + make_ill_conditioned_matrix_rows, make_matrix_rows, make_pivoting_matrix_rows, + make_vector_array, matrix_entry, nalgebra_inf_norm, vector_entry, }; /// Return a successful benchmark operation result or panic with the named operation. @@ -42,398 +43,648 @@ fn require_some(value: Option, operation: &str) -> T { value.unwrap_or_else(|| panic!("{operation} returned no result")) } -macro_rules! define_vs_linalg_benches_for_dim { - ($fn_name:ident, $d:literal) => { - paste! { - #[allow(clippy::too_many_lines)] - fn $fn_name(c: &mut Criterion) { - // Isolate each dimension's inputs to keep types and captures clean. - { - let a = require_ok( - Matrix::<$d>::try_from_rows(make_matrix_rows::<$d>()), - "la_stack matrix construction", +/// Build the deterministic la-stack matrix shared by a benchmark family. +fn la_matrix() -> Matrix { + require_ok( + Matrix::try_from_rows(make_matrix_rows()), + "la_stack matrix construction", + ) +} + +/// Build a deterministic la-stack vector with the requested offset. +fn la_vector(offset: f64, operation: &str) -> Vector { + require_ok(Vector::try_new(make_vector_array(offset)), operation) +} + +/// Build the deterministic nalgebra matrix shared by a benchmark family. +fn nalgebra_matrix() -> SMatrix { + SMatrix::from_fn(matrix_entry::) +} + +/// Build a deterministic nalgebra vector with the requested offset. +fn nalgebra_vector(offset: f64) -> SVector { + SVector::from_fn(|i, _| vector_entry(i, offset)) +} + +/// Build the deterministic faer matrix shared by a benchmark family. +fn faer_matrix() -> Mat { + Mat::from_fn(D, D, matrix_entry::) +} + +/// Build a deterministic faer column vector with the requested offset. +fn faer_vector(offset: f64) -> Mat { + Mat::from_fn(D, 1, |i, _| vector_entry(i, offset)) +} + +/// Register determinant benchmarks that include factorization work. +fn register_determinant_benchmarks(group: &mut BenchmarkGroup<'_, WallTime>) +where + Const: DimMin, Output = Const>, +{ + let a = la_matrix::(); + let na = nalgebra_matrix::(); + let fa = faer_matrix::(); + + group.bench_function("la_stack_det_via_lu", |bencher| { + bencher.iter_batched( + || a, + |a| { + let lu = require_ok( + black_box(a).lu(DEFAULT_SINGULAR_TOL), + "la_stack LU factorization", + ); + let det = require_ok(lu.det(), "la_stack LU determinant"); + black_box(det); + }, + BatchSize::SmallInput, + ); + }); + + group.bench_function("nalgebra_det_via_lu", |bencher| { + bencher.iter_batched( + || na, + |na| { + let lu = black_box(na).lu(); + let det = lu.determinant(); + black_box(det); + }, + BatchSize::SmallInput, + ); + }); + + group.bench_function("faer_det_via_lu", |bencher| { + bencher.iter_batched( + || &fa, + |fa| { + let lu = black_box(fa).partial_piv_lu(); + let det = faer_det_from_partial_piv_lu(&lu); + black_box(det); + }, + BatchSize::SmallInput, + ); + }); + + group.bench_function("la_stack_det", |bencher| { + bencher.iter_batched( + || a, + |a| { + let det = require_ok(black_box(a).det(), "la_stack determinant"); + black_box(det); + }, + BatchSize::SmallInput, + ); + }); +} + +/// Register LU, LDLT, and Cholesky factorization benchmarks. +fn register_factorization_benchmarks(group: &mut BenchmarkGroup<'_, WallTime>) +where + Const: DimMin, Output = Const>, +{ + let a = la_matrix::(); + let na = nalgebra_matrix::(); + let fa = faer_matrix::(); + + group.bench_function("la_stack_lu", |bencher| { + bencher.iter_batched( + || a, + |a| { + let lu = require_ok( + black_box(a).lu(DEFAULT_SINGULAR_TOL), + "la_stack LU factorization", ); - let rhs = require_ok( - Vector::<$d>::try_new(make_vector_array::<$d>(0.0)), - "la_stack RHS vector construction", + let _ = black_box(lu); + }, + BatchSize::SmallInput, + ); + }); + + group.bench_function("nalgebra_lu", |bencher| { + bencher.iter_batched( + || na, + |na| { + let lu = black_box(na).lu(); + let _ = black_box(lu); + }, + BatchSize::SmallInput, + ); + }); + + group.bench_function("faer_lu", |bencher| { + bencher.iter_batched( + || &fa, + |fa| { + let lu = black_box(fa).partial_piv_lu(); + let _ = black_box(lu); + }, + BatchSize::SmallInput, + ); + }); + + group.bench_function("la_stack_ldlt", |bencher| { + bencher.iter_batched( + || a, + |a| { + let ldlt = require_ok( + black_box(a).ldlt(DEFAULT_SINGULAR_TOL), + "la_stack LDLT factorization", ); - let v1 = require_ok( - Vector::<$d>::try_new(make_vector_array::<$d>(0.0)), - "la_stack vector construction", + let _ = black_box(ldlt); + }, + BatchSize::SmallInput, + ); + }); + + group.bench_function("nalgebra_cholesky", |bencher| { + bencher.iter_batched( + || na, + |na| { + let chol = + require_some(black_box(na).cholesky(), "nalgebra Cholesky factorization"); + black_box(chol); + }, + BatchSize::SmallInput, + ); + }); + + group.bench_function("faer_ldlt", |bencher| { + bencher.iter_batched( + || &fa, + |fa| { + let ldlt = require_ok(black_box(fa).ldlt(Side::Lower), "faer LDLT factorization"); + let _ = black_box(ldlt); + }, + BatchSize::SmallInput, + ); + }); +} + +/// Register solves that include LU factorization work. +fn register_lu_solve_benchmarks(group: &mut BenchmarkGroup<'_, WallTime>) +where + Const: DimMin, Output = Const>, +{ + let a = la_matrix::(); + let rhs = la_vector::(0.0, "la_stack RHS vector construction"); + let na = nalgebra_matrix::(); + let nrhs = nalgebra_vector::(0.0); + let fa = faer_matrix::(); + let frhs = faer_vector::(0.0); + + group.bench_function("la_stack_lu_solve", |bencher| { + bencher.iter_batched( + || (a, rhs), + |(a, rhs)| { + let lu = require_ok( + black_box(a).lu(DEFAULT_SINGULAR_TOL), + "la_stack LU factorization", ); - let v2 = require_ok( - Vector::<$d>::try_new(make_vector_array::<$d>(1.0)), - "la_stack vector construction", + let x = require_ok(lu.solve(black_box(rhs)), "la_stack LU solve"); + let _ = black_box(x); + }, + BatchSize::SmallInput, + ); + }); + + group.bench_function("nalgebra_lu_solve", |bencher| { + bencher.iter_batched( + || (na, nrhs), + |(na, nrhs)| { + let lu = black_box(na).lu(); + let x = require_some(lu.solve(black_box(&nrhs)), "nalgebra LU solve"); + black_box(x); + }, + BatchSize::SmallInput, + ); + }); + + group.bench_function("faer_lu_solve", |bencher| { + bencher.iter_batched( + || (&fa, &frhs), + |(fa, rhs)| { + let lu = black_box(fa).partial_piv_lu(); + let x = lu.solve(black_box(rhs)); + black_box(x); + }, + BatchSize::SmallInput, + ); + }); +} + +/// Register solves that include LDLT or Cholesky factorization work. +fn register_ldlt_solve_benchmarks(group: &mut BenchmarkGroup<'_, WallTime>) { + let a = la_matrix::(); + let rhs = la_vector::(0.0, "la_stack RHS vector construction"); + let na = nalgebra_matrix::(); + let nrhs = nalgebra_vector::(0.0); + let fa = faer_matrix::(); + let frhs = faer_vector::(0.0); + + group.bench_function("la_stack_ldlt_solve", |bencher| { + bencher.iter_batched( + || (a, rhs), + |(a, rhs)| { + let ldlt = require_ok( + black_box(a).ldlt(DEFAULT_SINGULAR_TOL), + "la_stack LDLT factorization", ); - let na = SMatrix::::from_fn(|r, c| matrix_entry::<$d>(r, c)); - let nrhs = SVector::::from_fn(|i, _| vector_entry(i, 0.0)); - let nv1 = SVector::::from_fn(|i, _| vector_entry(i, 0.0)); - let nv2 = SVector::::from_fn(|i, _| vector_entry(i, 1.0)); - - let fa = Mat::::from_fn($d, $d, |r, c| matrix_entry::<$d>(r, c)); - let frhs = Mat::::from_fn($d, 1, |i, _| vector_entry(i, 0.0)); - let fv1 = Mat::::from_fn($d, 1, |i, _| vector_entry(i, 0.0)); - let fv2 = Mat::::from_fn($d, 1, |i, _| vector_entry(i, 1.0)); - - // Precompute LU once for solve-only / det-only benchmarks. - let a_lu = require_ok(a.lu(DEFAULT_SINGULAR_TOL), "precomputed la_stack LU"); - let a_ldlt = require_ok(a.ldlt(DEFAULT_SINGULAR_TOL), "precomputed la_stack LDLT"); - let na_lu = na.clone().lu(); - let na_cholesky = require_some(na.clone().cholesky(), "precomputed nalgebra Cholesky"); - let fa_lu = fa.partial_piv_lu(); - let fa_ldlt = require_ok(fa.ldlt(Side::Lower), "precomputed faer LDLT"); - - let mut [] = c.benchmark_group(concat!("d", stringify!($d))); - - // === Determinant via LU (factor + det) === - [].bench_function("la_stack_det_via_lu", |bencher| { - bencher.iter(|| { - let lu = require_ok( - black_box(a).lu(DEFAULT_SINGULAR_TOL), - "la_stack LU factorization", - ); - let det = require_ok(lu.det(), "la_stack LU determinant"); - black_box(det); - }); - }); - - [].bench_function("nalgebra_det_via_lu", |bencher| { - bencher.iter(|| { - let lu = black_box(na.clone()).lu(); - let det = lu.determinant(); - black_box(det); - }); - }); - - [].bench_function("faer_det_via_lu", |bencher| { - bencher.iter(|| { - let lu = black_box(&fa).partial_piv_lu(); - let det = faer_det_from_partial_piv_lu(&lu); - black_box(det); - }); - }); - - // === Determinant via det() (closed-form for D≤4, LU for D≥5) === - [].bench_function("la_stack_det", |bencher| { - bencher.iter(|| { - let det = require_ok(black_box(a).det(), "la_stack determinant"); - black_box(det); - }); - }); - - // === LU factorization === - [].bench_function("la_stack_lu", |bencher| { - bencher.iter(|| { - let lu = require_ok( - black_box(a).lu(DEFAULT_SINGULAR_TOL), - "la_stack LU factorization", - ); - let _ = black_box(lu); - }); - }); - - [].bench_function("nalgebra_lu", |bencher| { - bencher.iter(|| { - let lu = black_box(na.clone()).lu(); - black_box(lu); - }); - }); - - [].bench_function("faer_lu", |bencher| { - bencher.iter(|| { - let lu = black_box(&fa).partial_piv_lu(); - black_box(lu); - }); - }); - - // === SPD factorization (LDLT / Cholesky) === - [].bench_function("la_stack_ldlt", |bencher| { - bencher.iter(|| { - let ldlt = require_ok( - black_box(a).ldlt(DEFAULT_SINGULAR_TOL), - "la_stack LDLT factorization", - ); - let _ = black_box(ldlt); - }); - }); - - [].bench_function("nalgebra_cholesky", |bencher| { - bencher.iter(|| { - let chol = require_some( - black_box(na.clone()).cholesky(), - "nalgebra Cholesky factorization", - ); - black_box(chol); - }); - }); - - [].bench_function("faer_ldlt", |bencher| { - bencher.iter(|| { - let ldlt = require_ok( - black_box(&fa).ldlt(Side::Lower), - "faer LDLT factorization", - ); - black_box(ldlt); - }); - }); - - // === LU solve (factor + solve) === - [].bench_function("la_stack_lu_solve", |bencher| { - bencher.iter(|| { - let lu = require_ok( - black_box(a).lu(DEFAULT_SINGULAR_TOL), - "la_stack LU factorization", - ); - let x = require_ok( - lu.solve(black_box(rhs)), - "la_stack LU solve", - ); - let _ = black_box(x); - }); - }); - - [].bench_function("nalgebra_lu_solve", |bencher| { - bencher.iter(|| { - let lu = black_box(na.clone()).lu(); - let x = require_some(lu.solve(black_box(&nrhs)), "nalgebra LU solve"); - black_box(x); - }); - }); - - [].bench_function("faer_lu_solve", |bencher| { - bencher.iter(|| { - let lu = black_box(&fa).partial_piv_lu(); - let x = lu.solve(black_box(&frhs)); - black_box(x); - }); - }); - - // === SPD solve (factor + solve) === - [].bench_function("la_stack_ldlt_solve", |bencher| { - bencher.iter(|| { - let ldlt = require_ok( - black_box(a).ldlt(DEFAULT_SINGULAR_TOL), - "la_stack LDLT factorization", - ); - let x = require_ok( - ldlt.solve(black_box(rhs)), - "la_stack LDLT solve", - ); - let _ = black_box(x); - }); - }); - - [].bench_function("nalgebra_cholesky_solve", |bencher| { - bencher.iter(|| { - let chol = require_some( - black_box(na.clone()).cholesky(), - "nalgebra Cholesky factorization", - ); - let x = chol.solve(black_box(&nrhs)); - black_box(x); - }); - }); - - [].bench_function("faer_ldlt_solve", |bencher| { - bencher.iter(|| { - let ldlt = require_ok( - black_box(&fa).ldlt(Side::Lower), - "faer LDLT factorization", - ); - let x = ldlt.solve(black_box(&frhs)); - black_box(x); - }); - }); - - // === Solve using a precomputed LU === - [].bench_function("la_stack_solve_from_lu", |bencher| { - bencher.iter(|| { - let x = require_ok( - a_lu.solve(black_box(rhs)), - "precomputed la_stack LU solve", - ); - let _ = black_box(x); - }); - }); - - [].bench_function("nalgebra_solve_from_lu", |bencher| { - bencher.iter(|| { - let x = require_some( - na_lu.solve(black_box(&nrhs)), - "precomputed nalgebra LU solve", - ); - black_box(x); - }); - }); - - [].bench_function("faer_solve_from_lu", |bencher| { - bencher.iter(|| { - let x = fa_lu.solve(black_box(&frhs)); - black_box(x); - }); - }); - - // === Solve using a precomputed SPD factorization === - [].bench_function("la_stack_solve_from_ldlt", |bencher| { - bencher.iter(|| { - let x = require_ok( - a_ldlt.solve(black_box(rhs)), - "precomputed la_stack LDLT solve", - ); - let _ = black_box(x); - }); - }); - - [].bench_function("nalgebra_solve_from_cholesky", |bencher| { - bencher.iter(|| { - let x = na_cholesky.solve(black_box(&nrhs)); - black_box(x); - }); - }); - - [].bench_function("faer_solve_from_ldlt", |bencher| { - bencher.iter(|| { - let x = fa_ldlt.solve(black_box(&frhs)); - black_box(x); - }); - }); - - // === Determinant from a precomputed LU === - [].bench_function("la_stack_det_from_lu", |bencher| { - bencher.iter(|| { - let det = require_ok(a_lu.det(), "precomputed la_stack LU determinant"); - black_box(det); - }); - }); - - [].bench_function("nalgebra_det_from_lu", |bencher| { - bencher.iter(|| { - let det = na_lu.determinant(); - black_box(det); - }); - }); - - [].bench_function("faer_det_from_lu", |bencher| { - bencher.iter(|| { - let det = faer_det_from_partial_piv_lu(&fa_lu); - black_box(det); - }); - }); - - // === Determinant from a precomputed SPD factorization === - [].bench_function("la_stack_det_from_ldlt", |bencher| { - bencher.iter(|| { - let det = require_ok(a_ldlt.det(), "precomputed la_stack LDLT determinant"); - black_box(det); - }); - }); - - [].bench_function("nalgebra_det_from_cholesky", |bencher| { - bencher.iter(|| { - let det = na_cholesky.determinant(); - black_box(det); - }); - }); - - [].bench_function("faer_det_from_ldlt", |bencher| { - bencher.iter(|| { - let det = faer_det_from_ldlt(&fa_ldlt); - black_box(det); - }); - }); - - // === Vector dot product === - [].bench_function("la_stack_dot", |bencher| { - bencher.iter(|| { - let result = require_ok(black_box(v1).dot(black_box(v2)), "la_stack dot"); - black_box(result); - }); - }); - - [].bench_function("nalgebra_dot", |bencher| { - bencher.iter(|| { - let result = black_box(&nv1).dot(black_box(&nv2)); - black_box(result); - }); - }); - - [].bench_function("faer_dot", |bencher| { - bencher.iter(|| { - let mut sum = 0.0; - let a = black_box(&fv1); - let b = black_box(&fv2); - for i in 0..$d { - sum = a[(i, 0)].mul_add(b[(i, 0)], sum); - } - black_box(sum); - }); - }); - - // === Vector norm squared === - [].bench_function("la_stack_norm2_sq", |bencher| { - bencher.iter(|| { - let result = require_ok(black_box(v1).norm2_sq(), "la_stack norm2_sq"); - black_box(result); - }); - }); - - [].bench_function("nalgebra_norm_squared", |bencher| { - bencher.iter(|| { - let result = black_box(&nv1).norm_squared(); - black_box(result); - }); - }); - - [].bench_function("faer_norm2_sq", |bencher| { - bencher.iter(|| { - let mut sum = 0.0; - let v = black_box(&fv1); - for i in 0..$d { - let x = v[(i, 0)]; - sum = x.mul_add(x, sum); - } - black_box(sum); - }); - }); - - // === Matrix infinity norm (max absolute row sum) === - [].bench_function("la_stack_inf_norm", |bencher| { - bencher.iter(|| { - let result = require_ok(black_box(a).inf_norm(), "la_stack inf_norm"); - black_box(result); - }); - }); - - [].bench_function("nalgebra_inf_norm", |bencher| { - bencher.iter(|| { - let result = nalgebra_inf_norm::<$d>(black_box(&na)); - black_box(result); - }); - }); - - [].bench_function("faer_inf_norm", |bencher| { - bencher.iter(|| { - let m = black_box(&fa); - let mut max_row_sum = 0.0; - - for r in 0..$d { - let mut row_sum = 0.0; - for c in 0..$d { - row_sum += m[(r, c)].abs(); - } - if row_sum > max_row_sum { - max_row_sum = row_sum; - } - } - - black_box(max_row_sum); - }); - }); - - [].finish(); + let x = require_ok(ldlt.solve(black_box(rhs)), "la_stack LDLT solve"); + let _ = black_box(x); + }, + BatchSize::SmallInput, + ); + }); + + group.bench_function("nalgebra_cholesky_solve", |bencher| { + bencher.iter_batched( + || (na, nrhs), + |(na, nrhs)| { + let chol = + require_some(black_box(na).cholesky(), "nalgebra Cholesky factorization"); + let x = chol.solve(black_box(&nrhs)); + black_box(x); + }, + BatchSize::SmallInput, + ); + }); + + group.bench_function("faer_ldlt_solve", |bencher| { + bencher.iter_batched( + || (&fa, &frhs), + |(fa, rhs)| { + let ldlt = require_ok(black_box(fa).ldlt(Side::Lower), "faer LDLT factorization"); + let x = ldlt.solve(black_box(rhs)); + black_box(x); + }, + BatchSize::SmallInput, + ); + }); +} + +/// Register solves using precomputed LU factorizations. +fn register_precomputed_lu_solve_benchmarks( + group: &mut BenchmarkGroup<'_, WallTime>, +) where + Const: DimMin, Output = Const>, +{ + let a = la_matrix::(); + let rhs = la_vector::(0.0, "la_stack RHS vector construction"); + let na = nalgebra_matrix::(); + let nrhs = nalgebra_vector::(0.0); + let fa = faer_matrix::(); + let frhs = faer_vector::(0.0); + let a_lu = require_ok(a.lu(DEFAULT_SINGULAR_TOL), "precomputed la_stack LU"); + let na_lu = na.lu(); + let fa_lu = fa.partial_piv_lu(); + + group.bench_function("la_stack_solve_from_lu", |bencher| { + bencher.iter(|| { + let x = require_ok( + black_box(&a_lu).solve(black_box(rhs)), + "precomputed la_stack LU solve", + ); + let _ = black_box(x); + }); + }); + + group.bench_function("nalgebra_solve_from_lu", |bencher| { + bencher.iter(|| { + let x = require_some( + black_box(&na_lu).solve(black_box(&nrhs)), + "precomputed nalgebra LU solve", + ); + black_box(x); + }); + }); + + group.bench_function("faer_solve_from_lu", |bencher| { + bencher.iter(|| { + let x = black_box(&fa_lu).solve(black_box(&frhs)); + black_box(x); + }); + }); +} + +/// Register solves using precomputed LDLT or Cholesky factorizations. +fn register_precomputed_ldlt_solve_benchmarks( + group: &mut BenchmarkGroup<'_, WallTime>, +) { + let a = la_matrix::(); + let rhs = la_vector::(0.0, "la_stack RHS vector construction"); + let na = nalgebra_matrix::(); + let nrhs = nalgebra_vector::(0.0); + let fa = faer_matrix::(); + let frhs = faer_vector::(0.0); + let a_ldlt = require_ok(a.ldlt(DEFAULT_SINGULAR_TOL), "precomputed la_stack LDLT"); + let na_cholesky = require_some(na.cholesky(), "precomputed nalgebra Cholesky"); + let fa_ldlt = require_ok(fa.ldlt(Side::Lower), "precomputed faer LDLT"); + + group.bench_function("la_stack_solve_from_ldlt", |bencher| { + bencher.iter(|| { + let x = require_ok( + black_box(&a_ldlt).solve(black_box(rhs)), + "precomputed la_stack LDLT solve", + ); + let _ = black_box(x); + }); + }); + + group.bench_function("nalgebra_solve_from_cholesky", |bencher| { + bencher.iter(|| { + let x = black_box(&na_cholesky).solve(black_box(&nrhs)); + black_box(x); + }); + }); + + group.bench_function("faer_solve_from_ldlt", |bencher| { + bencher.iter(|| { + let x = black_box(&fa_ldlt).solve(black_box(&frhs)); + black_box(x); + }); + }); +} + +/// Register determinant queries using precomputed LU factorizations. +fn register_precomputed_lu_determinant_benchmarks( + group: &mut BenchmarkGroup<'_, WallTime>, +) where + Const: DimMin, Output = Const>, +{ + let a = la_matrix::(); + let na = nalgebra_matrix::(); + let fa = faer_matrix::(); + let a_lu = require_ok(a.lu(DEFAULT_SINGULAR_TOL), "precomputed la_stack LU"); + let na_lu = na.lu(); + let fa_lu = fa.partial_piv_lu(); + + group.bench_function("la_stack_det_from_lu", |bencher| { + bencher.iter(|| { + let det = require_ok( + black_box(&a_lu).det(), + "precomputed la_stack LU determinant", + ); + black_box(det); + }); + }); + + group.bench_function("nalgebra_det_from_lu", |bencher| { + bencher.iter(|| { + let det = black_box(&na_lu).determinant(); + black_box(det); + }); + }); + + group.bench_function("faer_det_from_lu", |bencher| { + bencher.iter(|| { + let det = faer_det_from_partial_piv_lu(black_box(&fa_lu)); + black_box(det); + }); + }); +} + +/// Register determinant queries using precomputed LDLT or Cholesky factorizations. +fn register_precomputed_ldlt_determinant_benchmarks( + group: &mut BenchmarkGroup<'_, WallTime>, +) { + let a = la_matrix::(); + let na = nalgebra_matrix::(); + let fa = faer_matrix::(); + let a_ldlt = require_ok(a.ldlt(DEFAULT_SINGULAR_TOL), "precomputed la_stack LDLT"); + let na_cholesky = require_some(na.cholesky(), "precomputed nalgebra Cholesky"); + let fa_ldlt = require_ok(fa.ldlt(Side::Lower), "precomputed faer LDLT"); + + group.bench_function("la_stack_det_from_ldlt", |bencher| { + bencher.iter(|| { + let det = require_ok( + black_box(&a_ldlt).det(), + "precomputed la_stack LDLT determinant", + ); + black_box(det); + }); + }); + + group.bench_function("nalgebra_det_from_cholesky", |bencher| { + bencher.iter(|| { + let det = black_box(&na_cholesky).determinant(); + black_box(det); + }); + }); + + group.bench_function("faer_det_from_ldlt", |bencher| { + bencher.iter(|| { + let det = faer_det_from_ldlt(black_box(&fa_ldlt)); + black_box(det); + }); + }); +} + +/// Register vector dot-product and squared-norm benchmarks. +fn register_vector_benchmarks(group: &mut BenchmarkGroup<'_, WallTime>) { + let v1 = la_vector::(0.0, "la_stack vector construction"); + let v2 = la_vector::(1.0, "la_stack vector construction"); + let nv1 = nalgebra_vector::(0.0); + let nv2 = nalgebra_vector::(1.0); + let fv1 = faer_vector::(0.0); + let fv2 = faer_vector::(1.0); + + group.bench_function("la_stack_dot", |bencher| { + bencher.iter(|| { + let result = require_ok(black_box(&v1).dot(black_box(&v2)), "la_stack dot"); + black_box(result); + }); + }); + + group.bench_function("nalgebra_dot", |bencher| { + bencher.iter(|| { + let result = black_box(&nv1).dot(black_box(&nv2)); + black_box(result); + }); + }); + + group.bench_function("faer_dot", |bencher| { + bencher.iter(|| { + let mut sum = 0.0; + let a = black_box(&fv1); + let b = black_box(&fv2); + for i in 0..D { + sum = a[(i, 0)].mul_add(b[(i, 0)], sum); } - } + black_box(sum); + }); + }); + + group.bench_function("la_stack_norm2_sq", |bencher| { + bencher.iter(|| { + let result = require_ok(black_box(&v1).norm2_sq(), "la_stack norm2_sq"); + black_box(result); + }); + }); + + group.bench_function("nalgebra_norm_squared", |bencher| { + bencher.iter(|| { + let result = black_box(&nv1).norm_squared(); + black_box(result); + }); + }); + + group.bench_function("faer_norm2_sq", |bencher| { + bencher.iter(|| { + let v = black_box(&fv1); + let result = v.as_mat_ref().squared_norm_l2(); + black_box(result); + }); + }); +} + +/// Register matrix infinity-norm benchmarks. +fn register_matrix_norm_benchmarks(group: &mut BenchmarkGroup<'_, WallTime>) { + let a = la_matrix::(); + let na = nalgebra_matrix::(); + let fa = faer_matrix::(); + + group.bench_function("la_stack_inf_norm", |bencher| { + bencher.iter(|| { + let result = require_ok(black_box(&a).inf_norm(), "la_stack inf_norm"); + black_box(result); + }); + }); + + group.bench_function("nalgebra_inf_norm", |bencher| { + bencher.iter(|| { + let result = nalgebra_inf_norm::(black_box(&na)); + black_box(result); + }); + }); + + group.bench_function("faer_inf_norm", |bencher| { + bencher.iter(|| { + let m = black_box(&fa); + let mut max_row_sum = 0.0; + + for r in 0..D { + let mut row_sum = 0.0; + for c in 0..D { + row_sum += m[(r, c)].abs(); + } + if row_sum > max_row_sum { + max_row_sum = row_sum; + } + } + + black_box(max_row_sum); + }); + }); +} + +/// Register D=8 stress cases that exercise pivoting, conditioning, and scaled products. +fn register_stress_benchmarks(group: &mut BenchmarkGroup<'_, WallTime>) { + if D != 8 { + return; } + + let zero_tolerance = require_ok(Tolerance::try_new(0.0), "zero benchmark tolerance"); + let pivoting = require_ok( + Matrix::::try_from_rows(make_pivoting_matrix_rows()), + "pivoting benchmark matrix construction", + ); + let ill_conditioned = require_ok( + Matrix::::try_from_rows(make_ill_conditioned_matrix_rows()), + "ill-conditioned benchmark matrix construction", + ); + let balanced = require_ok( + Matrix::::try_from_rows(make_balanced_dynamic_range_rows()), + "balanced-range benchmark matrix construction", + ); + + group.bench_function("la_stack_lu_pivoting", |bencher| { + bencher.iter_batched( + || pivoting, + |matrix| { + let lu = require_ok( + black_box(matrix).lu(zero_tolerance), + "pivoting LU factorization", + ); + let _ = black_box(lu); + }, + BatchSize::SmallInput, + ); + }); + + group.bench_function("la_stack_lu_ill_conditioned", |bencher| { + bencher.iter_batched( + || ill_conditioned, + |matrix| { + let lu = require_ok( + black_box(matrix).lu(zero_tolerance), + "ill-conditioned LU factorization", + ); + let _ = black_box(lu); + }, + BatchSize::SmallInput, + ); + }); + + group.bench_function("la_stack_ldlt_ill_conditioned", |bencher| { + bencher.iter_batched( + || ill_conditioned, + |matrix| { + let ldlt = require_ok( + black_box(matrix).ldlt(zero_tolerance), + "ill-conditioned LDLT factorization", + ); + let _ = black_box(ldlt); + }, + BatchSize::SmallInput, + ); + }); + + let balanced_lu = require_ok( + balanced.lu(zero_tolerance), + "balanced-range LU factorization", + ); + let balanced_ldlt = require_ok( + balanced.ldlt(zero_tolerance), + "balanced-range LDLT factorization", + ); + + group.bench_function("la_stack_det_from_lu_balanced_range", |bencher| { + bencher.iter(|| { + let det = require_ok( + black_box(&balanced_lu).det(), + "balanced-range LU determinant", + ); + black_box(det); + }); + }); + + group.bench_function("la_stack_det_from_ldlt_balanced_range", |bencher| { + bencher.iter(|| { + let det = require_ok( + black_box(&balanced_ldlt).det(), + "balanced-range LDLT determinant", + ); + black_box(det); + }); + }); +} + +macro_rules! define_vs_linalg_benches_for_dim { + ($fn_name:ident, $d:literal) => { + fn $fn_name(c: &mut Criterion) { + let mut group = c.benchmark_group(concat!("d", stringify!($d))); + register_determinant_benchmarks::<$d>(&mut group); + register_factorization_benchmarks::<$d>(&mut group); + register_lu_solve_benchmarks::<$d>(&mut group); + register_ldlt_solve_benchmarks::<$d>(&mut group); + register_precomputed_lu_solve_benchmarks::<$d>(&mut group); + register_precomputed_ldlt_solve_benchmarks::<$d>(&mut group); + register_precomputed_lu_determinant_benchmarks::<$d>(&mut group); + register_precomputed_ldlt_determinant_benchmarks::<$d>(&mut group); + register_vector_benchmarks::<$d>(&mut group); + register_matrix_norm_benchmarks::<$d>(&mut group); + register_stress_benchmarks::<$d>(&mut group); + group.finish(); + } }; } diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 0000000..e6c93b4 --- /dev/null +++ b/clippy.toml @@ -0,0 +1,6 @@ +# Clippy configuration for the la-stack crate. + +# Keep lint behavior aligned with Cargo.toml and rust-toolchain.toml. +msrv = "1.96.0" + +# Lint levels remain owned by Cargo.toml and the canonical just recipes. diff --git a/docs/BENCHMARKING.md b/docs/BENCHMARKING.md index 15e9738..513c764 100644 --- a/docs/BENCHMARKING.md +++ b/docs/BENCHMARKING.md @@ -22,15 +22,15 @@ the commands measure and where their outputs go. ## Start Here -| Goal | Use | Output | Notes | -|------|-----|--------|-------| -| Clean local audit against the latest published release | `just performance-local` | `target/bench-reports/performance.md` | Self-contained; creates temporary worktrees and regenerates the release baseline locally. | -| Non-exact release-signal check against a specific release | `just performance-local-vs-linalg v0.4.3 v0.4.2` | `target/bench-reports/performance.md` | Narrower than `performance-local`; useful for LU/LDLT/dot/norm work. | -| Fast repeated comparisons while tuning one kernel | `just bench-save-baseline ` then `just bench-compare all-benches` | `target/bench-reports/performance.md` | Uses local `target/criterion/`; fastest loop after the baseline exists. | -| Full current la-stack vs nalgebra/faer comparison | `just bench-vs-linalg` | `target/criterion/` | Measures current la-stack, nalgebra, and faer rows. | -| README benchmark table and SVG plot | `just plot-vs-linalg-readme` after `just bench-vs-linalg` | `README.md`, `docs/assets/bench/` | Uses current `target/criterion` data. | -| Release PR performance artifact | `just performance-release v0.4.3 v0.4.2` | `docs/PERFORMANCE.md`, `docs/archive/performance/` | Mutates committed docs. Run during release preparation. | -| Compare already-published release assets | `just performance-github-assets v0.4.3 v0.4.2` | `target/bench-reports/github-assets-performance.md` | Uses GitHub Release baseline assets instead of local cargo runs. | +| Goal | Recipe | +|------|--------| +| Latest-release local audit | `just performance-local` | +| Release-signal check against tags | `just performance-local-vs-linalg v0.4.3 v0.4.2` | +| Fast saved-baseline loop | `just bench-save-baseline ` then `just bench-compare all-benches` | +| Full crate comparison | `just bench-vs-linalg` | +| README table and plot | `just plot-vs-linalg-readme` | +| Release report | `just performance-release v0.4.3 v0.4.2` | +| Published-asset comparison | `just performance-github-assets v0.4.3 v0.4.2` | Rule of thumb: @@ -55,9 +55,9 @@ factorization in the dependency version used here. **`exact`** (`benches/exact.rs`) measures exact-arithmetic methods (`det_exact`, `solve_exact`, `det_sign_exact`, strict `*_result` conversions, -and lossy `*_rounded_f64` conversions) alongside f64 baselines (`det`, -`det_direct`) across D=2-5. Use this suite to understand exact-arithmetic cost -and track optimization progress. +and lossy `*_rounded_f64` conversions) alongside the f64 `det` baseline across +D=2-5 and `det_direct` across its supported D=2-4 range. Use this suite to +understand exact-arithmetic cost and track optimization progress. ## Common Workflows @@ -70,9 +70,16 @@ with the latest published release?" just performance-local ``` -This creates isolated temporary worktrees, generates the latest published -release baseline locally, benchmarks the current tree on the same machine, and -writes `target/bench-reports/performance.md`. +This creates isolated temporary worktrees and runs both library revisions on the +same machine with the current checkout's benchmark sources, manifests, lockfile, +benchmark-input tests, recipes, and Rust toolchain. Only the baseline library +implementation comes from the release tag. Before either timing run, the command +runs `just test-bench-inputs` against that revision under the shared current +fixture harness. It writes `target/bench-reports/performance.md` and records both +commits, CPU, operating system, Rust toolchain, lockfile and harness digests, +Criterion selection/commands, and both correctness-gate results. The report +reader rejects malformed or mismatched provenance and incomplete selected-suite +coverage. This command does not depend on existing local `target/criterion/` baselines. It is slower than reusing a saved baseline, but less sensitive to stale local @@ -113,7 +120,8 @@ just bench-compare inf-norm-before vs_linalg all-benches The `just bench-compare` recipe uses positional arguments: `just bench-compare `. The underlying -`uv run bench-compare` CLI accepts the explicit `--suite` and `--scope` flags. +`uv run --locked bench-compare` CLI accepts the explicit `--suite` and +`--scope` flags. `just bench-save-baseline ` writes Criterion samples under `target/criterion/`. `just bench-save-last` saves the conventional local @@ -126,7 +134,9 @@ just bench-compare ``` Saved baselines persist across `git checkout` but not across `cargo clean`, and -they are not pushed to GitHub. +they are not pushed to GitHub. A manually saved baseline is comparable only when +its harness has not changed; use `performance-local` for a checked +revision-to-revision comparison. ### Update The README nalgebra/faer Table @@ -134,16 +144,24 @@ The README benchmark table and SVG plot are crate-to-crate comparisons from the current checkout: ```bash -just bench-vs-linalg just plot-vs-linalg-readme ``` -`just bench-vs-linalg` measures current la-stack, nalgebra, and faer rows. -`just plot-vs-linalg-readme` reads those Criterion results and updates: +This publication recipe validates the benchmark fixtures, runs a fresh complete +`vs_linalg` benchmark, and requires la-stack, nalgebra, and faer results for every +canonical dimension (D=2, 3, 4, 5, 8, 16, 32, and 64) before updating: - `README.md` - `docs/assets/bench/vs_linalg_lu_solve_median.csv` - `docs/assets/bench/vs_linalg_lu_solve_median.svg` +- `docs/assets/bench/vs_linalg_lu_solve_median.provenance.json` + +The provenance sidecar records the measured source state, CPU, operating system, +Rust toolchain, dependency lock and harness digests, Criterion dependency and +selection, dimensions, benchmark command, and correctness-gate result. Missing +coverage or provenance aborts publication. Use `--allow-partial` only for +exploratory CSV/SVG output; it cannot update README and its sidecar explicitly +marks measurement provenance unavailable. See `scripts/criterion_dim_plot.py --help` for plotting options. @@ -163,7 +181,8 @@ release preparation, passing both tags explicitly removes ambiguity. This command creates temporary worktrees, generates the comparison, writes `docs/PERFORMANCE.md`, and archives the previous committed report under `docs/archive/performance/`. Archive filenames are release-pair names such as -`v0.4.2-vs-v0.4.1.md`. +`v0.4.2-vs-v0.4.1.md`. Publication fails rather than emitting a partial report +when a selected suite or required dimension is absent. ### Compare Published Release Artifacts @@ -175,8 +194,15 @@ without running cargo locally: just performance-github-assets v0.4.3 v0.4.2 ``` -With no arguments, the recipe discovers the latest stable published GitHub -release and its previous stable release automatically. +With no arguments, the recipe discovers the latest and previous stable +published GitHub releases. + +Published artifacts preserve each release's original benchmark harness. Their +historical timing environments may not have been recorded, so report provenance +labels those fields unavailable rather than reconstructing them. The workflow +still runs the current independent fixture gate against both source revisions +under the shared current fixture harness before reading the assets. Use a local +shared-harness workflow before attributing a difference solely to library code. ## Output Locations @@ -187,15 +213,21 @@ release and its previous stable release automatically. | `target/bench-reports/github-assets-performance.md` | No | `performance-github-assets` | Local report from published release artifacts. | | `docs/PERFORMANCE.md` | Yes | `performance-release` | Latest curated release-to-release comparison. | | `docs/archive/performance/` | Yes | `performance-release` | Older curated release-to-release comparisons. | -| `docs/assets/bench/` | Yes | `plot-vs-linalg-readme` | README benchmark CSV/SVG assets. | -| GitHub Release asset `la-stack-$TAG-criterion-baseline.tar.gz` | Remote release artifact | `.github/workflows/release-benchmarks.yml` | Durable Criterion baseline archive for published releases. | +| `docs/assets/bench/` | Yes | `plot-vs-linalg-readme` | README benchmark CSV/SVG assets and JSON provenance. | +| GitHub Release | Remote | `.github/workflows/release-benchmarks.yml` | Criterion baseline archive. | + +Published baseline assets use the filename +`la-stack-$TAG-criterion-baseline.tar.gz`. ## `vs_linalg` Methodology `vs_linalg` is a per-kernel comparison, not a single aggregate score. Each row compares one operation for one dimension `D`, using Criterion's selected statistic from `target/criterion/d{D}/{benchmark}/{sample}/estimates.json`. -The README table uses `median.point_estimate` in nanoseconds. Lower is better. +The README table uses `median.point_estimate` in nanoseconds. Lower is better, +but point-estimate ratios alone are descriptive and do not establish a +statistically supported performance difference. Preserve Criterion confidence +intervals or repeat controlled runs when making a stronger claim. All three crates receive equivalent deterministic inputs for a given dimension: @@ -205,18 +237,35 @@ All three crates receive equivalent deterministic inputs for a given dimension: generator - each benchmark uses `black_box` around inputs and outputs to keep the measured operation visible to the optimizer +- precomputed-factor benchmarks pass the factor itself through `black_box` + before each solve or determinant query, preventing invariant captured factors + from being hoisted out of the measured closure +- consuming stack-matrix inputs are copied in Criterion batch setup, outside + the measured closure, so factorization rows measure kernels rather than the + harness's need to reuse one input +- borrowed operations receive references through `black_box`; in particular, + `inf_norm` does not copy the matrix inside the measured closure The integration smoke test `tests/vs_linalg_inputs.rs` reuses the benchmark input helpers and verifies that la-stack, nalgebra, and faer agree on the -determinant, solve, dot, and infinity-norm results for D=2..=5. Run it with: +determinant, solve, dot, squared-norm, and infinity-norm results for every +measured dimension: D=2, 3, 4, 5, 8, 16, 32, and 64. The same focused recipe +also tests exact-benchmark range and deterministic-generator configuration: ```bash -cargo test --features bench --test vs_linalg_inputs +just test-bench-inputs ``` Run that test when changing benchmark input construction, adding comparable kernels, or updating the `faer` or `nalgebra` benchmark dependencies. +The D=8 group also includes la-stack stress rows for a forced LU row swap, a +successful diagonal factorization spanning 112 binary exponents, and a balanced +dynamic-range determinant whose sequential factor product leaves the binary64 +range even though the final result is one. These rows keep pivoting, +ill-conditioning, and scaled-product cold paths visible alongside the shared +well-conditioned peer fixture. + The main comparable metrics are: - `det_via_lu` — factor the matrix and compute determinant from the LU factor @@ -247,48 +296,54 @@ of README plots and crate-to-crate comparison tables. ## Exact-Arithmetic Notes -The exact suite includes fixed per-dimension groups (`exact_d{2..5}`), random -percentile groups, and adversarial-input groups: +The exact suite includes fixed per-dimension groups (`exact_d{2..5}`), fixed +random-corpus groups, and adversarial-input groups: -- `exact_random_percentile_d{2..5}` — fixed-seed corpora of 50 strictly - diagonally-dominant random matrices per dimension. Each operation is - pre-timed across the corpus to select representative p50/p95/p99 inputs, then - Criterion measures those inputs normally. +- `exact_random_corpus_d{2..5}` — fixed-seed corpora of 50 strictly + diagonally-dominant random matrices per dimension. Every Criterion iteration + executes the full corpus in its stable order, so baseline and current + revisions receive identical workloads. Criterion reports time per complete + 50-input corpus and records throughput in elements. - `exact_near_singular_3x3` — a 2^-50 perturbation of a singular base matrix; - forces the Bareiss fallback in `det_sign_exact` and exercises the largest - intermediate `BigInt` values in `solve_exact`. + forces the direct `BigInt` fallback in `det_sign_exact` and exercises an + ill-conditioned exact solve. - `exact_large_entries_3x3` — diagonal entries near `f64::MAX / 2` stress `BigInt` growth during Bareiss forward elimination. - `exact_hilbert_4x4` / `exact_hilbert_5x5` — classically ill-conditioned - matrices whose non-terminating-in-binary entries stress the - `f64_decompose -> BigInt` scaling path. + matrices whose binary64 entries have varied mantissas and exponents, stressing + the `decompose_f64 -> BigInt` scaling path. -Each random percentile and adversarial group runs the same exact-arithmetic +Each random-corpus and adversarial group runs the same exact-arithmetic benches (`det_sign_exact`, `det_exact`, `solve_exact`, `solve_exact_f64_result`, `solve_exact_rounded_f64`) so tables are comparable across input classes. +Before timing begins, every fixed, adversarial, and corpus input is consumed into +a private-field `ValidatedExactInput` after checks by an independent exact +oracle. Timed and registration helpers accept only that proof-bearing wrapper. A +factorial-time Leibniz determinant over exact rational reconstructions verifies +determinant values and signs; exact residuals verify `A x = b`; and +strict/rounded binary64 results are checked for their exact bits, typed reason, +and first failing component. These checks run outside timed Criterion closures. +Any disagreement or unexpected error fails setup instead of becoming an +artificially fast measurement. + For exact-arithmetic comparisons against v0.4.2 or older baselines, rows such as `det_exact_rounded_f64 (vs det_exact_f64)` mean the current rounded API is being compared to the historical lossy `*_exact_f64` benchmark. Rows such as `det_exact_f64_result (vs det_exact_f64)` intentionally show the overhead of the new strict conversion contract against that same historical baseline. -The default `release-signal` scope reports exact-arithmetic rows whose inputs -are fixed across versions: deterministic D=2..=5 cases plus adversarial fixed -matrices. Random percentile groups are exploratory tail probes; each benchmark -run selects p50/p95/p99 input sets by timing the implementation under test, so -those rows can measure different corpus subsets across versions. Include them -when investigating tails with: - -```bash -uv run bench-compare v0.4.2 --suite exact --scope all-benches -``` +The default `release-signal` scope includes all exact-arithmetic groups because +their inputs and execution order are fixed across revisions. Historical +baselines created before the `exact_random_corpus_d*` names were introduced do +not have comparable full-corpus rows, so those rows appear once both sides of a +comparison provide the stable group. To generate a current snapshot without a saved baseline: ```bash -uv run bench-compare --snapshot +uv run --locked bench-compare --snapshot ``` ## Release Notes @@ -302,5 +357,6 @@ just bench-save-last ``` The durable published baseline is the GitHub Release artifact created by -`.github/workflows/release-benchmarks.yml`. The committed release comparison is -`docs/PERFORMANCE.md`, created by `just performance-release`. +`.github/workflows/release-benchmarks.yml`. That workflow runs the benchmark-input +correctness gate before timing or packaging the artifact. The committed release +comparison is `docs/PERFORMANCE.md`, created by `just performance-release`. diff --git a/docs/COVERAGE.md b/docs/COVERAGE.md index 056c1f3..66bab83 100644 --- a/docs/COVERAGE.md +++ b/docs/COVERAGE.md @@ -1,11 +1,12 @@ # Coverage -la-stack uses `cargo-llvm-cov` for local and CI coverage. Coverage runs use -Rust's LLVM source-based instrumentation with the same core test selection in -both environments: +la-stack uses `cargo-llvm-cov` with `cargo-nextest` for local and CI coverage. +Both coverage recipes use Rust's LLVM source-based instrumentation, run the +same unit and integration test surface, and select nextest's `coverage` +profile: ```bash -cargo llvm-cov --features exact --workspace --lib --tests +cargo llvm-cov nextest --features exact --workspace --lib --tests -P coverage ``` ## Local HTML @@ -24,36 +25,46 @@ target/llvm-cov/html/index.html The report opens automatically after generation. -## CI XML +## CI reports -Generate the CI-compatible Cobertura report with: +Generate the CI-compatible reports with: ```bash just coverage-ci ``` -The XML report is written to: +The Cobertura coverage report is written to: ```text coverage/cobertura.xml ``` -The Codecov workflow installs Rust's `llvm-tools-preview` component, installs -`cargo-llvm-cov`, caches the installed cargo binary by version, runs -`just coverage-ci`, verifies `coverage/cobertura.xml`, uploads that file to -Codecov, and archives the full `coverage/` directory. Local setup via -`just setup-tools` installs the same Rust component and cargo subcommand. +Nextest also writes JUnit test results to: + +```text +target/nextest/coverage/test-results/junit.xml +``` + +The Codecov workflow reads the `cargo-llvm-cov` and `cargo-nextest` versions +from the `justfile`, installs those exact versions plus Rust's +`llvm-tools-preview` component, and runs `just coverage-ci`. It verifies both +reports, uploads the Cobertura and JUnit files to Codecov, and archives each +report directory as a workflow artifact. Local setup via `just setup-tools` +installs the same Rust component and cargo subcommands. ## Migration Notes - Keep `just coverage-ci` as the single source of truth for CI coverage arguments; workflows should install tools and upload artifacts, not duplicate the coverage command. +- Keep nextest's `coverage` profile deterministic: retries are disabled, + timeouts allow for LLVM instrumentation overhead, and JUnit output is + configured in `.config/nextest.toml`. - Use `--cobertura --output-path coverage/cobertura.xml` for services that consume Cobertura XML. - Use `--open --output-dir target/llvm-cov` for local reports. -- Preserve the crate's full coverage surface with `--features exact - --workspace --lib --tests`. +- Preserve the crate's full coverage surface with the `exact` feature and + `--workspace --lib --tests`. - `cargo-llvm-cov` excludes workspace `tests/`, `examples/`, and `benches/` source files from reports by default, while still allowing integration tests to exercise library code. This matches the intended reporting surface here: diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md index 205cd47..db6e35f 100644 --- a/docs/PERFORMANCE.md +++ b/docs/PERFORMANCE.md @@ -2,14 +2,28 @@ **la-stack** v0.4.3 · `45affa8` (HEAD) · 2026-06-09 08:41:32 UTC **Statistic**: median -**Suite**: all +**Suite**: exact **Scope**: release-signal ## Benchmark Results Comparison against baseline **v0.4.2**: -Negative change = faster. Speedup > 1.00x = improvement. +**Harness provenance**: Historical per-release harnesses. These measurements +predate shared-harness enforcement, so differences may include benchmark-harness +changes as well as library changes. + +**Measurement provenance**: Unavailable. CPU, operating system, Rust toolchain, +full measured source states (including the baseline commit), dependency lock +digest, Criterion version/configuration, and confidence intervals were not +preserved with this historical report. The header's short hash identifies the +reporting checkout only; it does not establish both measured revisions. + +The change and speedup columns are descriptive ratios of median point estimates +only. A negative change or ratio above 1.00× means the recorded latest median was +lower; it does not establish a statistically supported speedup. Do not attribute +these differences to library code because the harnesses and environments were +not controlled or recorded comparably. ## Exact arithmetic @@ -17,29 +31,29 @@ Negative change = faster. Speedup > 1.00x = improvement. | Benchmark | v0.4.2 | Latest | Change | Speedup | |-----------|-------:|-------:|-------:|--------:| -| det | 0.9 ns | 0.7 ns | **-24.0%** | 1.32x | +| det | 0.9 ns | 0.7 ns | -24.0% | 1.32x | | det_direct | 1.0 ns | 1.0 ns | +2.1% | 0.98x | -| det_exact | 248.9 ns | 195.6 ns | **-21.4%** | 1.27x | -| det_exact_f64_result (vs det_exact_f64) | 429.1 ns | 167.6 ns | **-60.9%** | 2.56x | -| det_exact_rounded_f64 (vs det_exact_f64) | 429.1 ns | 375.2 ns | **-12.6%** | 1.14x | +| det_exact | 248.9 ns | 195.6 ns | -21.4% | 1.27x | +| det_exact_f64_result (vs det_exact_f64) | 429.1 ns | 167.6 ns | -60.9% | 2.56x | +| det_exact_rounded_f64 (vs det_exact_f64) | 429.1 ns | 375.2 ns | -12.6% | 1.14x | | det_sign_exact | 1.5 ns | 3.2 ns | +115.9% | 0.46x | -| solve_exact | 6.53 µs | 6.45 µs | **-1.1%** | 1.01x | -| solve_exact_f64_result (vs solve_exact_f64) | 6.90 µs | 6.60 µs | **-4.4%** | 1.05x | +| solve_exact | 6.53 µs | 6.45 µs | -1.1% | 1.01x | +| solve_exact_f64_result (vs solve_exact_f64) | 6.90 µs | 6.60 µs | -4.4% | 1.05x | | solve_exact_rounded_f64 (vs solve_exact_f64) | 6.90 µs | 7.02 µs | +1.7% | 0.98x | ### D=3 | Benchmark | v0.4.2 | Latest | Change | Speedup | |-----------|-------:|-------:|-------:|--------:| -| det | 1.8 ns | 1.5 ns | **-19.4%** | 1.24x | +| det | 1.8 ns | 1.5 ns | -19.4% | 1.24x | | det_direct | 2.0 ns | 2.0 ns | +2.4% | 0.98x | -| det_exact | 739.0 ns | 468.6 ns | **-36.6%** | 1.58x | -| det_exact_f64_result (vs det_exact_f64) | 913.1 ns | 435.6 ns | **-52.3%** | 2.10x | -| det_exact_rounded_f64 (vs det_exact_f64) | 913.1 ns | 648.1 ns | **-29.0%** | 1.41x | +| det_exact | 739.0 ns | 468.6 ns | -36.6% | 1.58x | +| det_exact_f64_result (vs det_exact_f64) | 913.1 ns | 435.6 ns | -52.3% | 2.10x | +| det_exact_rounded_f64 (vs det_exact_f64) | 913.1 ns | 648.1 ns | -29.0% | 1.41x | | det_sign_exact | 4.2 ns | 5.5 ns | +30.9% | 0.76x | -| solve_exact | 25.69 µs | 25.16 µs | **-2.1%** | 1.02x | -| solve_exact_f64_result (vs solve_exact_f64) | 26.16 µs | 25.42 µs | **-2.8%** | 1.03x | -| solve_exact_rounded_f64 (vs solve_exact_f64) | 26.16 µs | 25.67 µs | **-1.9%** | 1.02x | +| solve_exact | 25.69 µs | 25.16 µs | -2.1% | 1.02x | +| solve_exact_f64_result (vs solve_exact_f64) | 26.16 µs | 25.42 µs | -2.8% | 1.03x | +| solve_exact_rounded_f64 (vs solve_exact_f64) | 26.16 µs | 25.67 µs | -1.9% | 1.02x | ### D=4 @@ -47,67 +61,66 @@ Negative change = faster. Speedup > 1.00x = improvement. |-----------|-------:|-------:|-------:|--------:| | det | 3.3 ns | 4.5 ns | +38.1% | 0.72x | | det_direct | 3.7 ns | 4.3 ns | +17.6% | 0.85x | -| det_exact | 1.87 µs | 1.47 µs | **-21.8%** | 1.28x | -| det_exact_f64_result (vs det_exact_f64) | 2.04 µs | 1.47 µs | **-27.9%** | 1.39x | -| det_exact_rounded_f64 (vs det_exact_f64) | 2.04 µs | 1.63 µs | **-19.8%** | 1.25x | +| det_exact | 1.87 µs | 1.47 µs | -21.8% | 1.28x | +| det_exact_f64_result (vs det_exact_f64) | 2.04 µs | 1.47 µs | -27.9% | 1.39x | +| det_exact_rounded_f64 (vs det_exact_f64) | 2.04 µs | 1.63 µs | -19.8% | 1.25x | | det_sign_exact | 6.9 ns | 11.5 ns | +67.1% | 0.60x | -| solve_exact | 64.95 µs | 61.67 µs | **-5.1%** | 1.05x | -| solve_exact_f64_result (vs solve_exact_f64) | 66.35 µs | 62.37 µs | **-6.0%** | 1.06x | -| solve_exact_rounded_f64 (vs solve_exact_f64) | 66.35 µs | 63.59 µs | **-4.2%** | 1.04x | +| solve_exact | 64.95 µs | 61.67 µs | -5.1% | 1.05x | +| solve_exact_f64_result (vs solve_exact_f64) | 66.35 µs | 62.37 µs | -6.0% | 1.06x | +| solve_exact_rounded_f64 (vs solve_exact_f64) | 66.35 µs | 63.59 µs | -4.2% | 1.04x | ### D=5 | Benchmark | v0.4.2 | Latest | Change | Speedup | |-----------|-------:|-------:|-------:|--------:| -| det | 26.0 ns | 23.3 ns | **-10.6%** | 1.12x | -| det_direct | 4.5 ns | 2.5 ns | **-44.2%** | 1.79x | -| det_exact | 4.10 µs | 4.05 µs | **-1.3%** | 1.01x | -| det_exact_f64_result (vs det_exact_f64) | 4.21 µs | 4.02 µs | **-4.4%** | 1.05x | +| det | 26.0 ns | 23.3 ns | -10.6% | 1.12x | +| det_exact | 4.10 µs | 4.05 µs | -1.3% | 1.01x | +| det_exact_f64_result (vs det_exact_f64) | 4.21 µs | 4.02 µs | -4.4% | 1.05x | | det_exact_rounded_f64 (vs det_exact_f64) | 4.21 µs | 4.33 µs | +2.8% | 0.97x | | det_sign_exact | 3.94 µs | 3.96 µs | +0.6% | 0.99x | -| solve_exact | 130.82 µs | 126.75 µs | **-3.1%** | 1.03x | -| solve_exact_f64_result (vs solve_exact_f64) | 132.70 µs | 127.37 µs | **-4.0%** | 1.04x | -| solve_exact_rounded_f64 (vs solve_exact_f64) | 132.70 µs | 128.15 µs | **-3.4%** | 1.04x | +| solve_exact | 130.82 µs | 126.75 µs | -3.1% | 1.03x | +| solve_exact_f64_result (vs solve_exact_f64) | 132.70 µs | 127.37 µs | -4.0% | 1.04x | +| solve_exact_rounded_f64 (vs solve_exact_f64) | 132.70 µs | 128.15 µs | -3.4% | 1.04x | ### Near-singular 3x3 | Benchmark | v0.4.2 | Latest | Change | Speedup | |-----------|-------:|-------:|-------:|--------:| -| det_sign_exact | 705.2 ns | 444.2 ns | **-37.0%** | 1.59x | -| det_exact | 724.0 ns | 478.9 ns | **-33.9%** | 1.51x | -| solve_exact | 3.44 µs | 3.39 µs | **-1.6%** | 1.02x | -| solve_exact_f64_result (vs solve_exact_f64) | 3.47 µs | 3.36 µs | **-3.2%** | 1.03x | -| solve_exact_rounded_f64 (vs solve_exact_f64) | 3.47 µs | 3.39 µs | **-2.5%** | 1.03x | +| det_sign_exact | 705.2 ns | 444.2 ns | -37.0% | 1.59x | +| det_exact | 724.0 ns | 478.9 ns | -33.9% | 1.51x | +| solve_exact | 3.44 µs | 3.39 µs | -1.6% | 1.02x | +| solve_exact_f64_result (vs solve_exact_f64) | 3.47 µs | 3.36 µs | -3.2% | 1.03x | +| solve_exact_rounded_f64 (vs solve_exact_f64) | 3.47 µs | 3.39 µs | -2.5% | 1.03x | ### Large entries 3x3 | Benchmark | v0.4.2 | Latest | Change | Speedup | |-----------|-------:|-------:|-------:|--------:| -| det_sign_exact | 2.91 µs | 402.4 ns | **-86.2%** | 7.23x | -| det_exact | 2.94 µs | 434.0 ns | **-85.2%** | 6.76x | -| solve_exact | 82.81 µs | 81.57 µs | **-1.5%** | 1.02x | -| solve_exact_f64_result (vs solve_exact_f64) | 84.32 µs | 81.66 µs | **-3.1%** | 1.03x | -| solve_exact_rounded_f64 (vs solve_exact_f64) | 84.32 µs | 82.04 µs | **-2.7%** | 1.03x | +| det_sign_exact | 2.91 µs | 402.4 ns | -86.2% | 7.23x | +| det_exact | 2.94 µs | 434.0 ns | -85.2% | 6.76x | +| solve_exact | 82.81 µs | 81.57 µs | -1.5% | 1.02x | +| solve_exact_f64_result (vs solve_exact_f64) | 84.32 µs | 81.66 µs | -3.1% | 1.03x | +| solve_exact_rounded_f64 (vs solve_exact_f64) | 84.32 µs | 82.04 µs | -2.7% | 1.03x | ### Hilbert 4x4 | Benchmark | v0.4.2 | Latest | Change | Speedup | |-----------|-------:|-------:|-------:|--------:| | det_sign_exact | 6.9 ns | 11.5 ns | +66.4% | 0.60x | -| det_exact | 1.91 µs | 1.50 µs | **-21.7%** | 1.28x | -| solve_exact | 49.42 µs | 47.77 µs | **-3.3%** | 1.03x | -| solve_exact_f64_result (vs solve_exact_f64) | 50.38 µs | 47.67 µs | **-5.4%** | 1.06x | -| solve_exact_rounded_f64 (vs solve_exact_f64) | 50.38 µs | 48.17 µs | **-4.4%** | 1.05x | +| det_exact | 1.91 µs | 1.50 µs | -21.7% | 1.28x | +| solve_exact | 49.42 µs | 47.77 µs | -3.3% | 1.03x | +| solve_exact_f64_result (vs solve_exact_f64) | 50.38 µs | 47.67 µs | -5.4% | 1.06x | +| solve_exact_rounded_f64 (vs solve_exact_f64) | 50.38 µs | 48.17 µs | -4.4% | 1.05x | ### Hilbert 5x5 | Benchmark | v0.4.2 | Latest | Change | Speedup | |-----------|-------:|-------:|-------:|--------:| -| det_sign_exact | 4.09 µs | 3.91 µs | **-4.6%** | 1.05x | +| det_sign_exact | 4.09 µs | 3.91 µs | -4.6% | 1.05x | | det_exact | 4.00 µs | 4.02 µs | +0.6% | 0.99x | -| solve_exact | 98.71 µs | 95.41 µs | **-3.4%** | 1.03x | -| solve_exact_f64_result (vs solve_exact_f64) | 99.88 µs | 98.14 µs | **-1.7%** | 1.02x | -| solve_exact_rounded_f64 (vs solve_exact_f64) | 99.88 µs | 97.50 µs | **-2.4%** | 1.02x | +| solve_exact | 98.71 µs | 95.41 µs | -3.4% | 1.03x | +| solve_exact_f64_result (vs solve_exact_f64) | 99.88 µs | 98.14 µs | -1.7% | 1.02x | +| solve_exact_rounded_f64 (vs solve_exact_f64) | 99.88 µs | 97.50 µs | -2.4% | 1.02x | ## How to Update diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 07d7c00..3439dd0 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -44,6 +44,10 @@ This PR should primarily include version bumps, changelog updates, benchmark comparison updates, and documentation updates. All major code changes should already be on `main`. +Finalize release-facing metadata and documentation in this dedicated release +PR. Ordinary feature, fix, review, and hygiene work should not preemptively +bump versions or prepare release artifacts. + Small, critical fixes discovered during the release process may be included, but keep them minimal and release-critical. @@ -69,8 +73,28 @@ Update release metadata to match the crate version: - `CITATION.cff`: update `version` and `date-released` - `pyproject.toml`: update `[project] version` for the Python utility package +Review the citation identity fields at the same time: author name and contact, +ORCID, repository URL, and license. Preserve la-stack's Zenodo concept DOI +(`all versions`) unless the archival policy is deliberately changed; do not +replace it with a release-specific DOI. + +Refresh both committed lockfiles after those manual metadata edits: + +```bash +cargo metadata --format-version 1 --no-deps > /dev/null +uv lock +``` + Review version references in documentation: +```bash +uv run --locked check-docs-version-sync +``` + +The automated check covers package metadata, lockfiles, README dependency +snippets, and release-pinned README links. Then review historical references +that intentionally remain on older versions: + ```bash rg -n "\bv?[0-9]+\.[0-9]+\.[0-9]+\b" README.md docs/ CITATION.cff pyproject.toml || true ``` @@ -92,14 +116,15 @@ under `docs/archive/changelog/`. 4. Run benchmarks and update the README comparison table ```bash -# Run vs_linalg benchmarks (la-stack vs nalgebra vs faer) and update the -# README benchmark table + SVG plot -just bench-vs-linalg +# Validate inputs, run a fresh complete vs_linalg benchmark, and atomically +# update the README table plus CSV/SVG/JSON-provenance assets just plot-vs-linalg-readme ``` -Review the updated table in `README.md` and the plot in `docs/assets/` for -accuracy. +Review the updated table in `README.md`, the plot and CSV in `docs/assets/`, and +the adjacent provenance JSON. The publication command fails if the independent +correctness gate, canonical-dimension coverage, or required provenance is +incomplete. 5. Update the release performance comparison @@ -116,10 +141,16 @@ lexicographically sorted filenames such as `v0.4.2-vs-v0.4.1.md`. Iterative local reports still live under `target/bench-reports/`. For an explicit release repair, run `just performance-release `. To compare the stored GitHub Actions release assets instead of running cargo locally, use -`just performance-github-assets`. +`just performance-github-assets`. The local release workflow validates and then +compiles both library revisions with the current checkout's hashed benchmark +harness, recording source-state, environment, toolchain, dependency, Criterion, +and validation provenance. Stored release assets retain their original +per-release harnesses; unavailable historical measurement metadata is labelled +explicitly rather than treated as an isolated library-code comparison. After the GitHub Release is published, the `Release Benchmarks` workflow checks -out the release tag, saves a full Criterion baseline, and attaches +out the release tag, runs the independent benchmark-input tests, saves a full +Criterion baseline, and attaches `la-stack-$TAG-criterion-baseline.tar.gz` to the release. That release asset is the durable archive for historical baseline comparisons; the workflow also uploads a short-lived Actions artifact for debugging the run. @@ -131,6 +162,7 @@ comparison command reference. ```bash just ci +just cargo-lock-check just citation-check cargo publish --locked --allow-dirty --dry-run ``` @@ -138,7 +170,7 @@ cargo publish --locked --allow-dirty --dry-run 7. Stage and commit release artifacts ```bash -git add Cargo.toml Cargo.lock CITATION.cff pyproject.toml CHANGELOG.md README.md docs/ +git add Cargo.toml Cargo.lock CITATION.cff pyproject.toml uv.lock CHANGELOG.md README.md docs/ git commit -m "chore(release): release $TAG @@ -227,6 +259,29 @@ gh release create "$TAG" --title "$TAG" --notes-from-tag Always set the GitHub release title to the exact tag string, including the leading `v`. +7. Verify the durable Criterion baseline asset + +After the `Release Benchmarks` workflow completes, verify that the GitHub +release contains the expected long-lived baseline archive: + +```bash +gh release view "$TAG" --json assets \ + --jq ".assets[] | select(.name == \"la-stack-$TAG-criterion-baseline.tar.gz\") | .name" | cat +``` + +The command must print `la-stack-$TAG-criterion-baseline.tar.gz`. An Actions +artifact alone is not a durable release baseline. + +8. Clean up the merged release branch + +After publishing and asset verification succeed, remove the release branch +locally and on the remote: + +```bash +git branch -d "release/$TAG" +git push origin --delete "release/$TAG" +``` + --- ## Notes and tips diff --git a/docs/assets/bench/vs_linalg_lu_solve_median.provenance.json b/docs/assets/bench/vs_linalg_lu_solve_median.provenance.json new file mode 100644 index 0000000..d43cd72 --- /dev/null +++ b/docs/assets/bench/vs_linalg_lu_solve_median.provenance.json @@ -0,0 +1,36 @@ +{ + "artifact": "README vs_linalg dimension plot", + "artifact_files": { + "csv_sha256": "9422bc1f7000b8c7d4c009306c7d5f1e7ac73011189e5611ca7446054ad948b9", + "svg_sha256": "00a08cf79a7193d88563c6f07f6d5c351d3426020b26a358355393f2b9f47894" + }, + "criterion": { + "benchmark_command": "unavailable", + "confidence_interval_configuration": "unavailable; bounds are preserved in the CSV", + "criterion_dependency": "unavailable", + "dimensions": [ + 2, + 3, + 4, + 5, + 8, + 16, + 32, + 64 + ], + "log_y": true, + "metric": "lu_solve", + "sample": "new", + "statistic": "median" + }, + "measurement": { + "reason": "the v0.4.3 assets predate deterministic measurement-provenance capture", + "status": "unavailable" + }, + "publication": { + "artifact_commit": "925cbb72be101aa10e612f80b07c1cad8e298434", + "release": "v0.4.3", + "status": "historical" + }, + "schema": 1 +} diff --git a/docs/roadmap.md b/docs/roadmap.md index 7e81b93..85872e9 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -63,14 +63,29 @@ API-invariant cleanup: only as implementation helpers at algorithm boundaries. - The public prelude stays focused on downstream composition: raw boundary types, factorization handles, tolerances, crate errors, dispatch helpers, and - documented constants. Proof-bearing wrappers remain crate-private, and - exact-arithmetic integer/rational re-exports remain gated behind the `"exact"` - feature. + common defaults. Advanced determinant error-bound coefficients remain + explicit crate-root exports. Proof-bearing wrappers remain crate-private, + while the exact-arithmetic sign and integer/rational re-exports remain gated + behind the `"exact"` feature. +- Fallible raw boundaries advertise parsing explicitly: `Tolerance::try_new` + constructs validated tolerances, `Matrix::try_get` preserves index context, + and `Matrix::set` validates atomically before mutation. +- Exact determinant signs use `DeterminantSign` rather than an invalidable raw + integer. Because `Matrix` already carries the finite-entry proof and filter + range failures fall back to exact integer arithmetic, `det_sign_exact` returns + `DeterminantSign` infallibly; `as_i8` is reserved for numeric interoperability. - The public LDLT API remains `Matrix::ldlt`. Symmetry proof storage is kept - internal, `SymmetricMatrix` is not exported, asymmetric inputs return - `LaError::Asymmetric`, and negative LDLT pivots return - `LaError::NotPositiveSemidefinite` rather than being folded into - `LaError::Singular`. + internal and `SymmetricMatrix` is not exported. Asymmetric errors retain both + observed entries and their effective bound; negative pivots and zero pivots + with nonzero coupling use distinct `PositiveSemidefiniteViolation` values. +- The public error model keeps semantic categories typed: exact and numerical + singularity use distinct `SingularityReason` values, numerical rejection + retains its factorization/pivot/tolerance, and `NonFiniteOrigin` plus + `NonFiniteLocation` distinguish invalid inputs from arithmetic overflow. +- Strict exact-to-`f64` conversion reports `RequiresRounding` only when an + explicit rounded fallback can return a finite value; values above the + overflow-rounding midpoint report `NotFinite` consistently from strict and + rounded APIs. - The determinant error-bound constants `ERR_COEFF_2`, `ERR_COEFF_3`, and `ERR_COEFF_4` are documented as dimension-specific roundoff multipliers over the absolute Leibniz sum, not caller-tuned tolerances. diff --git a/dprint.json b/dprint.json index 1df2356..4ce4d36 100644 --- a/dprint.json +++ b/dprint.json @@ -1,19 +1,19 @@ { "$schema": "https://dprint.dev/schemas/v0.json", - "incremental": false, - "plugins": [ - "https://plugins.dprint.dev/g-plane/pretty_yaml-v0.6.0.wasm" - ], + "lineWidth": 160, + "indentWidth": 2, + "useTabs": false, + "newLineKind": "lf", "includes": [ - "**/*.yml", - "**/*.yaml", + "**/*.{yaml,yml}", "CITATION.cff" ], "excludes": [ - "**/target", - "CHANGELOG.md" + "target/**", + "target-*/**", + "**/target/**" ], - "yaml": { - "printWidth": 160 - } + "plugins": [ + "https://plugins.dprint.dev/g-plane/pretty_yaml-v0.6.0.wasm" + ] } diff --git a/examples/const_det_4x4.rs b/examples/const_det_4x4.rs index a9bc197..5e192a9 100644 --- a/examples/const_det_4x4.rs +++ b/examples/const_det_4x4.rs @@ -1,3 +1,5 @@ +#![forbid(unsafe_code)] + //! Compile-time 4×4 determinant via `det_direct()`. //! //! Because `det_direct` is a `const fn` (Rust 1.94+), the determinant is @@ -23,13 +25,13 @@ fn main() -> Result<(), LaError> { let mat = MAT?; println!("4×4 matrix:"); - for r in 0..4 { + for row in mat.as_rows() { print!(" ["); - for c in 0..4 { - if c > 0 { + for (col, value) in row.iter().enumerate() { + if col > 0 { print!(", "); } - print!("{:5.1}", mat.get_checked(r, c)?); + print!("{value:5.1}"); } println!("]"); } diff --git a/examples/det_5x5.rs b/examples/det_5x5.rs index 73b5da7..5acd06a 100644 --- a/examples/det_5x5.rs +++ b/examples/det_5x5.rs @@ -1,3 +1,5 @@ +#![forbid(unsafe_code)] + //! Compute the determinant of a 5×5 matrix via explicit LU factorization. use la_stack::prelude::*; diff --git a/examples/exact_det_3x3.rs b/examples/exact_det_3x3.rs index ae1bb96..70fb897 100644 --- a/examples/exact_det_3x3.rs +++ b/examples/exact_det_3x3.rs @@ -1,9 +1,10 @@ +#![forbid(unsafe_code)] + //! Exact determinant value for a near-singular 3×3 matrix. //! -//! This example demonstrates `det_exact()` and `det_exact_f64()`, which use -//! arbitrary-precision rational arithmetic to compute the provably correct -//! determinant value — even when the matrix is so close to singular that f64 -//! rounding could lose significant digits. +//! This example demonstrates `det_exact()` and [`ExactF64Conversion`], retaining +//! the provably correct rational determinant before converting that same value +//! to binary64 without repeating exact evaluation. //! //! Run with: `cargo run --features exact --example exact_det_3x3` @@ -28,23 +29,23 @@ fn main() -> Result<(), LaError> { unreachable!("D=3 is supported by det_direct"); }; let det_exact = m.det_exact()?; - let det_exact_as_f64 = m.det_exact_f64()?; + let det_exact_as_f64 = det_exact.try_to_f64()?; println!("Near-singular 3×3 matrix (perturbation = 2^-50 ≈ {perturbation:.2e}):"); - for r in 0..3 { + for row in m.as_rows() { print!(" ["); - for c in 0..3 { - if c > 0 { + for (col, value) in row.iter().enumerate() { + if col > 0 { print!(", "); } - print!("{:22.18}", m.get_checked(r, c)?); + print!("{value:22.18}"); } println!("]"); } println!(); println!("f64 det_direct() = {det_f64_approx:+.6e}"); println!("det_exact() = {det_exact}"); - println!("det_exact_f64() = {det_exact_as_f64:+.6e}"); + println!("exact.try_to_f64() = {det_exact_as_f64:+.6e}"); println!(); println!("The exact determinant is −3/2^50 ≈ −2.66e-15."); Ok(()) diff --git a/examples/exact_sign_3x3.rs b/examples/exact_sign_3x3.rs index 5bac14b..698e1e7 100644 --- a/examples/exact_sign_3x3.rs +++ b/examples/exact_sign_3x3.rs @@ -1,3 +1,5 @@ +#![forbid(unsafe_code)] + //! Exact determinant sign for a near-singular 3×3 matrix. //! //! This example demonstrates `det_sign_exact()`, which uses adaptive-precision @@ -23,23 +25,23 @@ fn main() -> Result<(), LaError> { [7.0, 8.0, 9.0], ])?; - let sign = m.det_sign_exact()?; + let sign = m.det_sign_exact(); let det_f64 = m.det()?; println!("Near-singular 3×3 matrix (perturbation = 2^-50 ≈ {perturbation:.2e}):"); - for r in 0..3 { + for row in m.as_rows() { print!(" ["); - for c in 0..3 { - if c > 0 { + for (col, value) in row.iter().enumerate() { + if col > 0 { print!(", "); } - print!("{:22.18}", m.get_checked(r, c)?); + print!("{value:22.18}"); } println!("]"); } println!(); println!("f64 det() = {det_f64:+.6e}"); - println!("det_sign_exact() = {sign}"); + println!("det_sign_exact() = {}", sign.as_i8()); println!(); println!("The exact sign is −1 (negative), matching the analytical result."); diff --git a/examples/exact_solve_3x3.rs b/examples/exact_solve_3x3.rs index 2afe790..f517fdb 100644 --- a/examples/exact_solve_3x3.rs +++ b/examples/exact_solve_3x3.rs @@ -1,3 +1,5 @@ +#![forbid(unsafe_code)] + //! Exact linear system solve for a near-singular 3×3 system. //! //! This example demonstrates `solve_exact()` and `solve_exact_f64()`. The exact @@ -27,18 +29,18 @@ fn main() -> Result<(), LaError> { // f64 LU solve (using zero pivot tolerance since the matrix is nearly singular // and would be rejected by DEFAULT_SINGULAR_TOL). - let lu_x = a.lu(Tolerance::new(0.0)?)?.solve(b)?.into_array(); + let lu_x = a.lu(Tolerance::try_new(0.0)?)?.solve(b)?.into_array(); // Exact solve. let exact_x = a.solve_exact(b)?; println!("Near-singular 3×3 system (perturbation = 2^-50 ≈ {perturbation:.2e}):"); - for r in 0..3 { + for row in a.as_rows() { print!(" ["); - for c in 0..3 { - if c > 0 { + for (col, value) in row.iter().enumerate() { + if col > 0 { print!(", "); } - print!("{:22.18}", a.get_checked(r, c)?); + print!("{value:22.18}"); } println!("]"); } @@ -57,7 +59,7 @@ fn main() -> Result<(), LaError> { "solve_exact(): x = [{}, {}, {}]", exact_x[0], exact_x[1], exact_x[2] ); - match a.solve_exact_f64(b) { + match exact_x.try_to_f64() { Ok(x) => { let x = x.into_array(); println!( @@ -67,7 +69,7 @@ fn main() -> Result<(), LaError> { } Err(err) if err.requires_rounding() => { println!("solve_exact_f64(): {err}"); - let x = a.solve_exact_rounded_f64(b)?.into_array(); + let x = exact_x.to_rounded_f64()?.into_array(); println!( "rounded fallback: x = [{:+.6e}, {:+.6e}, {:+.6e}]", x[0], x[1], x[2] diff --git a/examples/ldlt_solve_3x3.rs b/examples/ldlt_solve_3x3.rs index cbaca7d..abef061 100644 --- a/examples/ldlt_solve_3x3.rs +++ b/examples/ldlt_solve_3x3.rs @@ -1,3 +1,5 @@ +#![forbid(unsafe_code)] + //! Solve a 3×3 symmetric positive definite system via LDLT factorization. //! //! LDLT is the natural choice for SPD matrices (e.g. Gram matrices, covariance @@ -20,13 +22,13 @@ fn main() -> Result<(), LaError> { let det = ldlt.det()?; println!("A (3×3 SPD tridiagonal):"); - for r in 0..3 { + for row in a.as_rows() { print!(" ["); - for c in 0..3 { - if c > 0 { + for (col, value) in row.iter().enumerate() { + if col > 0 { print!(", "); } - print!("{:5.1}", a.get_checked(r, c)?); + print!("{value:5.1}"); } println!("]"); } diff --git a/examples/solve_5x5.rs b/examples/solve_5x5.rs index d8ae5c0..ccc0866 100644 --- a/examples/solve_5x5.rs +++ b/examples/solve_5x5.rs @@ -1,3 +1,5 @@ +#![forbid(unsafe_code)] + //! Solve a 5×5 linear system via LU factorization (with pivoting). use la_stack::prelude::*; diff --git a/justfile b/justfile index 70935eb..69afaeb 100644 --- a/justfile +++ b/justfile @@ -6,21 +6,29 @@ # Use bash with strict error handling for all recipes set shell := ["bash", "-euo", "pipefail", "-c"] -cargo_nextest_version := "0.9.137" +home_dir := env_var_or_default("HOME", env_var_or_default("USERPROFILE", "")) +cargo_home := env_var_or_default("CARGO_HOME", home_dir + "/.cargo") +path_separator := if os_family() == "windows" { ";" } else { ":" } +export PATH := cargo_home + "/bin" + path_separator + env_var("PATH") + +cargo_machete_version := "0.9.2" +cargo_nextest_version := "0.9.140" cargo_llvm_cov_version := "0.8.7" -dprint_version := "0.54.0" +dprint_version := "0.55.1" git_cliff_version := "2.13.1" -rumdl_version := "0.2.9" +just_version := "1.56.0" +rumdl_version := "0.2.30" taplo_version := "0.10.0" -typos_version := "1.47.2" -zizmor_version := "1.25.2" +typos_version := "1.48.0" +uv_version := "0.11.28" +zizmor_version := "1.26.1" # Internal helpers: ensure external tooling is installed _ensure-actionlint: #!/usr/bin/env bash set -euo pipefail command -v uv >/dev/null || { echo "❌ 'uv' not found. Install with the official installer: https://docs.astral.sh/uv/getting-started/installation/"; exit 1; } - uv run actionlint -version >/dev/null + uv run --locked actionlint -version >/dev/null _ensure-cargo-llvm-cov: #!/usr/bin/env bash @@ -29,9 +37,22 @@ _ensure-cargo-llvm-cov: if command -v cargo-llvm-cov >/dev/null; then installed_version="$(cargo llvm-cov --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)" fi - if [[ "$installed_version" != "{{cargo_llvm_cov_version}}" ]]; then - echo "❌ 'cargo-llvm-cov' {{cargo_llvm_cov_version}} not found. Install with:" - echo " cargo install --locked cargo-llvm-cov --version {{cargo_llvm_cov_version}}" + if [[ "$installed_version" != "{{ cargo_llvm_cov_version }}" ]]; then + echo "❌ 'cargo-llvm-cov' {{ cargo_llvm_cov_version }} not found. Install with:" + echo " cargo install --locked cargo-llvm-cov --version {{ cargo_llvm_cov_version }}" + exit 1 + fi + +_ensure-cargo-machete: + #!/usr/bin/env bash + set -euo pipefail + installed_version="" + if cargo machete --version >/dev/null 2>&1; then + installed_version="$(cargo machete --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)" + fi + if [[ "$installed_version" != "{{ cargo_machete_version }}" ]]; then + echo "❌ 'cargo-machete' {{ cargo_machete_version }} not found. Install with:" + echo " cargo install --locked cargo-machete --version {{ cargo_machete_version }}" exit 1 fi @@ -42,9 +63,9 @@ _ensure-cargo-nextest: if cargo nextest --version >/dev/null 2>&1; then installed_version="$(cargo nextest --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)" fi - if [[ "$installed_version" != "{{cargo_nextest_version}}" ]]; then - echo "❌ 'cargo-nextest' {{cargo_nextest_version}} not found. Install with:" - echo " cargo install --locked cargo-nextest --version {{cargo_nextest_version}}" + if [[ "$installed_version" != "{{ cargo_nextest_version }}" ]]; then + echo "❌ 'cargo-nextest' {{ cargo_nextest_version }} not found. Install with:" + echo " cargo install --locked cargo-nextest --version {{ cargo_nextest_version }}" exit 1 fi @@ -55,9 +76,9 @@ _ensure-dprint: if command -v dprint >/dev/null; then installed_version="$(dprint --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)" fi - if [[ "$installed_version" != "{{dprint_version}}" ]]; then - echo "❌ 'dprint' {{dprint_version}} not found. Install with:" - echo " cargo install --locked dprint --version {{dprint_version}}" + if [[ "$installed_version" != "{{ dprint_version }}" ]]; then + echo "❌ 'dprint' {{ dprint_version }} not found. Install with:" + echo " cargo install --locked dprint --version {{ dprint_version }}" exit 1 fi @@ -68,9 +89,9 @@ _ensure-git-cliff: if command -v git-cliff >/dev/null; then installed_version="$(git-cliff --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)" fi - if [[ "$installed_version" != "{{git_cliff_version}}" ]]; then - echo "❌ 'git-cliff' {{git_cliff_version}} not found. Install with:" - echo " cargo install --locked git-cliff --version {{git_cliff_version}}" + if [[ "$installed_version" != "{{ git_cliff_version }}" ]]; then + echo "❌ 'git-cliff' {{ git_cliff_version }} not found. Install with:" + echo " cargo install --locked git-cliff --version {{ git_cliff_version }}" exit 1 fi @@ -86,21 +107,23 @@ _ensure-rumdl: if command -v rumdl >/dev/null; then installed_version="$(rumdl --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)" fi - if [[ "$installed_version" != "{{rumdl_version}}" ]]; then - echo "❌ 'rumdl' {{rumdl_version}} not found. Install with:" - echo " cargo install --locked rumdl --version {{rumdl_version}}" + if [[ "$installed_version" != "{{ rumdl_version }}" ]]; then + echo "❌ 'rumdl' {{ rumdl_version }} not found. Install with:" + echo " cargo install --locked rumdl --version {{ rumdl_version }}" exit 1 fi _ensure-shellcheck: #!/usr/bin/env bash set -euo pipefail - command -v shellcheck >/dev/null || { echo "❌ 'shellcheck' not found. See 'just setup' or https://www.shellcheck.net"; exit 1; } + command -v uv >/dev/null || { echo "❌ 'uv' not found. See 'just setup' or https://docs.astral.sh/uv/"; exit 1; } + uv run --locked shellcheck --version >/dev/null _ensure-shfmt: #!/usr/bin/env bash set -euo pipefail - command -v shfmt >/dev/null || { echo "❌ 'shfmt' not found. See 'just setup' or install: brew install shfmt"; exit 1; } + command -v uv >/dev/null || { echo "❌ 'uv' not found. See 'just setup' or https://docs.astral.sh/uv/"; exit 1; } + uv run --locked shfmt --version >/dev/null _ensure-taplo: #!/usr/bin/env bash @@ -109,9 +132,9 @@ _ensure-taplo: if command -v taplo >/dev/null; then installed_version="$(taplo --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)" fi - if [[ "$installed_version" != "{{taplo_version}}" ]]; then - echo "❌ 'taplo' {{taplo_version}} not found. Install with:" - echo " cargo install --locked taplo-cli --version {{taplo_version}}" + if [[ "$installed_version" != "{{ taplo_version }}" ]]; then + echo "❌ 'taplo' {{ taplo_version }} not found. Install with:" + echo " cargo install --locked taplo-cli --version {{ taplo_version }}" exit 1 fi @@ -123,9 +146,9 @@ _ensure-typos: if command -v typos >/dev/null; then installed_version="$(typos --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)" fi - if [[ "$installed_version" != "{{typos_version}}" ]]; then - echo "❌ 'typos' {{typos_version}} not found. Install with:" - echo " cargo install --locked typos-cli --version {{typos_version}}" + if [[ "$installed_version" != "{{ typos_version }}" ]]; then + echo "❌ 'typos' {{ typos_version }} not found. Install with:" + echo " cargo install --locked typos-cli --version {{ typos_version }}" exit 1 fi @@ -134,6 +157,12 @@ _ensure-uv: set -euo pipefail command -v uv >/dev/null || { echo "❌ 'uv' not found. See 'just setup' or https://github.com/astral-sh/uv"; exit 1; } +_ensure-yamllint: + #!/usr/bin/env bash + set -euo pipefail + command -v uv >/dev/null || { echo "❌ 'uv' not found. See 'just setup' or https://docs.astral.sh/uv/"; exit 1; } + uv run --locked yamllint --version >/dev/null + _ensure-zizmor: #!/usr/bin/env bash set -euo pipefail @@ -141,9 +170,9 @@ _ensure-zizmor: if command -v zizmor >/dev/null; then installed_version="$(zizmor --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)" fi - if [[ "$installed_version" != "{{zizmor_version}}" ]]; then - echo "❌ 'zizmor' {{zizmor_version}} not found. Install with:" - echo " cargo install --locked zizmor --version {{zizmor_version}}" + if [[ "$installed_version" != "{{ zizmor_version }}" ]]; then + echo "❌ 'zizmor' {{ zizmor_version }} not found. Install with:" + echo " cargo install --locked zizmor --version {{ zizmor_version }}" exit 1 fi @@ -153,62 +182,64 @@ action-lint: _ensure-actionlint set -euo pipefail files=() while IFS= read -r -d '' file; do - files+=("$file") - done < <(git ls-files -z '.github/workflows/*.yml' '.github/workflows/*.yaml') + if [ -f "$file" ]; then + files+=("$file") + fi + done < <(git ls-files -co --exclude-standard -z -- '.github/workflows/*.yml' '.github/workflows/*.yaml') if [ "${#files[@]}" -gt 0 ]; then - printf '%s\0' "${files[@]}" | xargs -0 uv run actionlint + printf '%s\0' "${files[@]}" | xargs -0 uv run --locked actionlint else echo "No workflow files found to lint." fi # Benchmarks bench: - cargo bench --features bench + cargo bench --locked --features bench # Compare latest measurements against a saved baseline. # Defaults to the `last` full-release baseline. bench-compare baseline="last" suite="all" scope="release-signal": python-sync #!/usr/bin/env bash set -euo pipefail - baseline="{{baseline}}" - uv run bench-compare "$baseline" --suite "{{suite}}" --scope "{{scope}}" + baseline="{{ baseline }}" + uv run --locked bench-compare "$baseline" --suite "{{ suite }}" --scope "{{ scope }}" # Compile benchmarks without running them, treating warnings as errors. # This catches bench/release-profile-only warnings that won't show up in normal debug-profile runs. bench-compile: - RUSTFLAGS='-D warnings' cargo bench --no-run --features bench - RUSTFLAGS='-D warnings' cargo bench --no-run --features bench,exact --bench exact + RUSTFLAGS='-D warnings' cargo bench --locked --no-run --features bench + RUSTFLAGS='-D warnings' cargo bench --locked --no-run --features bench,exact --bench exact # Run the exact-arithmetic benchmark suite. bench-exact: - cargo bench --features bench,exact --bench exact + cargo bench --locked --features bench,exact --bench exact # Run the cheaper latest measurements used for latest-vs-last reports. bench-latest: bench-vs-linalg-la-stack bench-exact # Run latest measurements and render the latest-vs-last performance report. bench-latest-vs-last baseline="last": bench-latest python-sync - uv run bench-compare {{baseline}} + uv run --locked bench-compare {{ baseline }} # Run only la-stack vs_linalg measurements and render a non-exact performance report. bench-vs-linalg-latest-vs baseline="last": bench-vs-linalg-la-stack python-sync - uv run bench-compare {{baseline}} --suite vs_linalg --scope release-signal + uv run --locked bench-compare {{ baseline }} --suite vs_linalg --scope release-signal # Save a Criterion baseline. Defaults to all release-signal benchmark suites. bench-save-baseline tag suite="all": #!/usr/bin/env bash set -euo pipefail - suite="{{suite}}" + suite="{{ suite }}" case "$suite" in all) - cargo bench --features bench --bench vs_linalg -- --save-baseline {{tag}} - cargo bench --features bench,exact --bench exact -- --save-baseline {{tag}} + cargo bench --locked --features bench --bench vs_linalg -- --save-baseline {{ tag }} + cargo bench --locked --features bench,exact --bench exact -- --save-baseline {{ tag }} ;; exact) - cargo bench --features bench,exact --bench exact -- --save-baseline {{tag}} + cargo bench --locked --features bench,exact --bench exact -- --save-baseline {{ tag }} ;; vs_linalg) - cargo bench --features bench --bench vs_linalg -- --save-baseline {{tag}} + cargo bench --locked --features bench --bench vs_linalg -- --save-baseline {{ tag }} ;; *) echo "unknown benchmark suite: $suite" >&2 @@ -224,26 +255,26 @@ bench-save-last: bench-vs-linalg filter="": #!/usr/bin/env bash set -euo pipefail - filter="{{filter}}" + filter="{{ filter }}" if [ -n "$filter" ]; then - cargo bench --features bench --bench vs_linalg -- "$filter" + cargo bench --locked --features bench --bench vs_linalg -- "$filter" else - cargo bench --features bench --bench vs_linalg + cargo bench --locked --features bench --bench vs_linalg fi # Bench only la-stack rows from the vs_linalg suite for cheap latest-vs-last comparisons. bench-vs-linalg-la-stack: - cargo bench --features bench --bench vs_linalg -- la_stack + cargo bench --locked --features bench --bench vs_linalg -- la_stack # Quick iteration (reduced runtime, no Criterion HTML). bench-vs-linalg-quick filter="": #!/usr/bin/env bash set -euo pipefail - filter="{{filter}}" + filter="{{ filter }}" if [ -n "$filter" ]; then - cargo bench --features bench --bench vs_linalg -- "$filter" --quick --noplot + cargo bench --locked --features bench --bench vs_linalg -- "$filter" --quick --noplot else - cargo bench --features bench --bench vs_linalg -- --quick --noplot + cargo bench --locked --features bench --bench vs_linalg -- --quick --noplot fi # Build commands @@ -258,8 +289,8 @@ changelog: _ensure-git-cliff _ensure-rumdl python-sync #!/usr/bin/env bash set -euo pipefail GIT_CLIFF_OFFLINE=true git-cliff -o CHANGELOG.md - uv run postprocess-changelog - uv run archive-changelog + uv run --locked postprocess-changelog + uv run --locked archive-changelog archive_files=() if [ -d docs/archive/changelog ]; then while IFS= read -r -d '' file; do @@ -276,9 +307,9 @@ changelog: _ensure-git-cliff _ensure-rumdl python-sync changelog-unreleased version: _ensure-git-cliff _ensure-rumdl python-sync #!/usr/bin/env bash set -euo pipefail - GIT_CLIFF_OFFLINE=true git-cliff --tag {{version}} -o CHANGELOG.md - uv run postprocess-changelog - uv run archive-changelog + GIT_CLIFF_OFFLINE=true git-cliff --tag {{ version }} -o CHANGELOG.md + uv run --locked postprocess-changelog + uv run --locked archive-changelog archive_files=() if [ -d docs/archive/changelog ]; then while IFS= read -r -d '' file; do @@ -299,6 +330,10 @@ check: lint check-fast: cargo check +# Verify Cargo.toml and the committed Cargo.lock are synchronized. +cargo-lock-check: + cargo metadata --locked --format-version 1 --no-deps > /dev/null + # CI simulation: comprehensive validation (matches CI expectations) # Runs: checks + all tests (Rust + Python) + examples + bench compile ci: check bench-compile test-all examples @@ -315,7 +350,10 @@ clean: rm -rf coverage # Code quality and formatting -clippy: +clippy: clippy-all-targets + +clippy-all-targets: + cargo clippy --workspace --all-targets -- -D warnings -W clippy::pedantic -W clippy::nursery -W clippy::cargo cargo clippy --workspace --all-targets --all-features -- -D warnings -W clippy::pedantic -W clippy::nursery -W clippy::cargo # Clippy for the "exact" feature (catches feature-gated lint issues) @@ -330,28 +368,29 @@ _coverage_base_args := '''--features exact \ --verbose''' # Coverage analysis for local development (HTML output) -coverage: _ensure-cargo-llvm-cov +coverage: _ensure-cargo-llvm-cov _ensure-cargo-nextest #!/usr/bin/env bash set -euo pipefail mkdir -p target/llvm-cov - cargo llvm-cov {{_coverage_base_args}} --open --output-dir target/llvm-cov + cargo llvm-cov nextest {{ _coverage_base_args }} --open --output-dir target/llvm-cov -P coverage echo "Coverage report generated: target/llvm-cov/html/index.html" -# Coverage analysis for CI (XML output for codecov/codacy) -coverage-ci: _ensure-cargo-llvm-cov +# Coverage analysis for CI (XML output for Codecov) +coverage-ci: _ensure-cargo-llvm-cov _ensure-cargo-nextest #!/usr/bin/env bash set -euo pipefail mkdir -p coverage - cargo llvm-cov {{_coverage_base_args}} --cobertura --output-path coverage/cobertura.xml + cargo llvm-cov nextest {{ _coverage_base_args }} --cobertura --output-path coverage/cobertura.xml -P coverage # Default recipe shows available commands default: @just --list -# Documentation build check (includes exact feature for full API coverage) +# Documentation build checks for the default and exact-feature public APIs. doc-check: + RUSTDOCFLAGS='-D warnings' cargo doc --no-deps RUSTDOCFLAGS='-D warnings' cargo doc --no-deps --features exact # Examples @@ -373,7 +412,6 @@ examples: "target/debug/examples/${example}${exe_suffix}" done - # Fix (mutating): apply formatters/auto-fixes fix: toml-fmt fmt python-fix shell-fmt markdown-fix yaml-fix @echo "✅ Fixes applied!" @@ -409,8 +447,8 @@ help-workflows: @echo " just bench-vs-linalg-quick # Quick vs_linalg bench (reduced samples)" @echo "" @echo "Benchmark plotting:" - @echo " just plot-vs-linalg # Plot Criterion results (CSV + SVG)" - @echo " just plot-vs-linalg-readme # Plot + update README benchmark table" + @echo " just plot-vs-linalg # Plot Criterion results (CSV + SVG + provenance)" + @echo " just plot-vs-linalg-readme # Gate, rerun, and publish canonical README assets/table" @echo "" @echo "Changelog & releases:" @echo " just changelog # Regenerate CHANGELOG.md from full history" @@ -420,7 +458,7 @@ help-workflows: @echo "" @echo "Setup:" @echo " just setup # Setup project environment (depends on setup-tools)" - @echo " just setup-tools # Install/verify external tooling (best-effort)" + @echo " just setup-tools # Install/verify external tooling" @echo "" @echo "Testing:" @echo " just coverage # Generate coverage report (HTML)" @@ -428,6 +466,8 @@ help-workflows: @echo " just examples # Run examples" @echo " just test # Lib + doc tests (fast)" @echo " just test-all # All tests (Rust + Python)" + @echo " just test-bench-inputs # Benchmark input smoke tests" + @echo " just test-exact # Exact-feature tests and doctests" @echo " just test-integration # Integration tests" @echo " just test-python # Python tests only (pytest)" @echo "" @@ -436,11 +476,29 @@ help-workflows: # Lint groups (delaunay-style) lint: lint-code lint-docs lint-config -lint-code: fmt-check clippy doc-check python-check shell-check semgrep semgrep-test +lint-code: rust-core-check python-check shell-check + +lint-config: json-check toml-ci yaml-ci github-actions-check justfile-fmt-check + +lint-docs: markdown-ci + +github-actions-check: action-lint zizmor + @echo "✅ GitHub Actions checks complete!" + +markdown-ci: markdown-check spell-check + @echo "✅ Markdown checks complete!" + +python-ci: python-check test-python + @echo "✅ Python checks complete!" -lint-config: validate-json toml-lint toml-fmt-check yaml-check citation-check action-lint zizmor +rust-core-check: cargo-lock-check fmt-check clippy-all-targets doc-check semgrep semgrep-test unused-deps + @echo "✅ Rust core checks complete!" -lint-docs: markdown-check spell-check +toml-ci: toml-check + @echo "✅ TOML checks complete!" + +yaml-ci: yaml-check citation-check + @echo "✅ YAML/CFF checks complete!" # Markdown markdown-check: _ensure-rumdl @@ -448,10 +506,30 @@ markdown-check: _ensure-rumdl set -euo pipefail files=() while IFS= read -r -d '' file; do - files+=("$file") - done < <(git ls-files -z '*.md') + case "$file" in + CHANGELOG.md|docs/archive/*) continue ;; + esac + if [ -f "$file" ]; then + files+=("$file") + fi + done < <(git ls-files -co --exclude-standard -z -- '*.md') if [ "${#files[@]}" -gt 0 ]; then printf '%s\0' "${files[@]}" | xargs -0 -n100 rumdl check + violations=0 + for file in "${files[@]}"; do + line_number=0 + while IFS= read -r line || [[ -n "$line" ]]; do + line_number=$((line_number + 1)) + if [ "${#line}" -gt 160 ]; then + printf '%s:%d: line length %d exceeds 160\n' "$file" "$line_number" "${#line}" >&2 + violations=$((violations + 1)) + fi + done < "$file" + done + if [ "$violations" -gt 0 ]; then + echo "Markdown raw line-length check failed." >&2 + exit 1 + fi else echo "No markdown files found to check." fi @@ -461,8 +539,13 @@ markdown-fix: _ensure-rumdl set -euo pipefail files=() while IFS= read -r -d '' file; do - files+=("$file") - done < <(git ls-files -z '*.md') + case "$file" in + CHANGELOG.md|docs/archive/*) continue ;; + esac + if [ -f "$file" ]; then + files+=("$file") + fi + done < <(git ls-files -co --exclude-standard -z -- '*.md') if [ "${#files[@]}" -gt 0 ]; then echo "📝 rumdl check --fix (${#files[@]} files)" printf '%s\0' "${files[@]}" | xargs -0 -n100 rumdl check --fix @@ -474,102 +557,104 @@ markdown-lint: markdown-check # Backward-compatible alias for the GitHub Actions release-asset comparison. performance-archive-published current_tag="" baseline_tag="": - just performance-github-assets "{{current_tag}}" "{{baseline_tag}}" + just performance-github-assets "{{ current_tag }}" "{{ baseline_tag }}" # Compare stored GitHub Actions release benchmark assets without local cargo runs. performance-github-assets current_tag="" baseline_tag="": python-sync #!/usr/bin/env bash set -euo pipefail - current_tag="{{current_tag}}" - baseline_tag="{{baseline_tag}}" + current_tag="{{ current_tag }}" + baseline_tag="{{ baseline_tag }}" if [[ -n "$current_tag" || -n "$baseline_tag" ]]; then if [[ -z "$current_tag" || -z "$baseline_tag" ]]; then echo "current_tag and baseline_tag must be provided together" >&2 exit 2 fi - uv run archive-performance "$current_tag" "$baseline_tag" --github-assets --generate-in-temp-worktree --worktree-ref "$current_tag" --output-only --output target/bench-reports/github-assets-performance.md + uv run --locked archive-performance "$current_tag" "$baseline_tag" --github-assets --generate-in-temp-worktree --worktree-ref "$current_tag" --output-only --output target/bench-reports/github-assets-performance.md else - uv run archive-performance --published-latest --github-assets --generate-in-temp-worktree --output-only --output target/bench-reports/github-assets-performance.md + uv run --locked archive-performance --published-latest --github-assets --generate-in-temp-worktree --output-only --output target/bench-reports/github-assets-performance.md fi # Compare the current tree against the latest published release locally. performance-local: python-sync - uv run archive-performance --current-vs-latest --generate-in-temp-worktree --output-only --output target/bench-reports/performance.md + uv run --locked archive-performance --current-vs-latest --generate-in-temp-worktree --output-only --output target/bench-reports/performance.md # Compare current non-exact kernels locally without rerunning current peer crates. performance-local-vs-linalg current_tag="" baseline_tag="": python-sync #!/usr/bin/env bash set -euo pipefail - current_tag="{{current_tag}}" - baseline_tag="{{baseline_tag}}" + current_tag="{{ current_tag }}" + baseline_tag="{{ baseline_tag }}" if [[ -n "$current_tag" || -n "$baseline_tag" ]]; then if [[ -z "$current_tag" || -z "$baseline_tag" ]]; then echo "current_tag and baseline_tag must be provided together" >&2 exit 2 fi - uv run archive-performance "$current_tag" "$baseline_tag" --suite vs_linalg --generate-in-temp-worktree --worktree-ref HEAD --output-only --output target/bench-reports/performance.md + uv run --locked archive-performance "$current_tag" "$baseline_tag" --suite vs_linalg --generate-in-temp-worktree --worktree-ref HEAD --output-only --output target/bench-reports/performance.md else - uv run archive-performance --current-vs-latest --suite vs_linalg --generate-in-temp-worktree --output-only --output target/bench-reports/performance.md + uv run --locked archive-performance --current-vs-latest --suite vs_linalg --generate-in-temp-worktree --output-only --output target/bench-reports/performance.md fi - # Generate local release-signal measurements in a temp worktree, then promote/archive docs. performance-release current_tag="" baseline_tag="": python-sync #!/usr/bin/env bash set -euo pipefail - current_tag="{{current_tag}}" - baseline_tag="{{baseline_tag}}" + current_tag="{{ current_tag }}" + baseline_tag="{{ baseline_tag }}" if [[ -n "$current_tag" || -n "$baseline_tag" ]]; then if [[ -z "$current_tag" || -z "$baseline_tag" ]]; then echo "current_tag and baseline_tag must be provided together" >&2 exit 2 fi - uv run archive-performance "$current_tag" "$baseline_tag" --generate-in-temp-worktree --worktree-ref HEAD + uv run --locked archive-performance "$current_tag" "$baseline_tag" --generate-in-temp-worktree --worktree-ref HEAD else - uv run archive-performance --infer-release --generate-in-temp-worktree --worktree-ref HEAD + uv run --locked archive-performance --infer-release --generate-in-temp-worktree --worktree-ref HEAD fi # Plot: generate a single time-vs-dimension SVG from Criterion results. -plot-vs-linalg metric="lu_solve" stat="median" sample="new" log_y="false": python-sync +plot-vs-linalg metric="lu_solve" stat="median" sample="new" log_y="false" allow_partial="false": python-sync #!/usr/bin/env bash set -euo pipefail - args=(--metric "{{metric}}" --stat "{{stat}}" --sample "{{sample}}") - if [ "{{log_y}}" = "true" ]; then + args=(--metric "{{ metric }}" --stat "{{ stat }}" --sample "{{ sample }}") + if [ "{{ log_y }}" = "true" ]; then args+=(--log-y) fi - uv run criterion-dim-plot "${args[@]}" + if [ "{{ allow_partial }}" = "true" ]; then + args+=(--allow-partial) + fi + uv run --locked criterion-dim-plot "${args[@]}" -# Plot + update the README benchmark table between BENCH_TABLE markers. +# Validate fixtures, rerun the full comparison, and atomically publish the canonical README assets/table. plot-vs-linalg-readme metric="lu_solve" stat="median" sample="new" log_y="true": python-sync #!/usr/bin/env bash set -euo pipefail - args=(--metric "{{metric}}" --stat "{{stat}}" --sample "{{sample}}" --update-readme) - if [ "{{log_y}}" = "true" ]; then + args=(--metric "{{ metric }}" --stat "{{ stat }}" --sample "{{ sample }}" --update-readme) + if [ "{{ log_y }}" = "true" ]; then args+=(--log-y) fi - uv run criterion-dim-plot "${args[@]}" + uv run --locked criterion-dim-plot "${args[@]}" # Python tooling (uv) python-check: python-typecheck - uv run ruff format --check scripts/ - uv run ruff check scripts/ + uv run --locked ruff format --check scripts/ + uv run --locked ruff check scripts/ python-fix: python-sync - uv run ruff check scripts/ --fix - uv run ruff format scripts/ + uv run --locked ruff check scripts/ --fix + uv run --locked ruff format scripts/ python-lint: python-check python-sync: _ensure-uv - uv sync --group dev + uv sync --locked --group dev python-typecheck: python-sync - uv run ty check scripts/ --error all + uv run --locked ty check scripts/ --error all # Repository-owned Semgrep rules for project-specific diagnostics. semgrep: _ensure-uv - uv run semgrep --metrics off --error --strict --timeout 30 --config semgrep.yaml . - uv run check-docs-version-sync + uv run --locked semgrep --metrics off --error --strict --timeout 30 --config semgrep.yaml . + uv run --locked check-docs-version-sync # Fixture tests for repository-owned Semgrep rules. semgrep-test: _ensure-uv @@ -578,8 +663,8 @@ semgrep-test: _ensure-uv check_semgrep_fixture() { target="$1" - json="$(uv run semgrep scan --metrics off --json --quiet --strict --config semgrep.yaml "$target")" - SEMGREP_JSON="$json" uv run scripts/check_semgrep_fixtures.py "$target" + json="$(uv run --locked semgrep scan --metrics off --json --quiet --strict --config semgrep.yaml "$target")" + SEMGREP_JSON="$json" uv run --locked scripts/check_semgrep_fixtures.py "$target" } while IFS= read -r -d '' fixture; do @@ -594,21 +679,49 @@ setup: setup-tools echo "Note: Rust toolchain and components managed by rust-toolchain.toml (if present)" echo "" - echo "Installing Python tooling..." - uv sync --group dev - echo "" - echo "Building project..." cargo build echo "✅ Setup complete! Run 'just help-workflows' to see available commands." -# Development tooling installation (best-effort) +# Development tooling installation and verification setup-tools: #!/usr/bin/env bash set -euo pipefail have() { command -v "$1" >/dev/null 2>&1; } + installed_tool_version() { + case "$1" in + cargo-llvm-cov) + cargo llvm-cov --version 2>/dev/null + ;; + cargo-machete) + cargo machete --version 2>/dev/null + ;; + cargo-nextest) + cargo nextest --version 2>/dev/null + ;; + *) + "$1" --version 2>/dev/null + ;; + esac | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true + } + + verify_tool_version() { + local cmd="$1" + local expected="$2" + local actual="" + local resolved="" + + actual="$(installed_tool_version "$cmd")" + resolved="$(command -v "$cmd" 2>/dev/null || true)" + if [[ "$actual" != "$expected" ]]; then + echo "❌ '$cmd' resolves to '${resolved:-missing}' at version '${actual:-missing}', expected '$expected'." >&2 + return 1 + fi + echo " ✓ $cmd $actual" + } + echo "🔧 Ensuring tooling required by just recipes is installed..." echo "" echo "Ensuring Rust components..." @@ -616,47 +729,58 @@ setup-tools: echo "❌ 'rustup' not found. Install Rust via https://rustup.rs and re-run: just setup-tools" exit 1 fi - rustup component add clippy rustfmt rust-docs rust-src llvm-tools-preview + rustup component add clippy rustfmt rust-src llvm-tools-preview echo "" echo "Ensuring cargo tools..." - cargo_llvm_cov_version="{{cargo_llvm_cov_version}}" + just_version="{{ just_version }}" + if ! have just || [[ "$(just --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)" != "$just_version" ]]; then + cargo install --locked just --version "$just_version" + fi + + cargo_llvm_cov_version="{{ cargo_llvm_cov_version }}" if ! have cargo-llvm-cov || [[ "$(cargo llvm-cov --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)" != "$cargo_llvm_cov_version" ]]; then cargo install --locked cargo-llvm-cov --version "$cargo_llvm_cov_version" fi - cargo_nextest_version="{{cargo_nextest_version}}" + cargo_machete_version="{{ cargo_machete_version }}" + if ! cargo machete --version >/dev/null 2>&1 || [[ "$(cargo machete --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)" != "$cargo_machete_version" ]]; then + cargo install --locked cargo-machete --version "$cargo_machete_version" + fi + + cargo_nextest_version="{{ cargo_nextest_version }}" if ! cargo nextest --version >/dev/null 2>&1 || [[ "$(cargo nextest --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)" != "$cargo_nextest_version" ]]; then cargo install --locked cargo-nextest --version "$cargo_nextest_version" fi - dprint_version="{{dprint_version}}" + dprint_version="{{ dprint_version }}" if ! have dprint || [[ "$(dprint --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)" != "$dprint_version" ]]; then cargo install --locked dprint --version "$dprint_version" fi - git_cliff_version="{{git_cliff_version}}" + git_cliff_version="{{ git_cliff_version }}" if ! have git-cliff || [[ "$(git-cliff --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)" != "$git_cliff_version" ]]; then cargo install --locked git-cliff --version "$git_cliff_version" fi - rumdl_version="{{rumdl_version}}" + rumdl_version="{{ rumdl_version }}" if ! have rumdl || [[ "$(rumdl --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)" != "$rumdl_version" ]]; then cargo install --locked rumdl --version "$rumdl_version" fi - taplo_version="{{taplo_version}}" + taplo_version="{{ taplo_version }}" if ! have taplo || [[ "$(taplo --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)" != "$taplo_version" ]]; then cargo install --locked taplo-cli --version "$taplo_version" fi - typos_version="{{typos_version}}" + typos_version="{{ typos_version }}" if ! have typos || [[ "$(typos --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)" != "$typos_version" ]]; then cargo install --locked typos-cli --version "$typos_version" fi - zizmor_version="{{zizmor_version}}" + zizmor_version="{{ zizmor_version }}" if ! have zizmor || [[ "$(zizmor --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)" != "$zizmor_version" ]]; then cargo install --locked zizmor --version "$zizmor_version" fi echo "" + uv_version="{{ uv_version }}" if have uv; then echo "Ensuring uv-managed Python tools..." - uv sync --group dev + uv sync --locked --group dev echo "" else echo "❌ uv missing; cannot install project-managed Python tools." @@ -670,49 +794,43 @@ setup-tools: fi echo "" - echo "Verifying required commands are available..." - missing=0 - for cmd in cargo-llvm-cov dprint git-cliff jq rumdl taplo typos uv zizmor; do - if have "$cmd"; then - echo " ✓ $cmd" - else - echo " ✗ $cmd" - missing=1 - fi - done - if [ "$missing" -ne 0 ]; then - echo "" - echo "❌ Some required tools are still missing." - echo "Fix the installs above and re-run: just setup-tools" - exit 1 - fi - if cargo nextest --version >/dev/null 2>&1; then - echo " ✓ cargo nextest" - else - echo " ✗ cargo nextest" - exit 1 - fi - uv run actionlint -version >/dev/null + echo "Verifying required commands and versions..." + have jq || { echo "❌ 'jq' is still missing."; exit 1; } + echo " ✓ jq" + verify_tool_version just "$just_version" + verify_tool_version cargo-llvm-cov "$cargo_llvm_cov_version" + verify_tool_version cargo-machete "$cargo_machete_version" + verify_tool_version cargo-nextest "$cargo_nextest_version" + verify_tool_version dprint "$dprint_version" + verify_tool_version git-cliff "$git_cliff_version" + verify_tool_version rumdl "$rumdl_version" + verify_tool_version taplo "$taplo_version" + verify_tool_version typos "$typos_version" + verify_tool_version uv "$uv_version" + verify_tool_version zizmor "$zizmor_version" + uv run --locked actionlint -version >/dev/null echo " ✓ actionlint (uv)" - uv run semgrep --version >/dev/null - echo " ✓ semgrep (uv)" + for cmd in pytest ruff semgrep shellcheck shfmt ty yamllint; do + uv run --locked "$cmd" --version >/dev/null + echo " ✓ $cmd (uv)" + done echo "" echo "✅ Tooling setup complete." # Shell scripts -shell-check: +shell-check: _ensure-shellcheck _ensure-shfmt #!/usr/bin/env bash set -euo pipefail files=() while IFS= read -r -d '' file; do - files+=("$file") - done < <(git ls-files -z '*.sh') + if [ -f "$file" ]; then + files+=("$file") + fi + done < <(git ls-files -co --exclude-standard -z -- '*.sh') if [ "${#files[@]}" -gt 0 ]; then - command -v shellcheck >/dev/null || { echo "❌ 'shellcheck' not found."; exit 1; } - command -v shfmt >/dev/null || { echo "❌ 'shfmt' not found."; exit 1; } - printf '%s\0' "${files[@]}" | xargs -0 -n4 shellcheck -x - printf '%s\0' "${files[@]}" | xargs -0 shfmt -d + printf '%s\0' "${files[@]}" | xargs -0 -n4 uv run --locked shellcheck -x + printf '%s\0' "${files[@]}" | xargs -0 uv run --locked shfmt -d else echo "No shell files found to check." fi @@ -724,11 +842,13 @@ shell-fmt: _ensure-shfmt set -euo pipefail files=() while IFS= read -r -d '' file; do - files+=("$file") - done < <(git ls-files -z '*.sh') + if [ -f "$file" ]; then + files+=("$file") + fi + done < <(git ls-files -co --exclude-standard -z -- '*.sh') if [ "${#files[@]}" -gt 0 ]; then echo "🧹 shfmt -w (${#files[@]} files)" - printf '%s\0' "${files[@]}" | xargs -0 -n1 shfmt -w + printf '%s\0' "${files[@]}" | xargs -0 -n1 uv run --locked shfmt -w else echo "No shell files found to format." fi @@ -740,68 +860,69 @@ spell-check: _ensure-typos #!/usr/bin/env bash set -euo pipefail files=() - # Use -z for NUL-delimited output to handle filenames with spaces. - # - # Note: For renames/copies, `git status --porcelain -z` emits *two* NUL-separated paths. - # The ordering can differ depending on the porcelain output, so we read both and - # spell-check whichever one exists on disk. - while IFS= read -r -d '' status_line; do - status="${status_line:0:2}" - filename="${status_line:3}" - - # For renames/copies, consume the second path token to keep parsing in sync. - # Prefer the path that exists on disk to avoid passing stale paths to typos. - if [[ "$status" == *"R"* || "$status" == *"C"* ]]; then - if IFS= read -r -d '' other_path; then - if [ ! -e "$filename" ] && [ -e "$other_path" ]; then - filename="$other_path" - fi - fi - fi - - # Skip deletions (file may no longer exist). - if [[ "$status" == *"D"* ]]; then - continue + # Check every tracked file plus untracked, non-ignored additions. This keeps + # clean CI checkouts covered while validating new files before they are staged. + while IFS= read -r -d '' file; do + if [ -f "$file" ]; then + files+=("$file") fi - - files+=("$filename") - done < <(git status --porcelain -z --ignored=no) + done < <(git ls-files -co --exclude-standard -z) if [ "${#files[@]}" -gt 0 ]; then # Exclude typos.toml itself: it intentionally contains allowlisted fragments. printf '%s\0' "${files[@]}" | xargs -0 -n100 typos --config typos.toml --force-exclude --exclude typos.toml -- else - echo "No modified files to spell-check." + echo "No files found to spell-check." fi # Create an annotated git tag from the CHANGELOG.md section for the given version tag version: python-sync - uv run tag-release {{version}} + uv run --locked tag-release {{ version }} # Recreate an existing tag (delete + recreate) tag-force version: python-sync - uv run tag-release {{version}} --force + uv run --locked tag-release {{ version }} --force # Testing: runnable Rust tests use nextest; rustdoc doctests remain on cargo test. test: test-lib test-doc -test-all: test test-integration test-exact test-python +test-all: test-rust test-python @echo "✅ All tests passed" test-doc: cargo test --doc --verbose -# Tests for the "exact" feature (det_sign_exact + BigRational Bareiss) -test-exact: _ensure-cargo-nextest - cargo nextest run --features exact --verbose +test-doc-exact: + cargo test --features exact --doc --verbose + +# Tests for the "exact" feature (exact determinants, conversions, and Bareiss solves) +test-exact: _ensure-cargo-nextest test-doc-exact + cargo nextest run --profile ci --features exact --verbose + +# Smoke-test deterministic inputs and configuration shared with benchmark suites. +test-bench-inputs: _ensure-cargo-nextest + cargo nextest run --profile ci --features bench,exact --test vs_linalg_inputs --test exact_bench_config --verbose + +# Compile all integration-test targets without running them. +test-integration-compile: _ensure-cargo-nextest + cargo nextest run --all-features --tests --no-run test-integration: _ensure-cargo-nextest - cargo nextest run --test '*' --verbose + cargo nextest run --profile ci --tests --verbose test-lib: _ensure-cargo-nextest - cargo nextest run --lib --verbose + cargo nextest run --profile ci --lib --verbose + +# CI Rust bucket: all runnable unit/integration targets in one nextest pass. +test-rust-ci: _ensure-cargo-nextest + cargo nextest run --profile ci --all-features --lib --tests --verbose + +test-rust: test-rust-ci test-doc test-doc-exact + @echo "✅ Rust tests passed" + +test-unit: test-lib test-python: python-sync - uv run pytest -q + uv run --locked pytest -q # TOML toml-check: toml-fmt-check toml-lint @@ -813,8 +934,10 @@ toml-fmt: _ensure-taplo set -euo pipefail files=() while IFS= read -r -d '' file; do - files+=("$file") - done < <(git ls-files -z '*.toml') + if [ -f "$file" ]; then + files+=("$file") + fi + done < <(git ls-files -co --exclude-standard -z -- '*.toml') if [ "${#files[@]}" -gt 0 ]; then taplo fmt "${files[@]}" else @@ -826,8 +949,10 @@ toml-fmt-check: _ensure-taplo set -euo pipefail files=() while IFS= read -r -d '' file; do - files+=("$file") - done < <(git ls-files -z '*.toml') + if [ -f "$file" ]; then + files+=("$file") + fi + done < <(git ls-files -co --exclude-standard -z -- '*.toml') if [ "${#files[@]}" -gt 0 ]; then taplo fmt --check "${files[@]}" else @@ -839,22 +964,32 @@ toml-lint: _ensure-taplo set -euo pipefail files=() while IFS= read -r -d '' file; do - files+=("$file") - done < <(git ls-files -z '*.toml') + if [ -f "$file" ]; then + files+=("$file") + fi + done < <(git ls-files -co --exclude-standard -z -- '*.toml') if [ "${#files[@]}" -gt 0 ]; then taplo lint "${files[@]}" else echo "No TOML files found to lint." fi +# Check for unused direct Cargo dependencies. +unused-deps: _ensure-cargo-machete + cargo machete + # File validation +json-check: validate-json + validate-json: _ensure-jq #!/usr/bin/env bash set -euo pipefail files=() while IFS= read -r -d '' file; do - files+=("$file") - done < <(git ls-files -z '*.json') + if [ -f "$file" ]; then + files+=("$file") + fi + done < <(git ls-files -co --exclude-standard -z -- '*.json') if [ "${#files[@]}" -gt 0 ]; then printf '%s\0' "${files[@]}" | xargs -0 -n1 jq empty else @@ -862,15 +997,19 @@ validate-json: _ensure-jq fi # YAML -yaml-check: _ensure-dprint +yaml-check: yaml-fmt-check yaml-lint + +yaml-fmt-check: _ensure-dprint #!/usr/bin/env bash set -euo pipefail files=() while IFS= read -r -d '' file; do - files+=("$file") - done < <(git ls-files -z '*.yml' '*.yaml') + if [ -f "$file" ]; then + files+=("$file") + fi + done < <(git ls-files -co --exclude-standard -z -- '*.yml' '*.yaml' 'CITATION.cff') if [ "${#files[@]}" -gt 0 ]; then - printf '%s\0' "${files[@]}" | xargs -0 dprint check + printf '%s\0' "${files[@]}" | xargs -0 dprint check --incremental=false else echo "No YAML files found to check." fi @@ -880,15 +1019,35 @@ yaml-fix: _ensure-dprint set -euo pipefail files=() while IFS= read -r -d '' file; do - files+=("$file") - done < <(git ls-files -z '*.yml' '*.yaml') + if [ -f "$file" ]; then + files+=("$file") + fi + done < <(git ls-files -co --exclude-standard -z -- '*.yml' '*.yaml' 'CITATION.cff') if [ "${#files[@]}" -gt 0 ]; then - printf '%s\0' "${files[@]}" | xargs -0 dprint fmt + printf '%s\0' "${files[@]}" | xargs -0 dprint fmt --incremental=false else echo "No YAML files found to format." fi -yaml-lint: yaml-check +yaml-lint: _ensure-yamllint + #!/usr/bin/env bash + set -euo pipefail + files=() + while IFS= read -r -d '' file; do + if [ -f "$file" ]; then + files+=("$file") + fi + done < <(git ls-files -co --exclude-standard -z -- '*.yml' '*.yaml' 'CITATION.cff') + if [ "${#files[@]}" -gt 0 ]; then + echo "🔍 yamllint (${#files[@]} YAML/CFF files)" + uv run --locked yamllint --strict -c .yamllint "${files[@]}" + else + echo "No YAML files found to lint." + fi + +# Keep the command-memory layer itself canonically formatted. +justfile-fmt-check: + just --fmt --check # GitHub Actions security analysis zizmor: _ensure-zizmor diff --git a/pyproject.toml b/pyproject.toml index bd35633..b55cdc7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "la-stack-scripts" version = "0.4.3" description = "Python utility scripts for the la-stack Rust library" readme = "README.md" -requires-python = ">=3.13" +requires-python = ">=3.14" license = { text = "BSD-3-Clause" } authors = [ { name = "Adam Getchell", email = "adam@adamgetchell.org" }, @@ -20,7 +20,7 @@ classifiers = [ "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Topic :: Scientific/Engineering :: Mathematics", "Topic :: System :: Benchmarking", ] @@ -50,11 +50,10 @@ py-modules = [ "archive_changelog", "archive_performance", "bench_compare", "che [tool.ruff] line-length = 160 -target-version = "py313" src = [ "scripts" ] [tool.ruff.lint] -select = [ "E", "F", "W", "I", "N", "UP", "YTT", "S", "BLE", "FBT", "B", "A", "COM", "C4", "DTZ", "T10", "EM", "EXE", "ISC", "ICN", "G", "INP", "PIE", "T20", "PYI", "PT", "Q", "RSE", "RET", "SLF", "SIM", "TID", "TCH", "ARG", "PTH", "ERA", "PD", "PGH", "PL", "TRY", "NPY", "RUF" ] +select = [ "E", "F", "W", "ANN201", "ANN202", "ANN204", "C90", "I", "N", "UP", "YTT", "ASYNC", "S", "BLE", "FBT", "B", "A", "COM", "C4", "DTZ", "FA", "T10", "EM", "EXE", "FIX", "FLY", "FURB", "ISC", "ICN", "LOG", "G", "INP", "PERF", "PIE", "T20", "PYI", "PT", "Q", "RSE", "RET", "SLF", "SIM", "SLOT", "TC", "TID", "TD", "ARG", "PTH", "ERA", "PD", "PGH", "PL", "TRY", "NPY", "RUF", "D100", "D101", "D102", "D103", "D104", "D105", "D106", "D107" ] fixable = [ "ALL" ] unfixable = [ ] ignore = [ @@ -66,7 +65,6 @@ ignore = [ "PLR2004", # Magic value used in comparison - OK for CLI constants and thresholds "FBT001", # Boolean-typed positional argument - OK for CLI flags "FBT002", # Boolean default positional argument - OK for CLI flags - "BLE001", # Do not catch blind exception - OK for CLI robustness "T201", # print found - OK for CLI output "TRY300", # Consider moving statement to else block - OK for CLI control flow "TRY301", # Abstract raise to inner function - OK for straightforward CLI error handling @@ -76,15 +74,16 @@ ignore = [ "PTH123", # open() should be replaced by Path.open() - OK for some call sites "EM102", # Exception must not use f-string - OK for CLI error messages "TRY003", # Avoid specifying long messages outside exception class - OK for CLI reporting + "N806", # Uppercase local names are acceptable for constants and ANSI color codes + "F841", # Color constants may be assigned conditionally for CLI output + "PLW2901", # Loop-variable replacement is acceptable for line-processing transforms ] [tool.ruff.lint.per-file-ignores] -"scripts/tests/**/*.py" = [ - "S101", # asserts are fine in tests - "SLF001", # tests may call internal helpers - "PT019", # @patch decorator params are not pytest fixtures - "PLC0415", # test helpers may import inside functions -] +"**/tests/test_*.py" = [ "S101", "SLF001", "D101", "D102", "D103" ] + +[tool.ruff.lint.mccabe] +max-complexity = 10 [tool.ruff.lint.isort] known-first-party = [ @@ -123,14 +122,33 @@ python_files = [ "test_*.py", "*_test.py" ] python_classes = [ "Test*" ] python_functions = [ "test_*" ] +[tool.rumdl] +line-length = 160 +disable = [ + "MD018", + "MD024", + "MD029", + "MD033", + "MD037", + "MD038", + "MD041", + "MD060", +] + +[tool.rumdl.MD013] +line-length = 160 + [tool.uv] package = true [dependency-groups] dev = [ "actionlint-py==1.7.12.24", - "pytest==9.0.3", - "ruff>=0.15.14", - "semgrep==1.164.0", - "ty>=0.0.40", + "pytest==9.1.1", + "ruff==0.15.20", + "semgrep==1.168.0", + "shellcheck-py==0.11.0.1", + "shfmt-py==4.0.0", + "ty==0.0.56", + "yamllint==1.38.0", ] diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 0937290..3ca73be 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -2,24 +2,13 @@ # Pin to MSRV as specified in Cargo.toml channel = "1.96.0" -# Essential components for development +# Essential repository components. Keep checkout and CI setup lean; workflows +# install additional targets or components when they need them. components = [ - "cargo", # Package manager "clippy", # Linting (you use strict pedantic mode) "rustfmt", # Code formatting (you use cargo fmt --all) - "rust-docs", # Local documentation - "rust-std", # Standard library - "rust-src", # Source code (helpful for IDEs) - "rust-analyzer", # Language server for IDE support + "rust-src", # Source code for IDEs and local diagnostics ] -# Target the platforms you support (adjust as needed) -targets = [ - "x86_64-apple-darwin", # macOS Intel - "aarch64-apple-darwin", # macOS Apple Silicon - "x86_64-unknown-linux-gnu", # Linux - "x86_64-pc-windows-msvc", # Windows -] - -# Set this toolchain as the profile default -profile = "default" +# Keep host installs small; CI passes matrix targets explicitly. +profile = "minimal" diff --git a/scripts/README.md b/scripts/README.md index 7673fc5..15cc2e0 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -4,14 +4,26 @@ This directory contains Python utilities used during development of the `la-stac ## Setup -The Python tooling in this repo is managed with [`uv`](https://github.com/astral-sh/uv). +The Python 3.14 support tooling in this repo is managed with +[`uv`](https://github.com/astral-sh/uv) and resolved from `uv.lock`. ```bash just python-sync # or: -uv sync --group dev +uv sync --locked --group dev ``` +## Python maintenance rules + +- Keep Python 3.14 code precisely typed and add focused pytest coverage for + changed behavior and error paths. +- Mock subprocess results as `subprocess.CompletedProcess[str]`, matching the + production boundary. +- Catch only specific, recoverable exceptions; do not use broad + `except Exception` handlers. +- Give writer/parser pairs round-trip tests and explicit malformed-input tests. +- Update this README whenever Python entry points in `pyproject.toml` change. + ## How to use it ### Comparing performance @@ -24,8 +36,9 @@ The comparison script reads Criterion output and writes a local report to just bench-compare ``` -Use `uv run bench-compare --snapshot` for a no-baseline snapshot, or -`uv run bench-compare ` to compare against a named saved baseline. +Use `uv run --locked bench-compare --snapshot` for a no-baseline snapshot, or +`uv run --locked bench-compare ` to compare against a named saved +baseline. Use the top-level `just` workflows for routine release and local comparisons: @@ -40,6 +53,14 @@ just performance-release just performance-github-assets ``` +The local release workflows run the independent benchmark-input correctness gate +and then measure both library revisions with one hashed current benchmark +harness. Reports record source-state, environment, toolchain, dependency, +Criterion, harness, and validation provenance and fail on incomplete selected +coverage. Direct comparisons of separately published artifacts retain their +original per-release harnesses and label unavailable historical measurement +metadata explicitly. + See `docs/BENCHMARKING.md` for the current command matrix, local saved-baseline workflow, explicit tag arguments, output locations, and release-artifact comparison details. @@ -54,12 +75,14 @@ And writes: - `docs/assets/bench/vs_linalg_{metric}_{stat}.csv` - `docs/assets/bench/vs_linalg_{metric}_{stat}.svg` +- `docs/assets/bench/vs_linalg_{metric}_{stat}.provenance.json` To generate the single “time vs dimension” chart: By default, the benchmark suite runs for dimensions 2–5, 8, 16, 32, and 64. -1. Run the benchmarks you want to plot (this produces `target/criterion/...`): +1. For exploratory plots, run the benchmarks you want to plot (this produces + `target/criterion/...`): ```bash # full run (takes longer, better for README plots) @@ -69,19 +92,29 @@ just bench-vs-linalg lu_solve just bench-vs-linalg-quick lu_solve ``` -2. Generate the chart (median or mean): +2. Generate an exploratory chart (median or mean): ```bash # median (recommended) just plot-vs-linalg lu_solve median new true -# median + update README's benchmark table (between BENCH_TABLE markers) -just plot-vs-linalg-readme lu_solve median new true - # or mean just plot-vs-linalg lu_solve mean new true ``` +Use the dedicated publication path to update README's benchmark table (between +`BENCH_TABLE` markers): + +```bash +just plot-vs-linalg-readme lu_solve median new true +``` + +That recipe runs the benchmark-input gate and a fresh full `vs_linalg` benchmark, +requires la-stack/nalgebra/faer results at every canonical dimension, and then +publishes CSV, SVG, JSON provenance, and README together. Partial dimensions are +available only through the plotter's explicit `--allow-partial` exploratory +option and cannot update README. + This writes: - `docs/assets/bench/vs_linalg_lu_solve_median.csv` @@ -94,32 +127,32 @@ This writes: Plot a different metric: ```bash -uv run criterion-dim-plot --metric dot --stat median --sample new -uv run criterion-dim-plot --metric inf_norm --stat median --sample new +uv run --locked criterion-dim-plot --metric dot --stat median --sample new +uv run --locked criterion-dim-plot --metric inf_norm --stat median --sample new ``` Plot a different statistic: ```bash -uv run criterion-dim-plot --metric lu_solve --stat mean --sample new +uv run --locked criterion-dim-plot --metric lu_solve --stat mean --sample new ``` Plot the previous (baseline) sample instead of the newest run: ```bash -uv run criterion-dim-plot --metric lu_solve --stat median --sample base +uv run --locked criterion-dim-plot --metric lu_solve --stat median --sample base ``` Use a log-scale y-axis: ```bash -uv run criterion-dim-plot --metric lu_solve --stat median --sample new --log-y +uv run --locked criterion-dim-plot --metric lu_solve --stat median --sample new --log-y ``` Write to custom output paths: ```bash -uv run criterion-dim-plot \ +uv run --locked criterion-dim-plot \ --metric lu_solve --stat median --sample new \ --csv docs/assets/bench/custom.csv \ --out docs/assets/bench/custom.svg @@ -128,7 +161,7 @@ uv run criterion-dim-plot \ CSV only (skip SVG/gnuplot): ```bash -uv run criterion-dim-plot --no-plot --metric lu_solve --stat median --sample new +uv run --locked criterion-dim-plot --no-plot --metric lu_solve --stat median --sample new ``` ### gnuplot @@ -161,9 +194,9 @@ just changelog just changelog-unreleased v0.3.0 ``` -`just changelog` runs `git-cliff -o CHANGELOG.md` followed by -`postprocess-changelog` (strips trailing blank lines). Configuration -lives in `cliff.toml` at the repo root. +`just changelog` runs `git-cliff -o CHANGELOG.md`, strips trailing blank +lines, archives completed changelog series, and formats the generated Markdown. +Configuration lives in `cliff.toml` at the repo root. ### Creating a release tag diff --git a/scripts/archive_changelog.py b/scripts/archive_changelog.py index 339116a..81e4904 100755 --- a/scripts/archive_changelog.py +++ b/scripts/archive_changelog.py @@ -103,7 +103,7 @@ def _extract_link_defs(text: str) -> tuple[str, dict[str, str]]: CHANGELOG.md for every version heading. When the changelog is split into per-version blocks these definitions must be distributed to the correct output files so that headings like ``## [0.7.2]`` resolve and - no unused definitions trigger markdownlint MD053. + no unused definitions trigger rumdl MD053. Parameters: text: The full changelog text. @@ -312,6 +312,32 @@ def build_root( return postprocess_text("\n".join(parts)) +def _archive_dir_link_prefix(archive_dir: Path, changelog_parent: Path) -> str: + """Return the Markdown link prefix from a changelog to its archive directory.""" + try: + return archive_dir.relative_to(changelog_parent).as_posix() + except ValueError: + try: + archive_dir_rel = Path(os.path.relpath(archive_dir, changelog_parent)).as_posix() + except ValueError as err: + archive_dir_rel = archive_dir.as_posix() + LOGGER.warning( + "Could not compute relative archive directory: %s; archive_dir=%s changelog_parent=%s; generated Markdown links use %s", + err, + archive_dir, + changelog_parent, + archive_dir_rel, + ) + if archive_dir_rel == ".." or archive_dir_rel.startswith("../") or Path(archive_dir_rel).is_absolute(): + LOGGER.warning( + "Archive directory %s is outside changelog directory %s; generated Markdown links use %s", + archive_dir, + changelog_parent, + archive_dir_rel, + ) + return archive_dir_rel + + # --------------------------------------------------------------------------- # Orchestrator # --------------------------------------------------------------------------- @@ -359,28 +385,7 @@ def archive_changelog( _postprocess_existing_archives(archive_dir) return # only one minor series — nothing to archive yet - # Compute relative path from changelog location to archive dir. - try: - archive_dir_rel = archive_dir.relative_to(changelog_path.parent).as_posix() - except ValueError: - try: - archive_dir_rel = Path(os.path.relpath(archive_dir, changelog_path.parent)).as_posix() - except ValueError as err: - archive_dir_rel = archive_dir.as_posix() - LOGGER.warning( - "Could not compute relative archive directory: %s; archive_dir=%s changelog_parent=%s; generated Markdown links use %s", - err, - archive_dir, - changelog_path.parent, - archive_dir_rel, - ) - if archive_dir_rel == ".." or archive_dir_rel.startswith("../") or Path(archive_dir_rel).is_absolute(): - LOGGER.warning( - "Archive directory %s is outside changelog directory %s; generated Markdown links use %s", - archive_dir, - changelog_path.parent, - archive_dir_rel, - ) + archive_dir_rel = _archive_dir_link_prefix(archive_dir, changelog_path.parent) root_text = build_root( preamble, diff --git a/scripts/archive_performance.py b/scripts/archive_performance.py index 1caf958..9c9bedf 100644 --- a/scripts/archive_performance.py +++ b/scripts/archive_performance.py @@ -16,8 +16,10 @@ from __future__ import annotations import argparse +import hashlib import json import os +import platform import re import shutil import subprocess @@ -51,6 +53,17 @@ _BENCH_TIMEOUT_SECONDS = 7200 _COMMAND_TIMEOUT_SECONDS = 600 _HOW_TO_UPDATE_RE = re.compile(r"(?ms)^## How to Update\n.*\Z") +_BENCHMARK_HARNESS_DIRS = ("benches",) +_BENCHMARK_HARNESS_FILES = ( + "Cargo.toml", + "Cargo.lock", + "rust-toolchain.toml", + "justfile", + "tests/exact_bench_config.rs", + "tests/vs_linalg_inputs.rs", +) +_BENCHMARK_HARNESS_METADATA = ".la-stack-benchmark-harness.json" +_BENCHMARK_INPUT_GATE = ("just", "test-bench-inputs") type BaselineSource = Literal["local", "github-assets"] @@ -130,6 +143,17 @@ class PublishedRelease: published_at: str +@dataclass(frozen=True) +class BaselineRun: + """Validated baseline run details needed for final provenance.""" + + commit: str + command: tuple[str, ...] + harness_sha256: str + git_clean: bool + source_state_sha256: str + + def normalize_tag(tag: str) -> str: """Return *tag* with a leading ``v`` and no surrounding whitespace.""" normalized = tag.strip() @@ -381,8 +405,18 @@ def _format_command_failure(command: list[str], exc: subprocess.CalledProcessErr def _run_git(args: list[str], *, cwd: Path, timeout: int = _COMMAND_TIMEOUT_SECONDS) -> None: + _run_git_output(args, cwd=cwd, timeout=timeout) + + +def _run_git_output( + args: list[str], + *, + cwd: Path, + timeout: int = _COMMAND_TIMEOUT_SECONDS, + env: dict[str, str] | None = None, +) -> str: try: - run_git_command(args, cwd=cwd, timeout=timeout) + return run_git_command(args, cwd=cwd, timeout=timeout, env=env).stdout except subprocess.CalledProcessError as exc: raise RuntimeError(_format_command_failure(["git", *args], exc)) from exc @@ -399,6 +433,239 @@ def _run_tool(command: str, args: list[str], *, cwd: Path, timeout: int = _COMMA raise RuntimeError(_format_command_failure([command, *args], exc)) from exc +def _run_benchmark_input_gate(checkout: Path, *, env: dict[str, str] | None = None) -> None: + """Run the shared deterministic benchmark-fixture correctness gate.""" + _run_tool( + _BENCHMARK_INPUT_GATE[0], + list(_BENCHMARK_INPUT_GATE[1:]), + cwd=checkout, + timeout=_COMMAND_TIMEOUT_SECONDS, + env=env, + ) + + +def _sha256_file(path: Path) -> str: + """Return a lowercase SHA-256 digest for a required file.""" + if not path.is_file(): + msg = f"required provenance file is missing: {path}" + raise FileNotFoundError(msg) + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def _checkout_commit(checkout: Path) -> str: + """Return the full commit for a checkout, or an explicit unavailable label.""" + commit = _run_git_output(["--no-pager", "rev-parse", "HEAD"], cwd=checkout).strip() + return commit or "unavailable" + + +def _git_clean(checkout: Path) -> bool: + """Return whether Git reports a completely clean checkout.""" + status = _run_git_output( + ["--no-pager", "status", "--porcelain=v1", "--untracked-files=all"], + cwd=checkout, + ) + return not status.strip() + + +def _source_state_digest(checkout: Path) -> str: + """Hash measured library source content independent of commit cleanliness.""" + source_dir = checkout / "src" + if not source_dir.is_dir(): + msg = f"measured library source directory is missing: {source_dir}" + raise FileNotFoundError(msg) + files = sorted( + (path for path in source_dir.rglob("*") if path.is_file()), + key=lambda path: path.relative_to(checkout).as_posix(), + ) + if not files: + msg = f"measured library source directory contains no files: {source_dir}" + raise FileNotFoundError(msg) + digest = hashlib.sha256() + for path in files: + relative = path.relative_to(checkout).as_posix().encode() + payload = path.read_bytes() + digest.update(len(relative).to_bytes(8, "big")) + digest.update(relative) + digest.update(len(payload).to_bytes(8, "big")) + digest.update(payload) + return digest.hexdigest() + + +def _rustc_version(checkout: Path) -> str: + """Return one-line rustc version provenance for the active benchmark toolchain.""" + try: + result = run_safe_command( + "rustc", + ["--version"], + cwd=checkout, + timeout=_COMMAND_TIMEOUT_SECONDS, + env=_benchmark_env(checkout), + ) + except subprocess.CalledProcessError as exc: + raise RuntimeError(_format_command_failure(["rustc", "--version"], exc)) from exc + version = result.stdout.strip() + return version or "unavailable" + + +def _environment_metadata(checkout: Path, *, harness_sha256: str) -> dict[str, object]: + """Capture deterministic machine, toolchain, revision, and lock provenance.""" + cpu = platform.processor().strip() or platform.machine().strip() or "unavailable" + os_description = " ".join(part for part in (platform.system(), platform.release(), platform.machine()) if part).strip() + return { + "cargo_lock_sha256": _sha256_file(checkout / "Cargo.lock"), + "commit": _checkout_commit(checkout), + "correctness_gate": "passed", + "cpu": cpu, + "git_clean": _git_clean(checkout), + "harness_sha256": harness_sha256, + "os": os_description or "unavailable", + "rustc": _rustc_version(checkout), + "source_state_sha256": _source_state_digest(checkout), + } + + +def _criterion_dependency_version(checkout: Path) -> str: + """Return the resolved Criterion version, falling back to its manifest requirement.""" + lock_data = tomllib.loads(_read_text(checkout / "Cargo.lock")) + packages = lock_data.get("package") + if isinstance(packages, list): + for package in packages: + if isinstance(package, dict) and package.get("name") == "criterion": + version = package.get("version") + if isinstance(version, str) and version: + return version + + manifest = tomllib.loads(_read_text(checkout / "Cargo.toml")) + for section in ("dev-dependencies", "dependencies", "build-dependencies"): + dependencies = manifest.get(section) + if not isinstance(dependencies, dict): + continue + criterion = dependencies.get("criterion") + if isinstance(criterion, str) and criterion: + return f"manifest requirement {criterion}" + if isinstance(criterion, dict): + version = criterion.get("version") + if isinstance(version, str) and version: + return f"manifest requirement {version}" + msg = f"Criterion dependency version is unavailable in {checkout / 'Cargo.lock'} and {checkout / 'Cargo.toml'}" + raise ValueError(msg) + + +def _criterion_metadata( + *, + worktree: Path, + config: GenerationConfig, + baseline_command: tuple[str, ...], + current_command: tuple[str, ...], +) -> dict[str, object]: + """Record the exact Criterion selection and timing commands.""" + return { + "baseline_command": list(baseline_command), + "current_command": list(current_command), + "criterion_version": _criterion_dependency_version(worktree), + "sample": "new", + "scope": config.scope, + "statistic": "median", + "suite": config.suite, + } + + +def _write_local_run_provenance( + *, + worktree: Path, + config: GenerationConfig, + baseline_run: BaselineRun, + current_command: tuple[str, ...], +) -> None: + """Tie locally generated samples to their shared harness and environment.""" + publication = _environment_metadata(worktree, harness_sha256=baseline_run.harness_sha256) + measurement = { + "baseline_commit": baseline_run.commit, + "cargo_lock_sha256": publication["cargo_lock_sha256"], + "cpu": publication["cpu"], + "current_commit": publication["commit"], + "current_git_clean": publication["git_clean"], + "current_source_state_sha256": publication["source_state_sha256"], + "harness_sha256": baseline_run.harness_sha256, + "os": publication["os"], + "rustc": publication["rustc"], + "baseline_git_clean": baseline_run.git_clean, + "baseline_source_state_sha256": baseline_run.source_state_sha256, + "status": "recorded", + } + metadata = { + "baseline": config.baseline_tag, + "criterion": _criterion_metadata( + worktree=worktree, + config=config, + baseline_command=baseline_run.command, + current_command=current_command, + ), + "measurement": measurement, + "mode": "shared-current-harness", + "publication": publication, + "schema": 2, + "validation": { + "baseline_commit": baseline_run.commit, + "baseline_git_clean": baseline_run.git_clean, + "baseline_revision": "passed", + "baseline_source_state_sha256": baseline_run.source_state_sha256, + "command": list(_BENCHMARK_INPUT_GATE), + "current_commit": publication["commit"], + "current_git_clean": publication["git_clean"], + "current_revision": "passed", + "current_source_state_sha256": publication["source_state_sha256"], + "harness": "shared-current", + }, + } + _write_text( + worktree / "target" / "criterion" / _BENCHMARK_HARNESS_METADATA, + json.dumps(metadata, indent=2, sort_keys=True) + "\n", + ) + + +def _write_historical_asset_provenance( + *, + worktree: Path, + config: GenerationConfig, + baseline_run: BaselineRun, +) -> None: + """Record validation while explicitly declining to invent historical timing metadata.""" + publication = _environment_metadata(worktree, harness_sha256=baseline_run.harness_sha256) + metadata = { + "baseline": config.baseline_tag, + "criterion": _criterion_metadata( + worktree=worktree, + config=config, + baseline_command=("historical-release-asset", config.baseline_tag), + current_command=("historical-release-asset", config.current_tag), + ), + "measurement": { + "reason": "the downloaded release assets do not contain schema-2 measurement-environment provenance", + "status": "unavailable", + }, + "mode": "historical-assets", + "publication": publication, + "schema": 2, + "validation": { + "baseline_commit": baseline_run.commit, + "baseline_git_clean": baseline_run.git_clean, + "baseline_revision": "passed", + "baseline_source_state_sha256": baseline_run.source_state_sha256, + "command": list(_BENCHMARK_INPUT_GATE), + "current_commit": publication["commit"], + "current_git_clean": publication["git_clean"], + "current_revision": "passed", + "current_source_state_sha256": publication["source_state_sha256"], + "harness": "shared-current", + }, + } + _write_text( + worktree / "target" / "criterion" / _BENCHMARK_HARNESS_METADATA, + json.dumps(metadata, indent=2, sort_keys=True) + "\n", + ) + + def _current_rust_toolchain(checkout: Path) -> str | None: rust_toolchain = checkout / "rust-toolchain.toml" if not rust_toolchain.exists(): @@ -470,6 +737,88 @@ def _copy_criterion_sample(*, criterion_dir: Path, source_sample: str, target_sa raise FileNotFoundError(msg) +def _selected_criterion_groups(criterion_dir: Path, *, suite: str) -> list[Path]: + """Return selected Criterion group directories in deterministic order.""" + groups: list[Path] = [] + if not criterion_dir.is_dir(): + return groups + for child in criterion_dir.iterdir(): + if not child.is_dir(): + continue + is_exact = child.name.startswith("exact_") + is_vs_linalg = re.fullmatch(r"d[0-9]+", child.name) is not None + if (suite in {"all", "exact"} and is_exact) or (suite in {"all", "vs_linalg"} and is_vs_linalg): + groups.append(child) + return sorted(groups, key=lambda path: path.name) + + +def _purge_criterion_new_samples(*, criterion_dir: Path, suite: str) -> list[Path]: + """Remove stale selected-suite `new` samples while preserving named baselines.""" + removed: list[Path] = [] + for group in _selected_criterion_groups(criterion_dir, suite=suite): + for sample in sorted(group.glob("*/new")): + if sample.is_dir(): + shutil.rmtree(sample) + removed.append(sample) + return removed + + +def _benchmark_harness_files(checkout: Path) -> list[Path]: + """Return every file that defines the comparable benchmark harness.""" + files: list[Path] = [] + for relative in _BENCHMARK_HARNESS_FILES: + path = checkout / relative + if not path.is_file(): + msg = f"benchmark harness file is missing: {path}" + raise FileNotFoundError(msg) + files.append(path) + for relative in _BENCHMARK_HARNESS_DIRS: + directory = checkout / relative + if not directory.is_dir(): + msg = f"benchmark harness directory is missing: {directory}" + raise FileNotFoundError(msg) + files.extend(path for path in directory.rglob("*") if path.is_file()) + return sorted(files, key=lambda path: path.relative_to(checkout).as_posix()) + + +def _benchmark_harness_digest(checkout: Path) -> str: + """Hash benchmark sources, recipes, dependency resolution, and toolchain.""" + digest = hashlib.sha256() + for path in _benchmark_harness_files(checkout): + relative = path.relative_to(checkout).as_posix().encode() + payload = path.read_bytes() + digest.update(len(relative).to_bytes(8, "big")) + digest.update(relative) + digest.update(len(payload).to_bytes(8, "big")) + digest.update(payload) + return digest.hexdigest() + + +def _install_shared_benchmark_harness(*, source: Path, destination: Path) -> str: + """Replace a baseline checkout's harness with the current harness.""" + source_files = _benchmark_harness_files(source) + for relative in _BENCHMARK_HARNESS_DIRS: + source_dir = source / relative + destination_dir = destination / relative + if destination_dir.exists(): + shutil.rmtree(destination_dir) + shutil.copytree(source_dir, destination_dir) + for relative in _BENCHMARK_HARNESS_FILES: + destination_file = destination / relative + destination_file.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source / relative, destination_file) + + expected = _benchmark_harness_digest(source) + actual = _benchmark_harness_digest(destination) + if actual != expected: + msg = f"shared benchmark harness copy changed content: expected {expected}, found {actual}" + raise RuntimeError(msg) + if not source_files: + msg = "benchmark harness unexpectedly contained no files" + raise RuntimeError(msg) + return expected + + def _has_suite_aware_baseline_recipe(worktree: Path) -> bool: justfile = worktree / "justfile" return justfile.exists() and re.search(r'(?m)^bench-save-baseline\s+tag\s+suite(?:=|"|:|\s)', _read_text(justfile)) is not None @@ -482,9 +831,9 @@ def _baseline_tool_args(*, baseline_tag: str, suite: str, baseline_worktree: Pat return ("just", ["bench-save-baseline", baseline_tag, suite]) match suite: case "exact": - return ("cargo", ["bench", "--features", "bench,exact", "--bench", "exact", "--", "--save-baseline", baseline_tag]) + return ("cargo", ["bench", "--locked", "--features", "bench,exact", "--bench", "exact", "--", "--save-baseline", baseline_tag]) case "vs_linalg": - return ("cargo", ["bench", "--features", "bench", "--bench", "vs_linalg", "--", "--save-baseline", baseline_tag]) + return ("cargo", ["bench", "--locked", "--features", "bench", "--bench", "vs_linalg", "--", "--save-baseline", baseline_tag]) case _: msg = f"unsupported benchmark suite: {suite}" raise ValueError(msg) @@ -503,21 +852,27 @@ def _latest_recipe_args(*, suite: str) -> list[str]: raise ValueError(msg) -def _generate_release_baseline(*, baseline_tag: str, suite: str, repo_root: Path, target_worktree: Path, tmp_dir: Path) -> None: +def _generate_release_baseline(*, baseline_tag: str, suite: str, repo_root: Path, target_worktree: Path, tmp_dir: Path) -> BaselineRun: baseline_worktree = tmp_dir / "baseline-worktree" _run_git(["worktree", "add", "--detach", str(baseline_worktree), baseline_tag], cwd=repo_root) try: + harness_sha256 = _install_shared_benchmark_harness( + source=target_worktree, + destination=baseline_worktree, + ) baseline_command, baseline_args = _baseline_tool_args( baseline_tag=baseline_tag, suite=suite, baseline_worktree=baseline_worktree, ) + benchmark_env = _benchmark_env(repo_root) + _run_benchmark_input_gate(baseline_worktree, env=benchmark_env) _run_tool( baseline_command, baseline_args, cwd=baseline_worktree, timeout=_BENCH_TIMEOUT_SECONDS, - env=_benchmark_env(repo_root), + env=benchmark_env, ) baseline_criterion = baseline_worktree / "target" / "criterion" if not baseline_criterion.is_dir(): @@ -526,6 +881,13 @@ def _generate_release_baseline(*, baseline_tag: str, suite: str, repo_root: Path target_criterion = target_worktree / "target" / "criterion" target_criterion.parent.mkdir(parents=True, exist_ok=True) shutil.copytree(baseline_criterion, target_criterion, dirs_exist_ok=True) + return BaselineRun( + commit=_checkout_commit(baseline_worktree), + command=(baseline_command, *baseline_args), + harness_sha256=harness_sha256, + git_clean=_git_clean(baseline_worktree), + source_state_sha256=_source_state_digest(baseline_worktree), + ) finally: try: _run_git(["worktree", "remove", "--force", str(baseline_worktree)], cwd=repo_root) @@ -533,8 +895,8 @@ def _generate_release_baseline(*, baseline_tag: str, suite: str, repo_root: Path print(f"archive-performance: failed to remove baseline worktree: {exc}", file=sys.stderr) -def _prepare_local_release_baseline(*, baseline_tag: str, suite: str, repo_root: Path, target_worktree: Path, tmp_dir: Path) -> None: - _generate_release_baseline( +def _prepare_local_release_baseline(*, baseline_tag: str, suite: str, repo_root: Path, target_worktree: Path, tmp_dir: Path) -> BaselineRun: + return _generate_release_baseline( baseline_tag=baseline_tag, suite=suite, repo_root=repo_root, @@ -543,6 +905,36 @@ def _prepare_local_release_baseline(*, baseline_tag: str, suite: str, repo_root: ) +def _validate_release_revision( + *, + revision: str, + repo_root: Path, + harness_source: Path, + tmp_dir: Path, +) -> BaselineRun: + """Validate one historical revision with the shared current fixture harness.""" + validation_worktree = tmp_dir / "baseline-validation-worktree" + _run_git(["worktree", "add", "--detach", str(validation_worktree), revision], cwd=repo_root) + try: + harness_sha256 = _install_shared_benchmark_harness( + source=harness_source, + destination=validation_worktree, + ) + _run_benchmark_input_gate(validation_worktree, env=_benchmark_env(repo_root)) + return BaselineRun( + commit=_checkout_commit(validation_worktree), + command=("historical-release-asset", revision), + harness_sha256=harness_sha256, + git_clean=_git_clean(validation_worktree), + source_state_sha256=_source_state_digest(validation_worktree), + ) + finally: + try: + _run_git(["worktree", "remove", "--force", str(validation_worktree)], cwd=repo_root) + except RuntimeError as exc: + print(f"archive-performance: failed to remove baseline validation worktree: {exc}", file=sys.stderr) + + def _prepare_github_release_assets(*, current_tag: str, baseline_tag: str, repo_root: Path, target_worktree: Path, tmp_dir: Path) -> None: baseline_archive = _download_release_baseline( baseline_tag=baseline_tag, @@ -557,11 +949,28 @@ def _prepare_github_release_assets(*, current_tag: str, baseline_tag: str, repo_ target_dir = target_worktree / "target" _safe_extract_tar(baseline_archive, target_dir) _safe_extract_tar(current_archive, target_dir) + # Published artifacts retain their release-specific harnesses. Never let an + # embedded or stale local manifest claim that these samples shared one. + metadata_path = target_dir / "criterion" / _BENCHMARK_HARNESS_METADATA + if metadata_path.is_symlink() or metadata_path.is_file(): + metadata_path.unlink() + elif metadata_path.exists(): + msg = f"historical benchmark harness provenance path is not a file: {metadata_path}" + raise ValueError(msg) _copy_criterion_sample(criterion_dir=target_dir / "criterion", source_sample=current_tag, target_sample="new") def _apply_current_diff_to_worktree(*, repo_root: Path, worktree: Path) -> None: - diff = run_git_command(["diff", "--binary", "HEAD"], cwd=repo_root).stdout + # Build the patch through an isolated index so untracked, non-ignored files + # participate without changing the caller's real staging area. Git records + # binary blobs and symlink metadata directly and applies its normal safe-path + # checks when the patch is replayed in the detached worktree. + with tempfile.TemporaryDirectory(prefix="la-stack-current-tree-index-") as tmp: + env = os.environ.copy() + env["GIT_INDEX_FILE"] = str(Path(tmp) / "index") + _run_git_output(["read-tree", "HEAD"], cwd=repo_root, env=env) + _run_git_output(["add", "--all", "--", "."], cwd=repo_root, env=env) + diff = _run_git_output(["diff", "--cached", "--binary", "HEAD"], cwd=repo_root, env=env) if diff.strip(): try: run_git_command_with_input(["apply", "--binary"], diff, cwd=worktree) @@ -613,12 +1022,36 @@ def _render_report(*, worktree: Path, report: Path, config: GenerationConfig) -> ) -def _run_benchmarks_and_render_report(*, worktree: Path, report: Path, config: GenerationConfig) -> None: +def _run_benchmarks_and_render_report( + *, + worktree: Path, + report: Path, + config: GenerationConfig, + baseline_run: BaselineRun, +) -> None: benchmark_env = _benchmark_env(config.repo_root) + _run_benchmark_input_gate(worktree, env=benchmark_env) + _purge_criterion_new_samples( + criterion_dir=worktree / "target" / "criterion", + suite=config.suite, + ) if _has_current_release_signal_tooling(worktree): - _run_tool("just", _latest_recipe_args(suite=config.suite), cwd=worktree, timeout=_BENCH_TIMEOUT_SECONDS, env=benchmark_env) + current_command = ("just", *_latest_recipe_args(suite=config.suite)) else: - _run_tool("just", ["bench-exact"], cwd=worktree, timeout=_BENCH_TIMEOUT_SECONDS, env=benchmark_env) + current_command = ("cargo", "bench", "--locked", "--features", "bench,exact", "--bench", "exact") + _run_tool( + current_command[0], + list(current_command[1:]), + cwd=worktree, + timeout=_BENCH_TIMEOUT_SECONDS, + env=benchmark_env, + ) + _write_local_run_provenance( + worktree=worktree, + config=config, + baseline_run=baseline_run, + current_command=current_command, + ) _render_report(worktree=worktree, report=report, config=config) @@ -643,16 +1076,33 @@ def _generate_report_in_temp_worktree( target_worktree=worktree, tmp_dir=tmp_dir, ) + baseline_run = _validate_release_revision( + revision=config.baseline_tag, + repo_root=config.repo_root, + harness_source=worktree, + tmp_dir=tmp_dir, + ) + _run_benchmark_input_gate(worktree, env=_benchmark_env(config.repo_root)) + _write_historical_asset_provenance( + worktree=worktree, + config=config, + baseline_run=baseline_run, + ) _render_report(worktree=worktree, report=report, config=config) else: - _prepare_local_release_baseline( + baseline_run = _prepare_local_release_baseline( baseline_tag=config.baseline_tag, suite=config.suite, repo_root=config.repo_root, target_worktree=worktree, tmp_dir=tmp_dir, ) - _run_benchmarks_and_render_report(worktree=worktree, report=report, config=config) + _run_benchmarks_and_render_report( + worktree=worktree, + report=report, + config=config, + baseline_run=baseline_run, + ) return _read_text(report) finally: try: @@ -978,6 +1428,7 @@ def _run_archive_request(*, args: argparse.Namespace, paths: ArchivePaths, reque if args.github_assets: msg = "--github-assets requires --generate-in-temp-worktree" raise ValueError(msg) + _run_benchmark_input_gate(repo_root, env=_benchmark_env(repo_root)) return ArchiveResult( report_id=promote_report( source=paths.source, @@ -1012,8 +1463,6 @@ def main(argv: list[str] | None = None) -> int: except (ValueError, RuntimeError, FileNotFoundError, subprocess.CalledProcessError) as exc: print(f"archive-performance: {exc}", file=sys.stderr) return 1 - except Exception: - raise if result.action == "output": print(f"Generated benchmark report in a temporary worktree and wrote it to {paths.output}") diff --git a/scripts/bench_compare.py b/scripts/bench_compare.py index 8a1776c..34a59d4 100644 --- a/scripts/bench_compare.py +++ b/scripts/bench_compare.py @@ -24,13 +24,15 @@ import argparse import json +import math +import re import subprocess import sys import tomllib from dataclasses import dataclass from datetime import UTC, datetime from pathlib import Path -from typing import Protocol +from typing import Literal, Protocol, cast from criterion_dim_plot import METRICS from subprocess_utils import ExecutableNotFoundError, run_git_command @@ -42,7 +44,7 @@ # Groups and the benchmarks within each group that we track. # # Mirrors the structure of `benches/exact.rs`: general-case per-dimension -# groups (`exact_d{2..5}`), fixed-seed random percentile groups, plus +# groups (`exact_d{2..5}`), fixed-seed full-corpus groups, plus # adversarial/extreme-input groups that share a fixed five-bench layout # (`det_sign_exact`, `det_exact`, `solve_exact`, `solve_exact_f64_result`, # `solve_exact_rounded_f64`). @@ -53,11 +55,10 @@ "solve_exact_f64_result", "solve_exact_rounded_f64", ] -_RANDOM_PERCENTILE_BENCHES: list[str] = [f"{operation}_{percentile}" for operation in _EXTREME_BENCHES for percentile in ("p50", "p95", "p99")] +_RANDOM_CORPUS_BENCHES: list[str] = _EXTREME_BENCHES.copy() _EXACT_DIMENSION_BENCHES: list[str] = [ "det", - "det_direct", "det_exact", "det_exact_f64_result", "det_exact_rounded_f64", @@ -66,23 +67,28 @@ "solve_exact_f64_result", "solve_exact_rounded_f64", ] +_EXACT_DIMENSION_BENCHES_WITH_DIRECT: list[str] = [ + "det", + "det_direct", + *_EXACT_DIMENSION_BENCHES[1:], +] EXACT_GROUPS: dict[str, list[str]] = { - "exact_d2": _EXACT_DIMENSION_BENCHES, - "exact_d3": _EXACT_DIMENSION_BENCHES, - "exact_d4": _EXACT_DIMENSION_BENCHES, + "exact_d2": _EXACT_DIMENSION_BENCHES_WITH_DIRECT, + "exact_d3": _EXACT_DIMENSION_BENCHES_WITH_DIRECT, + "exact_d4": _EXACT_DIMENSION_BENCHES_WITH_DIRECT, "exact_d5": _EXACT_DIMENSION_BENCHES, - "exact_random_percentile_d2": _RANDOM_PERCENTILE_BENCHES, - "exact_random_percentile_d3": _RANDOM_PERCENTILE_BENCHES, - "exact_random_percentile_d4": _RANDOM_PERCENTILE_BENCHES, - "exact_random_percentile_d5": _RANDOM_PERCENTILE_BENCHES, + "exact_random_corpus_d2": _RANDOM_CORPUS_BENCHES, + "exact_random_corpus_d3": _RANDOM_CORPUS_BENCHES, + "exact_random_corpus_d4": _RANDOM_CORPUS_BENCHES, + "exact_random_corpus_d5": _RANDOM_CORPUS_BENCHES, "exact_near_singular_3x3": _EXTREME_BENCHES, "exact_large_entries_3x3": _EXTREME_BENCHES, "exact_hilbert_4x4": _EXTREME_BENCHES, "exact_hilbert_5x5": _EXTREME_BENCHES, } -EXACT_RELEASE_SIGNAL_GROUPS: frozenset[str] = frozenset(group for group in EXACT_GROUPS if not group.startswith("exact_random_percentile_d")) +EXACT_RELEASE_SIGNAL_GROUPS: frozenset[str] = frozenset(EXACT_GROUPS) # v0.4.2 and earlier named the lossy exact-to-f64 benches after the public # `*_exact_f64` API. Current benches split that behavior into strict `*_result` @@ -110,7 +116,7 @@ "solve_from_lu": [("la_stack_solve_from_ldlt", "nalgebra_solve_from_cholesky", "faer_solve_from_ldlt")], "det_from_lu": [("la_stack_det_from_ldlt", "nalgebra_det_from_cholesky", "faer_det_from_ldlt")], } -VS_LINALG_BENCH_ORDER: list[str] = [ +VS_LINALG_STANDARD_BENCH_ORDER: list[str] = [ bench for metric_key, metric in METRICS.items() for bench in ( @@ -124,7 +130,23 @@ ) ] -VS_LINALG_LA_STACK_BENCHES: frozenset[str] = frozenset(bench for bench in VS_LINALG_BENCH_ORDER if bench.startswith("la_stack_")) +VS_LINALG_D8_RELEASE_SIGNAL_BENCHES: list[str] = [ + "la_stack_lu_pivoting", + "la_stack_lu_ill_conditioned", + "la_stack_ldlt_ill_conditioned", + "la_stack_det_from_lu_balanced_range", + "la_stack_det_from_ldlt_balanced_range", +] +VS_LINALG_RELEASE_SIGNAL_BENCHES_BY_DIM: dict[int, list[str]] = { + 8: VS_LINALG_D8_RELEASE_SIGNAL_BENCHES, +} +VS_LINALG_CANONICAL_DIMS: tuple[int, ...] = (2, 3, 4, 5, 8, 16, 32, 64) +VS_LINALG_BENCH_ORDER: list[str] = [ + *VS_LINALG_STANDARD_BENCH_ORDER, + *VS_LINALG_D8_RELEASE_SIGNAL_BENCHES, +] + +VS_LINALG_LA_STACK_BENCHES: frozenset[str] = frozenset(bench for bench in VS_LINALG_STANDARD_BENCH_ORDER if bench.startswith("la_stack_")) VS_LINALG_BASELINE_PEERS: dict[str, tuple[str, str]] = {metric.la_bench: (metric.na_bench, metric.fa_bench) for metric in METRICS.values()} VS_LINALG_BASELINE_PEERS.update( { @@ -137,6 +159,22 @@ SUITE_CHOICES: tuple[str, ...] = ("all", "exact", "vs_linalg") SCOPE_CHOICES: tuple[str, ...] = ("release-signal", "all-benches") +type ChangeAssessment = Literal["improvement", "regression", "inconclusive", "unknown"] + + +@dataclass(frozen=True, slots=True) +class CriterionEstimate: + """A Criterion point estimate and its optional confidence interval.""" + + point_ns: float + ci_lo_ns: float | None + ci_hi_ns: float | None + + @property + def has_confidence_interval(self) -> bool: + """Return whether both confidence bounds were present.""" + return self.ci_lo_ns is not None and self.ci_hi_ns is not None + @dataclass(frozen=True, slots=True) class BenchResult: @@ -145,9 +183,22 @@ class BenchResult: suite: str group: str bench: str - point_ns: float - ci_lo_ns: float - ci_hi_ns: float + estimate: CriterionEstimate + + @property + def point_ns(self) -> float: + """Return the point estimate in nanoseconds.""" + return self.estimate.point_ns + + @property + def ci_lo_ns(self) -> float | None: + """Return the lower confidence bound, when Criterion recorded it.""" + return self.estimate.ci_lo_ns + + @property + def ci_hi_ns(self) -> float | None: + """Return the upper confidence bound, when Criterion recorded it.""" + return self.estimate.ci_hi_ns @dataclass(frozen=True, slots=True) @@ -157,13 +208,78 @@ class Comparison: suite: str group: str bench: str - baseline_ns: float - current_ns: float - speedup: float # baseline / current (>1 = faster) - pct_change: float # signed percent change (negative = faster) + baseline: CriterionEstimate + current: CriterionEstimate + assessment: ChangeAssessment baseline_bench: str | None = None - baseline_nalgebra_ns: float | None = None - baseline_faer_ns: float | None = None + baseline_nalgebra: CriterionEstimate | None = None + baseline_faer: CriterionEstimate | None = None + + @property + def baseline_ns(self) -> float: + """Return the baseline point estimate.""" + return self.baseline.point_ns + + @property + def current_ns(self) -> float: + """Return the current point estimate.""" + return self.current.point_ns + + @property + def speedup(self) -> float: + """Return baseline/current, where values above one are faster.""" + return self.baseline_ns / self.current_ns if self.current_ns > 0 else float("inf") + + @property + def pct_change(self) -> float: + """Return signed point-estimate change, where negative is faster.""" + if self.baseline_ns <= 0: + return 0.0 + return ((self.current_ns - self.baseline_ns) / self.baseline_ns) * 100.0 + + @property + def baseline_nalgebra_ns(self) -> float | None: + """Return the baseline nalgebra point estimate, when available.""" + return None if self.baseline_nalgebra is None else self.baseline_nalgebra.point_ns + + @property + def baseline_faer_ns(self) -> float | None: + """Return the baseline faer point estimate, when available.""" + return None if self.baseline_faer is None else self.baseline_faer.point_ns + + +@dataclass(frozen=True, slots=True) +class CoverageGap: + """An expected comparison row missing one or both Criterion samples.""" + + suite: str + group: str + bench: str + baseline_bench: str + missing_current: bool + missing_baseline: bool + + +@dataclass(frozen=True, slots=True) +class ComparisonCollection: + """Complete comparisons plus deterministic coverage gaps.""" + + comparisons: list[Comparison] + gaps: list[CoverageGap] + + +@dataclass(frozen=True, slots=True) +class HarnessProvenance: + """Validated benchmark measurement and correctness provenance.""" + + schema: int + mode: str + sha256: str | None + baseline: str + measurement: dict[str, object] | None = None + publication: dict[str, object] | None = None + criterion: dict[str, object] | None = None + validation: dict[str, object] | None = None @dataclass(frozen=True, slots=True) @@ -174,6 +290,7 @@ class ReportSettings: stat: str suite: str scope: str + harness_provenance: HarnessProvenance | None = None # --------------------------------------------------------------------------- @@ -195,11 +312,8 @@ def _dim_from_vs_linalg_group(name: str) -> int | None: return int(suffix) -def _read_estimate(estimates_json: Path, stat: str = "median") -> tuple[float, float, float]: - """Read a point estimate and confidence interval from Criterion estimates.json. - - Returns (point_ns, ci_lo_ns, ci_hi_ns). - """ +def _read_estimate(estimates_json: Path, stat: str = "median") -> CriterionEstimate: + """Read and validate a Criterion point estimate and confidence interval.""" try: data = json.loads(estimates_json.read_text(encoding="utf-8")) except json.JSONDecodeError as err: @@ -216,13 +330,19 @@ def _read_estimate(estimates_json: Path, stat: str = "median") -> tuple[float, f raise KeyError(msg) point = _read_numeric_field(stat_obj, "point_estimate", estimates_json, stat) - ci = stat_obj.get("confidence_interval") + if "confidence_interval" not in stat_obj: + return CriterionEstimate(point_ns=point, ci_lo_ns=None, ci_hi_ns=None) + ci = stat_obj["confidence_interval"] if not isinstance(ci, dict): - return (point, point, point) + msg = f"field 'confidence_interval' for stat '{stat}' in {estimates_json} is not an object" + raise TypeError(msg) - lo = _read_numeric_field(ci, "lower_bound", estimates_json, stat, default=point) - hi = _read_numeric_field(ci, "upper_bound", estimates_json, stat, default=point) - return (point, lo, hi) + lo = _read_numeric_field(ci, "lower_bound", estimates_json, stat) + hi = _read_numeric_field(ci, "upper_bound", estimates_json, stat) + if lo > hi: + msg = f"invalid confidence interval for stat '{stat}' in {estimates_json}: lower_bound {lo} exceeds upper_bound {hi}" + raise ValueError(msg) + return CriterionEstimate(point_ns=point, ci_lo_ns=lo, ci_hi_ns=hi) def _read_numeric_field( @@ -230,13 +350,9 @@ def _read_numeric_field( field: str, estimates_json: Path, stat: str, - *, - default: float | None = None, ) -> float: """Read a numeric Criterion field with file and statistic context.""" if field not in obj: - if default is not None: - return default msg = f"field '{field}' for stat '{stat}' not found in {estimates_json}" raise KeyError(msg) value = obj[field] @@ -244,10 +360,189 @@ def _read_numeric_field( msg = f"field '{field}' for stat '{stat}' in {estimates_json} is not numeric: {value!r}" raise TypeError(msg) try: - return float(value) + result = float(value) except ValueError as err: msg = f"field '{field}' for stat '{stat}' in {estimates_json} is not numeric: {value!r}" raise ValueError(msg) from err + if not math.isfinite(result) or result < 0: + msg = f"field '{field}' for stat '{stat}' in {estimates_json} must be finite and non-negative: {value!r}" + raise ValueError(msg) + return result + + +def _read_harness_provenance(criterion_dir: Path, *, expected_baseline: str) -> HarnessProvenance | None: + """Read provenance tied to the exact Criterion samples being compared.""" + provenance_path = criterion_dir / ".la-stack-benchmark-harness.json" + if not provenance_path.exists(): + return None + + try: + data = json.loads(provenance_path.read_text(encoding="utf-8")) + except json.JSONDecodeError as err: + msg = f"malformed benchmark harness provenance JSON in {provenance_path}: {err}" + raise ValueError(msg) from err + if not isinstance(data, dict): + msg = f"expected JSON object in {provenance_path}" + raise TypeError(msg) + + schema = data.get("schema") + mode = data.get("mode") + baseline = _required_metadata_string(data, "baseline", provenance_path) + if baseline != expected_baseline: + msg = f"benchmark harness provenance baseline {baseline!r} does not match requested Criterion baseline {expected_baseline!r} in {provenance_path}" + raise ValueError(msg) + + if not isinstance(schema, bool) and schema == 1: + if mode != "shared-current-harness": + msg = f"unsupported or missing mode in {provenance_path}: {mode!r}" + raise ValueError(msg) + sha256 = _required_sha256(data, "sha256", provenance_path) + return HarnessProvenance(schema=1, mode=mode, sha256=sha256, baseline=baseline) + + if isinstance(schema, bool) or schema != 2: + msg = f"unsupported or missing schema in {provenance_path}: expected 1 or 2, got {schema!r}" + raise ValueError(msg) + if mode not in {"shared-current-harness", "historical-assets"}: + msg = f"unsupported or missing mode in {provenance_path}: {mode!r}" + raise ValueError(msg) + + measurement = _required_metadata_object(data, "measurement", provenance_path) + publication = _required_metadata_object(data, "publication", provenance_path) + criterion = _required_metadata_object(data, "criterion", provenance_path) + validation = _required_metadata_object(data, "validation", provenance_path) + _validate_measurement_metadata(measurement, mode=mode, path=provenance_path) + _validate_environment_metadata(publication, path=provenance_path, context="publication") + _validate_criterion_metadata(criterion, path=provenance_path) + _validate_validation_metadata(validation, path=provenance_path) + + sha256: str | None = None + if measurement.get("status") == "recorded": + sha256 = _required_sha256(measurement, "harness_sha256", provenance_path) + return HarnessProvenance( + schema=2, + mode=mode, + sha256=sha256, + baseline=baseline, + measurement=measurement, + publication=publication, + criterion=criterion, + validation=validation, + ) + + +def _required_metadata_object(data: dict[str, object], field: str, path: Path) -> dict[str, object]: + """Return a required provenance object with contextual diagnostics.""" + value = data.get(field) + if not isinstance(value, dict) or not all(isinstance(key, str) for key in value): + msg = f"invalid or missing {field} object in {path}" + raise ValueError(msg) + return cast("dict[str, object]", value) + + +def _required_metadata_string(data: dict[str, object], field: str, path: Path) -> str: + """Return a required non-empty provenance string.""" + value = data.get(field) + if not isinstance(value, str) or not value.strip(): + msg = f"invalid or missing {field} in {path}" + raise ValueError(msg) + return value + + +def _required_sha256(data: dict[str, object], field: str, path: Path) -> str: + """Return a required lowercase SHA-256 digest.""" + value = data.get(field) + if not isinstance(value, str) or re.fullmatch(r"[0-9a-f]{64}", value) is None: + msg = f"invalid or missing {field} in {path}: expected 64 lowercase hexadecimal characters" + raise ValueError(msg) + return value + + +def _validate_environment_metadata(data: dict[str, object], *, path: Path, context: str) -> None: + """Validate deterministic environment fields used to reproduce a run.""" + for field in ("cpu", "os", "rustc", "commit"): + _required_metadata_string(data, field, path) + _required_sha256(data, "cargo_lock_sha256", path) + _required_sha256(data, "harness_sha256", path) + _required_sha256(data, "source_state_sha256", path) + if not isinstance(data.get("git_clean"), bool): + msg = f"invalid or missing {context}.git_clean in {path}" + raise TypeError(msg) + gate = _required_metadata_string(data, "correctness_gate", path) + if gate != "passed": + msg = f"{context}.correctness_gate in {path} must be 'passed', got {gate!r}" + raise ValueError(msg) + + +def _validate_measurement_metadata(data: dict[str, object], *, mode: object, path: Path) -> None: + """Validate recorded or explicitly unavailable measurement provenance.""" + status = _required_metadata_string(data, "status", path) + if status == "recorded": + if mode != "shared-current-harness": + msg = f"recorded measurement provenance in {path} requires shared-current-harness mode" + raise ValueError(msg) + for field in ("cpu", "os", "rustc", "current_commit", "baseline_commit"): + _required_metadata_string(data, field, path) + _required_sha256(data, "cargo_lock_sha256", path) + _required_sha256(data, "harness_sha256", path) + _required_sha256(data, "current_source_state_sha256", path) + _required_sha256(data, "baseline_source_state_sha256", path) + for field in ("current_git_clean", "baseline_git_clean"): + if not isinstance(data.get(field), bool): + msg = f"invalid or missing measurement.{field} in {path}" + raise TypeError(msg) + return + if status == "unavailable": + _required_metadata_string(data, "reason", path) + return + msg = f"unsupported measurement.status in {path}: {status!r}" + raise ValueError(msg) + + +def _validate_criterion_metadata(data: dict[str, object], *, path: Path) -> None: + """Validate the Criterion selection and exact commands used for the report.""" + for field in ("suite", "scope", "statistic", "sample", "criterion_version"): + _required_metadata_string(data, field, path) + for field in ("baseline_command", "current_command"): + value = data.get(field) + if not isinstance(value, list) or not value or not all(isinstance(part, str) and part for part in value): + msg = f"invalid or missing criterion.{field} in {path}" + raise ValueError(msg) + + +def _validate_validation_metadata(data: dict[str, object], *, path: Path) -> None: + """Require fixture validation for both compared revisions.""" + command = data.get("command") + if command != ["just", "test-bench-inputs"]: + msg = f"invalid validation.command in {path}: expected ['just', 'test-bench-inputs']" + raise ValueError(msg) + for field in ("current_revision", "baseline_revision"): + value = _required_metadata_string(data, field, path) + if value != "passed": + msg = f"validation.{field} in {path} must be 'passed', got {value!r}" + raise ValueError(msg) + for field in ("current_commit", "baseline_commit"): + _required_metadata_string(data, field, path) + for field in ("current_source_state_sha256", "baseline_source_state_sha256"): + _required_sha256(data, field, path) + for field in ("current_git_clean", "baseline_git_clean"): + if not isinstance(data.get(field), bool): + msg = f"invalid or missing validation.{field} in {path}" + raise TypeError(msg) + + +def _assess_change(baseline: CriterionEstimate, current: CriterionEstimate) -> ChangeAssessment: + """Classify a change conservatively from non-overlapping Criterion intervals.""" + if not baseline.has_confidence_interval or not current.has_confidence_interval: + return "unknown" + + if baseline.ci_lo_ns is None or baseline.ci_hi_ns is None or current.ci_lo_ns is None or current.ci_hi_ns is None: + msg = "confidence-interval presence invariant violated" + raise AssertionError(msg) + if current.ci_hi_ns < baseline.ci_lo_ns: + return "improvement" + if current.ci_lo_ns > baseline.ci_hi_ns: + return "regression" + return "inconclusive" def _collect_exact_results(criterion_dir: Path, sample: str, stat: str) -> list[BenchResult]: @@ -264,8 +559,8 @@ def _collect_exact_results(criterion_dir: Path, sample: str, stat: str) -> list[ if not est_path.exists(): continue - point, lo, hi = _read_estimate(est_path, stat) - results.append(BenchResult(suite="exact", group=group, bench=bench, point_ns=point, ci_lo_ns=lo, ci_hi_ns=hi)) + estimate = _read_estimate(est_path, stat) + results.append(BenchResult(suite="exact", group=group, bench=bench, estimate=estimate)) return results @@ -324,8 +619,8 @@ def _collect_vs_linalg_results(criterion_dir: Path, sample: str, stat: str) -> l for _dim, group_dir in sorted(dim_groups, key=lambda item: item[0]): for bench in _ordered_vs_linalg_benches(group_dir, sample): est_path = group_dir / bench / sample / "estimates.json" - point, lo, hi = _read_estimate(est_path, stat) - results.append(BenchResult(suite="vs_linalg", group=group_dir.name, bench=bench, point_ns=point, ci_lo_ns=lo, ci_hi_ns=hi)) + estimate = _read_estimate(est_path, stat) + results.append(BenchResult(suite="vs_linalg", group=group_dir.name, bench=bench, estimate=estimate)) return results @@ -345,79 +640,98 @@ def _collect_exact_comparisons( baseline_name: str, stat: str, scope: str, -) -> list[Comparison]: - """Compare current exact-arithmetic results against a named baseline.""" +) -> ComparisonCollection: + """Compare exact results while retaining every missing expected row.""" comparisons: list[Comparison] = [] + gaps: list[CoverageGap] = [] for group, benches in EXACT_GROUPS.items(): if scope == "release-signal" and group not in EXACT_RELEASE_SIGNAL_GROUPS: continue group_dir = criterion_dir / group - if not group_dir.is_dir(): - continue - for bench in benches: new_path = group_dir / bench / "new" / "estimates.json" baseline_bench, base_path = _exact_baseline_path(group_dir, bench, baseline_name) - - if not new_path.exists() or not base_path.exists(): + missing_current = not new_path.exists() + missing_baseline = not base_path.exists() + + if missing_current or missing_baseline: + gaps.append( + CoverageGap( + suite="exact", + group=group, + bench=bench, + baseline_bench=baseline_bench, + missing_current=missing_current, + missing_baseline=missing_baseline, + ) + ) continue - new_point, _, _ = _read_estimate(new_path, stat) - base_point, _, _ = _read_estimate(base_path, stat) - - speedup = base_point / new_point if new_point > 0 else float("inf") - pct_change = ((new_point - base_point) / base_point) * 100.0 if base_point > 0 else 0.0 + current = _read_estimate(new_path, stat) + baseline = _read_estimate(base_path, stat) comparisons.append( Comparison( suite="exact", group=group, bench=bench, - baseline_ns=base_point, - current_ns=new_point, - speedup=speedup, - pct_change=pct_change, + baseline=baseline, + current=current, + assessment=_assess_change(baseline, current), baseline_bench=baseline_bench if baseline_bench != bench else None, ) ) - return comparisons + expected_groups = [group for group in EXACT_GROUPS if scope != "release-signal" or group in EXACT_RELEASE_SIGNAL_GROUPS] + if not any((criterion_dir / group).is_dir() for group in expected_groups): + gaps.append(_entire_suite_gap("exact")) + + return ComparisonCollection(comparisons=comparisons, gaps=gaps) def _ordered_vs_linalg_comparison_benches(group_dir: Path, baseline_name: str, scope: str) -> list[str]: - """Return present vs_linalg benches that have both current and baseline data.""" - present = { - child.name - for child in group_dir.iterdir() - if child.is_dir() and (child / "new" / "estimates.json").exists() and (child / baseline_name / "estimates.json").exists() - } + """Return expected or discovered comparison rows in stable order.""" + dim = _dim_from_vs_linalg_group(group_dir.name) if scope == "release-signal": - present = present.intersection(VS_LINALG_LA_STACK_BENCHES) + present = set(VS_LINALG_LA_STACK_BENCHES) + if dim is not None: + present.update(VS_LINALG_RELEASE_SIGNAL_BENCHES_BY_DIM.get(dim, [])) + else: + present = { + child.name + for child in group_dir.iterdir() + if child.is_dir() and ((child / "new" / "estimates.json").exists() or (child / baseline_name / "estimates.json").exists()) + } + ordered = [bench for bench in VS_LINALG_BENCH_ORDER if bench in present] extras = sorted(present.difference(VS_LINALG_BENCH_ORDER)) return [*ordered, *extras] -def _read_optional_point(estimates_json: Path, stat: str) -> float | None: - """Read an optional Criterion point estimate.""" +def _read_optional_estimate(estimates_json: Path, stat: str) -> CriterionEstimate | None: + """Read an optional Criterion estimate.""" if not estimates_json.exists(): return None - point, _, _ = _read_estimate(estimates_json, stat) - return point + return _read_estimate(estimates_json, stat) -def _baseline_peer_times(group_dir: Path, bench: str, baseline_name: str, stat: str) -> tuple[float | None, float | None]: +def _baseline_peer_estimates( + group_dir: Path, + bench: str, + baseline_name: str, + stat: str, +) -> tuple[CriterionEstimate | None, CriterionEstimate | None]: """Return last-release nalgebra/faer context for a la-stack vs_linalg bench.""" peers = VS_LINALG_BASELINE_PEERS.get(bench) if peers is None: return (None, None) nalgebra_bench, faer_bench = peers - nalgebra_ns = _read_optional_point(group_dir / nalgebra_bench / baseline_name / "estimates.json", stat) - faer_ns = _read_optional_point(group_dir / faer_bench / baseline_name / "estimates.json", stat) - return (nalgebra_ns, faer_ns) + nalgebra = _read_optional_estimate(group_dir / nalgebra_bench / baseline_name / "estimates.json", stat) + faer = _read_optional_estimate(group_dir / faer_bench / baseline_name / "estimates.json", stat) + return (nalgebra, faer) def _comparison_bench_label(comparison: Comparison) -> str: @@ -432,46 +746,136 @@ def _collect_vs_linalg_comparisons( baseline_name: str, stat: str, scope: str, -) -> list[Comparison]: - """Compare current vs_linalg results against a named baseline.""" +) -> ComparisonCollection: + """Compare vs_linalg results while retaining one-sided rows.""" comparisons: list[Comparison] = [] + gaps: list[CoverageGap] = [] dim_groups: list[tuple[int, Path]] = [] - for group_dir in criterion_dir.iterdir(): - if not group_dir.is_dir(): - continue - dim = _dim_from_vs_linalg_group(group_dir.name) - if dim is None: - continue - dim_groups.append((dim, group_dir)) + if scope == "release-signal": + dim_groups.extend((dim, criterion_dir / f"d{dim}") for dim in VS_LINALG_CANONICAL_DIMS) + else: + for group_dir in criterion_dir.iterdir(): + if not group_dir.is_dir(): + continue + dim = _dim_from_vs_linalg_group(group_dir.name) + if dim is None: + continue + dim_groups.append((dim, group_dir)) for _dim, group_dir in sorted(dim_groups, key=lambda item: item[0]): - for bench in _ordered_vs_linalg_comparison_benches(group_dir, baseline_name, scope): + expected_benches = _ordered_vs_linalg_comparison_benches(group_dir, baseline_name, scope) + for bench in expected_benches: new_path = group_dir / bench / "new" / "estimates.json" base_path = group_dir / bench / baseline_name / "estimates.json" + missing_current = not new_path.exists() + missing_baseline = not base_path.exists() + + if missing_current or missing_baseline: + gaps.append( + CoverageGap( + suite="vs_linalg", + group=group_dir.name, + bench=bench, + baseline_bench=bench, + missing_current=missing_current, + missing_baseline=missing_baseline, + ) + ) + continue - new_point, _, _ = _read_estimate(new_path, stat) - base_point, _, _ = _read_estimate(base_path, stat) - baseline_nalgebra_ns, baseline_faer_ns = _baseline_peer_times(group_dir, bench, baseline_name, stat) - - speedup = base_point / new_point if new_point > 0 else float("inf") - pct_change = ((new_point - base_point) / base_point) * 100.0 if base_point > 0 else 0.0 + current = _read_estimate(new_path, stat) + baseline = _read_estimate(base_path, stat) + baseline_nalgebra, baseline_faer = _baseline_peer_estimates(group_dir, bench, baseline_name, stat) comparisons.append( Comparison( suite="vs_linalg", group=group_dir.name, bench=bench, - baseline_ns=base_point, - current_ns=new_point, - speedup=speedup, - pct_change=pct_change, - baseline_nalgebra_ns=baseline_nalgebra_ns, - baseline_faer_ns=baseline_faer_ns, + baseline=baseline, + current=current, + assessment=_assess_change(baseline, current), + baseline_nalgebra=baseline_nalgebra, + baseline_faer=baseline_faer, ) ) - return comparisons + present_dim_groups = [group_dir for _, group_dir in dim_groups if group_dir.is_dir()] + if not present_dim_groups: + gaps.append(_entire_suite_gap("vs_linalg")) + + return ComparisonCollection(comparisons=comparisons, gaps=gaps) + + +def _entire_suite_gap(suite: str) -> CoverageGap: + """Return an explicit marker for a wholly absent selected suite.""" + return CoverageGap( + suite=suite, + group="(entire suite)", + bench="all selected rows", + baseline_bench="all selected rows", + missing_current=True, + missing_baseline=True, + ) + + +def _snapshot_coverage_errors( + criterion_dir: Path, + *, + sample: str, + suite: str, + scope: str, +) -> list[str]: + """Return deterministic coverage errors for a selected snapshot scope.""" + errors: list[str] = [] + selected_suites = ("exact", "vs_linalg") if suite == "all" else (suite,) + + if "exact" in selected_suites: + errors.extend(_exact_snapshot_coverage_errors(criterion_dir, sample=sample, scope=scope)) + + if "vs_linalg" in selected_suites: + errors.extend(_vs_linalg_snapshot_coverage_errors(criterion_dir, sample=sample, scope=scope)) + + return errors + + +def _exact_snapshot_coverage_errors(criterion_dir: Path, *, sample: str, scope: str) -> list[str]: + """Return exact-suite snapshot gaps.""" + if not any((criterion_dir / group).is_dir() for group in EXACT_GROUPS): + return ["exact: entire selected suite is absent"] + if scope != "release-signal": + return [] + return [ + f"exact: missing {group}/{bench}/{sample}/estimates.json" + for group, benches in EXACT_GROUPS.items() + for bench in benches + if not (criterion_dir / group / bench / sample / "estimates.json").is_file() + ] + + +def _vs_linalg_snapshot_coverage_errors(criterion_dir: Path, *, sample: str, scope: str) -> list[str]: + """Return vs_linalg snapshot gaps, including canonical dimensions.""" + discovered_dims = {dim for child in criterion_dir.iterdir() if child.is_dir() and (dim := _dim_from_vs_linalg_group(child.name)) is not None} + if not discovered_dims: + return ["vs_linalg: entire selected suite is absent"] + if scope != "release-signal": + return [] + + errors: list[str] = [] + for dim in VS_LINALG_CANONICAL_DIMS: + group_dir = criterion_dir / f"d{dim}" + if not group_dir.is_dir(): + errors.append(f"vs_linalg: missing canonical dimension d{dim}") + continue + expected = set(VS_LINALG_LA_STACK_BENCHES) + expected.update(VS_LINALG_RELEASE_SIGNAL_BENCHES_BY_DIM.get(dim, [])) + errors.extend( + f"vs_linalg: missing d{dim}/{bench}/{sample}/estimates.json" + for bench in VS_LINALG_BENCH_ORDER + if bench in expected and not (group_dir / bench / sample / "estimates.json").is_file() + ) + return errors def _collect_comparisons( @@ -480,14 +884,19 @@ def _collect_comparisons( stat: str, suite: str = "all", scope: str = "release-signal", -) -> list[Comparison]: - """Compare current (new) results against a named baseline.""" +) -> ComparisonCollection: + """Compare current results and report every expected coverage gap.""" comparisons: list[Comparison] = [] + gaps: list[CoverageGap] = [] if suite in ("all", "exact"): - comparisons.extend(_collect_exact_comparisons(criterion_dir, baseline_name, stat, scope)) + exact = _collect_exact_comparisons(criterion_dir, baseline_name, stat, scope) + comparisons.extend(exact.comparisons) + gaps.extend(exact.gaps) if suite in ("all", "vs_linalg"): - comparisons.extend(_collect_vs_linalg_comparisons(criterion_dir, baseline_name, stat, scope)) - return comparisons + vs_linalg = _collect_vs_linalg_comparisons(criterion_dir, baseline_name, stat, scope) + comparisons.extend(vs_linalg.comparisons) + gaps.extend(vs_linalg.gaps) + return ComparisonCollection(comparisons=comparisons, gaps=gaps) # --------------------------------------------------------------------------- @@ -504,15 +913,35 @@ def _format_time(ns: float) -> str: return f"{ns / 1_000_000:.2f} ms" -def _format_pct(pct: float) -> str: - """Format percent change with sign and colour hint.""" - if pct < -1.0: - return f"**{pct:+.1f}%**" # bold for improvements - if pct > 1.0: - return f"{pct:+.1f}%" +def _format_pct(pct: float, assessment: ChangeAssessment) -> str: + """Format point-estimate change without implying statistical significance.""" + del assessment return f"{pct:+.1f}%" +def _format_confidence_interval(estimate: CriterionEstimate) -> str: + """Format a Criterion confidence interval without inventing missing bounds.""" + if estimate.ci_lo_ns is None or estimate.ci_hi_ns is None: + return "unavailable" + return f"[{_format_time(estimate.ci_lo_ns)}, {_format_time(estimate.ci_hi_ns)}]" + + +def _format_estimate(estimate: CriterionEstimate) -> str: + """Format a point estimate together with its Criterion interval.""" + return f"{_format_time(estimate.point_ns)} {_format_confidence_interval(estimate)}" + + +def _assessment_label(assessment: ChangeAssessment) -> str: + """Return a concise, explicit confidence-interval assessment label.""" + labels: dict[ChangeAssessment, str] = { + "improvement": "faster point estimate; marginal CIs separated", + "regression": "slower point estimate; marginal CIs separated", + "inconclusive": "marginal CIs overlap", + "unknown": "marginal CI unavailable", + } + return labels[assessment] + + class _GroupedItem(Protocol): @property def suite(self) -> str: ... @@ -548,11 +977,11 @@ def _suite_heading(suite: str) -> str: def _group_heading(group: str) -> str: """Turn a Criterion group name into a readable heading.""" - # exact_d3 -> "D=3", exact_random_percentile_d3 -> - # "Random percentile D=3", exact_near_singular_3x3 -> + # exact_d3 -> "D=3", exact_random_corpus_d3 -> + # "Random corpus D=3", exact_near_singular_3x3 -> # "Near-singular 3x3", exact_hilbert_4x4 -> "Hilbert 4x4", etc. - if group.startswith("exact_random_percentile_d"): - return f"Random percentile D={group.removeprefix('exact_random_percentile_d')}" + if group.startswith("exact_random_corpus_d"): + return f"Random corpus D={group.removeprefix('exact_random_corpus_d')}" if group.startswith("exact_d"): return f"D={group.removeprefix('exact_d')}" if group == "exact_near_singular_3x3": @@ -584,11 +1013,11 @@ def _snapshot_tables(results: list[BenchResult], stat: str) -> str: lines = [ f"### {_group_heading_for_suite(suite, group)}", "", - f"| Benchmark | {stat_label} | 95% CI |", + f"| Benchmark | {stat_label} | Criterion CI |", "|-----------|-------:|-------:|", ] for r in items: - ci_range = f"[{_format_time(r.ci_lo_ns)}, {_format_time(r.ci_hi_ns)}]" + ci_range = _format_confidence_interval(r.estimate) lines.append(f"| {r.bench} | {_format_time(r.point_ns)} | {ci_range} |") sections.append("\n".join(lines)) @@ -608,32 +1037,37 @@ def _comparison_tables(comparisons: list[Comparison], baseline_name: str) -> str "", ] if has_peer_context: + header = ( + f"| Benchmark | {baseline_name} (point + CI) | Latest (point + CI) | Point-estimate change | CI relation | Point-estimate ratio | " + f"{baseline_name} nalgebra | {baseline_name} faer |" + ) lines.extend( [ - f"| Benchmark | {baseline_name} | Latest | Change | Speedup | {baseline_name} nalgebra | {baseline_name} faer |", - "|-----------|-------:|-------:|-------:|--------:|-------:|-------:|", + header, + "|-----------|-------:|-------:|-------:|:-----------|--------:|-------:|-------:|", ] ) else: lines.extend( [ - f"| Benchmark | {baseline_name} | Latest | Change | Speedup |", - "|-----------|-------:|-------:|-------:|--------:|", + f"| Benchmark | {baseline_name} (point + CI) | Latest (point + CI) | Point-estimate change | CI relation | Point-estimate ratio |", + "|-----------|-------:|-------:|-------:|:-----------|--------:|", ] ) for c in items: cells = [ _comparison_bench_label(c), - _format_time(c.baseline_ns), - _format_time(c.current_ns), - _format_pct(c.pct_change), + _format_estimate(c.baseline), + _format_estimate(c.current), + _format_pct(c.pct_change, c.assessment), + _assessment_label(c.assessment), f"{c.speedup:.2f}x", ] if has_peer_context: cells.extend( [ - _format_time(c.baseline_nalgebra_ns) if c.baseline_nalgebra_ns is not None else "", - _format_time(c.baseline_faer_ns) if c.baseline_faer_ns is not None else "", + _format_estimate(c.baseline_nalgebra) if c.baseline_nalgebra is not None else "—", + _format_estimate(c.baseline_faer) if c.baseline_faer is not None else "—", ] ) lines.append(f"| {' | '.join(cells)} |") @@ -642,6 +1076,33 @@ def _comparison_tables(comparisons: list[Comparison], baseline_name: str) -> str return "\n\n".join(sections) +def _coverage_table(gaps: list[CoverageGap], baseline_name: str) -> str: + """Render missing current/baseline rows in deterministic collection order.""" + if not gaps: + return "" + + lines = [ + "## Incomplete Comparison Coverage", + "", + "These expected rows were not classified because one or both Criterion samples are missing.", + "", + "| Suite | Group | Benchmark | Missing sample(s) |", + "|:------|:------|:----------|:------------------|", + ] + for gap in gaps: + missing: list[str] = [] + if gap.missing_current: + missing.append("current (`new`)") + if gap.missing_baseline: + missing.append(f"baseline (`{baseline_name}`)") + bench_label = gap.bench + if gap.baseline_bench != gap.bench: + bench_label = f"{gap.bench} (baseline row: {gap.baseline_bench})" + lines.append(f"| {_suite_heading(gap.suite)} | {gap.group} | {bench_label} | {', '.join(missing)} |") + + return "\n".join(lines) + + def _read_cargo_version(root: Path) -> str: cargo_toml = root / "Cargo.toml" if not cargo_toml.exists(): @@ -662,12 +1123,12 @@ def _get_git_info(root: Path) -> tuple[str, str]: try: result = run_git_command(["--no-pager", "rev-parse", "--short", "HEAD"], cwd=root) short_hash = result.stdout.strip() - except (ExecutableNotFoundError, subprocess.CalledProcessError): + except ExecutableNotFoundError, subprocess.CalledProcessError: pass try: result = run_git_command(["--no-pager", "rev-parse", "--abbrev-ref", "HEAD"], cwd=root) branch = result.stdout.strip() - except (ExecutableNotFoundError, subprocess.CalledProcessError): + except ExecutableNotFoundError, subprocess.CalledProcessError: pass return short_hash, branch @@ -697,7 +1158,24 @@ def _generate_markdown( if settings.baseline_name: lines.append(f"Comparison against baseline **{settings.baseline_name}**:") lines.append("") - lines.append("Negative change = faster. Speedup > 1.00x = improvement.") + lines.append( + "Negative point-estimate change means the current point estimate is smaller; " + "a baseline/current point-estimate ratio above 1.00 has the same meaning." + ) + lines.append( + "The CI-relation column reports only whether the two marginal Criterion intervals overlap. " + "These are not paired confidence intervals for the change, so the report makes no " + "statistical-significance or performance-improvement claim from interval separation." + ) + lines.append("") + if settings.harness_provenance is None: + lines.append( + "**Reproducibility provenance**: unavailable — these Criterion samples predate provenance capture or were produced outside the " + "publication pipeline. CPU, OS, rustc, commit, dependency lock, harness digest, Criterion configuration, and two-revision " + "fixture validation are unknown." + ) + else: + lines.extend(_provenance_markdown(settings.harness_provenance)) else: lines.append("Current performance snapshot (no baseline comparison).") @@ -735,6 +1213,85 @@ def _generate_markdown( return "\n".join(lines) + "\n" +def _provenance_markdown(provenance: HarnessProvenance) -> list[str]: + """Render validated provenance without implying facts absent from metadata.""" + if provenance.schema == 1: + return [ + "**Harness provenance**: legacy shared current harness metadata from " + f"`.la-stack-benchmark-harness.json` (baseline source `{provenance.baseline}`, SHA-256 `{provenance.sha256}`). " + "CPU, OS, rustc, commits, Criterion configuration, and fixture-gate results were not recorded." + ] + + if provenance.measurement is None or provenance.publication is None or provenance.criterion is None or provenance.validation is None: + msg = "schema-2 benchmark provenance invariant violated" + raise AssertionError(msg) + + measurement = provenance.measurement + publication = provenance.publication + criterion = provenance.criterion + validation = provenance.validation + baseline_command = cast("list[str]", criterion["baseline_command"]) + current_command = cast("list[str]", criterion["current_command"]) + lines = ["### Reproducibility Provenance", ""] + if measurement["status"] == "recorded": + lines.extend( + [ + "**Measurement environment**: recorded for both samples under one shared current harness.", + "", + f"- CPU: `{measurement['cpu']}`", + f"- OS: `{measurement['os']}`", + f"- rustc: `{measurement['rustc']}`", + f"- Current commit: `{measurement['current_commit']}`", + f"- Current Git clean: `{str(measurement['current_git_clean']).lower()}`", + f"- Current source-state SHA-256: `{measurement['current_source_state_sha256']}`", + f"- Baseline commit: `{measurement['baseline_commit']}`", + f"- Baseline Git clean: `{str(measurement['baseline_git_clean']).lower()}`", + f"- Baseline source-state SHA-256: `{measurement['baseline_source_state_sha256']}`", + f"- Cargo.lock SHA-256: `{measurement['cargo_lock_sha256']}`", + f"- Benchmark harness SHA-256: `{measurement['harness_sha256']}`", + ] + ) + else: + lines.extend( + [ + f"**Measurement environment**: unavailable — {measurement['reason']}", + "", + "The publication environment below validates report generation, not the historical timing environment.", + ] + ) + + lines.extend( + [ + "", + f"- Publication CPU: `{publication['cpu']}`", + f"- Publication OS: `{publication['os']}`", + f"- Publication rustc: `{publication['rustc']}`", + f"- Publication commit: `{publication['commit']}`", + f"- Publication Git clean: `{str(publication['git_clean']).lower()}`", + f"- Publication source-state SHA-256: `{publication['source_state_sha256']}`", + f"- Publication Cargo.lock SHA-256: `{publication['cargo_lock_sha256']}`", + f"- Publication harness SHA-256: `{publication['harness_sha256']}`", + f"- Criterion suite/scope: `{criterion['suite']}` / `{criterion['scope']}`", + f"- Criterion statistic/sample: `{criterion['statistic']}` / `{criterion['sample']}`", + f"- Criterion dependency version: `{criterion['criterion_version']}`", + f"- Baseline command: `{' '.join(baseline_command)}`", + f"- Current command: `{' '.join(current_command)}`", + "- Correctness gate: `just test-bench-inputs` passed against both the current and baseline revisions using the shared current fixture harness.", + ( + f"- Validated current revision: `{validation['current_commit']}` " + f"(Git clean: `{str(validation['current_git_clean']).lower()}`, " + f"source-state SHA-256: `{validation['current_source_state_sha256']}`)" + ), + ( + f"- Validated baseline revision: `{validation['baseline_commit']}` " + f"(Git clean: `{str(validation['baseline_git_clean']).lower()}`, " + f"source-state SHA-256: `{validation['baseline_source_state_sha256']}`)" + ), + ] + ) + return lines + + # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- @@ -802,7 +1359,8 @@ def _save_baseline_hint(suite: str, baseline: str) -> str: return f"just bench-save-baseline {baseline}" -def main(argv: list[str] | None = None) -> int: +def main(argv: list[str] | None = None) -> int: # noqa: PLR0911 + """Generate a benchmark snapshot or comparison report from CLI arguments.""" args = _parse_args(sys.argv[1:] if argv is None else argv) root = _repo_root() @@ -817,10 +1375,31 @@ def main(argv: list[str] | None = None) -> int: return 2 baseline_name = None if args.snapshot else args.baseline + harness_provenance: HarnessProvenance | None = None if baseline_name: - comparisons = _collect_comparisons(criterion_dir, baseline_name, args.stat, args.suite, args.scope) - if not comparisons: + try: + harness_provenance = _read_harness_provenance(criterion_dir, expected_baseline=baseline_name) + except (OSError, TypeError, ValueError) as err: + print(f"Invalid benchmark harness provenance: {err}", file=sys.stderr) + return 2 + + collection = _collect_comparisons(criterion_dir, baseline_name, args.stat, args.suite, args.scope) + if collection.gaps: + print( + f"Incomplete benchmark coverage: {len(collection.gaps)} required comparison row(s) are missing; report publication aborted.", + file=sys.stderr, + ) + print(_coverage_table(collection.gaps, baseline_name), file=sys.stderr) + if not collection.comparisons: + print( + f"No comparison data found for baseline '{baseline_name}'.\n" + f"Save a baseline first:\n {_save_baseline_hint(args.suite, baseline_name)}\n" + f"Then run benchmarks:\n {_run_bench_hint(args.suite)}\n", + file=sys.stderr, + ) + return 2 + if not collection.comparisons: print( f"No comparison data found for baseline '{baseline_name}'.\n" f"Save a baseline first:\n {_save_baseline_hint(args.suite, baseline_name)}\n" @@ -828,8 +1407,19 @@ def main(argv: list[str] | None = None) -> int: file=sys.stderr, ) return 2 - table = _comparison_tables(comparisons, baseline_name) + table = _comparison_tables(collection.comparisons, baseline_name) else: + coverage_errors = _snapshot_coverage_errors( + criterion_dir, + sample="new", + suite=args.suite, + scope=args.scope, + ) + if coverage_errors: + print("Incomplete benchmark coverage; snapshot publication aborted:", file=sys.stderr) + for error in coverage_errors: + print(f" - {error}", file=sys.stderr) + return 2 results = _collect_results(criterion_dir, "new", args.stat, args.suite) if not results: print( @@ -844,6 +1434,7 @@ def main(argv: list[str] | None = None) -> int: stat=args.stat, suite=args.suite, scope=args.scope, + harness_provenance=harness_provenance, ) md = _generate_markdown(root, table, settings) diff --git a/scripts/check_docs_version_sync.py b/scripts/check_docs_version_sync.py index 95ef260..ab1fce1 100644 --- a/scripts/check_docs_version_sync.py +++ b/scripts/check_docs_version_sync.py @@ -1,4 +1,4 @@ -"""Check that documentation dependency snippets match Cargo.toml.""" +"""Check release-version references against the Cargo package version.""" from __future__ import annotations @@ -7,6 +7,7 @@ import sys import tomllib from dataclasses import dataclass +from enum import StrEnum from pathlib import Path from typing import TypeGuard @@ -18,9 +19,12 @@ ".ruff_cache", ".tmp_pycache", ".venv", + "archive", "target", + "tests", } ) +SKIP_MARKDOWN_FILES = frozenset({"CHANGELOG.md"}) type ParsedObject = dict[str, object] @@ -38,57 +42,195 @@ def _require_parsed_object(value: object, context: str) -> ParsedObject: return value +def _read_toml(path: Path) -> ParsedObject: + data: object = tomllib.loads(path.read_text(encoding="utf-8")) + return _require_parsed_object(data, str(path)) + + +def _require_table(data: ParsedObject, key: str, path: Path) -> ParsedObject: + table = data.get(key) + if not _is_parsed_object(table): + msg = f"{path} is missing a [{key}] table" + raise TypeError(msg) + return table + + +def _require_string(data: ParsedObject, key: str, context: str) -> str: + value = data.get(key) + if not isinstance(value, str): + msg = f"{context} is missing a string {key}" + raise TypeError(msg) + return value + + @dataclass(frozen=True, slots=True) class PackageInfo: - """Cargo package identity used in documented dependency snippets.""" + """Cargo package identity that defines the expected release version.""" + + name: str + version: str + + +@dataclass(frozen=True, slots=True) +class PythonProjectInfo: + """Python support-package identity used to locate its uv lock entry.""" name: str version: str +class ReferenceKind(StrEnum): + """A release surface whose version must match Cargo.toml.""" + + CARGO_LOCK = "Cargo.lock root package" + CITATION = "CITATION.cff version" + DEPENDENCY_SNIPPET = "documentation dependency snippet" + PYPROJECT = "pyproject.toml project" + README_TAG_LINK = "README tag-pinned link" + UV_LOCK = "uv.lock editable package" + + @dataclass(frozen=True, slots=True) -class DependencySnippet: - """A documented dependency version snippet for the current package.""" +class VersionReference: + """A parsed release-version reference with source location.""" path: Path line: int version: str + kind: ReferenceKind text: str @dataclass(frozen=True, slots=True) class VersionMismatch: - """A dependency snippet whose version does not match Cargo.toml.""" + """A release-version reference that does not match Cargo.toml.""" - snippet: DependencySnippet + reference: VersionReference package: PackageInfo def _read_cargo_package_info(cargo_toml: Path) -> PackageInfo: - data: object = tomllib.loads(cargo_toml.read_text(encoding="utf-8")) - cargo = _require_parsed_object(data, str(cargo_toml)) - package = cargo.get("package") - if not _is_parsed_object(package): - msg = f"{cargo_toml} is missing a [package] table" + package = _require_table(_read_toml(cargo_toml), "package", cargo_toml) + return PackageInfo( + name=_require_string(package, "name", f"{cargo_toml} [package]"), + version=_require_string(package, "version", f"{cargo_toml} [package]"), + ) + + +def _read_python_project_info(pyproject_toml: Path) -> PythonProjectInfo: + project = _require_table(_read_toml(pyproject_toml), "project", pyproject_toml) + return PythonProjectInfo( + name=_require_string(project, "name", f"{pyproject_toml} [project]"), + version=_require_string(project, "version", f"{pyproject_toml} [project]"), + ) + + +def _toml_table_key_line(path: Path, table_name: str, key: str) -> int: + current_table: str | None = None + key_re = re.compile(rf"^{re.escape(key)}\s*=") + for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): + stripped = line.strip() + if stripped.startswith("[") and stripped.endswith("]"): + current_table = stripped.strip("[]") + elif current_table == table_name and key_re.match(stripped): + return line_number + msg = f"{path} [{table_name}] is missing {key}" + raise TypeError(msg) + + +def _version_reference(path: Path, line: int, version: str, kind: ReferenceKind) -> VersionReference: + lines = path.read_text(encoding="utf-8").splitlines() + if not 1 <= line <= len(lines): + msg = f"{path} has no line {line} for {kind}" raise TypeError(msg) + return VersionReference(path=path, line=line, version=version, kind=kind, text=lines[line - 1].strip()) + - name = package.get("name") - if not isinstance(name, str): - msg = f"{cargo_toml} is missing a string package.name" +def _package_entries(path: Path) -> list[ParsedObject]: + packages = _read_toml(path).get("package") + if not isinstance(packages, list): + msg = f"{path} is missing [[package]] entries" raise TypeError(msg) + entries: list[ParsedObject] = [] + for index, package in enumerate(packages, start=1): + entries.append(_require_parsed_object(package, f"{path} [[package]] entry {index}")) + return entries - version = package.get("version") - if not isinstance(version, str): - msg = f"{cargo_toml} is missing a string package.version" + +def _array_table_key_line(path: Path, table_name: str, table_index: int, key: str) -> int: + current_index = -1 + in_target_table = False + key_re = re.compile(rf"^{re.escape(key)}\s*=") + for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): + stripped = line.strip() + if stripped == f"[[{table_name}]]": + current_index += 1 + in_target_table = current_index == table_index + elif stripped.startswith("[["): + in_target_table = False + elif in_target_table and key_re.match(stripped): + return line_number + msg = f"{path} [[{table_name}]] entry {table_index + 1} is missing {key}" + raise TypeError(msg) + + +def _single_package_reference( + path: Path, entries: list[ParsedObject], candidate_indices: list[int], package_name: str, kind: ReferenceKind +) -> VersionReference: + if len(candidate_indices) != 1: + msg = f"{path} must contain exactly one {kind} named {package_name!r}; found {len(candidate_indices)}" raise TypeError(msg) - return PackageInfo(name=name, version=version) + index = candidate_indices[0] + version = _require_string(entries[index], "version", f"{path} [[package]] entry {index + 1}") + line = _array_table_key_line(path, "package", index, "version") + return _version_reference(path, line, version, kind) + + +def _cargo_lock_reference(path: Path, package: PackageInfo) -> VersionReference: + entries = _package_entries(path) + candidate_indices = [index for index, entry in enumerate(entries) if entry.get("name") == package.name and "source" not in entry] + return _single_package_reference(path, entries, candidate_indices, package.name, ReferenceKind.CARGO_LOCK) + + +def _pyproject_reference(path: Path, project: PythonProjectInfo) -> VersionReference: + line = _toml_table_key_line(path, "project", "version") + return _version_reference(path, line, project.version, ReferenceKind.PYPROJECT) + + +def _uv_lock_reference(path: Path, project: PythonProjectInfo) -> VersionReference: + entries = _package_entries(path) + candidate_indices: list[int] = [] + for index, entry in enumerate(entries): + source = entry.get("source") + if entry.get("name") == project.name and _is_parsed_object(source) and isinstance(source.get("editable"), str): + candidate_indices.append(index) + return _single_package_reference(path, entries, candidate_indices, project.name, ReferenceKind.UV_LOCK) + + +_CITATION_VERSION_RE = re.compile(r"^version:\s*(?P['\"]?)(?P[0-9A-Za-z][0-9A-Za-z.+-]*)(?P=quote)\s*(?:#.*)?$") + + +def _citation_reference(path: Path) -> VersionReference: + references: list[VersionReference] = [] + for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): + if not line.startswith("version:"): + continue + match = _CITATION_VERSION_RE.fullmatch(line) + if match is None: + msg = f"{path}:{line_number}: top-level version must be a non-empty scalar" + raise TypeError(msg) + references.append(_version_reference(path, line_number, match.group("version"), ReferenceKind.CITATION)) + if len(references) != 1: + msg = f"{path} must contain exactly one top-level version; found {len(references)}" + raise TypeError(msg) + return references[0] def _iter_markdown_files(root: Path) -> list[Path]: markdown_files: list[Path] = [] for dirpath, dirnames, filenames in os.walk(root): dirnames[:] = [dirname for dirname in dirnames if not (set((Path(dirpath) / dirname).relative_to(root).parts) & SKIP_DIRS)] - markdown_files.extend(Path(dirpath) / filename for filename in filenames if filename.endswith(".md")) + markdown_files.extend(Path(dirpath) / filename for filename in filenames if filename.endswith(".md") and filename not in SKIP_MARKDOWN_FILES) return sorted(markdown_files) @@ -97,52 +239,80 @@ def _dependency_regex(package_name: str) -> re.Pattern[str]: return re.compile(rf'(?[^"]+)"|\{{[^}}]*version\s*=\s*"(?P[^"]+)"[^}}]*\}})') -def _dependency_snippets(path: Path, package_name: str) -> list[DependencySnippet]: +def _dependency_references(path: Path, package_name: str) -> list[VersionReference]: dependency_re = _dependency_regex(package_name) - snippets: list[DependencySnippet] = [] + references: list[VersionReference] = [] for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): for match in dependency_re.finditer(line): version = match.group("plain") or match.group("table") - snippets.append( - DependencySnippet( + references.append( + VersionReference( path=path, line=line_number, version=version, + kind=ReferenceKind.DEPENDENCY_SNIPPET, text=line.strip(), ) ) - return snippets + return references + + +_README_TAG_LINK_RE = re.compile( + r"https://(?:github\.com/acgetchell/la-stack/(?:blob|raw|tree)/|raw\.githubusercontent\.com/acgetchell/la-stack/)" + r"v(?P[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?)(?=/|\b)" +) + + +def _readme_tag_references(path: Path) -> list[VersionReference]: + references: list[VersionReference] = [] + for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): + references.extend( + VersionReference(path, line_number, match.group("version"), ReferenceKind.README_TAG_LINK, line.strip()) + for match in _README_TAG_LINK_RE.finditer(line) + ) + return references + + +def _version_references(root: Path, package: PackageInfo) -> list[VersionReference]: + pyproject_path = root / "pyproject.toml" + project = _read_python_project_info(pyproject_path) + references = [ + _cargo_lock_reference(root / "Cargo.lock", package), + _pyproject_reference(pyproject_path, project), + _uv_lock_reference(root / "uv.lock", project), + _citation_reference(root / "CITATION.cff"), + ] + for path in _iter_markdown_files(root): + references.extend(_dependency_references(path, package.name)) + references.extend(_readme_tag_references(root / "README.md")) + return references def find_version_mismatches(root: Path) -> list[VersionMismatch]: - """Return documented dependency snippets for this crate that are stale.""" + """Return release-version references that differ from Cargo.toml.""" package = _read_cargo_package_info(root / "Cargo.toml") - mismatches: list[VersionMismatch] = [] - for path in _iter_markdown_files(root): - for snippet in _dependency_snippets(path, package.name): - if snippet.version != package.version: - mismatches.append(VersionMismatch(snippet=snippet, package=package)) - return mismatches + return [VersionMismatch(reference=reference, package=package) for reference in _version_references(root, package) if reference.version != package.version] def main() -> int: + """Check release-version references against the Cargo package version.""" root = Path(sys.argv[1]).resolve() if len(sys.argv) > 1 else Path.cwd() try: mismatches = find_version_mismatches(root) except (OSError, TypeError, tomllib.TOMLDecodeError) as error: - print(f"Could not check documentation dependency versions: {error}", file=sys.stderr) + print(f"Could not check release-version synchronization: {error}", file=sys.stderr) return 1 if not mismatches: return 0 - print("Documentation dependency snippets are out of sync with Cargo.toml:", file=sys.stderr) + print("Release-version references are out of sync with Cargo.toml:", file=sys.stderr) for mismatch in mismatches: - snippet = mismatch.snippet - rel_path = snippet.path.relative_to(root) + reference = mismatch.reference + rel_path = reference.path.relative_to(root) print( - f" {rel_path}:{snippet.line}: {mismatch.package.name} found {snippet.version}, expected {mismatch.package.version}: {snippet.text}", + f" {rel_path}:{reference.line}: {reference.kind} found {reference.version}, expected {mismatch.package.version}: {reference.text}", file=sys.stderr, ) return 1 diff --git a/scripts/check_semgrep_fixtures.py b/scripts/check_semgrep_fixtures.py index 675d51d..4740e44 100644 --- a/scripts/check_semgrep_fixtures.py +++ b/scripts/check_semgrep_fixtures.py @@ -82,34 +82,47 @@ def _semgrep_results() -> SemgrepResults | None: return SemgrepResults(results=tuple(parsed_results)) -def main() -> int: - path = _path_argument(sys.argv) - if path is None: - return 1 - +def _expected_rule_counts(path: Path) -> collections.Counter[str]: expected: collections.Counter[str] = collections.Counter() for line in path.read_text(encoding="utf-8").splitlines(): for match in RULE_ANNOTATION.finditer(line): expected.update(rule_id.strip() for rule_id in match.group(1).split(",") if rule_id.strip()) + return expected - semgrep = _semgrep_results() - if semgrep is None: - return 1 +def _actual_rule_counts(semgrep: SemgrepResults) -> collections.Counter[str] | None: actual: collections.Counter[str] = collections.Counter() malformed_results: list[str] = [] for index, result in enumerate(semgrep.results): check_id = result.get("check_id") - if not isinstance(check_id, str): + if isinstance(check_id, str): + actual.update([check_id]) + else: malformed_results.append(f"result {index} is missing string field 'check_id'") - continue - actual.update([check_id]) + if not malformed_results: + return actual - if malformed_results: - print("Invalid SEMGREP_JSON shape:", file=sys.stderr) - for malformed in malformed_results: - print(f" {malformed}", file=sys.stderr) + print("Invalid SEMGREP_JSON shape:", file=sys.stderr) + for malformed in malformed_results: + print(f" {malformed}", file=sys.stderr) + return None + + +def main() -> int: + """Compare expected fixture annotations with the supplied Semgrep results.""" + path = _path_argument(sys.argv) + if path is None: + return 1 + + expected = _expected_rule_counts(path) + + semgrep = _semgrep_results() + if semgrep is None: + return 1 + + actual = _actual_rule_counts(semgrep) + if actual is None: return 1 if actual == expected: diff --git a/scripts/criterion_dim_plot.py b/scripts/criterion_dim_plot.py index b93d82a..647efc6 100644 --- a/scripts/criterion_dim_plot.py +++ b/scripts/criterion_dim_plot.py @@ -15,20 +15,27 @@ from __future__ import annotations import argparse +import hashlib import json import math +import platform import re import shutil import subprocess import sys +import tempfile import tomllib from dataclasses import dataclass from pathlib import Path -from typing import Final, Protocol, TypeGuard +from typing import Final, Protocol, TypeGuard, cast + +from subprocess_utils import ExecutableNotFoundError, run_git_command, run_safe_command @dataclass(frozen=True, slots=True) class Metric: + """Criterion benchmark names and display title for one plotted metric.""" + la_bench: str na_bench: str fa_bench: str @@ -37,6 +44,8 @@ class Metric: @dataclass(frozen=True, slots=True) class PlotRequest: + """Validated inputs required to render a benchmark SVG.""" + csv_path: Path out_svg: Path title: str @@ -62,10 +71,13 @@ class PlotCliArgs: no_plot: bool update_readme: bool readme: str + allow_partial: bool @dataclass(frozen=True, slots=True) class Row: + """Validated timing estimates for one benchmark dimension.""" + dim: int la_time: float la_lo: float @@ -78,6 +90,7 @@ class Row: fa_hi: float def __post_init__(self) -> None: + """Reject invalid dimensions, timings, and confidence intervals.""" if self.dim <= 0: msg = f"dimension must be positive: {self.dim}" raise ValueError(msg) @@ -187,6 +200,27 @@ def no_plot(self) -> bool: ... ), } +CANONICAL_DIMS: Final[tuple[int, ...]] = (2, 3, 4, 5, 8, 16, 32, 64) +_PUBLICATION_GATE: Final[tuple[str, ...]] = ("just", "test-bench-inputs") +_PUBLICATION_BENCHMARK: Final[tuple[str, ...]] = ( + "cargo", + "bench", + "--locked", + "--features", + "bench", + "--bench", + "vs_linalg", +) +_COMMAND_TIMEOUT_SECONDS: Final[int] = 7200 +_PROVENANCE_HARNESS_FILES: Final[tuple[str, ...]] = ( + "Cargo.toml", + "Cargo.lock", + "rust-toolchain.toml", + "justfile", + "tests/exact_bench_config.rs", + "tests/vs_linalg_inputs.rs", +) + def _repo_root() -> Path: return Path(__file__).resolve().parents[1] @@ -375,7 +409,8 @@ def _pct_reduction(baseline: float, value: float) -> str: def _markdown_table(rows: list[Row], stat: str) -> str: lines = [ - f"| D | la-stack {stat} (ns) | nalgebra {stat} (ns) | faer {stat} (ns) | la-stack vs nalgebra | la-stack vs faer |", + f"| D | la-stack {stat} (ns) | nalgebra {stat} (ns) | faer {stat} (ns) | " + "la-stack point-estimate reduction vs nalgebra | la-stack point-estimate reduction vs faer |", "|---:|--------------------:|--------------------:|----------------:|---------------------:|----------------:|", ] @@ -428,11 +463,6 @@ def _gp_quote(s: str) -> str: def _render_svg_with_gnuplot(req: PlotRequest) -> None: - gnuplot_path = shutil.which("gnuplot") - if gnuplot_path is None: - msg = "gnuplot not found. Install it (macOS: `brew install gnuplot`) or re-run with --no-plot." - raise FileNotFoundError(msg) - req.out_svg.parent.mkdir(parents=True, exist_ok=True) xtics = ", ".join(str(d) for d in req.dims) @@ -467,9 +497,11 @@ def _render_svg_with_gnuplot(req: PlotRequest) -> None: ] ) - # Safe: gnuplot executable is resolved via PATH; input is a generated script with fully - # quoted file paths. - subprocess.run([gnuplot_path], input="\n".join(gp_lines), text=True, check=True) # noqa: S603 + try: + run_safe_command("gnuplot", [], input="\n".join(gp_lines)) + except ExecutableNotFoundError as exc: + msg = "gnuplot not found. Install it (macOS: `brew install gnuplot`) or re-run with --no-plot." + raise FileNotFoundError(msg) from exc def _parse_args(argv: list[str]) -> PlotCliArgs: @@ -528,6 +560,11 @@ def _parse_args(argv: list[str]) -> PlotCliArgs: default="README.md", help="Path to README file to update (default: README.md at repo root).", ) + parser.add_argument( + "--allow-partial", + action="store_true", + help="Allow incomplete dimensions for exploratory CSV/SVG output; incompatible with --update-readme.", + ) args = parser.parse_args(argv) return PlotCliArgs( @@ -541,6 +578,7 @@ def _parse_args(argv: list[str]) -> PlotCliArgs: no_plot=_required_bool_attr(args, "no_plot"), update_readme=_required_bool_attr(args, "update_readme"), readme=_required_str_attr(args, "readme"), + allow_partial=_required_bool_attr(args, "allow_partial"), ) @@ -595,8 +633,17 @@ def _collect_rows(criterion_dir: Path, dims: list[int], metric: Metric, stat: st na_est = group_dir / metric.na_bench / sample / "estimates.json" fa_est = group_dir / metric.fa_bench / sample / "estimates.json" - if not la_est.exists() or not na_est.exists() or not fa_est.exists(): - skipped.append(f"d{d} (missing {metric.la_bench}, {metric.na_bench}, or {metric.fa_bench})") + missing = [ + bench + for bench, path in ( + (metric.la_bench, la_est), + (metric.na_bench, na_est), + (metric.fa_bench, fa_est), + ) + if not path.exists() + ] + if missing: + skipped.append(f"d{d} (missing {', '.join(missing)})") continue la, la_lo, la_hi = _read_estimate(la_est, stat) @@ -620,6 +667,390 @@ def _collect_rows(criterion_dir: Path, dims: list[int], metric: Metric, stat: st return (rows, skipped) +def _run_publication_benchmarks(root: Path) -> None: + """Validate fixtures, then produce fresh README publication measurements.""" + _run_publication_command(root, _PUBLICATION_GATE) + criterion_dir = root / "target" / "criterion" + with tempfile.TemporaryDirectory(prefix="la-stack-stale-criterion-") as tmp: + backup_root = Path(tmp) + moved = _stage_existing_new_samples(criterion_dir, backup_root) + try: + _run_publication_command(root, _PUBLICATION_BENCHMARK) + except RuntimeError: + _remove_vs_linalg_new_samples(criterion_dir) + for relative in moved: + source = backup_root / relative + destination = criterion_dir / relative + destination.parent.mkdir(parents=True, exist_ok=True) + source.replace(destination) + raise + + +def _run_publication_command(root: Path, command: tuple[str, ...]) -> None: + """Run one publication command with complete failure context.""" + try: + run_safe_command( + command[0], + list(command[1:]), + cwd=root, + timeout=_COMMAND_TIMEOUT_SECONDS, + ) + except subprocess.CalledProcessError as exc: + stderr = exc.stderr.strip() if isinstance(exc.stderr, str) else "" + detail = f"\nstderr:\n{stderr}" if stderr else "" + msg = f"publication command failed ({exc.returncode}): {' '.join(command)}{detail}" + raise RuntimeError(msg) from exc + + +def _vs_linalg_new_samples(criterion_dir: Path) -> list[Path]: + """Return every current vs_linalg sample in deterministic order.""" + if not criterion_dir.is_dir(): + return [] + return sorted( + ( + sample + for group in criterion_dir.iterdir() + if group.is_dir() and _dim_from_group_dir(group.name) is not None + for sample in group.glob("*/new") + if sample.is_dir() + ), + key=lambda path: path.relative_to(criterion_dir).as_posix(), + ) + + +def _stage_existing_new_samples(criterion_dir: Path, backup_root: Path) -> list[Path]: + """Move stale `new` samples aside so only the fresh timing run can satisfy coverage.""" + moved: list[Path] = [] + for sample in _vs_linalg_new_samples(criterion_dir): + relative = sample.relative_to(criterion_dir) + backup = backup_root / relative + backup.parent.mkdir(parents=True, exist_ok=True) + sample.replace(backup) + moved.append(relative) + return moved + + +def _remove_vs_linalg_new_samples(criterion_dir: Path) -> None: + """Remove partial current samples before restoring a failed publication run.""" + for sample in _vs_linalg_new_samples(criterion_dir): + shutil.rmtree(sample) + + +def _git_value(root: Path, args: list[str]) -> str: + """Return deterministic Git provenance or an explicit unavailable label.""" + try: + value = run_git_command(args, cwd=root).stdout.strip() + except ExecutableNotFoundError, subprocess.CalledProcessError: + return "unavailable" + return value or "unavailable" + + +def _git_status_metadata(root: Path) -> tuple[bool | None, str]: + """Return checkout cleanliness and a deterministic digest of porcelain status.""" + try: + status = run_git_command( + ["--no-pager", "status", "--porcelain=v1", "--untracked-files=all"], + cwd=root, + ).stdout + except ExecutableNotFoundError, subprocess.CalledProcessError: + return (None, hashlib.sha256(b"unavailable").hexdigest()) + return (not status.strip(), hashlib.sha256(status.encode()).hexdigest()) + + +def _source_state_digest(root: Path) -> tuple[str, bool]: + """Hash the measured library source so dirty runs remain identifiable.""" + source_dir = root / "src" + files = ( + sorted( + (path for path in source_dir.rglob("*") if path.is_file()), + key=lambda path: path.relative_to(root).as_posix(), + ) + if source_dir.is_dir() + else [] + ) + digest = hashlib.sha256() + if not files: + digest.update(b"MISSING:src/") + return (digest.hexdigest(), True) + for path in files: + relative = path.relative_to(root).as_posix().encode() + payload = path.read_bytes() + digest.update(len(relative).to_bytes(8, "big")) + digest.update(relative) + digest.update(len(payload).to_bytes(8, "big")) + digest.update(payload) + return (digest.hexdigest(), False) + + +def _provenance_harness_files(root: Path) -> tuple[list[Path], list[str]]: + """Return stable harness files and explicitly list any missing members.""" + files: list[Path] = [] + missing: list[str] = [] + for relative in _PROVENANCE_HARNESS_FILES: + path = root / relative + if path.is_file(): + files.append(path) + else: + missing.append(relative) + benches = root / "benches" + if benches.is_dir(): + files.extend(path for path in benches.rglob("*") if path.is_file()) + else: + missing.append("benches/") + return (sorted(set(files), key=lambda path: path.relative_to(root).as_posix()), sorted(missing)) + + +def _provenance_harness_digest(root: Path) -> tuple[str, list[str]]: + """Hash the benchmark harness while making missing inputs visible.""" + files, missing = _provenance_harness_files(root) + digest = hashlib.sha256() + for path in files: + relative = path.relative_to(root).as_posix().encode() + payload = path.read_bytes() + digest.update(len(relative).to_bytes(8, "big")) + digest.update(relative) + digest.update(len(payload).to_bytes(8, "big")) + digest.update(payload) + for relative in missing: + marker = f"MISSING:{relative}".encode() + digest.update(len(marker).to_bytes(8, "big")) + digest.update(marker) + return (digest.hexdigest(), missing) + + +def _rustc_version(root: Path) -> str: + """Return the rustc version used by the publication workflow.""" + try: + value = run_safe_command( + "rustc", + ["--version"], + cwd=root, + timeout=60, + ).stdout.strip() + except ExecutableNotFoundError, subprocess.CalledProcessError: + return "unavailable" + return value or "unavailable" + + +def _capture_provenance( + root: Path, + *, + args: PlotCliArgs, + dims: list[int], + measurement_recorded: bool, +) -> dict[str, object]: + """Capture deterministic provenance for CSV/SVG and README publication.""" + harness_sha256, missing_harness_files = _provenance_harness_digest(root) + cargo_lock = root / "Cargo.lock" + cargo_lock_sha256 = hashlib.sha256(cargo_lock.read_bytes()).hexdigest() if cargo_lock.is_file() else "unavailable" + cpu = platform.processor().strip() or platform.machine().strip() or "unavailable" + os_description = " ".join(part for part in (platform.system(), platform.release(), platform.machine()) if part).strip() or "unavailable" + git_clean, git_status_sha256 = _git_status_metadata(root) + source_state_sha256, source_missing = _source_state_digest(root) + environment: dict[str, object] = { + "cargo_lock_sha256": cargo_lock_sha256, + "commit": _git_value(root, ["--no-pager", "rev-parse", "HEAD"]), + "cpu": cpu, + "git_clean": git_clean, + "git_status_sha256": git_status_sha256, + "harness_sha256": harness_sha256, + "missing_harness_files": missing_harness_files, + "os": os_description, + "rustc": _rustc_version(root), + "source_missing": source_missing, + "source_state_sha256": source_state_sha256, + } + measurement: dict[str, object] + if measurement_recorded: + measurement = {"status": "recorded", **environment} + else: + measurement = { + "reason": "the exploratory renderer did not run the benchmark command that produced these Criterion samples", + "status": "unavailable", + } + criterion_version = _read_cargo_dependency_versions(root / "Cargo.toml", {"criterion"}).get("criterion", "unavailable") + return { + "artifact": "README vs_linalg dimension plot" if args.update_readme else "exploratory vs_linalg dimension plot", + "criterion": { + "benchmark_command": list(_PUBLICATION_BENCHMARK) if measurement_recorded else "unavailable", + "criterion_dependency": criterion_version, + "dimensions": dims, + "log_y": args.log_y, + "metric": args.metric, + "sample": args.sample, + "statistic": args.stat, + }, + "measurement": measurement, + "publication": { + **environment, + "correctness_gate": "passed" if measurement_recorded else "not-run-exploratory", + }, + "schema": 1, + } + + +def _write_provenance(path: Path, provenance: dict[str, object]) -> None: + """Write stable, sorted JSON provenance beside generated benchmark assets.""" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(provenance, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def _validate_readme_target(root: Path, args: PlotCliArgs) -> int: # noqa: C901, PLR0911 + """Validate publication-only CLI invariants and README markers before timing.""" + if not args.update_readme: + return 0 + if args.allow_partial: + print("--allow-partial is exploratory-only and cannot be combined with --update-readme", file=sys.stderr) + return 2 + if args.sample != "new": + print("README publication requires --sample new so the gated timing run is the data being published", file=sys.stderr) + return 2 + if args.no_plot: + print("README publication requires SVG rendering; --no-plot is exploratory-only", file=sys.stderr) + return 2 + criterion_dir = _resolve_under_root(root, args.criterion_dir).resolve() + expected_criterion_dir = (root / "target" / "criterion").resolve() + if criterion_dir != expected_criterion_dir: + print( + f"README publication requires Criterion output at {expected_criterion_dir}; got {criterion_dir}", + file=sys.stderr, + ) + return 2 + readme_path = _resolve_under_root(root, args.readme) + canonical_readme = (root / "README.md").resolve() + if readme_path.resolve() == canonical_readme: + expected_svg, expected_csv = _resolve_output_paths(root, args.metric, args.stat, None, None) + selected_svg, selected_csv = _resolve_output_paths(root, args.metric, args.stat, args.out, args.csv) + if selected_svg.resolve() != expected_svg.resolve() or selected_csv.resolve() != expected_csv.resolve(): + print( + f"README publication requires the canonical CSV/SVG destinations referenced by README.md; expected {expected_csv} and {expected_svg}", + file=sys.stderr, + ) + return 2 + marker_begin, marker_end = _readme_table_markers(args.metric, args.stat, args.sample) + try: + lines = readme_path.read_text(encoding="utf-8").splitlines() + except OSError as exc: + print(str(exc), file=sys.stderr) + return 2 + begin_count = sum(line.strip() == marker_begin for line in lines) + end_count = sum(line.strip() == marker_end for line in lines) + if begin_count != 1 or end_count != 1: + print(f"README markers not found or not unique (begin={begin_count}, end={end_count}).", file=sys.stderr) + return 2 + begin_idx = next(index for index, line in enumerate(lines) if line.strip() == marker_begin) + end_idx = next(index for index, line in enumerate(lines) if line.strip() == marker_end) + if begin_idx >= end_idx: + print("README markers are out of order.", file=sys.stderr) + return 2 + return 0 + + +def _replace_staged_files(pairs: list[tuple[Path, Path]], backup_dir: Path) -> None: + """Replace a group of publication files and roll back on any failure.""" + backups: dict[Path, Path | None] = {} + for index, (_staged, destination) in enumerate(pairs): + destination.parent.mkdir(parents=True, exist_ok=True) + if destination.is_file(): + backup = backup_dir / f"backup-{index}" + shutil.copy2(destination, backup) + backups[destination] = backup + elif destination.exists(): + msg = f"publication destination is not a regular file: {destination}" + raise ValueError(msg) + else: + backups[destination] = None + + replaced: list[Path] = [] + try: + for staged, destination in pairs: + staged.replace(destination) + replaced.append(destination) + except OSError: + for destination in reversed(replaced): + backup = backups[destination] + if backup is None: + destination.unlink(missing_ok=True) + else: + backup.replace(destination) + raise + + +def _stage_and_publish_outputs( # noqa: PLR0913 + *, + root: Path, + args: PlotCliArgs, + rows: list[Row], + req: PlotRequest, + provenance: dict[str, object], + skipped: list[str], +) -> int: + """Render every output in isolation, then replace publication files together.""" + final_provenance = req.csv_path.with_suffix(".provenance.json") + with tempfile.TemporaryDirectory(prefix=".criterion-dim-plot-", dir=root) as tmp: + stage_dir = Path(tmp) + staged_csv = stage_dir / "benchmark.csv" + staged_svg = stage_dir / "benchmark.svg" + staged_provenance = stage_dir / "benchmark.provenance.json" + _write_csv(staged_csv, rows) + _write_provenance(staged_provenance, provenance) + + pairs: list[tuple[Path, Path]] = [ + (staged_csv, req.csv_path), + (staged_provenance, final_provenance), + ] + if not args.no_plot: + staged_request = PlotRequest( + csv_path=staged_csv, + out_svg=staged_svg, + title=req.title, + stat=req.stat, + dims=req.dims, + la_label=req.la_label, + na_label=req.na_label, + fa_label=req.fa_label, + log_y=req.log_y, + ) + try: + _render_svg_with_gnuplot(staged_request) + except (FileNotFoundError, subprocess.CalledProcessError) as exc: + print(str(exc), file=sys.stderr) + print("No benchmark publication files were changed.", file=sys.stderr) + return 1 + pairs.append((staged_svg, req.out_svg)) + + if args.update_readme: + readme_path = _resolve_under_root(root, args.readme) + staged_readme = stage_dir / "README.md" + shutil.copy2(readme_path, staged_readme) + marker_begin, marker_end = _readme_table_markers(args.metric, args.stat, args.sample) + try: + _update_readme_table(staged_readme, marker_begin, marker_end, _markdown_table(rows, args.stat)) + except (OSError, ValueError) as exc: + print(str(exc), file=sys.stderr) + print("No benchmark publication files were changed.", file=sys.stderr) + return 2 + pairs.append((staged_readme, readme_path)) + + try: + _replace_staged_files(pairs, stage_dir) + except (OSError, ValueError) as exc: + print(f"could not publish benchmark artifacts atomically: {exc}", file=sys.stderr) + return 2 + + if skipped: + print("Warning: some dimension groups were skipped:") + for item in skipped: + print(f" - {item}") + print(f"Wrote CSV: {req.csv_path}") + if not args.no_plot: + print(f"Wrote SVG: {req.out_svg}") + print(f"Wrote provenance: {final_provenance}") + if args.update_readme: + print(f"Updated README table: {_resolve_under_root(root, args.readme)}") + return 0 + + def _maybe_update_readme(root: Path, args: _ReadmeArgs, rows: list[Row]) -> int: if not args.update_readme: return 0 @@ -663,11 +1094,22 @@ def _maybe_render_plot(args: _RenderArgs, req: PlotRequest, skipped: list[str]) return 0 -def main(argv: list[str] | None = None) -> int: +def main(argv: list[str] | None = None) -> int: # noqa: C901, PLR0911, PLR0912, PLR0915 + """Generate benchmark CSV and optional SVG or README output.""" args = _parse_args(sys.argv[1:] if argv is None else argv) root = _repo_root() + rc = _validate_readme_target(root, args) + if rc != 0: + return rc + if args.update_readme: + try: + _run_publication_benchmarks(root) + except (FileNotFoundError, RuntimeError) as exc: + print(str(exc), file=sys.stderr) + return 2 + versions = _detect_versions(root) _print_versions(versions) @@ -677,7 +1119,10 @@ def main(argv: list[str] | None = None) -> int: criterion_dir = _resolve_under_root(root, args.criterion_dir) - dims = _discover_dims(criterion_dir) if criterion_dir.exists() else [] + discovered_dims = _discover_dims(criterion_dir) if criterion_dir.exists() else [] + dims = discovered_dims if args.allow_partial else list(CANONICAL_DIMS) + if not args.allow_partial and not discovered_dims: + dims = [] if not dims: print( f"No Criterion results found under {criterion_dir}.\n\nRun benchmarks first, e.g.:\n cargo bench --bench vs_linalg\n", @@ -700,13 +1145,43 @@ def main(argv: list[str] | None = None) -> int: print("Skipped groups:", *skipped, sep="\n - ", file=sys.stderr) return 2 - _write_csv(out_csv, rows) - - rc = _maybe_update_readme(root, args, rows) - if rc != 0: - return rc + if not args.allow_partial and skipped: + print( + "Canonical benchmark coverage is incomplete; no CSV, SVG, provenance, or README file was written.", + file=sys.stderr, + ) + print("Required dimensions: " + ", ".join(f"D={dim}" for dim in CANONICAL_DIMS), file=sys.stderr) + print("Coverage gaps:", *skipped, sep="\n - ", file=sys.stderr) + return 2 dims_present = [row.dim for row in rows] + provenance = _capture_provenance( + root, + args=args, + dims=dims_present, + measurement_recorded=args.update_readme, + ) + publication = provenance.get("publication") + if not isinstance(publication, dict): + msg = "publication provenance invariant violated" + raise TypeError(msg) + missing_harness_files = publication.get("missing_harness_files") + if not isinstance(missing_harness_files, list) or not all(isinstance(path, str) for path in missing_harness_files): + msg = "publication missing_harness_files invariant violated" + raise AssertionError(msg) + provenance_gaps = list(cast("list[str]", missing_harness_files)) + if publication.get("source_missing") is True: + provenance_gaps.append("src/") + provenance_gaps.extend(field for field in ("cargo_lock_sha256", "commit", "cpu", "os", "rustc") if publication.get(field) == "unavailable") + if publication.get("git_clean") is None: + provenance_gaps.append("git status") + if args.update_readme and provenance_gaps: + print( + "Publication provenance is incomplete; no CSV, SVG, provenance, or README file was written because required fields are unavailable: " + + ", ".join(provenance_gaps), + file=sys.stderr, + ) + return 2 title = f"{metric.title}: {args.stat} time vs dimension" req = PlotRequest( @@ -721,7 +1196,14 @@ def main(argv: list[str] | None = None) -> int: log_y=args.log_y, ) - return _maybe_render_plot(args, req, skipped) + return _stage_and_publish_outputs( + root=root, + args=args, + rows=rows, + req=req, + provenance=provenance, + skipped=skipped, + ) if __name__ == "__main__": diff --git a/scripts/postprocess_changelog.py b/scripts/postprocess_changelog.py index a511907..06f1327 100644 --- a/scripts/postprocess_changelog.py +++ b/scripts/postprocess_changelog.py @@ -25,7 +25,7 @@ import sys from pathlib import Path -# markdownlint MD013 line-length limit used by this project. +# rumdl MD013 line-length limit used by this project. MAX_LINE_WIDTH = 160 # Tokenise a line into atomic markdown units that must not be split. @@ -538,8 +538,8 @@ def _normalize_indented_heading(line: str) -> str: git-cliff indents commit bodies under each changelog entry. If a historical commit body contains an ATX heading such as ``## Correctness Fixes``, the - rendered changelog contains `` ## Correctness Fixes``. Markdownlint still - treats that as a heading, but MD023 requires headings to start at column 0. + rendered changelog contains `` ## Correctness Fixes``. rumdl still treats + that as a heading, but MD023 requires headings to start at column 0. Keeping the text as bold prose preserves readability without changing the generated changelog hierarchy. """ diff --git a/scripts/subprocess_utils.py b/scripts/subprocess_utils.py index 379d05d..074f18d 100644 --- a/scripts/subprocess_utils.py +++ b/scripts/subprocess_utils.py @@ -181,7 +181,7 @@ def check_git_repo() -> bool: """Return true when the current directory is inside a git repository.""" try: run_git_command(["rev-parse", "--git-dir"]) - except (ExecutableNotFoundError, subprocess.CalledProcessError): + except ExecutableNotFoundError, subprocess.CalledProcessError: return False else: return True @@ -191,7 +191,7 @@ def check_git_history() -> bool: """Return true when the current git repository has at least one commit.""" try: run_git_command(["log", "--oneline", "-n", "1"]) - except (ExecutableNotFoundError, subprocess.CalledProcessError): + except ExecutableNotFoundError, subprocess.CalledProcessError: return False else: return True diff --git a/scripts/tests/__init__.py b/scripts/tests/__init__.py index 8b13789..2f9b1e9 100644 --- a/scripts/tests/__init__.py +++ b/scripts/tests/__init__.py @@ -1 +1 @@ - +"""Tests for the repository's Python support scripts.""" diff --git a/scripts/tests/test_archive_performance.py b/scripts/tests/test_archive_performance.py index cc7665b..4666f81 100644 --- a/scripts/tests/test_archive_performance.py +++ b/scripts/tests/test_archive_performance.py @@ -3,6 +3,7 @@ from __future__ import annotations import io +import json import subprocess import tarfile from pathlib import Path @@ -32,6 +33,10 @@ def _result(stdout: str = "") -> SimpleNamespace: return SimpleNamespace(stdout=stdout) +def _git(repo_root: Path, *args: str) -> str: + return archive_performance.run_git_command(list(args), cwd=repo_root).stdout + + def _report(version: str, baseline: str) -> str: return ( "# Benchmark Performance\n\n" @@ -72,7 +77,7 @@ def _legacy_report(version: str, baseline: str) -> str: ) -def _write_baseline_archive(path: Path) -> None: +def _write_baseline_archive(path: Path, *, include_harness_metadata: bool = False) -> None: tag = path.name.removeprefix("la-stack-").removesuffix("-criterion-baseline.tar.gz") fixture_dir = path.parent / f"baseline-fixture-{tag}" criterion_dir = fixture_dir / "criterion" @@ -81,6 +86,18 @@ def _write_baseline_archive(path: Path) -> None: sample_dir = criterion_dir / "exact_d2" / "det_exact" / tag sample_dir.mkdir(parents=True) (sample_dir / "estimates.json").write_text('{"median":{"point_estimate":1.0}}\n', encoding="utf-8") + if include_harness_metadata: + (criterion_dir / archive_performance._BENCHMARK_HARNESS_METADATA).write_text( + json.dumps( + { + "baseline": tag, + "mode": "shared-current-harness", + "schema": 1, + "sha256": "a" * 64, + } + ), + encoding="utf-8", + ) with tarfile.open(path, "w:gz") as tar: tar.add(criterion_dir, arcname="criterion") @@ -95,6 +112,20 @@ def _write_unsafe_baseline_archive(path: Path) -> None: def _write_current_benchmark_tooling(worktree: Path) -> None: (worktree / "scripts").mkdir(parents=True, exist_ok=True) + (worktree / "benches" / "common").mkdir(parents=True, exist_ok=True) + (worktree / "src").mkdir(parents=True, exist_ok=True) + (worktree / "tests").mkdir(parents=True, exist_ok=True) + (worktree / "benches" / "vs_linalg.rs").write_text("fn main() {}\n", encoding="utf-8") + (worktree / "benches" / "common" / "inputs.rs").write_text("pub const INPUT: f64 = 1.0;\n", encoding="utf-8") + (worktree / "src" / "lib.rs").write_text("pub fn fixture() {}\n", encoding="utf-8") + (worktree / "tests" / "exact_bench_config.rs").write_text("// exact fixture\n", encoding="utf-8") + (worktree / "tests" / "vs_linalg_inputs.rs").write_text("// linalg fixture\n", encoding="utf-8") + (worktree / "Cargo.toml").write_text( + '[package]\nname = "fixture"\nversion = "0.1.0"\n[dev-dependencies]\ncriterion = "0.7.0"\n', + encoding="utf-8", + ) + (worktree / "Cargo.lock").write_text("version = 4\n", encoding="utf-8") + (worktree / "rust-toolchain.toml").write_text('[toolchain]\nchannel = "1.96.0"\n', encoding="utf-8") (worktree / "justfile").write_text( 'bench-save-baseline tag suite="all":\nbench-latest: bench-vs-linalg-la-stack bench-exact\n', encoding="utf-8", @@ -103,11 +134,161 @@ def _write_current_benchmark_tooling(worktree: Path) -> None: def _write_legacy_benchmark_tooling(worktree: Path) -> None: - (worktree / "scripts").mkdir(parents=True, exist_ok=True) + _write_current_benchmark_tooling(worktree) (worktree / "justfile").write_text("bench-exact:\n", encoding="utf-8") (worktree / "scripts" / "bench_compare.py").write_text('parser.add_argument("--output")\n', encoding="utf-8") +def test_shared_benchmark_harness_replaces_baseline_content_and_has_stable_digest(tmp_path: Path) -> None: + current = tmp_path / "current" + baseline = tmp_path / "baseline" + current.mkdir() + baseline.mkdir() + _write_current_benchmark_tooling(current) + _write_current_benchmark_tooling(baseline) + (baseline / "benches" / "vs_linalg.rs").write_text("fn obsolete() {}\n", encoding="utf-8") + (baseline / "Cargo.lock").write_text("version = 3\n", encoding="utf-8") + (baseline / "justfile").write_text("obsolete-benchmark-recipe:\n", encoding="utf-8") + + digest = archive_performance._install_shared_benchmark_harness( + source=current, + destination=baseline, + ) + + assert digest == archive_performance._benchmark_harness_digest(current) + assert digest == archive_performance._benchmark_harness_digest(baseline) + assert (baseline / "benches" / "vs_linalg.rs").read_text(encoding="utf-8") == "fn main() {}\n" + assert (baseline / "justfile").read_text(encoding="utf-8") == (current / "justfile").read_text(encoding="utf-8") + + +def test_github_release_assets_discard_embedded_shared_harness_metadata(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + for tag in ("v0.4.2", "v0.4.3"): + _write_baseline_archive( + tmp_path / f"la-stack-{tag}-criterion-baseline.tar.gz", + include_harness_metadata=True, + ) + + def fake_download(*, baseline_tag: str, download_dir: Path, repo_root: Path) -> Path: + del download_dir, repo_root + return tmp_path / f"la-stack-{baseline_tag}-criterion-baseline.tar.gz" + + target_worktree = tmp_path / "worktree" + target_worktree.mkdir() + monkeypatch.setattr(archive_performance, "_download_release_baseline", fake_download) + + archive_performance._prepare_github_release_assets( + current_tag="v0.4.3", + baseline_tag="v0.4.2", + repo_root=tmp_path, + target_worktree=target_worktree, + tmp_dir=tmp_path, + ) + + criterion_dir = target_worktree / "target" / "criterion" + assert not (criterion_dir / archive_performance._BENCHMARK_HARNESS_METADATA).exists() + assert (criterion_dir / "exact_d2" / "det_exact" / "new" / "estimates.json").is_file() + + +def test_purge_selected_new_samples_preserves_named_baselines_and_other_suites(tmp_path: Path) -> None: + criterion_dir = tmp_path / "criterion" + exact_new = criterion_dir / "exact_d2" / "det_exact" / "new" + exact_baseline = criterion_dir / "exact_d2" / "det_exact" / "v0.4.2" + linalg_new = criterion_dir / "d2" / "la_stack_lu" / "new" + for directory in (exact_new, exact_baseline, linalg_new): + directory.mkdir(parents=True) + (directory / "estimates.json").write_text("{}\n", encoding="utf-8") + + removed = archive_performance._purge_criterion_new_samples( + criterion_dir=criterion_dir, + suite="exact", + ) + + assert removed == [exact_new] + assert not exact_new.exists() + assert exact_baseline.is_dir() + assert linalg_new.is_dir() + + +def test_apply_current_diff_includes_complete_current_tree_without_mutating_index(tmp_path: Path) -> None: + repo_root = tmp_path / "repo" + worktree = tmp_path / "worktree" + repo_root.mkdir() + _git(repo_root, "init", "--quiet") + _git(repo_root, "config", "user.name", "Test User") + _git(repo_root, "config", "user.email", "test@example.com") + _git(repo_root, "config", "commit.gpgsign", "false") + + (repo_root / ".gitignore").write_text("ignored.bin\n", encoding="utf-8") + tracked = repo_root / "tracked.txt" + tracked.write_text("committed\n", encoding="utf-8") + _git(repo_root, "add", "--", ".gitignore", "tracked.txt") + _git(repo_root, "commit", "--quiet", "-m", "initial") + _git(repo_root, "worktree", "add", "--quiet", "--detach", str(worktree), "HEAD") + + tracked.write_text("staged\n", encoding="utf-8") + _git(repo_root, "add", "--", "tracked.txt") + tracked.write_text("working tree\n", encoding="utf-8") + binary_payload = bytes(range(256)) * 2 + binary = repo_root / "--untracked.bin" + binary.write_bytes(binary_payload) + link = repo_root / "untracked-link" + try: + link.symlink_to(binary.name) + except OSError as exc: + pytest.skip(f"symlinks unavailable: {exc}") + (repo_root / "ignored.bin").write_bytes(b"ignored\x00payload") + + status_before = _git(repo_root, "status", "--porcelain=v1", "--untracked-files=all") + archive_performance._apply_current_diff_to_worktree(repo_root=repo_root, worktree=worktree) + + assert (worktree / "tracked.txt").read_text(encoding="utf-8") == "working tree\n" + assert (worktree / binary.name).read_bytes() == binary_payload + applied_link = worktree / link.name + assert applied_link.is_symlink() + assert applied_link.readlink() == Path(binary.name) + assert not (worktree / "ignored.bin").exists() + assert _git(repo_root, "show", ":tracked.txt") == "staged\n" + assert _git(repo_root, "status", "--porcelain=v1", "--untracked-files=all") == status_before + + +def test_apply_current_diff_fails_loudly_and_cleans_temporary_index( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + repo_root = tmp_path / "repo" + worktree = tmp_path / "worktree" + repo_root.mkdir() + worktree.mkdir() + temporary_index: Path | None = None + + def fake_run_git(args: Sequence[str], cwd: Path | None = None, **kwargs: Any) -> SimpleNamespace: + nonlocal temporary_index + assert cwd == repo_root + env = kwargs["env"] + temporary_index = Path(env["GIT_INDEX_FILE"]) + assert temporary_index.parent.is_dir() + if args == ["add", "--all", "--", "."]: + raise subprocess.CalledProcessError( + 128, + ["git", *args], + output="snapshot stdout", + stderr="cannot snapshot current tree", + ) + return _result() + + monkeypatch.setattr(archive_performance, "run_git_command", fake_run_git) + + with pytest.raises(RuntimeError) as exc_info: + archive_performance._apply_current_diff_to_worktree(repo_root=repo_root, worktree=worktree) + + error = str(exc_info.value) + assert "command failed (128): git add --all -- ." in error + assert "snapshot stdout" in error + assert "cannot snapshot current tree" in error + assert temporary_index is not None + assert not temporary_index.parent.exists() + + def test_normalize_tag_adds_leading_v() -> None: assert normalize_tag("0.4.2") == "v0.4.2" assert normalize_tag("v0.4.2") == "v0.4.2" @@ -263,6 +444,25 @@ def test_benchmark_env_respects_existing_toolchain_override(tmp_path: Path, monk assert archive_performance._benchmark_env(tmp_path) is None +@pytest.mark.parametrize("suite", ["exact", "vs_linalg"]) +def test_fallback_baseline_cargo_commands_enforce_lockfile( + tmp_path: Path, + suite: str, +) -> None: + worktree = tmp_path / "legacy" + worktree.mkdir() + _write_legacy_benchmark_tooling(worktree) + + command, args = archive_performance._baseline_tool_args( + baseline_tag="v0.4.2", + suite=suite, + baseline_worktree=worktree, + ) + + assert command == "cargo" + assert args[:2] == ["bench", "--locked"] + + def test_promote_report_archives_previous_and_updates_sorted_index(tmp_path: Path) -> None: source = tmp_path / "target" / "bench-reports" / "performance.md" current = tmp_path / "docs" / "PERFORMANCE.md" @@ -383,7 +583,11 @@ def test_promote_report_rewrites_legacy_update_instructions(tmp_path: Path) -> N assert "git checkout" not in archived_text -def test_main_promotes_generated_report_to_docs_performance(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: +def test_main_promotes_generated_report_to_docs_performance( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: source = tmp_path / "target" / "bench-reports" / "performance.md" current = tmp_path / "docs" / "PERFORMANCE.md" archive_dir = tmp_path / "docs" / "archive" / "performance" @@ -393,6 +597,13 @@ def test_main_promotes_generated_report_to_docs_performance(tmp_path: Path, caps source.write_text(generated, encoding="utf-8") current.parent.mkdir(parents=True) current.write_text(_report("0.4.2", "v0.4.1"), encoding="utf-8") + gates: list[Path] = [] + monkeypatch.chdir(tmp_path) + monkeypatch.setattr( + archive_performance, + "_run_benchmark_input_gate", + lambda checkout, **_kwargs: gates.append(checkout), + ) rc = main( [ @@ -410,15 +621,21 @@ def test_main_promotes_generated_report_to_docs_performance(tmp_path: Path, caps assert rc == 0 assert current.read_text(encoding="utf-8") == archive_performance._normalize_how_to_update(generated) assert (archive_dir / "v0.4.2-vs-v0.4.1.md").exists() + assert gates == [tmp_path] assert "Current performance report: v0.4.3 vs v0.4.2" in capsys.readouterr().out -def test_main_reports_release_pair_mismatch_to_stderr(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: +def test_main_reports_release_pair_mismatch_to_stderr( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: source = tmp_path / "target" / "bench-reports" / "performance.md" current = tmp_path / "docs" / "PERFORMANCE.md" archive_dir = tmp_path / "docs" / "archive" / "performance" source.parent.mkdir(parents=True) source.write_text(_report("0.4.3", "v0.4.2"), encoding="utf-8") + monkeypatch.setattr(archive_performance, "_run_benchmark_input_gate", lambda *_args, **_kwargs: None) rc = main( [ @@ -512,7 +729,7 @@ def fake_run_safe(command: str, args: Sequence[str], cwd: Path | None = None, ** assert any(kind == "git" and args[:3] == ("worktree", "add", "--detach") and args[4] == "v0.4.3" for kind, args, _ in calls) assert any(kind == "just" and args == ("bench-exact",) for kind, args, _ in calls) assert any(kind == "uv" and "--suite" in args and args[args.index("--suite") + 1] == "exact" for kind, args, _ in calls) - assert not any(kind == "git" and args == ("diff", "--binary", "HEAD") for kind, args, _ in calls) + assert not any(kind == "git" and args[:1] == ("read-tree",) for kind, args, _ in calls) assert not any(kind == "git-stdin" for kind, _, _ in calls) @@ -632,7 +849,11 @@ def fake_run_safe(command: str, args: Sequence[str], cwd: Path | None = None, ** assert any(kind == "git" and args[:3] == ("worktree", "remove", "--force") for kind, args, _ in calls) -def test_generate_report_generates_release_baseline_locally(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: +def test_generate_report_generates_release_baseline_locally( # noqa: PLR0915 + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: monkeypatch.delenv("RUSTUP_TOOLCHAIN", raising=False) (tmp_path / "rust-toolchain.toml").write_text('[toolchain]\nchannel = "1.96.0"\n', encoding="utf-8") current = tmp_path / "docs" / "PERFORMANCE.md" @@ -659,12 +880,31 @@ def fake_run_safe(command: str, args: Sequence[str], cwd: Path | None = None, ** if command == "just" and args == ["bench-save-baseline", "v0.4.2"]: assert kwargs["env"]["RUSTUP_TOOLCHAIN"] == "1.96.0" assert cwd is not None + assert "bench-latest" in (cwd / "justfile").read_text(encoding="utf-8") criterion_dir = cwd / "target" / "criterion" criterion_dir.mkdir(parents=True) (criterion_dir / "baseline.txt").write_text("baseline\n", encoding="utf-8") + for sample in ("new", "v0.4.2"): + estimates = criterion_dir / "exact_d2" / "det_exact" / sample / "estimates.json" + estimates.parent.mkdir(parents=True, exist_ok=True) + estimates.write_text("{}\n", encoding="utf-8") if command == "just" and args == ["bench-latest"]: assert kwargs["env"]["RUSTUP_TOOLCHAIN"] == "1.96.0" + assert cwd is not None + criterion_dir = cwd / "target" / "criterion" / "exact_d2" / "det_exact" + assert not (criterion_dir / "new").exists() + assert (criterion_dir / "v0.4.2" / "estimates.json").is_file() if command == "uv": + assert cwd is not None + metadata = json.loads((cwd / "target" / "criterion" / archive_performance._BENCHMARK_HARNESS_METADATA).read_text(encoding="utf-8")) + assert metadata["baseline"] == "v0.4.2" + assert metadata["mode"] == "shared-current-harness" + assert metadata["schema"] == 2 + assert metadata["measurement"]["harness_sha256"] == archive_performance._benchmark_harness_digest(cwd) + assert metadata["measurement"]["current_source_state_sha256"] == archive_performance._source_state_digest(cwd) + assert metadata["criterion"]["criterion_version"] == "manifest requirement 0.7.0" + assert metadata["validation"]["baseline_revision"] == "passed" + assert metadata["validation"]["current_revision"] == "passed" output = Path(args[args.index("--output") + 1]) output.write_text(_report("0.4.3", "v0.4.2"), encoding="utf-8") return _result() @@ -697,10 +937,24 @@ def fake_run_safe(command: str, args: Sequence[str], cwd: Path | None = None, ** assert any(kind == "just" and args == ("bench-save-baseline", "v0.4.2") for kind, args, _ in calls) assert any(kind == "just" and args == ("bench-latest",) for kind, args, _ in calls) assert any(kind == "uv" and "--suite" in args for kind, args, _ in calls) + baseline_timing_index = next(index for index, (kind, args, _) in enumerate(calls) if kind == "just" and args == ("bench-save-baseline", "v0.4.2")) + current_timing_index = next(index for index, (kind, args, _) in enumerate(calls) if kind == "just" and args == ("bench-latest",)) + baseline_gate_index = next( + index + for index, (kind, args, cwd) in enumerate(calls) + if kind == "just" and args == ("test-bench-inputs",) and cwd is not None and cwd.name == "baseline-worktree" + ) + current_gate_index = next( + index + for index, (kind, args, cwd) in enumerate(calls) + if kind == "just" and args == ("test-bench-inputs",) and cwd is not None and cwd.name == "worktree" + ) + assert baseline_gate_index < baseline_timing_index + assert current_gate_index < current_timing_index assert sum(1 for kind, args, _ in calls if kind == "git" and args[:3] == ("worktree", "remove", "--force")) == 2 -def test_generate_report_vs_linalg_suite_skips_exact_current_benches(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: +def test_generate_report_vs_linalg_suite_uses_copied_current_baseline_recipe(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("RUSTUP_TOOLCHAIN", raising=False) (tmp_path / "rust-toolchain.toml").write_text('[toolchain]\nchannel = "1.96.0"\n', encoding="utf-8") output = tmp_path / "target" / "bench-reports" / "performance.md" @@ -723,9 +977,10 @@ def fake_run_git_with_input(args: Sequence[str], input_data: str, cwd: Path | No def fake_run_safe(command: str, args: Sequence[str], cwd: Path | None = None, **kwargs: Any) -> SimpleNamespace: calls.append((command, tuple(args), cwd)) - if command == "cargo" and args == ["bench", "--features", "bench", "--bench", "vs_linalg", "--", "--save-baseline", "v0.4.2"]: + if command == "just" and args == ["bench-save-baseline", "v0.4.2", "vs_linalg"]: assert kwargs["env"]["RUSTUP_TOOLCHAIN"] == "1.96.0" assert cwd is not None + assert "bench-latest" in (cwd / "justfile").read_text(encoding="utf-8") criterion_dir = cwd / "target" / "criterion" criterion_dir.mkdir(parents=True) (criterion_dir / "baseline.txt").write_text("baseline\n", encoding="utf-8") @@ -754,11 +1009,9 @@ def fake_run_safe(command: str, args: Sequence[str], cwd: Path | None = None, ** assert report_id.archive_name == "v0.4.3-vs-v0.4.2.md" assert output.read_text(encoding="utf-8") == _normalized_report("0.4.3", "v0.4.2") - assert any( - kind == "cargo" and args == ("bench", "--features", "bench", "--bench", "vs_linalg", "--", "--save-baseline", "v0.4.2") for kind, args, _ in calls - ) + assert any(kind == "just" and args == ("bench-save-baseline", "v0.4.2", "vs_linalg") for kind, args, _ in calls) assert any(kind == "just" and args == ("bench-vs-linalg-la-stack",) for kind, args, _ in calls) - assert not any(kind == "just" and args == ("bench-save-baseline", "v0.4.2", "vs_linalg") for kind, args, _ in calls) + assert not any(kind == "cargo" for kind, _, _ in calls) assert not any(kind == "just" and args == ("bench-exact",) for kind, args, _ in calls) assert not any(kind == "just" and args == ("bench-latest",) for kind, args, _ in calls) @@ -1061,7 +1314,7 @@ def fake_run_git(args: Sequence[str], cwd: Path | None = None, **kwargs: Any) -> worktree = Path(args[3]) worktree.mkdir(parents=True) _write_current_benchmark_tooling(worktree) - if args == ["diff", "--binary", "HEAD"]: + if args == ["diff", "--cached", "--binary", "HEAD"]: return _result("diff --git a/README.md b/README.md\n") return _result() @@ -1158,9 +1411,10 @@ def fake_run_safe(command: str, args: Sequence[str], cwd: Path | None = None, ** assert report_id.archive_name == "v0.4.2-vs-v0.4.1.md" assert current.read_text(encoding="utf-8") == _normalized_report("0.4.2", "v0.4.1") assert any(kind == "git" and args[:3] == ("worktree", "add", "--detach") and args[4] == "v0.4.2" for kind, args, _ in calls) - assert any(kind == "just" and args == ("bench-exact",) for kind, args, _ in calls) + assert any(kind == "cargo" and args[:2] == ("bench", "--locked") and "exact" in args for kind, args, _ in calls) + assert not any(kind == "just" and args == ("bench-exact",) for kind, args, _ in calls) assert not any(kind == "just" and args == ("bench-latest",) for kind, args, _ in calls) assert not any(kind == "uv" and "--suite" in args for kind, args, _ in calls) assert not any(kind == "uv" and "--scope" in args for kind, args, _ in calls) - assert not any(kind == "git" and args == ("diff", "--binary", "HEAD") for kind, args, _ in calls) + assert not any(kind == "git" and args[:1] == ("read-tree",) for kind, args, _ in calls) assert not any(kind == "git-stdin" for kind, _, _ in calls) diff --git a/scripts/tests/test_bench_compare.py b/scripts/tests/test_bench_compare.py index 1d78e98..b3775e5 100644 --- a/scripts/tests/test_bench_compare.py +++ b/scripts/tests/test_bench_compare.py @@ -1,8 +1,10 @@ +"""Tests for exact-arithmetic benchmark comparison reports.""" + from __future__ import annotations import json import re -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast import pytest @@ -12,15 +14,24 @@ from pathlib import Path -def _write_estimates(path: Path, stat: str, median: float) -> None: +def _write_estimates( + path: Path, + stat: str, + median: float, + *, + lower: float | None = None, + upper: float | None = None, +) -> None: """Write a minimal Criterion estimates.json.""" + lower = median * 0.9 if lower is None else lower + upper = median * 1.1 if upper is None else upper path.parent.mkdir(parents=True, exist_ok=True) path.write_text( json.dumps( { stat: { "point_estimate": median, - "confidence_interval": {"lower_bound": median * 0.9, "upper_bound": median * 1.1}, + "confidence_interval": {"lower_bound": lower, "upper_bound": upper}, } } ), @@ -28,6 +39,82 @@ def _write_estimates(path: Path, stat: str, median: float) -> None: ) +def _write_harness_provenance( + criterion_dir: Path, + *, + sha256: str = "a" * 64, + baseline: str = "v0.4.3", +) -> None: + """Write valid shared-harness provenance metadata.""" + criterion_dir.mkdir(parents=True, exist_ok=True) + (criterion_dir / ".la-stack-benchmark-harness.json").write_text( + json.dumps( + { + "schema": 1, + "mode": "shared-current-harness", + "sha256": sha256, + "baseline": baseline, + } + ), + encoding="utf-8", + ) + + +def _schema2_provenance_data() -> dict[str, object]: + environment: dict[str, object] = { + "cargo_lock_sha256": "b" * 64, + "commit": "current-commit", + "correctness_gate": "passed", + "cpu": "test-cpu", + "git_clean": False, + "harness_sha256": "a" * 64, + "os": "TestOS 1 x86_64", + "rustc": "rustc 1.88.0", + "source_state_sha256": "c" * 64, + } + return { + "baseline": "v0.4.3", + "criterion": { + "baseline_command": ["just", "bench-save-baseline", "v0.4.3"], + "criterion_version": "0.7.0", + "current_command": ["just", "bench-latest"], + "sample": "new", + "scope": "release-signal", + "statistic": "median", + "suite": "all", + }, + "measurement": { + "baseline_commit": "baseline-commit", + "baseline_git_clean": False, + "baseline_source_state_sha256": "d" * 64, + "cargo_lock_sha256": "b" * 64, + "cpu": "test-cpu", + "current_commit": "current-commit", + "current_git_clean": False, + "current_source_state_sha256": "c" * 64, + "harness_sha256": "a" * 64, + "os": "TestOS 1 x86_64", + "rustc": "rustc 1.88.0", + "status": "recorded", + }, + "mode": "shared-current-harness", + "publication": environment, + "schema": 2, + "validation": { + "baseline_commit": "baseline-commit", + "baseline_git_clean": False, + "baseline_revision": "passed", + "baseline_source_state_sha256": "d" * 64, + "command": ["just", "test-bench-inputs"], + "current_commit": "current-commit", + "current_git_clean": False, + "current_revision": "passed", + "current_source_state_sha256": "c" * 64, + "harness": "shared-current", + }, + } + + def _build_criterion_tree(criterion_dir: Path, stat: str = "median") -> None: """Create a fake Criterion directory with exact benchmark results.""" for d, det, det_exact in [(2, 1.0, 4000.0), (3, 5.0, 21000.0)]: @@ -44,10 +131,10 @@ def _build_criterion_tree(criterion_dir: Path, stat: str = "median") -> None: _write_estimates(ns_group / "solve_exact_f64_result" / "new" / "estimates.json", stat, 51000.0) _write_estimates(ns_group / "solve_exact_rounded_f64" / "new" / "estimates.json", stat, 52000.0) - random_group = criterion_dir / "exact_random_percentile_d3" - _write_estimates(random_group / "det_exact_p95" / "new" / "estimates.json", stat, 33000.0) - _write_estimates(random_group / "solve_exact_f64_result_p95" / "new" / "estimates.json", stat, 54000.0) - _write_estimates(random_group / "solve_exact_rounded_f64_p95" / "new" / "estimates.json", stat, 55000.0) + random_group = criterion_dir / "exact_random_corpus_d3" + _write_estimates(random_group / "det_exact" / "new" / "estimates.json", stat, 33000.0) + _write_estimates(random_group / "solve_exact_f64_result" / "new" / "estimates.json", stat, 54000.0) + _write_estimates(random_group / "solve_exact_rounded_f64" / "new" / "estimates.json", stat, 55000.0) def _build_vs_linalg_tree(criterion_dir: Path, stat: str = "median") -> None: @@ -86,14 +173,14 @@ def test_milliseconds(self) -> None: class TestFormatPct: - def test_improvement_is_bold(self) -> None: - assert bench_compare._format_pct(-10.0) == "**-10.0%**" + def test_ci_separated_point_change_is_not_bold(self) -> None: + assert bench_compare._format_pct(-10.0, "improvement") == "-10.0%" - def test_regression_not_bold(self) -> None: - assert bench_compare._format_pct(10.0) == "+10.0%" + def test_inconclusive_point_improvement_is_not_bold(self) -> None: + assert bench_compare._format_pct(-10.0, "inconclusive") == "-10.0%" def test_small_change(self) -> None: - assert bench_compare._format_pct(0.5) == "+0.5%" + assert bench_compare._format_pct(0.5, "inconclusive") == "+0.5%" # --------------------------------------------------------------------------- @@ -108,8 +195,8 @@ def test_dimension_group(self) -> None: def test_near_singular(self) -> None: assert bench_compare._group_heading("exact_near_singular_3x3") == "Near-singular 3x3" - def test_random_percentile(self) -> None: - assert bench_compare._group_heading("exact_random_percentile_d4") == "Random percentile D=4" + def test_random_corpus(self) -> None: + assert bench_compare._group_heading("exact_random_corpus_d4") == "Random corpus D=4" def test_large_entries(self) -> None: assert bench_compare._group_heading("exact_large_entries_3x3") == "Large entries 3x3" @@ -124,6 +211,12 @@ def test_unknown_passthrough(self) -> None: assert bench_compare._group_heading("something_else") == "something_else" +def test_exact_registry_only_tracks_supported_direct_determinants() -> None: + for dimension in (2, 3, 4): + assert "det_direct" in bench_compare.EXACT_GROUPS[f"exact_d{dimension}"] + assert "det_direct" not in bench_compare.EXACT_GROUPS["exact_d5"] + + # --------------------------------------------------------------------------- # read_estimate # --------------------------------------------------------------------------- @@ -132,24 +225,24 @@ def test_unknown_passthrough(self) -> None: def test_read_estimate_success(tmp_path: Path) -> None: est = tmp_path / "estimates.json" _write_estimates(est, "median", 42.0) - point, lo, hi = bench_compare._read_estimate(est, "median") - assert point == 42.0 - assert lo == pytest.approx(42.0 * 0.9) - assert hi == pytest.approx(42.0 * 1.1) + estimate = bench_compare._read_estimate(est, "median") + assert estimate.point_ns == 42.0 + assert estimate.ci_lo_ns == pytest.approx(42.0 * 0.9) + assert estimate.ci_hi_ns == pytest.approx(42.0 * 1.1) def test_read_estimate_no_ci(tmp_path: Path) -> None: - """When confidence_interval is missing, all three values equal the point estimate.""" + """Missing confidence bounds remain unavailable rather than implying certainty.""" est = tmp_path / "estimates.json" est.parent.mkdir(parents=True, exist_ok=True) est.write_text( json.dumps({"median": {"point_estimate": 99.0}}), encoding="utf-8", ) - point, lo, hi = bench_compare._read_estimate(est, "median") - assert point == 99.0 - assert lo == 99.0 - assert hi == 99.0 + estimate = bench_compare._read_estimate(est, "median") + assert estimate.point_ns == 99.0 + assert estimate.ci_lo_ns is None + assert estimate.ci_hi_ns is None def test_read_estimate_missing_stat(tmp_path: Path) -> None: @@ -196,6 +289,41 @@ def test_read_estimate_non_numeric_ci_bound_names_field(tmp_path: Path) -> None: bench_compare._read_estimate(est, "median") +def test_read_estimate_rejects_partial_confidence_interval(tmp_path: Path) -> None: + est = tmp_path / "estimates.json" + est.write_text( + json.dumps( + { + "median": { + "point_estimate": 1.0, + "confidence_interval": {"lower_bound": 0.9}, + } + } + ), + encoding="utf-8", + ) + + with pytest.raises(KeyError, match="field 'upper_bound'"): + bench_compare._read_estimate(est, "median") + + +def test_read_estimate_rejects_reversed_confidence_interval(tmp_path: Path) -> None: + est = tmp_path / "estimates.json" + _write_estimates(est, "median", 10.0, lower=12.0, upper=11.0) + + with pytest.raises(ValueError, match=r"lower_bound 12\.0 exceeds upper_bound 11\.0"): + bench_compare._read_estimate(est, "median") + + +@pytest.mark.parametrize("point", [float("nan"), float("inf"), -1.0]) +def test_read_estimate_rejects_invalid_timing(tmp_path: Path, point: float) -> None: + est = tmp_path / "estimates.json" + est.write_text(json.dumps({"median": {"point_estimate": point}}), encoding="utf-8") + + with pytest.raises(ValueError, match="must be finite and non-negative"): + bench_compare._read_estimate(est, "median") + + # --------------------------------------------------------------------------- # collect_results / collect_comparisons # --------------------------------------------------------------------------- @@ -204,11 +332,11 @@ def test_read_estimate_non_numeric_ci_bound_names_field(tmp_path: Path) -> None: def test_collect_results(tmp_path: Path) -> None: _build_criterion_tree(tmp_path) results = bench_compare._collect_results(tmp_path, "new", "median") - assert len(results) == 18 # 6 benches x 2 dims + 3 near-singular + 3 random percentile + assert len(results) == 18 # 6 benches x 2 dims + 3 near-singular + 3 random corpus groups = {r.group for r in results} assert "exact_d2" in groups assert "exact_d3" in groups - assert "exact_random_percentile_d3" in groups + assert "exact_random_corpus_d3" in groups assert "exact_near_singular_3x3" in groups @@ -226,13 +354,18 @@ def test_collect_comparisons(tmp_path: Path) -> None: _write_estimates(group / "det_exact" / "v0.3.0" / "estimates.json", "median", det_exact) _write_estimates(group / "det_exact_f64" / "v0.3.0" / "estimates.json", "median", det_exact * 1.1) _write_estimates(group / "solve_exact_f64" / "v0.3.0" / "estimates.json", "median", det_exact * 1.3) - random_group = tmp_path / "exact_random_percentile_d3" - _write_estimates(random_group / "det_exact_p95" / "v0.3.0" / "estimates.json", "median", 66000.0) - _write_estimates(random_group / "solve_exact_f64_p95" / "v0.3.0" / "estimates.json", "median", 108000.0) - - comparisons = bench_compare._collect_comparisons(tmp_path, "v0.3.0", "median") - assert len(comparisons) == 12 # 6 benches x 2 dims (near-singular has no baseline) - assert {c.group for c in comparisons} == {"exact_d2", "exact_d3"} + random_group = tmp_path / "exact_random_corpus_d3" + _write_estimates(random_group / "det_exact" / "v0.3.0" / "estimates.json", "median", 66000.0) + _write_estimates(random_group / "solve_exact_f64" / "v0.3.0" / "estimates.json", "median", 108000.0) + + collection = bench_compare._collect_comparisons(tmp_path, "v0.3.0", "median") + comparisons = collection.comparisons + assert len(comparisons) == 15 # fixed dimensions plus the stable random corpus + assert {c.group for c in comparisons} == { + "exact_d2", + "exact_d3", + "exact_random_corpus_d3", + } for c in comparisons: assert c.speedup == pytest.approx(c.baseline_ns / c.current_ns) assert {(c.bench, c.baseline_bench) for c in comparisons if c.baseline_bench is not None} == { @@ -242,18 +375,19 @@ def test_collect_comparisons(tmp_path: Path) -> None: ("solve_exact_rounded_f64", "solve_exact_f64"), } - all_comparisons = bench_compare._collect_comparisons( + all_collection = bench_compare._collect_comparisons( tmp_path, "v0.3.0", "median", scope="all-benches", ) + all_comparisons = all_collection.comparisons assert len(all_comparisons) == 15 - random_comparisons = [c for c in all_comparisons if c.group == "exact_random_percentile_d3"] + random_comparisons = [c for c in all_comparisons if c.group == "exact_random_corpus_d3"] assert {(c.bench, c.baseline_bench) for c in random_comparisons} == { - ("det_exact_p95", None), - ("solve_exact_f64_result_p95", "solve_exact_f64_p95"), - ("solve_exact_rounded_f64_p95", "solve_exact_f64_p95"), + ("det_exact", None), + ("solve_exact_f64_result", "solve_exact_f64"), + ("solve_exact_rounded_f64", "solve_exact_f64"), } @@ -265,7 +399,7 @@ def test_collect_comparisons_zero_current(tmp_path: Path) -> None: # Baseline has a normal value. _write_estimates(group / "det" / "v0.3.0" / "estimates.json", "median", 5.0) - comparisons = bench_compare._collect_comparisons(tmp_path, "v0.3.0", "median") + comparisons = bench_compare._collect_comparisons(tmp_path, "v0.3.0", "median").comparisons assert len(comparisons) == 1 c = comparisons[0] assert c.speedup == float("inf") @@ -274,13 +408,106 @@ def test_collect_comparisons_zero_current(tmp_path: Path) -> None: def test_collect_comparisons_missing_baseline(tmp_path: Path) -> None: _build_criterion_tree(tmp_path) - comparisons = bench_compare._collect_comparisons(tmp_path, "v0.3.0", "median") - assert comparisons == [] + collection = bench_compare._collect_comparisons(tmp_path, "v0.3.0", "median", suite="exact") + assert collection.comparisons == [] + det_gap = next(gap for gap in collection.gaps if gap.group == "exact_d2" and gap.bench == "det") + assert not det_gap.missing_current + assert det_gap.missing_baseline + + +def test_collect_comparisons_records_each_missing_side_in_registry_order(tmp_path: Path) -> None: + group = tmp_path / "exact_d2" + _write_estimates(group / "det" / "new" / "estimates.json", "median", 10.0) + _write_estimates(group / "det_direct" / "last" / "estimates.json", "median", 20.0) + + collection = bench_compare._collect_comparisons(tmp_path, "last", "median", suite="exact") + first_three = collection.gaps[:3] + assert [gap.bench for gap in first_three] == ["det", "det_direct", "det_exact"] + assert (first_three[0].missing_current, first_three[0].missing_baseline) == (False, True) + assert (first_three[1].missing_current, first_three[1].missing_baseline) == (True, False) + assert (first_three[2].missing_current, first_three[2].missing_baseline) == (True, True) + + +def test_collect_comparisons_reports_wholly_absent_selected_suite(tmp_path: Path) -> None: + group = tmp_path / "exact_d2" + _write_estimates(group / "det" / "new" / "estimates.json", "median", 10.0) + _write_estimates(group / "det" / "last" / "estimates.json", "median", 20.0) + + collection = bench_compare._collect_comparisons( + tmp_path, + "last", + "median", + suite="all", + scope="all-benches", + ) + + assert any(gap.suite == "vs_linalg" and gap.group == "(entire suite)" and gap.bench == "all selected rows" for gap in collection.gaps) + + +def test_release_signal_comparison_requires_every_canonical_vs_linalg_dimension(tmp_path: Path) -> None: + group = tmp_path / "d2" + _write_estimates(group / "la_stack_lu" / "new" / "estimates.json", "median", 10.0) + _write_estimates(group / "la_stack_lu" / "last" / "estimates.json", "median", 20.0) + + collection = bench_compare._collect_comparisons(tmp_path, "last", "median", suite="vs_linalg") + + missing_groups = {gap.group for gap in collection.gaps} + assert {"d3", "d4", "d5", "d8", "d16", "d32", "d64"} <= missing_groups + + +def test_collect_comparisons_classifies_from_criterion_intervals(tmp_path: Path) -> None: + group = tmp_path / "exact_d2" + fixtures = { + "det": ((100.0, 95.0, 105.0), (80.0, 75.0, 85.0), "improvement"), + "det_direct": ((100.0, 95.0, 105.0), (120.0, 115.0, 125.0), "regression"), + "det_exact": ((100.0, 90.0, 110.0), (95.0, 85.0, 105.0), "inconclusive"), + } + for bench, (baseline, current, _assessment) in fixtures.items(): + _write_estimates( + group / bench / "last" / "estimates.json", + "median", + baseline[0], + lower=baseline[1], + upper=baseline[2], + ) + _write_estimates( + group / bench / "new" / "estimates.json", + "median", + current[0], + lower=current[1], + upper=current[2], + ) + + no_ci_path = group / "det_exact_f64_result" / "new" / "estimates.json" + no_ci_path.parent.mkdir(parents=True, exist_ok=True) + no_ci_path.write_text(json.dumps({"median": {"point_estimate": 80.0}}), encoding="utf-8") + _write_estimates(group / "det_exact_f64_result" / "last" / "estimates.json", "median", 100.0) + + comparisons = bench_compare._collect_comparisons(tmp_path, "last", "median", suite="exact").comparisons + assessments = {comparison.bench: comparison.assessment for comparison in comparisons} + assert assessments == { + "det": "improvement", + "det_direct": "regression", + "det_exact": "inconclusive", + "det_exact_f64_result": "unknown", + } + + +def test_d8_release_signal_rows_are_explicit_and_report_missing_baselines(tmp_path: Path) -> None: + group = tmp_path / "d8" + for bench in bench_compare.VS_LINALG_D8_RELEASE_SIGNAL_BENCHES: + _write_estimates(group / bench / "new" / "estimates.json", "median", 10.0) + + collection = bench_compare._collect_comparisons(tmp_path, "last", "median", suite="vs_linalg") + special_gaps = [gap for gap in collection.gaps if gap.bench in bench_compare.VS_LINALG_D8_RELEASE_SIGNAL_BENCHES] + assert [gap.bench for gap in special_gaps] == bench_compare.VS_LINALG_D8_RELEASE_SIGNAL_BENCHES + assert all(not gap.missing_current and gap.missing_baseline for gap in special_gaps) def test_collect_vs_linalg_release_signal_uses_baseline_peer_context(tmp_path: Path) -> None: _build_vs_linalg_tree(tmp_path) - comparisons = bench_compare._collect_comparisons(tmp_path, "last", "median") + collection = bench_compare._collect_comparisons(tmp_path, "last", "median", suite="vs_linalg") + comparisons = collection.comparisons assert [c.bench for c in comparisons] == ["la_stack_lu_solve", "la_stack_ldlt_solve"] lu = comparisons[0] @@ -296,13 +523,19 @@ def test_collect_vs_linalg_release_signal_uses_baseline_peer_context(tmp_path: P def test_collect_vs_linalg_all_benches_includes_latest_peer_rows(tmp_path: Path) -> None: _build_vs_linalg_tree(tmp_path) - comparisons = bench_compare._collect_comparisons(tmp_path, "last", "median", scope="all-benches") + collection = bench_compare._collect_comparisons(tmp_path, "last", "median", suite="vs_linalg", scope="all-benches") + comparisons = collection.comparisons assert [c.bench for c in comparisons] == [ "la_stack_lu_solve", "nalgebra_lu_solve", "la_stack_ldlt_solve", ] + assert {(gap.bench, gap.missing_current, gap.missing_baseline) for gap in collection.gaps} == { + ("faer_lu_solve", True, False), + ("nalgebra_cholesky_solve", True, False), + ("faer_ldlt_solve", True, False), + } # --------------------------------------------------------------------------- @@ -316,16 +549,16 @@ def test_snapshot_tables_per_dimension(tmp_path: Path) -> None: tables = bench_compare._snapshot_tables(results, "median") assert "### D=2" in tables assert "### D=3" in tables - assert "### Random percentile D=3" in tables + assert "### Random corpus D=3" in tables assert "### Near-singular 3x3" in tables - assert "| Benchmark | Median | 95% CI |" in tables + assert "| Benchmark | Median | Criterion CI |" in tables def test_snapshot_tables_uses_stat_label(tmp_path: Path) -> None: _build_criterion_tree(tmp_path, stat="mean") results = bench_compare._collect_results(tmp_path, "new", "mean") tables = bench_compare._snapshot_tables(results, "mean") - assert "| Benchmark | Mean | 95% CI |" in tables + assert "| Benchmark | Mean | Criterion CI |" in tables assert "Median" not in tables @@ -338,23 +571,136 @@ def test_comparison_tables_per_dimension(tmp_path: Path) -> None: _write_estimates(group / "det_exact_f64" / "v0.3.0" / "estimates.json", "median", det_exact * 1.1) _write_estimates(group / "solve_exact_f64" / "v0.3.0" / "estimates.json", "median", det_exact * 1.3) - comparisons = bench_compare._collect_comparisons(tmp_path, "v0.3.0", "median") + comparisons = bench_compare._collect_comparisons(tmp_path, "v0.3.0", "median").comparisons tables = bench_compare._comparison_tables(comparisons, "v0.3.0") assert "### D=2" in tables assert "### D=3" in tables - assert "| Benchmark | v0.3.0 | Latest | Change | Speedup |" in tables + assert "| Benchmark | v0.3.0 (point + CI) | Latest (point + CI) | Point-estimate change | CI relation | Point-estimate ratio |" in tables assert "det_exact_rounded_f64 (vs det_exact_f64)" in tables assert "solve_exact_f64_result (vs solve_exact_f64)" in tables def test_comparison_tables_include_vs_linalg_peer_context(tmp_path: Path) -> None: _build_vs_linalg_tree(tmp_path) - comparisons = bench_compare._collect_comparisons(tmp_path, "last", "median") + comparisons = bench_compare._collect_comparisons(tmp_path, "last", "median", suite="vs_linalg").comparisons tables = bench_compare._comparison_tables(comparisons, "last") - assert "| Benchmark | last | Latest | Change | Speedup | last nalgebra | last faer |" in tables - assert "| la_stack_lu_solve | 20.0 ns | 10.0 ns | **-50.0%** | 2.00x | 30.0 ns | 40.0 ns |" in tables - assert "| la_stack_ldlt_solve | 24.0 ns | 12.0 ns | **-50.0%** | 2.00x | 36.0 ns | 48.0 ns |" in tables + assert ( + "| Benchmark | last (point + CI) | Latest (point + CI) | Point-estimate change | CI relation | " + "Point-estimate ratio | last nalgebra | last faer |" in tables + ) + assert ( + "| la_stack_lu_solve | 20.0 ns [18.0 ns, 22.0 ns] | 10.0 ns [9.0 ns, 11.0 ns] | -50.0% | " + "faster point estimate; marginal CIs separated | 2.00x | " + "30.0 ns [27.0 ns, 33.0 ns] | 40.0 ns [36.0 ns, 44.0 ns] |" in tables + ) + assert ( + "| la_stack_ldlt_solve | 24.0 ns [21.6 ns, 26.4 ns] | 12.0 ns [10.8 ns, 13.2 ns] | -50.0% | " + "faster point estimate; marginal CIs separated | 2.00x | " + "36.0 ns [32.4 ns, 39.6 ns] | 48.0 ns [43.2 ns, 52.8 ns] |" in tables + ) + + +def test_coverage_table_makes_missing_samples_explicit(tmp_path: Path) -> None: + group = tmp_path / "exact_d2" + _write_estimates(group / "det" / "new" / "estimates.json", "median", 10.0) + _write_estimates(group / "det_direct" / "last" / "estimates.json", "median", 20.0) + collection = bench_compare._collect_comparisons(tmp_path, "last", "median", suite="exact") + + table = bench_compare._coverage_table(collection.gaps, "last") + assert "## Incomplete Comparison Coverage" in table + assert "| Exact arithmetic | exact_d2 | det | baseline (`last`) |" in table + assert "| Exact arithmetic | exact_d2 | det_direct | current (`new`) |" in table + assert "| Exact arithmetic | exact_d2 | det_exact | current (`new`), baseline (`last`) |" in table + + +# --------------------------------------------------------------------------- +# Harness provenance +# --------------------------------------------------------------------------- + + +def test_read_harness_provenance_validates_shared_harness_metadata(tmp_path: Path) -> None: + _write_harness_provenance(tmp_path) + + provenance = bench_compare._read_harness_provenance(tmp_path, expected_baseline="v0.4.3") + + assert provenance == bench_compare.HarnessProvenance( + schema=1, + mode="shared-current-harness", + sha256="a" * 64, + baseline="v0.4.3", + ) + + +def test_read_harness_provenance_is_optional(tmp_path: Path) -> None: + assert bench_compare._read_harness_provenance(tmp_path, expected_baseline="v0.4.3") is None + + +def test_read_schema2_provenance_records_versions_dirty_source_and_both_gates(tmp_path: Path) -> None: + tmp_path.mkdir(parents=True, exist_ok=True) + (tmp_path / ".la-stack-benchmark-harness.json").write_text( + json.dumps(_schema2_provenance_data()), + encoding="utf-8", + ) + + provenance = bench_compare._read_harness_provenance(tmp_path, expected_baseline="v0.4.3") + + assert provenance is not None + assert provenance.schema == 2 + assert provenance.criterion is not None + assert provenance.criterion["criterion_version"] == "0.7.0" + markdown = bench_compare._provenance_markdown(provenance) + rendered = "\n".join(markdown) + assert "Criterion dependency version: `0.7.0`" in rendered + assert "Current Git clean: `false`" in rendered + assert "Validated baseline revision: `baseline-commit`" in rendered + + +def test_read_schema2_provenance_requires_criterion_version(tmp_path: Path) -> None: + data = _schema2_provenance_data() + criterion = data["criterion"] + assert isinstance(criterion, dict) + del cast("dict[str, object]", criterion)["criterion_version"] + (tmp_path / ".la-stack-benchmark-harness.json").write_text(json.dumps(data), encoding="utf-8") + + with pytest.raises(ValueError, match="criterion_version"): + bench_compare._read_harness_provenance(tmp_path, expected_baseline="v0.4.3") + + +def test_read_harness_provenance_rejects_different_requested_baseline(tmp_path: Path) -> None: + _write_harness_provenance(tmp_path, baseline="v0.4.3") + + with pytest.raises(ValueError, match="does not match requested Criterion baseline 'last'"): + bench_compare._read_harness_provenance(tmp_path, expected_baseline="last") + + +@pytest.mark.parametrize( + ("field", "value", "message"), + [ + ("schema", 3, "unsupported or missing schema"), + ("mode", "independent-harnesses", "unsupported or missing mode"), + ("sha256", "not-a-digest", "invalid or missing sha256"), + ("baseline", "", "invalid or missing baseline"), + ], +) +def test_read_harness_provenance_rejects_malformed_fields( + tmp_path: Path, + field: str, + value: object, + message: str, +) -> None: + data: dict[str, object] = { + "schema": 1, + "mode": "shared-current-harness", + "sha256": "a" * 64, + "baseline": "v0.4.3", + } + data[field] = value + tmp_path.mkdir(parents=True, exist_ok=True) + (tmp_path / ".la-stack-benchmark-harness.json").write_text(json.dumps(data), encoding="utf-8") + + with pytest.raises(ValueError, match=message): + bench_compare._read_harness_provenance(tmp_path, expected_baseline="v0.4.3") # --------------------------------------------------------------------------- @@ -367,13 +713,25 @@ def test_main_snapshot_writes_output(tmp_path: Path) -> None: _build_criterion_tree(criterion_dir) output = tmp_path / "PERFORMANCE.md" - rc = bench_compare.main(["--snapshot", "--criterion-dir", str(criterion_dir), "--output", str(output)]) + rc = bench_compare.main( + [ + "--snapshot", + "--suite", + "exact", + "--scope", + "all-benches", + "--criterion-dir", + str(criterion_dir), + "--output", + str(output), + ] + ) assert rc == 0 assert output.exists() text = output.read_text(encoding="utf-8") assert "### D=2" in text - assert "### Random percentile D=3" in text + assert "### Random corpus D=3" in text assert "### Near-singular 3x3" in text assert "just performance-local" in text assert "just performance-release" in text @@ -396,3 +754,66 @@ def test_main_comparison_no_baseline(tmp_path: Path, capsys: pytest.CaptureFixtu rc = bench_compare.main(["v0.3.0", "--criterion-dir", str(criterion_dir), "--output", str(tmp_path / "out.md")]) assert rc == 2 assert "No comparison data" in capsys.readouterr().err + + +def test_main_comparison_refuses_incomplete_coverage_before_writing(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + criterion_dir = tmp_path / "criterion" + group = criterion_dir / "exact_d2" + _write_estimates(group / "det" / "new" / "estimates.json", "median", 10.0) + _write_estimates(group / "det" / "v0.4.3" / "estimates.json", "median", 20.0) + _write_harness_provenance(criterion_dir) + output = tmp_path / "report.md" + + rc = bench_compare.main(["v0.4.3", "--suite", "exact", "--criterion-dir", str(criterion_dir), "--output", str(output)]) + + assert rc == 2 + assert not output.exists() + error = capsys.readouterr().err + assert "Incomplete benchmark coverage" in error + assert "## Incomplete Comparison Coverage" in error + + +def test_generate_markdown_labels_absent_provenance_unavailable(tmp_path: Path) -> None: + report = bench_compare._generate_markdown( + tmp_path, + "tables", + bench_compare.ReportSettings( + baseline_name="last", + stat="median", + suite="exact", + scope="release-signal", + ), + ) + + assert "**Reproducibility provenance**: unavailable" in report + assert "CPU, OS, rustc, commit, dependency lock" in report + assert "performance-improvement claim" in report + + +def test_main_rejects_malformed_harness_provenance(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + criterion_dir = tmp_path / "criterion" + criterion_dir.mkdir() + (criterion_dir / ".la-stack-benchmark-harness.json").write_text("{not json", encoding="utf-8") + + rc = bench_compare.main(["last", "--criterion-dir", str(criterion_dir), "--output", str(tmp_path / "report.md")]) + + assert rc == 2 + assert "Invalid benchmark harness provenance" in capsys.readouterr().err + + +def test_main_rejects_harness_provenance_for_a_different_baseline( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + criterion_dir = tmp_path / "criterion" + group = criterion_dir / "exact_d2" + _write_estimates(group / "det" / "new" / "estimates.json", "median", 10.0) + _write_estimates(group / "det" / "last" / "estimates.json", "median", 20.0) + _write_harness_provenance(criterion_dir, baseline="v0.4.3") + output = tmp_path / "report.md" + + rc = bench_compare.main(["last", "--suite", "exact", "--criterion-dir", str(criterion_dir), "--output", str(output)]) + + assert rc == 2 + assert "does not match requested Criterion baseline 'last'" in capsys.readouterr().err + assert not output.exists() diff --git a/scripts/tests/test_check_docs_version_sync.py b/scripts/tests/test_check_docs_version_sync.py index cfbe071..bb0b1f0 100644 --- a/scripts/tests/test_check_docs_version_sync.py +++ b/scripts/tests/test_check_docs_version_sync.py @@ -1,49 +1,55 @@ +"""Tests for documentation and package-version synchronization checks.""" + from __future__ import annotations from typing import TYPE_CHECKING +import pytest + import check_docs_version_sync if TYPE_CHECKING: from pathlib import Path -def test_find_version_mismatches_accepts_matching_dependency_snippets(tmp_path: Path) -> None: - (tmp_path / "Cargo.toml").write_text( - "\n".join( - [ - "[package]", - 'name = "other-crate"', - 'version = "1.2.3"', - ] - ), - encoding="utf-8", +_CARGO_TOML = '[package]\nname = "other-crate"\nversion = "1.2.3"' +_VERSION = "1.2.3" + + +def _write_project( + root: Path, + *, + metadata_version: str = _VERSION, + readme: str | None = None, +) -> None: + readme_text = ( + readme + if readme is not None + else f'other-crate = "{_VERSION}"\n[doc](https://github.com/acgetchell/la-stack/blob/v{_VERSION}/README.md)\n[raw](https://raw.githubusercontent.com/acgetchell/la-stack/v{_VERSION}/README.md)\n' ) - (tmp_path / "README.md").write_text( - "\n".join( - [ - 'other-crate = "1.2.3"', - 'other-crate = { version = "1.2.3", features = ["exact"] }', - 'la-stack = "0.4.1"', - ] - ), - encoding="utf-8", + files = { + "Cargo.toml": f"{_CARGO_TOML}\n", + "Cargo.lock": f'version = 4\n\n[[package]]\nname = "other-crate"\nversion = "{metadata_version}"\n', + "pyproject.toml": f'[project]\nname = "other-crate-scripts"\nversion = "{metadata_version}"\n', + "uv.lock": f'version = 1\n\n[[package]]\nname = "other-crate-scripts"\nversion = "{metadata_version}"\nsource = {{ editable = "." }}\n', + "CITATION.cff": f"cff-version: 1.2.0\nversion: {metadata_version}\n", + "README.md": readme_text, + } + for filename, content in files.items(): + (root / filename).write_text(content, encoding="utf-8") + + +def test_find_version_mismatches_accepts_matching_dependency_snippets(tmp_path: Path) -> None: + _write_project( + tmp_path, + readme='other-crate = "1.2.3"\nother-crate = { version = "1.2.3", features = ["exact"] }\nla-stack = "0.4.1"', ) assert check_docs_version_sync.find_version_mismatches(tmp_path) == [] def test_find_version_mismatches_reports_stale_dependency_snippets(tmp_path: Path) -> None: - (tmp_path / "Cargo.toml").write_text( - "\n".join( - [ - "[package]", - 'name = "other-crate"', - 'version = "1.2.3"', - ] - ), - encoding="utf-8", - ) + _write_project(tmp_path) docs = tmp_path / "docs" docs.mkdir() (docs / "install.md").write_text( @@ -54,24 +60,15 @@ def test_find_version_mismatches_reports_stale_dependency_snippets(tmp_path: Pat mismatches = check_docs_version_sync.find_version_mismatches(tmp_path) assert len(mismatches) == 1 - assert mismatches[0].snippet.path == docs / "install.md" - assert mismatches[0].snippet.line == 1 - assert mismatches[0].snippet.version == "1.2.2" + assert mismatches[0].reference.path == docs / "install.md" + assert mismatches[0].reference.line == 1 + assert mismatches[0].reference.version == "1.2.2" assert mismatches[0].package.name == "other-crate" assert mismatches[0].package.version == "1.2.3" def test_find_version_mismatches_handles_reordered_inline_table_keys(tmp_path: Path) -> None: - (tmp_path / "Cargo.toml").write_text( - "\n".join( - [ - "[package]", - 'name = "other-crate"', - 'version = "1.2.3"', - ] - ), - encoding="utf-8", - ) + _write_project(tmp_path) docs = tmp_path / "docs" docs.mkdir() install_doc = docs / "install.md" @@ -83,8 +80,74 @@ def test_find_version_mismatches_handles_reordered_inline_table_keys(tmp_path: P mismatches = check_docs_version_sync.find_version_mismatches(tmp_path) assert len(mismatches) == 1 - assert mismatches[0].snippet.path == install_doc - assert mismatches[0].snippet.line == 1 - assert mismatches[0].snippet.version == "1.2.2" + assert mismatches[0].reference.path == install_doc + assert mismatches[0].reference.line == 1 + assert mismatches[0].reference.version == "1.2.2" assert mismatches[0].package.name == "other-crate" assert mismatches[0].package.version == "1.2.3" + + +def test_find_version_mismatches_reports_all_release_metadata(tmp_path: Path) -> None: + _write_project( + tmp_path, + metadata_version="1.2.2", + ) + + mismatches = check_docs_version_sync.find_version_mismatches(tmp_path) + + assert [(mismatch.reference.kind, mismatch.reference.path.name, mismatch.reference.line, mismatch.reference.version) for mismatch in mismatches] == [ + (check_docs_version_sync.ReferenceKind.CARGO_LOCK, "Cargo.lock", 5, "1.2.2"), + (check_docs_version_sync.ReferenceKind.PYPROJECT, "pyproject.toml", 3, "1.2.2"), + (check_docs_version_sync.ReferenceKind.UV_LOCK, "uv.lock", 5, "1.2.2"), + (check_docs_version_sync.ReferenceKind.CITATION, "CITATION.cff", 2, "1.2.2"), + ] + + +def test_find_version_mismatches_reports_readme_tag_links(tmp_path: Path) -> None: + _write_project( + tmp_path, + readme=( + "[doc](https://github.com/acgetchell/la-stack/blob/v1.2.2/README.md)\n" + "[raw](https://raw.githubusercontent.com/acgetchell/la-stack/v1.2.1/README.md)\n" + "[moving](https://github.com/acgetchell/la-stack/blob/main/README.md)\n" + ), + ) + + mismatches = check_docs_version_sync.find_version_mismatches(tmp_path) + + assert [mismatch.reference.kind for mismatch in mismatches] == [check_docs_version_sync.ReferenceKind.README_TAG_LINK] * 2 + assert [mismatch.reference.line for mismatch in mismatches] == [1, 2] + assert [mismatch.reference.version for mismatch in mismatches] == ["1.2.2", "1.2.1"] + + +def test_find_version_mismatches_ignores_historical_docs_and_test_fixtures(tmp_path: Path) -> None: + _write_project(tmp_path) + archive = tmp_path / "docs" / "archive" + archive.mkdir(parents=True) + fixtures = tmp_path / "tests" / "fixtures" + fixtures.mkdir(parents=True) + stale_snippet = 'other-crate = "0.1.0"\n' + (tmp_path / "CHANGELOG.md").write_text(stale_snippet, encoding="utf-8") + (archive / "old.md").write_text(stale_snippet, encoding="utf-8") + (fixtures / "example.md").write_text(stale_snippet, encoding="utf-8") + + assert check_docs_version_sync.find_version_mismatches(tmp_path) == [] + + +def test_find_version_mismatches_rejects_missing_editable_uv_package(tmp_path: Path) -> None: + _write_project(tmp_path) + (tmp_path / "uv.lock").write_text( + 'version = 1\n\n[[package]]\nname = "other-crate-scripts"\nversion = "1.2.3"\nsource = { registry = "https://pypi.org/simple" }\n', + encoding="utf-8", + ) + + with pytest.raises(TypeError, match=r"exactly one uv\.lock editable package"): + check_docs_version_sync.find_version_mismatches(tmp_path) + + +def test_find_version_mismatches_rejects_malformed_citation_version(tmp_path: Path) -> None: + _write_project(tmp_path) + (tmp_path / "CITATION.cff").write_text('cff-version: 1.2.0\nversion: "\n', encoding="utf-8") + + with pytest.raises(TypeError, match=r"CITATION\.cff:2: top-level version"): + check_docs_version_sync.find_version_mismatches(tmp_path) diff --git a/scripts/tests/test_check_semgrep_fixtures.py b/scripts/tests/test_check_semgrep_fixtures.py index 8246b61..211aaca 100644 --- a/scripts/tests/test_check_semgrep_fixtures.py +++ b/scripts/tests/test_check_semgrep_fixtures.py @@ -1,3 +1,5 @@ +"""Tests for Semgrep fixture-annotation validation.""" + from __future__ import annotations import json @@ -35,13 +37,7 @@ def test_semgrep_results_rejects_malformed_result_objects(monkeypatch: pytest.Mo def test_main_accepts_matching_annotations(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: fixture = tmp_path / "fixture.rs" fixture.write_text( - "\n".join( - [ - "// ruleid: rust.foo, rust.bar", - "// ruleid: rust.foo", - "", - ] - ), + "// ruleid: rust.foo, rust.bar\n// ruleid: rust.foo\n", encoding="utf-8", ) monkeypatch.setenv( diff --git a/scripts/tests/test_criterion_dim_plot.py b/scripts/tests/test_criterion_dim_plot.py index 0ba6d50..1560f8f 100644 --- a/scripts/tests/test_criterion_dim_plot.py +++ b/scripts/tests/test_criterion_dim_plot.py @@ -1,9 +1,14 @@ +"""Tests for Criterion dimension-report generation and README updates.""" + from __future__ import annotations import argparse import json import re +import subprocess import tomllib +from dataclasses import replace +from types import SimpleNamespace from typing import TYPE_CHECKING, cast import pytest @@ -93,7 +98,10 @@ def test_markdown_table_formats_values_and_pct() -> None: table = criterion_dim_plot._markdown_table(rows, stat="median") - assert "| D | la-stack median (ns) | nalgebra median (ns) | faer median (ns) | la-stack vs nalgebra | la-stack vs faer |" in table + assert ( + "| D | la-stack median (ns) | nalgebra median (ns) | faer median (ns) | " + "la-stack point-estimate reduction vs nalgebra | la-stack point-estimate reduction vs faer |" in table + ) assert "| 2 | 50.000 | 100.000 | 200.000 | +50.0% | +75.0% |" in table # thousand separator and sign assert "| 64 | 1,000.000 | 900.000 | 800.000 | -11.1% | -25.0% |" in table @@ -159,18 +167,7 @@ def test_update_readme_table_replaces_only_between_markers(tmp_path: Path) -> No readme = tmp_path / "README.md" readme.write_text( - "\n".join( - [ - "# Title", - "before", - marker_begin, - "old line 1", - "old line 2", - marker_end, - "after", - "", - ] - ), + f"# Title\nbefore\n{marker_begin}\nold line 1\nold line 2\n{marker_end}\nafter\n", encoding="utf-8", ) @@ -209,7 +206,7 @@ def test_update_readme_table_errors_on_out_of_order_markers(tmp_path: Path) -> N marker_begin, marker_end = criterion_dim_plot._readme_table_markers("lu_solve", "median", "new") readme = tmp_path / "README.md" - readme.write_text("\n".join([marker_end, marker_begin, ""]), encoding="utf-8") + readme.write_text(f"{marker_end}\n{marker_begin}\n", encoding="utf-8") with pytest.raises(criterion_dim_plot.ReadmeMarkerError, match=r"out of order"): criterion_dim_plot._update_readme_table(readme, marker_begin, marker_end, "| x |") @@ -220,14 +217,7 @@ def test_update_readme_table_errors_on_non_unique_markers(tmp_path: Path) -> Non readme = tmp_path / "README.md" readme.write_text( - "\n".join( - [ - marker_begin, - marker_begin, - marker_end, - "", - ] - ), + f"{marker_begin}\n{marker_begin}\n{marker_end}\n", encoding="utf-8", ) @@ -235,9 +225,90 @@ def test_update_readme_table_errors_on_non_unique_markers(tmp_path: Path) -> Non criterion_dim_plot._update_readme_table(readme, marker_begin, marker_end, "| x |") -def test_main_update_readme_no_plot_happy_path(tmp_path: Path) -> None: +def _publication_args() -> criterion_dim_plot.PlotCliArgs: + return criterion_dim_plot.PlotCliArgs( + metric="lu_solve", + stat="median", + sample="new", + criterion_dir="target/criterion", + out=None, + csv=None, + log_y=True, + no_plot=False, + update_readme=True, + readme="README.md", + allow_partial=False, + ) + + +def test_readme_publication_rejects_noncanonical_data_and_asset_paths_before_timing( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + begin, end = criterion_dim_plot._readme_table_markers("lu_solve", "median", "new") + (tmp_path / "README.md").write_text(f"{begin}\nold\n{end}\n", encoding="utf-8") + + assert ( + criterion_dim_plot._validate_readme_target( + tmp_path, + replace(_publication_args(), criterion_dir="stale-results"), + ) + == 2 + ) + assert "requires Criterion output" in capsys.readouterr().err + + assert ( + criterion_dim_plot._validate_readme_target( + tmp_path, + replace(_publication_args(), csv="custom.csv"), + ) + == 2 + ) + assert "canonical CSV/SVG destinations" in capsys.readouterr().err + + +def test_readme_publication_rejects_no_plot_before_timing( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + begin, end = criterion_dim_plot._readme_table_markers("lu_solve", "median", "new") + (tmp_path / "README.md").write_text(f"{begin}\nold\n{end}\n", encoding="utf-8") + + assert ( + criterion_dim_plot._validate_readme_target( + tmp_path, + replace(_publication_args(), no_plot=True), + ) + == 2 + ) + assert "--no-plot is exploratory-only" in capsys.readouterr().err + + +def test_fixture_readme_may_use_custom_asset_destinations(tmp_path: Path) -> None: + begin, end = criterion_dim_plot._readme_table_markers("lu_solve", "median", "new") + fixture = tmp_path / "README.fixture.md" + fixture.write_text(f"{begin}\nold\n{end}\n", encoding="utf-8") + + assert ( + criterion_dim_plot._validate_readme_target( + tmp_path, + replace( + _publication_args(), + readme=str(fixture), + csv="custom.csv", + out="custom.svg", + ), + ) + == 0 + ) + + +def test_main_update_readme_happy_path( # noqa: PLR0915 + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: # Create a minimal Criterion directory structure for lu_solve. - criterion_dir = tmp_path / "criterion" + criterion_dir = tmp_path / "target" / "criterion" def write_estimates(path: Path, median: float) -> None: path.parent.mkdir(parents=True, exist_ok=True) @@ -253,17 +324,57 @@ def write_estimates(path: Path, median: float) -> None: encoding="utf-8", ) - for d, la, na, fa in [(2, 10.0, 20.0, 40.0), (8, 100.0, 50.0, 200.0)]: + for d in criterion_dim_plot.CANONICAL_DIMS: + la, na, fa = (float(d * 5), float(d * 10), float(d * 20)) base = criterion_dir / f"d{d}" write_estimates(base / "la_stack_lu_solve" / "new" / "estimates.json", la) write_estimates(base / "nalgebra_lu_solve" / "new" / "estimates.json", na) write_estimates(base / "faer_lu_solve" / "new" / "estimates.json", fa) - readme = tmp_path / "README.md" + readme = tmp_path / "README.fixture.md" marker_begin, marker_end = criterion_dim_plot._readme_table_markers("lu_solve", "median", "new") - readme.write_text("\n".join(["# Bench", marker_begin, "placeholder", marker_end, ""]), encoding="utf-8") + readme.write_text(f"# Bench\n{marker_begin}\nplaceholder\n{marker_end}\n", encoding="utf-8") + + (tmp_path / "Cargo.toml").write_text( + '[package]\nname = "fixture"\nversion = "0.1.0"\n[dev-dependencies]\ncriterion = "0.7"\nnalgebra = "0.34"\nfaer = "0.22"\n', + encoding="utf-8", + ) + (tmp_path / "Cargo.lock").write_text("version = 4\n", encoding="utf-8") + (tmp_path / "rust-toolchain.toml").write_text('[toolchain]\nchannel = "1.88.0"\n', encoding="utf-8") + (tmp_path / "justfile").write_text("test-bench-inputs:\n", encoding="utf-8") + for relative in ("tests/exact_bench_config.rs", "tests/vs_linalg_inputs.rs", "benches/vs_linalg.rs", "src/lib.rs"): + path = tmp_path / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("// fixture\n", encoding="utf-8") + + calls: list[tuple[str, tuple[str, ...]]] = [] + + def fake_run_safe(command: str, args: list[str], **_kwargs: object) -> SimpleNamespace: + calls.append((command, tuple(args))) + if command == "cargo": + for dimension in criterion_dim_plot.CANONICAL_DIMS: + base = criterion_dir / f"d{dimension}" + write_estimates(base / "la_stack_lu_solve" / "new" / "estimates.json", float(dimension * 5)) + write_estimates(base / "nalgebra_lu_solve" / "new" / "estimates.json", float(dimension * 10)) + write_estimates(base / "faer_lu_solve" / "new" / "estimates.json", float(dimension * 20)) + return SimpleNamespace(stdout="rustc 1.88.0\n" if command == "rustc" else "") + + def fake_run_git(args: list[str], **_kwargs: object) -> SimpleNamespace: + if "status" in args: + return SimpleNamespace(stdout=" M src/lib.rs\n") + return SimpleNamespace(stdout="0123456789abcdef\n") + + monkeypatch.setattr(criterion_dim_plot, "_repo_root", lambda: tmp_path) + monkeypatch.setattr(criterion_dim_plot, "run_safe_command", fake_run_safe) + monkeypatch.setattr(criterion_dim_plot, "run_git_command", fake_run_git) + + def fake_render(request: criterion_dim_plot.PlotRequest) -> None: + request.out_svg.write_text("\n", encoding="utf-8") + + monkeypatch.setattr(criterion_dim_plot, "_render_svg_with_gnuplot", fake_render) out_csv = tmp_path / "out.csv" + out_svg = tmp_path / "out.svg" rc = criterion_dim_plot.main( [ @@ -277,25 +388,38 @@ def write_estimates(path: Path, median: float) -> None: str(criterion_dir), "--csv", str(out_csv), - "--no-plot", + "--out", + str(out_svg), "--update-readme", "--readme", str(readme), ] ) assert rc == 0 + assert out_svg.read_text(encoding="utf-8") == "\n" + assert calls[:2] == [ + ("just", ("test-bench-inputs",)), + ("cargo", ("bench", "--locked", "--features", "bench", "--bench", "vs_linalg")), + ] # CSV written csv_text = out_csv.read_text(encoding="utf-8") assert csv_text.startswith("D,la_stack,la_lo,la_hi,nalgebra,na_lo,na_hi,faer,fa_lo,fa_hi\n") assert "2,10.0" in csv_text - assert "8,100.0" in csv_text + assert "64,320.0" in csv_text # README updated with computed table readme_text = readme.read_text(encoding="utf-8") assert "placeholder" not in readme_text assert "| 2 | 10.000 | 20.000 | 40.000 | +50.0% | +75.0% |" in readme_text - assert "| 8 | 100.000 | 50.000 | 200.000 | -100.0% | +50.0% |" in readme_text + assert "| 64 | 320.000 | 640.000 | 1,280.000 | +50.0% | +75.0% |" in readme_text + + provenance = json.loads(out_csv.with_suffix(".provenance.json").read_text(encoding="utf-8")) + assert provenance["measurement"]["status"] == "recorded" + assert provenance["publication"]["correctness_gate"] == "passed" + assert provenance["publication"]["git_clean"] is False + assert provenance["criterion"]["benchmark_command"][:3] == ["cargo", "bench", "--locked"] + assert re.fullmatch(r"[0-9a-f]{64}", provenance["publication"]["source_state_sha256"]) def test_dim_parsing_and_discovery(tmp_path: Path) -> None: @@ -314,19 +438,17 @@ def test_dim_parsing_and_discovery(tmp_path: Path) -> None: def test_toml_helpers_read_versions(tmp_path: Path) -> None: cargo_toml = tmp_path / "Cargo.toml" cargo_toml.write_text( - "\n".join( - [ - "# comment line", - "[package]", - 'version = "1.2.3" # inline comment', - "", - "[dependencies]", - 'nalgebra = "0.34.0"', - 'faer = { version = "0.21.4" }', - "", - "[dev-dependencies]", - 'serde = "1.0"', - ] + ( + "# comment line\n" + "[package]\n" + 'version = "1.2.3" # inline comment\n' + "\n" + "[dependencies]\n" + 'nalgebra = "0.34.0"\n' + 'faer = { version = "0.21.4" }\n' + "\n" + "[dev-dependencies]\n" + 'serde = "1.0"' ), encoding="utf-8", ) @@ -548,7 +670,7 @@ def test_write_csv_and_collect_rows(tmp_path: Path) -> None: ) rows2, skipped = criterion_dim_plot._collect_rows(criterion_dir, [2], metric, "median", "new") assert rows2 == [] - assert skipped == ["d2 (missing la_stack_lu_solve, nalgebra_lu_solve, or faer_lu_solve)"] + assert skipped == ["d2 (missing nalgebra_lu_solve, faer_lu_solve)"] def test_resolve_paths(tmp_path: Path) -> None: @@ -649,3 +771,227 @@ def test_main_error_paths(tmp_path: Path) -> None: ] ) assert rc == 2 + + +def test_main_requires_canonical_dimensions_before_writing(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + criterion_dir = tmp_path / "criterion" + metric = criterion_dim_plot.METRICS["lu_solve"] + for bench in (metric.la_bench, metric.na_bench, metric.fa_bench): + estimates = criterion_dir / "d2" / bench / "new" / "estimates.json" + estimates.parent.mkdir(parents=True, exist_ok=True) + estimates.write_text( + json.dumps( + { + "median": { + "point_estimate": 1.0, + "confidence_interval": {"lower_bound": 0.9, "upper_bound": 1.1}, + } + } + ), + encoding="utf-8", + ) + output = tmp_path / "out.csv" + monkeypatch.setattr(criterion_dim_plot, "_repo_root", lambda: tmp_path) + + rc = criterion_dim_plot.main(["--criterion-dir", str(criterion_dir), "--csv", str(output), "--no-plot"]) + + assert rc == 2 + assert not output.exists() + assert not output.with_suffix(".provenance.json").exists() + + +def test_main_partial_mode_is_explicit_and_labels_measurement_unavailable( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + criterion_dir = tmp_path / "criterion" + metric = criterion_dim_plot.METRICS["lu_solve"] + for bench in (metric.la_bench, metric.na_bench, metric.fa_bench): + estimates = criterion_dir / "d2" / bench / "new" / "estimates.json" + estimates.parent.mkdir(parents=True, exist_ok=True) + estimates.write_text( + json.dumps( + { + "median": { + "point_estimate": 1.0, + "confidence_interval": {"lower_bound": 0.9, "upper_bound": 1.1}, + } + } + ), + encoding="utf-8", + ) + output = tmp_path / "out.csv" + monkeypatch.setattr(criterion_dim_plot, "_repo_root", lambda: tmp_path) + + rc = criterion_dim_plot.main( + [ + "--criterion-dir", + str(criterion_dir), + "--csv", + str(output), + "--no-plot", + "--allow-partial", + ] + ) + + assert rc == 0 + provenance = json.loads(output.with_suffix(".provenance.json").read_text(encoding="utf-8")) + assert provenance["measurement"]["status"] == "unavailable" + assert provenance["publication"]["correctness_gate"] == "not-run-exploratory" + + +def test_publication_gate_failure_stops_before_timing(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + calls: list[tuple[str, tuple[str, ...]]] = [] + + def fail_gate(command: str, args: list[str], **_kwargs: object) -> SimpleNamespace: + calls.append((command, tuple(args))) + raise subprocess.CalledProcessError(1, [command, *args], stderr="fixture failure") + + monkeypatch.setattr(criterion_dim_plot, "run_safe_command", fail_gate) + + with pytest.raises(RuntimeError, match="just test-bench-inputs"): + criterion_dim_plot._run_publication_benchmarks(tmp_path) + + assert calls == [("just", ("test-bench-inputs",))] + + +def test_failed_timing_restores_staged_new_samples( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + old_estimate = tmp_path / "target" / "criterion" / "d2" / "la_stack_lu" / "new" / "estimates.json" + old_estimate.parent.mkdir(parents=True) + old_estimate.write_text("old\n", encoding="utf-8") + + def fail_timing(command: str, args: list[str], **_kwargs: object) -> SimpleNamespace: + if command == "cargo": + old_estimate.parent.mkdir(parents=True, exist_ok=True) + old_estimate.write_text("partial\n", encoding="utf-8") + raise subprocess.CalledProcessError(1, [command, *args], stderr="timing failed") + return SimpleNamespace(stdout="") + + monkeypatch.setattr(criterion_dim_plot, "run_safe_command", fail_timing) + + with pytest.raises(RuntimeError, match="cargo bench"): + criterion_dim_plot._run_publication_benchmarks(tmp_path) + + assert old_estimate.read_text(encoding="utf-8") == "old\n" + + +def test_readme_publication_cannot_reuse_stale_new_samples( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + criterion_dir = tmp_path / "target" / "criterion" + metric = criterion_dim_plot.METRICS["lu_solve"] + + def write_dimension(dimension: int) -> None: + for bench in (metric.la_bench, metric.na_bench, metric.fa_bench): + estimates = criterion_dir / f"d{dimension}" / bench / "new" / "estimates.json" + estimates.parent.mkdir(parents=True, exist_ok=True) + estimates.write_text( + json.dumps( + { + "median": { + "point_estimate": 1.0, + "confidence_interval": {"lower_bound": 0.9, "upper_bound": 1.1}, + } + } + ), + encoding="utf-8", + ) + + for dimension in criterion_dim_plot.CANONICAL_DIMS: + write_dimension(dimension) + begin, end = criterion_dim_plot._readme_table_markers("lu_solve", "median", "new") + readme = tmp_path / "README.stale-fixture.md" + readme.write_text(f"{begin}\nstale\n{end}\n", encoding="utf-8") + output = tmp_path / "benchmark.csv" + + def fake_run_safe(command: str, _args: list[str], **_kwargs: object) -> SimpleNamespace: + if command == "cargo": + write_dimension(2) + return SimpleNamespace(stdout="") + + monkeypatch.setattr(criterion_dim_plot, "_repo_root", lambda: tmp_path) + monkeypatch.setattr(criterion_dim_plot, "run_safe_command", fake_run_safe) + + rc = criterion_dim_plot.main( + [ + "--criterion-dir", + str(criterion_dir), + "--csv", + str(output), + "--update-readme", + "--readme", + str(readme), + ] + ) + + assert rc == 2 + assert not output.exists() + assert "stale" in readme.read_text(encoding="utf-8") + assert not (criterion_dir / "d3" / metric.la_bench / "new" / "estimates.json").exists() + + +def test_staged_publication_leaves_existing_assets_unchanged_when_render_fails( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + csv_path = tmp_path / "benchmark.csv" + svg_path = tmp_path / "benchmark.svg" + provenance_path = csv_path.with_suffix(".provenance.json") + readme = tmp_path / "README.md" + for path, text in ( + (csv_path, "old csv\n"), + (svg_path, "old svg\n"), + (provenance_path, "old provenance\n"), + ): + path.write_text(text, encoding="utf-8") + begin, end = criterion_dim_plot._readme_table_markers("lu_solve", "median", "new") + readme.write_text(f"before\n{begin}\nold table\n{end}\nafter\n", encoding="utf-8") + + def fail_render(_request: criterion_dim_plot.PlotRequest) -> None: + raise subprocess.CalledProcessError(1, ["gnuplot"], stderr="render failed") + + monkeypatch.setattr(criterion_dim_plot, "_render_svg_with_gnuplot", fail_render) + args = criterion_dim_plot.PlotCliArgs( + metric="lu_solve", + stat="median", + sample="new", + criterion_dir="target/criterion", + out=str(svg_path), + csv=str(csv_path), + log_y=False, + no_plot=False, + update_readme=True, + readme=str(readme), + allow_partial=False, + ) + row = criterion_dim_plot.Row(2, 1.0, 0.9, 1.1, 2.0, 1.9, 2.1, 3.0, 2.9, 3.1) + request = criterion_dim_plot.PlotRequest( + csv_path=csv_path, + out_svg=svg_path, + title="title", + stat="median", + dims=(2,), + la_label="la-stack", + na_label="nalgebra", + fa_label="faer", + log_y=False, + ) + + rc = criterion_dim_plot._stage_and_publish_outputs( + root=tmp_path, + args=args, + rows=[row], + req=request, + provenance={"schema": 1}, + skipped=[], + ) + + assert rc == 1 + assert csv_path.read_text(encoding="utf-8") == "old csv\n" + assert svg_path.read_text(encoding="utf-8") == "old svg\n" + assert provenance_path.read_text(encoding="utf-8") == "old provenance\n" + assert "old table" in readme.read_text(encoding="utf-8") diff --git a/scripts/tests/test_subprocess_utils.py b/scripts/tests/test_subprocess_utils.py index a5ebe98..a2a6723 100644 --- a/scripts/tests/test_subprocess_utils.py +++ b/scripts/tests/test_subprocess_utils.py @@ -104,29 +104,38 @@ def test_passes_stdin_data(self) -> None: assert result.returncode == 0 assert result.stdout.strip() # should be a 40-char hex hash - @patch("subprocess_utils.get_safe_executable", return_value="/usr/bin/git") - @patch("subprocess_utils.subprocess.run") - def test_input_data_forwarded(self, mock_run: MagicMock, _mock_exe: MagicMock) -> None: + def test_input_data_forwarded(self) -> None: """Verify input_data is passed as the 'input' kwarg to subprocess.run.""" - run_git_command_with_input(["tag", "-a", "v1.0.0", "-F", "-"], input_data="tag body") + with ( + patch("subprocess_utils.get_safe_executable", return_value="/usr/bin/git") as mock_executable, + patch("subprocess_utils.subprocess.run") as mock_run, + ): + run_git_command_with_input(["tag", "-a", "v1.0.0", "-F", "-"], input_data="tag body") + mock_executable.assert_called_once_with("git") mock_run.assert_called_once() _args, kwargs = mock_run.call_args assert kwargs["input"] == "tag body" class TestAdditionalHelpers: - @patch("subprocess_utils.get_safe_executable", return_value="/usr/bin/cargo") - @patch("subprocess_utils.subprocess.run") - def test_run_cargo_command_uses_safe_executable(self, mock_run: MagicMock, _mock_exe: MagicMock) -> None: - run_cargo_command(["--version"]) + def test_run_cargo_command_uses_safe_executable(self) -> None: + with ( + patch("subprocess_utils.get_safe_executable", return_value="/usr/bin/cargo") as mock_executable, + patch("subprocess_utils.subprocess.run") as mock_run, + ): + run_cargo_command(["--version"]) + mock_executable.assert_called_once_with("cargo") mock_run.assert_called_once() args, _kwargs = mock_run.call_args assert args[0] == ["/usr/bin/cargo", "--version"] - @patch("subprocess_utils.get_safe_executable", return_value="/usr/bin/gnuplot") - @patch("subprocess_utils.subprocess.run") - def test_run_safe_command_uses_safe_executable(self, mock_run: MagicMock, _mock_exe: MagicMock) -> None: - run_safe_command("gnuplot", ["--version"]) + def test_run_safe_command_uses_safe_executable(self) -> None: + with ( + patch("subprocess_utils.get_safe_executable", return_value="/usr/bin/gnuplot") as mock_executable, + patch("subprocess_utils.subprocess.run") as mock_run, + ): + run_safe_command("gnuplot", ["--version"]) + mock_executable.assert_called_once_with("gnuplot") mock_run.assert_called_once() args, _kwargs = mock_run.call_args assert args[0] == ["/usr/bin/gnuplot", "--version"] diff --git a/scripts/tests/test_tag_release.py b/scripts/tests/test_tag_release.py index eb6c2cf..f7b6087 100644 --- a/scripts/tests/test_tag_release.py +++ b/scripts/tests/test_tag_release.py @@ -233,18 +233,19 @@ class TestCreateTag: @patch("tag_release.extract_changelog_section") def test_creates_annotated_tag( self, - _mock_extract: MagicMock, + mock_extract: MagicMock, mock_find: MagicMock, - _mock_exists: MagicMock, + mock_exists: MagicMock, mock_git_input: MagicMock, tmp_path: Path, ) -> None: changelog = tmp_path / "CHANGELOG.md" changelog.write_text(_SAMPLE_CHANGELOG, encoding="utf-8") mock_find.return_value = changelog - _mock_extract.return_value = ("### Added\n\n- Something new", changelog) + mock_extract.return_value = ("### Added\n\n- Something new", changelog) tag_release.create_tag("v1.0.0") + mock_exists.assert_called_once_with("v1.0.0") mock_git_input.assert_called_once() call_args = mock_git_input.call_args @@ -258,8 +259,8 @@ def test_creates_annotated_tag( def test_oversized_creates_reference_tag( self, mock_find: MagicMock, - _mock_exists: MagicMock, - _mock_url: MagicMock, + mock_exists: MagicMock, + mock_url: MagicMock, mock_git_input: MagicMock, tmp_path: Path, ) -> None: @@ -275,6 +276,8 @@ def test_oversized_creates_reference_tag( with patch("tag_release.extract_changelog_section", return_value=(big_section, changelog)): tag_release.create_tag("v1.0.0") + mock_exists.assert_called_once_with("v1.0.0") + mock_url.assert_called_once_with() mock_git_input.assert_called_once() tag_message = mock_git_input.call_args[1]["input_data"] assert "See full changelog" in tag_message @@ -282,9 +285,10 @@ def test_oversized_creates_reference_tag( assert len(tag_message) < 1000 @patch("tag_release._tag_exists", return_value=True) - def test_existing_tag_without_force_exits(self, _mock_exists: MagicMock) -> None: + def test_existing_tag_without_force_exits(self, mock_exists: MagicMock) -> None: with pytest.raises(SystemExit): tag_release.create_tag("v1.0.0", force=False) + mock_exists.assert_called_once_with("v1.0.0") @patch("tag_release.run_git_command_with_input") @patch("tag_release._tag_exists", return_value=True) @@ -292,19 +296,20 @@ def test_existing_tag_without_force_exits(self, _mock_exists: MagicMock) -> None @patch("tag_release.extract_changelog_section") def test_force_recreates_tag( self, - _mock_extract: MagicMock, + mock_extract: MagicMock, mock_find: MagicMock, - _mock_exists: MagicMock, + mock_exists: MagicMock, mock_git_input: MagicMock, tmp_path: Path, ) -> None: changelog = tmp_path / "CHANGELOG.md" changelog.write_text(_SAMPLE_CHANGELOG, encoding="utf-8") mock_find.return_value = changelog - _mock_extract.return_value = ("### Fixed\n\n- Bug fix", changelog) + mock_extract.return_value = ("### Fixed\n\n- Bug fix", changelog) tag_release.create_tag("v1.0.0", force=True) + mock_exists.assert_called_once_with("v1.0.0") mock_git_input.assert_called_once() assert mock_git_input.call_args[0][0] == ["tag", "-f", "-a", "v1.0.0", "-F", "-", "--cleanup=verbatim"] @@ -315,9 +320,9 @@ def test_force_recreates_tag( def test_force_does_not_delete_tag_if_changelog_fails( self, mock_git_input: MagicMock, - _mock_extract: MagicMock, + mock_extract: MagicMock, mock_find: MagicMock, - _mock_exists: MagicMock, + mock_exists: MagicMock, tmp_path: Path, ) -> None: """Tag must not be deleted if changelog extraction fails.""" @@ -328,6 +333,8 @@ def test_force_does_not_delete_tag_if_changelog_fails( with pytest.raises(LookupError): tag_release.create_tag("v1.0.0", force=True) + mock_exists.assert_called_once_with("v1.0.0") + mock_extract.assert_called_once() mock_git_input.assert_not_called() diff --git a/semgrep.yaml b/semgrep.yaml index f053965..5af07eb 100644 --- a/semgrep.yaml +++ b/semgrep.yaml @@ -30,6 +30,26 @@ rules: mod $MOD { ... } + - pattern-not-inside: | + #[cfg(any(test, ...))] + mod $MOD { + ... + } + - pattern-not-inside: | + #[cfg(any(test, ...))] + fn $FUNC(...) { + ... + } + - pattern-not-inside: | + #[cfg(test)] + impl $TYPE { + ... + } + - pattern-not-inside: | + #[cfg(any(test, ...))] + impl $TYPE { + ... + } - pattern-not-inside: | #[cfg(test)] fn $FUNC(...) { @@ -57,6 +77,9 @@ rules: - pattern: $VALUE.unwrap_or_else(|| f64::NAN) - pattern: $VALUE.unwrap_or_else(|| f64::INFINITY) - pattern: $VALUE.unwrap_or_else(|| f64::NEG_INFINITY) + - pattern: $VALUE.unwrap_or_else(|| std::f64::NAN) + - pattern: $VALUE.unwrap_or_else(|| std::f64::INFINITY) + - pattern: $VALUE.unwrap_or_else(|| std::f64::NEG_INFINITY) - id: la-stack.rust.no-public-infallible-raw-f64-constructors languages: @@ -74,24 +97,94 @@ rules: include: - "/src/**/*.rs" - "/tests/semgrep/src/project_rules/raw_f64_constructors.rs" - pattern-regex: '(?m)^\s*pub\s+(?:const\s+)?fn\s+(?:new|from_rows)\s*\([^)]*(?:\[\s*f64\s*;\s*D\s*\]|\[\s*\[\s*f64\s*;\s*D\s*\]\s*;\s*D\s*\])[^)]*\)\s*->\s*(?:Self|(?:Matrix|Vector)\s*<)' + pattern-regex: '(?m)^\s*pub\s+(?:const\s+)?fn\s+(?:new|from_rows)\s*\([^)]*(?:\[\s*f64\s*;\s*D\s*\]|\[\s*\[\s*f64\s*;\s*D\s*\]\s*;\s*D\s*\])[^)]*\)\s*->\s*(?:Self|(?:Matrix|Vector)\s*<)' # yamllint disable-line rule:line-length - id: la-stack.rust.no-public-unchecked-finite-constructors languages: - rust severity: WARNING - message: "Unchecked Matrix/Vector constructors must stay crate-private; public callers must parse through fallible constructors." + message: "Finite Matrix/Vector escape hatches must stay module-private; crate-visible helpers can forge invalid proof values." metadata: category: correctness rationale: >- - Matrix and Vector are finite proof types. Public unchecked constructors - would let downstream callers bypass parsing and construct invalid finite - proofs. + Matrix and Vector are finite proof types. Public or crate-visible raw + constructors and mutable-storage accessors let other modules bypass + parsing, so later computations can no longer trust those proofs. paths: include: - - "/src/**/*.rs" + - "/src/matrix.rs" + - "/src/vector.rs" + - "/tests/semgrep/src/project_rules/finite_api_contract.rs" + pattern-regex: '(?m)^\s*pub(?:\s*\(\s*(?:crate|super|in\s+[^)]+)\s*\))?\s+(?:const\s+)?fn\s+(?:new_unchecked(?:_[A-Za-z0-9_]+)?|from_rows_unchecked(?:_[A-Za-z0-9_]+)?|rows_mut_unchecked(?:_[A-Za-z0-9_]+)?)\s*\(' # yamllint disable-line rule:line-length + + - id: la-stack.rust.det-sign-exact-must-be-infallible + languages: + - generic + severity: WARNING + message: "det_sign_exact is total for every finite Matrix and must return DeterminantSign directly." + metadata: + category: correctness + rationale: >- + Matrix carries finite-entry evidence, filter range failures fall back to + exact integer arithmetic, and determinant sign does not need value-scale + bookkeeping. Returning Result would discard that proof and invite + defensive rescanning on the hot path. + paths: + include: + - "/src/exact.rs" + - "/tests/semgrep/src/project_rules/finite_api_contract.rs" + pattern-regex: '(?m)^\s*pub\s+(?:const\s+)?fn\s+det_sign_exact\s*\([^)]*\)\s*->(?!\s*DeterminantSign\b)\s*[^{;\n]+' # yamllint disable-line rule:line-length + + - id: la-stack.rust.exact-benchmark-validation-must-return-proof + languages: + - generic + severity: WARNING + message: "validate_exact_fixture must return ValidatedExactInput directly so callers cannot discard validation evidence." + metadata: + category: correctness + rationale: >- + Criterion registration and timed helpers must accept only a validated + fixture, so every standalone benchmark run proves its inputs before + measurement rather than relying on a separately invoked smoke test. + paths: + include: + - "/benches/common/exact.rs" + - "/tests/semgrep/src/project_rules/finite_api_contract.rs" + pattern-regex: '(?ms)^\s*pub\s+fn\s+validate_exact_fixture\b(?:(?!\{).|\n){0,500}\)(?!\s*->\s*ValidatedExactInput\s*<)\s*(?:->[^\{]+)?\{' # yamllint disable-line rule:line-length + + - id: la-stack.rust.validated-exact-input-fields-private + languages: + - regex + severity: WARNING + message: "ValidatedExactInput fields must stay private so validation evidence cannot be replaced after construction." + metadata: + category: correctness + rationale: >- + A validated benchmark fixture is a proof-bearing type, not a passive + DTO. Private fields plus infallible accessors keep the checked matrix/RHS + pair intact through benchmark registration and timing. + paths: + include: + - "/benches/common/exact.rs" + - "/tests/semgrep/src/project_rules/finite_api_contract.rs" + pattern-regex: '(?ms)^\s*pub\s+struct\s+ValidatedExactInput\s*(?:<[^>{]*>)?\s*\{(?:(?!^\s*\}).|\n)*^\s*pub(?:\s*\(\s*(?:crate|super|in\s+[^)]+)\s*\))?\s+(?:matrix|rhs)\s*:' # yamllint disable-line rule:line-length + + - id: la-stack.rust.exact-benchmark-helpers-require-validated-input + languages: + - generic + severity: WARNING + message: "Exact benchmark execution helpers must accept ValidatedExactInput, not raw ExactInput fixtures." + metadata: + category: correctness + rationale: >- + Keeping raw fixtures out of registration and timed helper signatures + makes independent oracle validation a type-checked prerequisite for + every measured exact operation. + paths: + include: + - "/benches/exact.rs" - "/tests/semgrep/src/project_rules/finite_api_contract.rs" - pattern-regex: '(?m)^\s*pub\s+(?:const\s+)?fn\s+(?:new_unchecked|from_rows_unchecked)\s*\(' + pattern-regex: '(?ms)^\s*fn\s+(?:run_|bench_|register_)[A-Za-z0-9_]*\b(?:(?!\{).|\n){0,800}\bExactInput\s*<' # yamllint disable-line rule:line-length - id: la-stack.rust.no-public-matrix-vector-storage-fields languages: @@ -118,9 +211,9 @@ rules: metadata: category: api rationale: >- - The matrix and vector modules contain crate-internal unchecked helpers. - Making the modules public would expose implementation details that - bypass the clean API surface. + The matrix and vector modules own raw storage and internal + invariant-preserving construction details. Making the modules public + would expose implementation details that bypass the curated API surface. paths: include: - "/src/**/*.rs" @@ -166,7 +259,7 @@ rules: include: - "/src/**/*.rs" - "/tests/semgrep/src/project_rules/public_api_panic_paths.rs" - pattern-regex: '(?ms)^\s*pub\s+(?:const\s+|async\s+|unsafe\s+)*fn\s+[A-Za-z_][A-Za-z0-9_]*[^;{]*\{(?:(?!^\s*\}).|\n){0,1000}(?:panic!|assert!|debug_assert!|unreachable!|\.unwrap\s*\(|\.expect\s*\()' + pattern-regex: '(?ms)^\s*pub\s+(?:const\s+|async\s+|unsafe\s+)*fn\s+[A-Za-z_][A-Za-z0-9_]*[^;{]*\{(?:(?!^\s*\}).|\n){0,1000}(?:panic!|assert!|debug_assert!|unreachable!|\.unwrap\s*\(|\.expect\s*\()' # yamllint disable-line rule:line-length - id: la-stack.rust.public-error-enums-non-exhaustive languages: @@ -248,6 +341,8 @@ rules: include: - "/.github/workflows/**/*.yml" - "/.github/workflows/**/*.yaml" + - "/tests/semgrep/.github/workflows/**/*.yml" + - "/tests/semgrep/.github/workflows/**/*.yaml" patterns: - pattern-regex: '(?m)^\s*uses:\s*(?!\./)(?!docker://)[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)?@(?![a-fA-F0-9]{40}(?:\s+#|$))[^\s#]+' @@ -263,8 +358,10 @@ rules: include: - "/.github/workflows/**/*.yml" - "/.github/workflows/**/*.yaml" + - "/tests/semgrep/.github/workflows/**/*.yml" + - "/tests/semgrep/.github/workflows/**/*.yaml" patterns: - - pattern-regex: '(?m)^\s*uses:\s*(?!\./)(?!docker://)(?!(?:actions/checkout|actions/cache|actions/download-artifact|actions/github-script|actions/setup-python|actions/upload-artifact|actions-rust-lang/setup-rust-toolchain|astral-sh/setup-uv|codacy/codacy-analysis-cli-action|codecov/codecov-action|github/codeql-action/(?:upload-sarif|init|analyze)|taiki-e/cache-cargo-install-action|zizmorcore/zizmor-action)@)[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)?@' + - pattern-regex: '(?m)^\s*uses:\s*(?!\./)(?!docker://)(?!(?:actions/checkout|actions/cache|actions/download-artifact|actions/github-script|actions/setup-python|actions/upload-artifact|actions-rust-lang/setup-rust-toolchain|astral-sh/setup-uv|codecov/codecov-action|github/codeql-action/(?:upload-sarif|init|analyze)|swatinem/rust-cache|taiki-e/cache-cargo-install-action|zizmorcore/zizmor-action)@)[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)?@' # yamllint disable-line rule:line-length - id: la-stack.github-actions.external-action-version-comment languages: @@ -278,6 +375,8 @@ rules: include: - "/.github/workflows/**/*.yml" - "/.github/workflows/**/*.yaml" + - "/tests/semgrep/.github/workflows/**/*.yml" + - "/tests/semgrep/.github/workflows/**/*.yaml" patterns: - pattern-regex: '(?m)^\s*uses:\s*(?!\./)(?!docker://)[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)?@[a-fA-F0-9]{40}\s*$' @@ -285,17 +384,609 @@ rules: languages: - regex severity: WARNING - message: "Document non-mutating just check commands before mutating just fix commands." + message: "Document non-mutating just checks before mutating just fixers." + metadata: + category: maintainability + rationale: >- + User-facing workflow examples should show non-mutating checks before + mutating fixers so formatting drift is inspected before files are + rewritten. + paths: + include: + - "/**/*.md" + - "/justfile" + pattern-either: + - pattern-regex: '(?ms)^\s*(?:@echo\s+"?\s*)?just\s+fix\b[^\n]*(?:\n\s*(?:#[^\n]*)?)*?\n\s*(?:@echo\s+"?\s*)?just\s+check\b' # yamllint disable-line rule:line-length + - pattern-regex: '(?ms)^\s*(?:@echo\s+"?\s*)?just\s+python-fix\b[^\n]*(?:\n\s*(?:#[^\n]*)?)*?\n\s*(?:@echo\s+"?\s*)?just\s+python-check\b' # yamllint disable-line rule:line-length + - pattern-regex: '(?ms)^\s*(?:@echo\s+"?\s*)?just\s+yaml-fix\b[^\n]*(?:\n\s*(?:#[^\n]*)?)*?\n\s*(?:@echo\s+"?\s*)?just\s+yaml-check\b' # yamllint disable-line rule:line-length + - pattern-regex: '(?ms)^\s*(?:@echo\s+"?\s*)?just\s+markdown-fix\b[^\n]*(?:\n\s*(?:#[^\n]*)?)*?\n\s*(?:@echo\s+"?\s*)?just\s+markdown-check\b' # yamllint disable-line rule:line-length + - pattern-regex: '(?ms)^\s*(?:@echo\s+"?\s*)?just\s+toml-fix\b[^\n]*(?:\n\s*(?:#[^\n]*)?)*?\n\s*(?:@echo\s+"?\s*)?just\s+toml-check\b' # yamllint disable-line rule:line-length + - pattern-regex: '(?ms)^\s*(?:@echo\s+"?\s*)?just\s+shell-fix\b[^\n]*(?:\n\s*(?:#[^\n]*)?)*?\n\s*(?:@echo\s+"?\s*)?just\s+shell-check\b' # yamllint disable-line rule:line-length + + - id: la-stack.github-actions.checkout-persist-credentials-false + languages: + - regex + severity: WARNING + message: "Set persist-credentials: false on actions/checkout steps." + metadata: + category: security + rationale: >- + Checkout credentials should not remain available to later workflow steps + unless a job explicitly needs them. + paths: + include: + - "/.github/workflows/**/*.yml" + - "/.github/workflows/**/*.yaml" + - "/tests/semgrep/.github/workflows/**/*.yml" + - "/tests/semgrep/.github/workflows/**/*.yaml" + patterns: + - pattern-regex: '(?m)^[ \t]*(?:-[ \t]*)?uses:[ \t]*actions/checkout@[a-fA-F0-9]{40}[^\n]*\n(?!(?:[ \t]*with:\n)?(?:[ \t]{10,}\S[^\n]*\n){0,8}[ \t]{10,}persist-credentials:[ \t]*false\b)' # yamllint disable-line rule:line-length + + - id: la-stack.github-actions.no-pull-request-target + languages: + - regex + severity: WARNING + message: "Avoid pull_request_target; use pull_request unless a workflow has a documented security review." + metadata: + category: security + rationale: >- + pull_request_target runs with base-repository privileges and is risky + for workflows that touch untrusted PR content. + paths: + include: + - "/.github/workflows/**/*.yml" + - "/.github/workflows/**/*.yaml" + - "/tests/semgrep/.github/workflows/**/*.yml" + - "/tests/semgrep/.github/workflows/**/*.yaml" + pattern-regex: '(?m)^\s*pull_request_target\s*:' + + - id: la-stack.github-actions.github-script-no-expression-interpolation + languages: + - regex + severity: WARNING + message: "Pass GitHub expression values to actions/github-script through env instead of interpolating inside script blocks." + metadata: + category: security + rationale: >- + Interpolating GitHub expressions directly into JavaScript can turn + workflow context strings into executable code. + paths: + include: + - "/.github/workflows/**/*.yml" + - "/.github/workflows/**/*.yaml" + - "/tests/semgrep/.github/workflows/**/*.yml" + - "/tests/semgrep/.github/workflows/**/*.yaml" + patterns: + - pattern-regex: '(?ms)^\s*script:\s*\|\n(?:(?!^\s*-\s+(?:name|uses):).)*\$\{\{' # yamllint disable-line rule:line-length + + - id: la-stack.github-actions.uv-sync-locked-in-workflows + languages: + - regex + severity: WARNING + message: "Use uv sync --locked in GitHub Actions workflows." + metadata: + category: reproducibility + rationale: "Workflow dependency installs should fail on lockfile drift instead of silently resolving new versions." + paths: + include: + - "/.github/workflows/**/*.yml" + - "/.github/workflows/**/*.yaml" + - "/tests/semgrep/.github/workflows/**/*.yml" + - "/tests/semgrep/.github/workflows/**/*.yaml" + patterns: + - pattern-regex: '(?m)^\s*(?:run:\s*)?uv\s+sync\b(?![^\n]*\s--locked\b)[^\n]*$' # yamllint disable-line rule:line-length + + - id: la-stack.rust.no-silent-conversion-fallbacks + languages: + - rust + severity: WARNING + message: "Handle numeric conversion failures explicitly instead of hiding them behind unwrap_or fallbacks." + metadata: + category: correctness + rationale: >- + Exact and floating-point paths should preserve conversion failure context + instead of silently substituting sentinel values. + paths: + include: + - "/src/**/*.rs" + - "/tests/semgrep/src/project_rules/portable_policy.rs" + patterns: + - pattern-either: + - pattern: NumCast::from($VALUE).unwrap_or($FALLBACK) + - pattern: NumCast::from($VALUE).unwrap_or_else($FALLBACK) + - pattern: num_traits::NumCast::from($VALUE).unwrap_or($FALLBACK) + - pattern: num_traits::NumCast::from($VALUE).unwrap_or_else($FALLBACK) + - pattern: cast::<$FROM, $TO>($VALUE).unwrap_or($FALLBACK) + - pattern: cast::<$FROM, $TO>($VALUE).unwrap_or_else($FALLBACK) + - pattern: num_traits::cast::<$FROM, $TO>($VALUE).unwrap_or($FALLBACK) + - pattern: num_traits::cast::<$FROM, $TO>($VALUE).unwrap_or_else($FALLBACK) + - patterns: + - pattern: $SAFE(...).unwrap_or($FALLBACK) + - metavariable-regex: + metavariable: $SAFE + regex: safe_[A-Za-z0-9_]+ + - pattern-not-inside: | + mod tests { + ... + } + + - id: la-stack.rust.no-silent-conversion-fallbacks-in-public-samples + languages: + - rust + severity: WARNING + message: "Handle numeric conversion failures explicitly in public samples." + metadata: + category: correctness + rationale: >- + Examples, benchmarks, and integration tests should model explicit + conversion-failure handling instead of silently substituting values. + paths: + include: + - "/examples/**/*.rs" + - "/benches/**/*.rs" + - "/tests/*.rs" + - "/tests/semgrep/src/project_rules/portable_policy.rs" + pattern-either: + - pattern: NumCast::from($VALUE).unwrap_or($FALLBACK) + - pattern: NumCast::from($VALUE).unwrap_or_else($FALLBACK) + - pattern: num_traits::NumCast::from($VALUE).unwrap_or($FALLBACK) + - pattern: num_traits::NumCast::from($VALUE).unwrap_or_else($FALLBACK) + - pattern: cast::<$FROM, $TO>($VALUE).unwrap_or($FALLBACK) + - pattern: cast::<$FROM, $TO>($VALUE).unwrap_or_else($FALLBACK) + - pattern: num_traits::cast::<$FROM, $TO>($VALUE).unwrap_or($FALLBACK) + - pattern: num_traits::cast::<$FROM, $TO>($VALUE).unwrap_or_else($FALLBACK) + - patterns: + - pattern: $SAFE(...).unwrap_or($FALLBACK) + - metavariable-regex: + metavariable: $SAFE + regex: safe_[A-Za-z0-9_]+ + + - id: la-stack.rust.no-partial-cmp-ordering-defaults + languages: + - rust + severity: WARNING + message: "Handle incomparable partial_cmp results explicitly instead of defaulting to an Ordering." + metadata: + category: correctness + rationale: >- + Floating-point ordering code must not silently fold NaN comparisons into + Equal, Less, or Greater. + paths: + include: + - "/src/**/*.rs" + - "/tests/semgrep/src/project_rules/portable_policy.rs" + patterns: + - pattern-either: + - pattern: $LEFT.partial_cmp($RIGHT).unwrap_or(Ordering::$ORDER) + - pattern: $LEFT.partial_cmp($RIGHT).unwrap_or(std::cmp::Ordering::$ORDER) + - pattern: $LEFT.partial_cmp($RIGHT).unwrap_or_else($FALLBACK) + - pattern-not-inside: | + mod tests { + ... + } + + - id: la-stack.rust.no-function-local-use-in-src + languages: + - rust + severity: WARNING + message: "Move production imports to module scope instead of declaring use items inside functions." metadata: category: maintainability - rationale: "User-facing workflow docs should encourage validation before mutation." + rationale: "Module-scope imports keep production dependency shape visible." + paths: + include: + - "/src/**/*.rs" + - "/tests/semgrep/src/project_rules/portable_policy.rs" + patterns: + - pattern: use $PATH; + - pattern-inside: | + fn $FUNC(...) { + ... + } + - pattern-not-inside: | + mod tests { + ... + } + - pattern-not-inside: | + #[cfg(test)] + mod $MOD { + ... + } + - pattern-not-inside: | + #[cfg(any(test, ...))] + mod $MOD { + ... + } + + - id: la-stack.rust.no-module-scope-cfg-test-use + languages: + - generic + severity: WARNING + message: "Move test-only imports into the test module instead of gating module-scope use items with #[cfg(test)]." + metadata: + category: maintainability + rationale: "Unit-test dependencies should live inside the test module that uses them." + paths: + include: + - "/src/**/*.rs" + - "/tests/*.rs" + - "/benches/**/*.rs" + - "/examples/**/*.rs" + - "/tests/semgrep/src/project_rules/portable_policy.rs" + pattern-regex: '(?m)^[ \t]*#\[cfg\(test\)\][ \t]*\n[ \t]*use[ \t]+[^;]+;' + + - id: la-stack.rust.no-public-api-cfg-test-shim + languages: + - generic + severity: WARNING + message: "Do not expose public APIs through cfg(test); use a real feature gate or keep helpers inside mod tests." + metadata: + category: api + rationale: >- + Public APIs should not exist only to satisfy unit tests. Use a real + feature gate for downstream functionality or private test helpers. + paths: + include: + - "/src/**/*.rs" + - "/tests/*.rs" + - "/benches/**/*.rs" + - "/examples/**/*.rs" + - "/tests/semgrep/src/project_rules/portable_policy.rs" + pattern-regex: '(?m)^[ \t]*#\[cfg\((?:test|any\([^\]\n]*\btest\b[^\]\n]*\))\)\][^\n]*\n(?:[ \t]*#\[[^\]\n]*\][^\n]*\n|[ \t]*#\[[^\n]*\n(?:[ \t]+[^\n]*\n){0,8}[ \t]*[)\]]+[^\n]*\n|[ \t]*///[^\n]*\n|[ \t]*\n){0,8}[ \t]*pub[ \t]+(?:use|(?:const[ \t]+)?fn)\b' # yamllint disable-line rule:line-length + + - id: la-stack.rust.borrowed-view-types-require-lifetime + languages: + - generic + severity: WARNING + message: "Types named *View must be lifetime-bound borrowed observations of canonical storage." + metadata: + category: correctness + rationale: >- + A View name promises a borrowed observation, not an owned snapshot. + Detached values should be named Handle, Key, Snapshot, or Report. + paths: + include: + - "/src/**/*.rs" + - "/tests/*.rs" + - "/benches/**/*.rs" + - "/examples/**/*.rs" + - "/tests/semgrep/src/project_rules/portable_policy.rs" + pattern-regex: >- + (?m)^\s*(?:pub(?:\s*\([^)]*\))?\s+)?struct\s+[A-Za-z0-9_]*View\s*(?:<\s*(?!')[^>{}]*>|where|\{|;|\() + + - id: la-stack.rust.no-public-unchecked-apis + languages: + - generic + severity: WARNING + message: "Keep *_unchecked APIs private so invalid-state escape hatches do not become public contracts." + metadata: + category: correctness + rationale: >- + Unchecked helpers should remain behind validated constructors, parsers, + or setters instead of becoming downstream API contracts. + paths: + include: + - "/src/**/*.rs" + - "/tests/semgrep/src/project_rules/portable_policy.rs" + pattern-regex: '^\s*pub\s+(?:const\s+|async\s+|unsafe\s+)?fn\s+(?:[A-Za-z_][A-Za-z0-9_]*_unchecked|from_unchecked_[A-Za-z0-9_]*)\s*\(' # yamllint disable-line rule:line-length + + - id: la-stack.rust.no-unwrap-expect-in-markdown-examples + languages: + - generic + severity: WARNING + message: "Use fallible Result/? flow instead of unwrap*() or expect() in public Markdown examples." + metadata: + category: correctness + rationale: "Public examples should teach typed error handling instead of panic-based control flow." paths: include: - - "/AGENTS.md" - "/README.md" - "/docs/**/*.md" - - "/justfile" + - "/tests/semgrep/docs/**/*.md" exclude: - "/docs/archive/**" + pattern-regex: '\.(?:unwrap(?:_[A-Za-z0-9_]+)?|expect)\s*\(' + + - id: la-stack.rust.no-box-dyn-error-in-doctests + languages: + - generic + severity: WARNING + message: "Use Result<_, LaError> or another typed error in doctests instead of erased dynamic errors." + metadata: + category: maintainability + rationale: "Public documentation should model the crate's typed error surface." + paths: + include: + - "/src/**/*.rs" + - "/tests/semgrep/doctests/**/*.txt" + - "/tests/semgrep/src/project_rules/portable_policy.rs" + pattern-regex: '^\s*//[!/].*(?:Box\s*<\s*dyn\s+(?:std::error::Error|Error)|&\s*dyn\s+(?:std::error::Error|Error)|anyhow::Error|anyhow::Result)' # yamllint disable-line rule:line-length + + - id: la-stack.rust.prefer-assert-matches-in-doctests + languages: + - generic + severity: WARNING + message: "Use core::assert_matches! instead of assert!(matches!(...)) in public documentation examples." + metadata: + category: maintainability + rationale: "assert_matches! reports the unmatched value instead of only a bare boolean." + paths: + include: + - "/src/**/*.rs" + - "/tests/semgrep/doctests/**/*.txt" + - "/tests/semgrep/src/project_rules/portable_policy.rs" + pattern-regex: '^\s*//[!/].*\bassert!\s*\(\s*matches!\s*\(' + + - id: la-stack.rust.no-box-dyn-error-in-src + languages: + - rust + severity: WARNING + message: "Use a typed error enum instead of Box, &dyn Error, or anyhow::Error." + metadata: + category: maintainability + rationale: "Production fallible paths should preserve typed error contracts." + paths: + include: + - "/src/**/*.rs" + - "/tests/semgrep/src/project_rules/portable_policy.rs" + patterns: + - pattern-regex: >- + ^[ \t]*[^\n]*(\bBox\s*<\s*dyn\s+(std::error::)?Error\b|&\s*dyn\s+(std::error::)?Error\b|\banyhow::Error\b) + - pattern-not-regex: '^[ \t]*(//|///|/\*|\*)' + - pattern-not-inside: | + mod tests { + ... + } + - pattern-not-inside: | + #[cfg(test)] + mod $MOD { + ... + } + - pattern-not-inside: | + #[cfg(any(test, ...))] + mod $MOD { + ... + } + - pattern-not-inside: | + #[cfg(test)] + fn $FUNC(...) { + ... + } + - pattern-not-inside: | + #[cfg(any(test, ...))] + fn $FUNC(...) { + ... + } + - pattern-not-inside: | + #[cfg(test)] + impl $TYPE { + ... + } + - pattern-not-inside: | + #[cfg(any(test, ...))] + impl $TYPE { + ... + } + + - id: la-stack.rust.no-ignored-fallible-results + languages: + - generic + severity: WARNING + message: "Do not discard fallible values with let _ =; inspect the value or use explicit drop(...)." + metadata: + category: correctness + rationale: "Tests and samples should prove what a fallible call returned, not only that it did not error." + paths: + include: + - "/tests/*.rs" + - "/benches/**/*.rs" + - "/examples/**/*.rs" + - "/tests/semgrep/src/project_rules/portable_policy.rs" + pattern-regex: '(?ms)^\s*let\s+_\s*=\s*(?:(?!;).)*?(?:\?\s*;|\.(?:unwrap|expect|or_abort)\s*\()' + + - id: la-stack.rust.no-box-dyn-error-in-examples-benches + languages: + - rust + severity: WARNING + message: "Use concrete crate errors in examples and benchmarks instead of dynamic error erasure." + metadata: + category: maintainability + rationale: "Examples and benchmarks should model typed error handling that users can copy." + paths: + include: + - "/examples/**/*.rs" + - "/benches/**/*.rs" + - "/tests/semgrep/src/project_rules/portable_policy.rs" + patterns: + - pattern-regex: >- + ^[ \t]*[^\n]*(\bBox\s*<\s*dyn\s+(std::error::)?Error\b|&\s*dyn\s+(std::error::)?Error\b|\banyhow::Error\b) + - pattern-not-regex: '^[ \t]*(//|///|/\*|\*)' + + - id: la-stack.rust.no-clippy-allow-lints + languages: + - rust + severity: WARNING + message: "Use #[expect(..., reason = ...)] instead of #[allow(...)] for Clippy suppressions." + metadata: + category: maintainability + rationale: >- + Clippy suppressions should be checked by the compiler and carry a + stable reason so stale suppressions are noticed during linting. + paths: + include: + - "/src/**/*.rs" + - "/tests/*.rs" + - "/benches/**/*.rs" + - "/tests/semgrep/src/project_rules/portable_policy.rs" + pattern-regex: '^\s*#\s*\[\s*allow\s*\(\s*clippy::' + + - id: la-stack.rust.no-ignored-tests + languages: + - generic + severity: WARNING + message: "Gate slow tests with an explicit cfg feature instead of #[ignore]." + metadata: + category: maintainability + rationale: "Explicit test buckets keep nextest and local test commands aligned." + paths: + include: + - "/src/**/*.rs" + - "/tests/*.rs" + - "/benches/**/*.rs" + - "/tests/semgrep/src/project_rules/portable_policy.rs" + pattern-regex: '^\s*#\s*\[\s*ignore(?:\s|=|\])' + + - id: la-stack.rust.expect-requires-reason + languages: + - rust + severity: WARNING + message: 'Add reason = "..." to #[expect(...)] so lint suppressions remain auditable.' + metadata: + category: maintainability + rationale: "Documented lint expectations make stale suppressions reviewable." + paths: + include: + - "/src/**/*.rs" + - "/tests/*.rs" + - "/benches/**/*.rs" + - "/tests/semgrep/src/project_rules/portable_policy.rs" patterns: - - pattern-regex: '(?ms)\bjust\s+fix\b.{0,400}\bjust\s+check\b|\bjust\s+python-fix\b.{0,400}\bjust\s+python-check\b' + - pattern-regex: '^\s*#\s*\[\s*expect\s*\([^\]\n]*\)\s*\]' + - pattern-not-regex: '\breason\s*=' + + - id: la-stack.python.no-broad-exception + languages: + - python + severity: WARNING + message: "Catch a specific exception type instead of broad Exception." + metadata: + category: maintainability + rationale: "Python tooling should surface expected failure modes explicitly." + paths: + include: + - "/scripts/**/*.py" + - "/tests/semgrep/scripts/tests/python_exceptions.py" + pattern-regex: '^\s*except\s+Exception(?:\s+as\s+\w+)?\s*:' + + - id: la-stack.python.no-raw-exception-in-tests + languages: + - python + severity: WARNING + message: "Raise a specific exception type in Python tests instead of raw Exception." + metadata: + category: maintainability + rationale: "Typed exceptions make expected failures easier to diagnose." + paths: + include: + - "/scripts/tests/**/*.py" + - "/tests/semgrep/scripts/tests/python_exceptions.py" + pattern: raise Exception(...) + + - id: la-stack.python.no-adhoc-completedprocess-mock + languages: + - python + severity: WARNING + message: "Use subprocess.CompletedProcess[str] instead of ad hoc Mock stdout/returncode objects." + metadata: + category: maintainability + rationale: "Command-wrapper mocks should stay aligned with the real subprocess result shape." + paths: + include: + - "/scripts/tests/**/*.py" + - "/tests/semgrep/scripts/tests/python_exceptions.py" + pattern-either: + - patterns: + - pattern-either: + - pattern: | + $PROC = Mock(...) + ... + $PROC.stdout = ... + - pattern: | + $PROC = unittest.mock.Mock(...) + ... + $PROC.stdout = ... + - pattern: | + $PROC = mock.Mock(...) + ... + $PROC.stdout = ... + - pattern: | + $PROC = MagicMock(...) + ... + $PROC.stdout = ... + - pattern: | + $PROC = unittest.mock.MagicMock(...) + ... + $PROC.stdout = ... + - pattern: | + $PROC = mock.MagicMock(...) + ... + $PROC.stdout = ... + - patterns: + - pattern-either: + - pattern: | + $PROC = Mock(...) + ... + $PROC.returncode = ... + - pattern: | + $PROC = unittest.mock.Mock(...) + ... + $PROC.returncode = ... + - pattern: | + $PROC = mock.Mock(...) + ... + $PROC.returncode = ... + - pattern: | + $PROC = MagicMock(...) + ... + $PROC.returncode = ... + - pattern: | + $PROC = unittest.mock.MagicMock(...) + ... + $PROC.returncode = ... + - pattern: | + $PROC = mock.MagicMock(...) + ... + $PROC.returncode = ... + - pattern: Mock(..., stdout=..., ...) + - pattern: unittest.mock.Mock(..., stdout=..., ...) + - pattern: mock.Mock(..., stdout=..., ...) + - pattern: MagicMock(..., stdout=..., ...) + - pattern: unittest.mock.MagicMock(..., stdout=..., ...) + - pattern: mock.MagicMock(..., stdout=..., ...) + - pattern: Mock(..., returncode=..., ...) + - pattern: unittest.mock.Mock(..., returncode=..., ...) + - pattern: mock.Mock(..., returncode=..., ...) + - pattern: MagicMock(..., returncode=..., ...) + - pattern: unittest.mock.MagicMock(..., returncode=..., ...) + - pattern: mock.MagicMock(..., returncode=..., ...) + + - id: la-stack.python.no-direct-subprocess-run-outside-wrapper + languages: + - python + severity: WARNING + message: "Use subprocess_utils command wrappers instead of calling subprocess.run directly." + metadata: + category: security + rationale: "Support scripts should inherit the repository's subprocess hardening." + paths: + include: + - "/scripts/**/*.py" + - "/tests/semgrep/scripts/tests/python_exceptions.py" + exclude: + - "/scripts/subprocess_utils.py" + - "/scripts/tests/**/*.py" + pattern: subprocess.run(...) + + - id: la-stack.python.no-untyped-defs-in-scripts + languages: + - python + severity: WARNING + message: "Add an explicit return annotation to script functions." + metadata: + category: maintainability + rationale: "Typed script helpers are easier for ty and reviewers to reason about." + paths: + include: + - "/scripts/**/*.py" + - "/tests/semgrep/scripts/tests/python_exceptions.py" + pattern-regex: '^( {0,4}|\t)(async\s+)?def\s+[A-Za-z_][A-Za-z0-9_]*\([^#\n]*\)\s*:' diff --git a/src/error.rs b/src/error.rs index 4d2c370..e47d5f5 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,16 +1,245 @@ #![forbid(unsafe_code)] -//! Error types and helpers for linear algebra operations. +//! Typed error categories and helpers for linear algebra operations. use core::fmt; -use crate::Tolerance; +/// Floating-point operation that produced a non-finite intermediate or result. +/// +/// # Examples +/// ``` +/// use la_stack::ArithmeticOperation; +/// +/// assert_eq!(ArithmeticOperation::LuSolve.to_string(), "LU solve"); +/// ``` +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum ArithmeticOperation { + /// Matrix infinity-norm calculation. + MatrixInfinityNorm, + /// Matrix symmetry validation. + SymmetryCheck, + /// LU factorization. + LuFactorization, + /// LDLT factorization. + LdltFactorization, + /// Forward or backward substitution with an LU factorization. + LuSolve, + /// Forward, diagonal, or backward substitution with an LDLT factorization. + LdltSolve, + /// Determinant calculation. + Determinant, + /// Determinant error-bound calculation. + DeterminantErrorBound, + /// Vector dot-product calculation. + VectorDotProduct, + /// Vector squared-norm calculation. + VectorSquaredNorm, +} + +impl fmt::Display for ArithmeticOperation { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::MatrixInfinityNorm => "matrix infinity norm", + Self::SymmetryCheck => "symmetry check", + Self::LuFactorization => "LU factorization", + Self::LdltFactorization => "LDLT factorization", + Self::LuSolve => "LU solve", + Self::LdltSolve => "LDLT solve", + Self::Determinant => "determinant", + Self::DeterminantErrorBound => "determinant error bound", + Self::VectorDotProduct => "vector dot product", + Self::VectorSquaredNorm => "vector squared norm", + }) + } +} + +/// Factorization whose pivot policy rejected a matrix as numerically singular. +/// +/// # Examples +/// ``` +/// use la_stack::FactorizationKind; +/// +/// assert_eq!(FactorizationKind::Ldlt.to_string(), "LDLT"); +/// ``` +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum FactorizationKind { + /// LU factorization with partial pivoting. + Lu, + /// LDLT factorization without pivoting. + Ldlt, +} + +impl fmt::Display for FactorizationKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Lu => "LU", + Self::Ldlt => "LDLT", + }) + } +} + +/// Reason a raw tolerance cannot become a [`crate::Tolerance`]. +/// +/// # Examples +/// ``` +/// use la_stack::prelude::*; +/// +/// match LaError::invalid_tolerance(-1.0) { +/// LaError::InvalidTolerance { +/// reason: InvalidToleranceReason::Negative, +/// .. +/// } => {} +/// _ => unreachable!("a finite negative tolerance has a negative reason"), +/// } +/// ``` +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum InvalidToleranceReason { + /// The tolerance is finite but negative. + Negative, + /// The tolerance is NaN or positive/negative infinity. + NotFinite, +} + +/// Location at which a non-finite value was observed. +/// +/// # Examples +/// ``` +/// use la_stack::prelude::*; +/// +/// match LaError::non_finite_input_matrix(1, 2) { +/// LaError::NonFinite { +/// location: NonFiniteLocation::MatrixCell { row, col, .. }, +/// .. +/// } => assert_eq!((row, col), (1, 2)), +/// _ => unreachable!("constructor returns a matrix-cell location"), +/// } +/// ``` +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum NonFiniteLocation { + /// Cell `(row, col)` in matrix-shaped storage or computation. + #[non_exhaustive] + MatrixCell { + /// Matrix row. + row: usize, + /// Matrix column. + col: usize, + }, + /// Entry in a vector input. + #[non_exhaustive] + VectorEntry { + /// Vector index. + index: usize, + }, + /// Indexed step in a factorization, solve, or reduction. + #[non_exhaustive] + Step { + /// Step index. + index: usize, + }, + /// Scalar value without a meaningful matrix or vector coordinate. + Scalar, +} + +/// Provenance of a non-finite value. +/// +/// # Examples +/// ``` +/// use la_stack::prelude::*; +/// +/// let err = LaError::non_finite_computation_scalar(ArithmeticOperation::Determinant); +/// match err { +/// LaError::NonFinite { +/// origin: NonFiniteOrigin::Computation { operation, .. }, +/// .. +/// } => assert_eq!(operation, ArithmeticOperation::Determinant), +/// _ => unreachable!("constructor preserves computation provenance"), +/// } +/// ``` +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum NonFiniteOrigin { + /// The caller supplied a non-finite input. + Input, + /// Finite inputs produced a non-finite arithmetic result. + #[non_exhaustive] + Computation { + /// Operation that produced the value. + operation: ArithmeticOperation, + }, +} + +/// Reason a symmetric matrix is outside the positive-semidefinite LDLT domain. +/// +/// # Examples +/// ``` +/// use la_stack::prelude::*; +/// +/// match LaError::not_positive_semidefinite_negative(1, -3.0) { +/// LaError::NotPositiveSemidefinite { +/// violation: PositiveSemidefiniteViolation::NegativePivot { value, .. }, +/// .. +/// } => assert_eq!(value, -3.0), +/// _ => unreachable!("constructor preserves the PSD violation"), +/// } +/// ``` +#[derive(Clone, Copy, Debug, PartialEq)] +#[non_exhaustive] +pub enum PositiveSemidefiniteViolation { + /// LDLT produced a strictly negative diagonal pivot. + #[non_exhaustive] + NegativePivot { + /// Observed negative pivot value. + value: f64, + }, + /// A zero diagonal pivot still has a non-zero coupling below it. + #[non_exhaustive] + ZeroPivotCoupling { + /// Row containing the non-zero coupling. + row: usize, + /// Observed coupling value. + value: f64, + }, +} + +/// Mathematical or numerical reason a matrix was classified as singular. +/// +/// # Examples +/// ``` +/// use la_stack::prelude::*; +/// +/// match LaError::singular_numerical(1, FactorizationKind::Lu, 0.0, 1e-12) { +/// LaError::Singular { +/// reason: SingularityReason::Numerical { factorization, .. }, .. +/// } => assert_eq!(factorization, FactorizationKind::Lu), +/// _ => unreachable!("constructor preserves the singularity reason"), +/// } +/// ``` +#[derive(Clone, Copy, Debug, PartialEq)] +#[non_exhaustive] +pub enum SingularityReason { + /// The algorithm proved that the pivot is exactly zero. + Exact, + /// A finite pivot was rejected by the factorization tolerance. + #[non_exhaustive] + Numerical { + /// Factorization applying the tolerance policy. + factorization: FactorizationKind, + /// Absolute magnitude of the rejected pivot. + pivot_magnitude: f64, + /// Tolerance against which the magnitude was compared. + tolerance: f64, + }, +} /// Reason an exact result cannot satisfy an exact-to-`f64` conversion contract. /// /// `RequiresRounding` is recoverable when the caller is willing to opt into a -/// rounded exact-to-`f64` API. `NotFinite` means even the rounded result would -/// not be a finite `f64`. +/// rounded exact-to-`f64` API. `NotFinite` means no finite `f64` can represent +/// the result even after rounding. /// /// # Examples /// ``` @@ -31,214 +260,228 @@ pub enum UnrepresentableReason { /// A finite `f64` exists only after rounding, but the requested conversion /// requires an exact binary64 representation. RequiresRounding, - /// The exact value would convert to NaN or infinity rather than a finite - /// `f64`. + /// No finite `f64` can represent the exact value after rounding. NotFinite, } /// Linear algebra errors. /// -/// This enum is `#[non_exhaustive]` — downstream `match` arms must include a -/// wildcard (`_`) pattern to compile, allowing new variants to be added in -/// future minor releases without breaking existing code. +/// This enum and each struct-style variant are `#[non_exhaustive]` so downstream +/// matches must retain a wildcard for future error categories and fields. /// /// # Examples /// ``` /// use la_stack::prelude::*; /// -/// let err = LaError::unrepresentable(None, UnrepresentableReason::RequiresRounding); -/// assert!(err.requires_rounding()); -/// -/// match LaError::unsupported_dimension(8, MAX_STACK_MATRIX_DISPATCH_DIM) { -/// LaError::UnsupportedDimension { requested, max } => { -/// assert_eq!((requested, max), (8, MAX_STACK_MATRIX_DISPATCH_DIM)); -/// } -/// _ => unreachable!("constructor returns the requested variant"), +/// match LaError::singular_exact(2) { +/// LaError::Singular { +/// pivot_col, +/// reason: SingularityReason::Exact, +/// .. +/// } => assert_eq!(pivot_col, 2), +/// _ => unreachable!("constructor returns an exact singularity"), /// } /// ``` #[derive(Clone, Copy, Debug, PartialEq)] #[non_exhaustive] pub enum LaError { - /// The matrix is (numerically) singular. + /// A matrix is exactly or numerically singular. + #[non_exhaustive] Singular { - /// The factorization column/step where a suitable pivot/diagonal could not be found. + /// Factorization column or step where a usable pivot was unavailable. pivot_col: usize, + /// Typed reason for the singularity classification. + reason: SingularityReason, }, - /// A non-finite value (NaN/∞) was encountered. - /// - /// The `(row, col)` coordinate follows a consistent convention across the crate: - /// - /// - `row: Some(r), col: c` — the non-finite value is tied to a matrix/factor - /// cell at `(r, c)`, either because a stored input/factor cell is already - /// non-finite or because factorization computed a non-finite value for - /// that cell before storing it. - /// - `row: None, col: c` — the non-finite value is tied to a vector entry, - /// determinant product, solve accumulator, or other scalar/intermediate - /// that has no matrix row coordinate. + /// A caller input or arithmetic result is NaN or infinite. + #[non_exhaustive] NonFinite { - /// Row of the non-finite entry for a stored matrix cell, or `None` for - /// a vector-input entry or a computed intermediate. See the variant - /// docs for the full convention. - row: Option, - /// Column index (stored cell), vector index, or factorization/solve - /// step where the non-finite value was detected. - col: usize, + /// Typed location of the value. + location: NonFiniteLocation, + /// Whether the value came from input or a particular computation. + origin: NonFiniteOrigin, }, - /// An exact result cannot satisfy the requested finite `f64` conversion. - /// - /// Returned by [`Matrix::det_exact_f64`](crate::Matrix::det_exact_f64) and - /// [`Matrix::solve_exact_f64`](crate::Matrix::solve_exact_f64) (requires the - /// `exact` feature) when the exact rational value is too large, too small, - /// or would require rounding in binary64. Also returned by the rounded - /// exact-to-`f64` APIs when the rounded result would be NaN or infinite. + /// An exact result cannot satisfy the requested finite-`f64` conversion. + #[non_exhaustive] Unrepresentable { - /// For vector results (e.g. `solve_exact_f64`), the index of the - /// component that failed conversion. `None` for scalar results. + /// Failed vector component, or `None` for a scalar result. index: Option, - /// Why the requested conversion cannot return a finite `f64`. + /// Reason the conversion contract cannot be satisfied. reason: UnrepresentableReason, }, /// Exact determinant scaling overflowed the internal exponent representation. + #[non_exhaustive] DeterminantScaleOverflow { /// Matrix dimension `D`. dim: usize, - /// Minimum decomposed f64 exponent among non-zero matrix entries. + /// Minimum decomposed binary64 exponent among non-zero entries. min_exponent: i32, }, - /// A requested runtime matrix dimension has no stack-dispatch arm. + /// A runtime matrix dimension has no stack-dispatch arm. + #[non_exhaustive] UnsupportedDimension { /// Runtime dimension requested by the caller. requested: usize, - /// Largest runtime dimension supported by the dispatch helper. + /// Largest dimension supported by the dispatch helper. max: usize, }, /// A matrix index is outside the `D×D` storage domain. + #[non_exhaustive] IndexOutOfBounds { - /// Requested row index. + /// Requested row. row: usize, - /// Requested column index. + /// Requested column. col: usize, - /// Matrix dimension `D`; valid row and column indices are `< dim`. + /// Matrix dimension `D`; valid indices are less than this value. dim: usize, }, - /// A tolerance value is not finite and non-negative. + /// A raw tolerance is negative or non-finite. + #[non_exhaustive] InvalidTolerance { - /// Raw tolerance supplied by the caller. + /// Raw value supplied by the caller. value: f64, + /// Typed reason the value violates the tolerance invariant. + reason: InvalidToleranceReason, }, /// A matrix required to be symmetric has an asymmetric off-diagonal pair. + #[non_exhaustive] Asymmetric { - /// Row index of the first asymmetric pair. + /// Row of the upper-triangular entry. row: usize, - /// Column index of the first asymmetric pair. + /// Column of the upper-triangular entry. col: usize, /// Matrix dimension `D`. dim: usize, + /// Observed entry at `(row, col)`. + upper: f64, + /// Observed entry at `(col, row)`. + lower: f64, + /// Maximum absolute difference allowed by the symmetry check. + allowed_abs_diff: f64, }, - /// A symmetric matrix failed the positive-semidefinite LDLT domain check. + /// A symmetric matrix is outside the positive-semidefinite LDLT domain. + #[non_exhaustive] NotPositiveSemidefinite { - /// Factorization column/step where a negative LDLT diagonal was found. + /// LDLT pivot column or step where the violation was detected. pivot_col: usize, - /// Negative diagonal value observed at that step. - value: f64, + /// Typed PSD-domain violation. + violation: PositiveSemidefiniteViolation, }, } impl LaError { - /// Construct a [`LaError::NonFinite`] pinpointing a stored matrix cell at `(row, col)`. - /// - /// Use this for non-finite values read from a stored [`Matrix`](crate::Matrix) - /// entry or factorization cell, and for non-finite factorization updates - /// that would be stored at `(row, col)` if accepted. The resulting error has - /// `row: Some(row), col`, matching the matrix/factor-cell convention - /// documented on [`NonFinite`](Self::NonFinite). For vector-input entries - /// or scalar intermediates without a matrix row coordinate, use - /// [`non_finite_at`](Self::non_finite_at). - /// - /// # Examples - /// ``` - /// use la_stack::prelude::*; - /// - /// assert_eq!( - /// LaError::non_finite_cell(1, 2), - /// LaError::NonFinite { - /// row: Some(1), - /// col: 2, - /// } - /// ); - /// ``` + /// Construct a [`LaError::Singular`] error proving that the pivot at + /// `pivot_col` is exactly zero. + #[inline] + #[must_use] + pub const fn singular_exact(pivot_col: usize) -> Self { + Self::Singular { + pivot_col, + reason: SingularityReason::Exact, + } + } + + /// Construct a [`LaError::Singular`] error for a pivot rejected by a + /// factorization tolerance, preserving the factorization, pivot magnitude, + /// and tolerance in [`SingularityReason::Numerical`]. + #[inline] + #[must_use] + pub const fn singular_numerical( + pivot_col: usize, + factorization: FactorizationKind, + pivot_magnitude: f64, + tolerance: f64, + ) -> Self { + Self::Singular { + pivot_col, + reason: SingularityReason::Numerical { + factorization, + pivot_magnitude, + tolerance, + }, + } + } + + /// Construct a [`LaError::NonFinite`] input error located at matrix cell + /// `(row, col)`. #[inline] #[must_use] - pub const fn non_finite_cell(row: usize, col: usize) -> Self { + pub const fn non_finite_input_matrix(row: usize, col: usize) -> Self { Self::NonFinite { - row: Some(row), - col, + location: NonFiniteLocation::MatrixCell { row, col }, + origin: NonFiniteOrigin::Input, } } - /// Construct a [`LaError::NonFinite`] pinpointing a vector-input entry or - /// computed scalar/intermediate at index `col`. - /// - /// Use this for non-finite values in a [`Vector`](crate::Vector) input, - /// determinant scalar, tolerance-scale accumulator, or solve accumulator - /// that overflowed during forward/back substitution. The resulting error - /// has `row: None, col`, matching the vector/scalar-intermediate convention - /// documented on [`NonFinite`](Self::NonFinite). For stored matrix cells or - /// computed factorization updates tied to a matrix cell, use - /// [`non_finite_cell`](Self::non_finite_cell). - /// - /// # Examples - /// ``` - /// use la_stack::prelude::*; - /// - /// assert_eq!( - /// LaError::non_finite_at(2), - /// LaError::NonFinite { row: None, col: 2 } - /// ); - /// ``` + /// Construct a [`LaError::NonFinite`] input error located at vector entry + /// `index`. #[inline] #[must_use] - pub const fn non_finite_at(col: usize) -> Self { - Self::NonFinite { row: None, col } + pub const fn non_finite_input_vector(index: usize) -> Self { + Self::NonFinite { + location: NonFiniteLocation::VectorEntry { index }, + origin: NonFiniteOrigin::Input, + } } - /// Construct a [`LaError::Unrepresentable`] for exact-to-`f64` conversion. - /// - /// # Examples - /// ``` - /// use la_stack::prelude::*; - /// - /// assert_eq!( - /// LaError::unrepresentable(Some(2), UnrepresentableReason::RequiresRounding), - /// LaError::Unrepresentable { - /// index: Some(2), - /// reason: UnrepresentableReason::RequiresRounding, - /// } - /// ); - /// ``` + /// Construct a [`LaError::NonFinite`] input error for a scalar without an + /// index or matrix coordinate. + #[inline] + #[must_use] + pub const fn non_finite_input_scalar() -> Self { + Self::NonFinite { + location: NonFiniteLocation::Scalar, + origin: NonFiniteOrigin::Input, + } + } + + /// Construct a [`LaError::NonFinite`] computation error at matrix cell + /// `(row, col)`, retaining the originating `operation`. + #[inline] + #[must_use] + pub const fn non_finite_computation_matrix( + operation: ArithmeticOperation, + row: usize, + col: usize, + ) -> Self { + Self::NonFinite { + location: NonFiniteLocation::MatrixCell { row, col }, + origin: NonFiniteOrigin::Computation { operation }, + } + } + + /// Construct a [`LaError::NonFinite`] computation error at `index`, + /// retaining the originating `operation`. + #[inline] + #[must_use] + pub const fn non_finite_computation_step(operation: ArithmeticOperation, index: usize) -> Self { + Self::NonFinite { + location: NonFiniteLocation::Step { index }, + origin: NonFiniteOrigin::Computation { operation }, + } + } + + /// Construct a scalar [`LaError::NonFinite`] computation error retaining + /// the originating `operation`. + #[inline] + #[must_use] + pub const fn non_finite_computation_scalar(operation: ArithmeticOperation) -> Self { + Self::NonFinite { + location: NonFiniteLocation::Scalar, + origin: NonFiniteOrigin::Computation { operation }, + } + } + + /// Construct a [`LaError::Unrepresentable`] conversion failure for a scalar + /// (`index = None`) or vector component (`index = Some(_)`). #[inline] #[must_use] pub const fn unrepresentable(index: Option, reason: UnrepresentableReason) -> Self { Self::Unrepresentable { index, reason } } - /// Return the reason for an exact-to-`f64` conversion failure. - /// - /// This is a concise alternative to matching the full - /// [`LaError::Unrepresentable`] variant when callers only need the - /// conversion reason. - /// - /// # Examples - /// ``` - /// use la_stack::prelude::*; - /// - /// let err = LaError::unrepresentable(None, UnrepresentableReason::RequiresRounding); - /// assert_eq!( - /// err.unrepresentable_reason(), - /// Some(UnrepresentableReason::RequiresRounding) - /// ); - /// assert_eq!(LaError::Singular { pivot_col: 0 }.unrepresentable_reason(), None); - /// ``` + /// Return the typed exact-to-`f64` conversion reason, or `None` for every + /// other error variant. #[inline] #[must_use] pub const fn unrepresentable_reason(&self) -> Option { @@ -248,23 +491,8 @@ impl LaError { } } - /// Return `true` when strict exact-to-`f64` conversion only failed because - /// rounding would be required. - /// - /// This is useful at the call site that wants to retry with an explicit - /// rounded exact-to-`f64` API while still propagating non-finite conversion - /// failures. - /// - /// # Examples - /// ``` - /// use la_stack::prelude::*; - /// - /// let err = LaError::unrepresentable(None, UnrepresentableReason::RequiresRounding); - /// assert!(err.requires_rounding()); - /// - /// let err = LaError::unrepresentable(None, UnrepresentableReason::NotFinite); - /// assert!(!err.requires_rounding()); - /// ``` + /// Return whether this is a `RequiresRounding` conversion failure for which + /// retrying with an explicit rounded API may succeed. #[inline] #[must_use] pub const fn requires_rounding(&self) -> bool { @@ -277,217 +505,228 @@ impl LaError { ) } - /// Construct a [`LaError::DeterminantScaleOverflow`] for exact determinant scaling. - /// - /// # Examples - /// ``` - /// use la_stack::prelude::*; - /// - /// assert_eq!( - /// LaError::determinant_scale_overflow(3, -1074), - /// LaError::DeterminantScaleOverflow { - /// dim: 3, - /// min_exponent: -1074, - /// } - /// ); - /// ``` + /// Construct a [`LaError::DeterminantScaleOverflow`] retaining the matrix + /// dimension and minimum decomposed entry exponent. #[inline] #[must_use] pub const fn determinant_scale_overflow(dim: usize, min_exponent: i32) -> Self { Self::DeterminantScaleOverflow { dim, min_exponent } } - /// Construct a [`LaError::UnsupportedDimension`] for runtime stack dispatch. - /// - /// # Examples - /// ``` - /// use la_stack::prelude::*; - /// - /// assert_eq!( - /// LaError::unsupported_dimension(8, MAX_STACK_MATRIX_DISPATCH_DIM), - /// LaError::UnsupportedDimension { - /// requested: 8, - /// max: MAX_STACK_MATRIX_DISPATCH_DIM, - /// } - /// ); - /// ``` + /// Construct a [`LaError::UnsupportedDimension`] retaining the requested + /// and maximum supported dimensions. #[inline] #[must_use] pub const fn unsupported_dimension(requested: usize, max: usize) -> Self { Self::UnsupportedDimension { requested, max } } - /// Construct a [`LaError::IndexOutOfBounds`] for a `D×D` matrix index. - /// - /// # Examples - /// ``` - /// use la_stack::prelude::*; - /// - /// assert_eq!( - /// LaError::index_out_of_bounds(2, 0, 2), - /// LaError::IndexOutOfBounds { - /// row: 2, - /// col: 0, - /// dim: 2, - /// } - /// ); - /// ``` + /// Construct a [`LaError::IndexOutOfBounds`] retaining the requested matrix + /// coordinates and dimension. #[inline] #[must_use] pub const fn index_out_of_bounds(row: usize, col: usize, dim: usize) -> Self { Self::IndexOutOfBounds { row, col, dim } } - /// Construct a [`LaError::InvalidTolerance`] for a raw tolerance value. + /// Construct an invalid-tolerance error and classify its typed reason. /// - /// # Examples - /// ``` - /// use la_stack::prelude::*; - /// - /// assert_eq!( - /// LaError::invalid_tolerance(-1.0), - /// LaError::InvalidTolerance { value: -1.0 } - /// ); - /// ``` + /// This low-level constructor assumes `value` has already failed the + /// tolerance invariant; raw caller input should normally be parsed through + /// [`crate::Tolerance::try_new`]. + /// Non-finiteness takes precedence over negativity, so negative infinity is + /// classified as [`InvalidToleranceReason::NotFinite`]. #[inline] #[must_use] pub const fn invalid_tolerance(value: f64) -> Self { - Self::InvalidTolerance { value } + let reason = if value.is_finite() { + InvalidToleranceReason::Negative + } else { + InvalidToleranceReason::NotFinite + }; + Self::InvalidTolerance { value, reason } } - /// Construct a [`LaError::Asymmetric`] for a `D×D` matrix. - /// - /// # Examples - /// ``` - /// use la_stack::prelude::*; - /// - /// assert_eq!( - /// LaError::asymmetric(0, 1, 3), - /// LaError::Asymmetric { - /// row: 0, - /// col: 1, - /// dim: 3, - /// } - /// ); - /// ``` + /// Construct a [`LaError::Asymmetric`] error for the pair `(row, col)` and + /// `(col, row)`, retaining both observed values and the effective absolute + /// difference bound. #[inline] #[must_use] - pub const fn asymmetric(row: usize, col: usize, dim: usize) -> Self { - Self::Asymmetric { row, col, dim } + pub const fn asymmetric( + row: usize, + col: usize, + dim: usize, + upper: f64, + lower: f64, + allowed_abs_diff: f64, + ) -> Self { + Self::Asymmetric { + row, + col, + dim, + upper, + lower, + allowed_abs_diff, + } } - /// Construct a [`LaError::NotPositiveSemidefinite`] for LDLT factorization. - /// - /// # Examples - /// ``` - /// use la_stack::prelude::*; - /// - /// assert_eq!( - /// LaError::not_positive_semidefinite(1, -3.0), - /// LaError::NotPositiveSemidefinite { - /// pivot_col: 1, - /// value: -3.0, - /// } - /// ); - /// ``` + /// Construct a [`LaError::NotPositiveSemidefinite`] error for a negative + /// LDLT diagonal pivot. #[inline] #[must_use] - pub const fn not_positive_semidefinite(pivot_col: usize, value: f64) -> Self { - Self::NotPositiveSemidefinite { pivot_col, value } + pub const fn not_positive_semidefinite_negative(pivot_col: usize, value: f64) -> Self { + Self::NotPositiveSemidefinite { + pivot_col, + violation: PositiveSemidefiniteViolation::NegativePivot { value }, + } } - /// Parse a raw tolerance into a finite, non-negative [`Tolerance`]. - /// - /// # Examples - /// ``` - /// use la_stack::prelude::*; - /// - /// assert_eq!(LaError::validate_tolerance(1e-12)?.get(), 1e-12); - /// - /// let raw = 0.0; - /// let tol = LaError::validate_tolerance(raw)?; - /// let _lu = Matrix::<2>::identity().lu(tol)?; - /// - /// assert_eq!( - /// LaError::validate_tolerance(-1.0), - /// Err(LaError::InvalidTolerance { value: -1.0 }) - /// ); - /// # Ok::<(), LaError>(()) - /// ``` - /// - /// # Errors - /// Returns [`LaError::InvalidTolerance`] when `value` is NaN, infinite, or - /// negative. + /// Construct a [`LaError::NotPositiveSemidefinite`] error for a zero LDLT + /// diagonal with a non-zero coupling at `row`, distinguishing the observed + /// positive-semidefinite violation from an uncoupled singular pivot. #[inline] - pub const fn validate_tolerance(value: f64) -> Result { - Tolerance::new(value) + #[must_use] + pub const fn not_positive_semidefinite_zero_coupling( + pivot_col: usize, + row: usize, + value: f64, + ) -> Self { + Self::NotPositiveSemidefinite { + pivot_col, + violation: PositiveSemidefiniteViolation::ZeroPivotCoupling { row, value }, + } + } +} + +/// Write the structured location portion of [`LaError::NonFinite`]'s public +/// display contract without allocating an intermediate string. +/// +fn write_non_finite_location( + f: &mut fmt::Formatter<'_>, + location: NonFiniteLocation, +) -> fmt::Result { + match location { + NonFiniteLocation::MatrixCell { row, col } => { + write!(f, "matrix cell ({row}, {col})") + } + NonFiniteLocation::VectorEntry { index } => write!(f, "vector entry {index}"), + NonFiniteLocation::Step { index } => write!(f, "step {index}"), + NonFiniteLocation::Scalar => f.write_str("scalar value"), + } +} + +/// Write a [`LaError::NonFinite`] message from its structured location and +/// origin while distinguishing scalar input from a computed scalar result. +fn write_non_finite( + f: &mut fmt::Formatter<'_>, + location: NonFiniteLocation, + origin: NonFiniteOrigin, +) -> fmt::Result { + match (location, origin) { + (NonFiniteLocation::Scalar, NonFiniteOrigin::Input) => { + f.write_str("non-finite scalar input") + } + (NonFiniteLocation::Scalar, NonFiniteOrigin::Computation { operation }) => { + write!(f, "non-finite scalar result computed during {operation}") + } + (location, NonFiniteOrigin::Input) => { + f.write_str("non-finite input value at ")?; + write_non_finite_location(f, location) + } + (location, NonFiniteOrigin::Computation { operation }) => { + write!(f, "non-finite value computed during {operation} at ")?; + write_non_finite_location(f, location) + } } } impl fmt::Display for LaError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match *self { - Self::Singular { pivot_col } => { - write!(f, "singular matrix at pivot column {pivot_col}") - } - Self::NonFinite { row: Some(r), col } => { - write!(f, "non-finite value at ({r}, {col})") - } - Self::NonFinite { row: None, col } => { - write!(f, "non-finite value at index {col}") - } + Self::Singular { + pivot_col, + reason: SingularityReason::Exact, + } => write!(f, "matrix is exactly singular at pivot column {pivot_col}"), + Self::Singular { + pivot_col, + reason: + SingularityReason::Numerical { + factorization, + pivot_magnitude, + tolerance, + }, + } => write!( + f, + "matrix is numerically singular during {factorization} factorization at pivot column {pivot_col}: pivot magnitude {pivot_magnitude} <= tolerance {tolerance}" + ), + Self::NonFinite { location, origin } => write_non_finite(f, location, origin), Self::Unrepresentable { - index: Some(i), + index: Some(index), reason: UnrepresentableReason::RequiresRounding, } => write!( f, - "exact result requires rounding to fit finite f64 at index {i}" + "exact result requires rounding to fit finite f64 at index {index}" ), Self::Unrepresentable { index: None, reason: UnrepresentableReason::RequiresRounding, - } => write!(f, "exact result requires rounding to fit finite f64"), + } => f.write_str("exact result requires rounding to fit finite f64"), Self::Unrepresentable { - index: Some(i), + index: Some(index), reason: UnrepresentableReason::NotFinite, - } => write!(f, "exact result does not round to finite f64 at index {i}"), + } => write!( + f, + "exact result has no finite f64 representation after rounding at index {index}" + ), Self::Unrepresentable { index: None, reason: UnrepresentableReason::NotFinite, - } => write!(f, "exact result does not round to finite f64"), - Self::DeterminantScaleOverflow { dim, min_exponent } => { - write!( - f, - "exact determinant scale exponent overflows for dimension {dim} with minimum entry exponent {min_exponent}" - ) - } - Self::UnsupportedDimension { requested, max } => { - write!( - f, - "unsupported matrix dimension {requested}; maximum stack-dispatch dimension is {max}" - ) - } - Self::IndexOutOfBounds { row, col, dim } => { - write!( - f, - "matrix index ({row}, {col}) is out of bounds for dimension {dim}" - ) - } - Self::InvalidTolerance { value } => { - write!(f, "invalid tolerance {value}; expected finite value >= 0") - } - Self::Asymmetric { row, col, dim } => { - write!( - f, - "matrix is not symmetric for dimension {dim}: asymmetric pair ({row}, {col})" - ) - } - Self::NotPositiveSemidefinite { pivot_col, value } => { - write!( - f, - "matrix is not positive semidefinite at LDLT pivot column {pivot_col}: diagonal value {value} < 0" - ) - } + } => f.write_str("exact result has no finite f64 representation after rounding"), + Self::DeterminantScaleOverflow { dim, min_exponent } => write!( + f, + "exact determinant scale exponent overflows for dimension {dim} with minimum entry exponent {min_exponent}" + ), + Self::UnsupportedDimension { requested, max } => write!( + f, + "unsupported matrix dimension {requested}; maximum stack-dispatch dimension is {max}" + ), + Self::IndexOutOfBounds { row, col, dim } => write!( + f, + "matrix index ({row}, {col}) is out of bounds for dimension {dim}" + ), + Self::InvalidTolerance { + value, + reason: InvalidToleranceReason::Negative, + } => write!(f, "invalid tolerance {value}; expected value >= 0"), + Self::InvalidTolerance { + value, + reason: InvalidToleranceReason::NotFinite, + } => write!(f, "invalid tolerance {value}; expected a finite value"), + Self::Asymmetric { + row, + col, + dim, + upper, + lower, + allowed_abs_diff, + } => write!( + f, + "matrix is not symmetric for dimension {dim}: entry ({row}, {col}) = {upper} and entry ({col}, {row}) = {lower} differ by more than allowed absolute difference {allowed_abs_diff}" + ), + Self::NotPositiveSemidefinite { + pivot_col, + violation: PositiveSemidefiniteViolation::NegativePivot { value }, + } => write!( + f, + "matrix is not positive semidefinite at LDLT pivot column {pivot_col}: diagonal value {value} < 0" + ), + Self::NotPositiveSemidefinite { + pivot_col, + violation: PositiveSemidefiniteViolation::ZeroPivotCoupling { row, value }, + } => write!( + f, + "matrix is not positive semidefinite at LDLT pivot column {pivot_col}: zero diagonal has non-zero coupling at row {row} with value {value}" + ), } } } @@ -496,223 +735,204 @@ impl std::error::Error for LaError {} #[cfg(test)] mod tests { - use super::*; - - use core::assert_matches; - - #[test] - fn laerror_display_formats_singular() { - let err = LaError::Singular { pivot_col: 3 }; - assert_eq!(err.to_string(), "singular matrix at pivot column 3"); - } + use std::error::Error; - #[test] - fn laerror_display_formats_nonfinite_with_row() { - let err = LaError::NonFinite { - row: Some(1), - col: 2, - }; - assert_eq!(err.to_string(), "non-finite value at (1, 2)"); - } - - #[test] - fn laerror_display_formats_nonfinite_without_row() { - let err = LaError::NonFinite { row: None, col: 3 }; - assert_eq!(err.to_string(), "non-finite value at index 3"); - } + use super::*; + use crate::MAX_STACK_MATRIX_DISPATCH_DIM; #[test] - fn laerror_display_formats_unrepresentable_requires_rounding() { - let err = LaError::Unrepresentable { - index: None, - reason: UnrepresentableReason::RequiresRounding, - }; + fn category_displays_are_concise() { + assert_eq!(FactorizationKind::Lu.to_string(), "LU"); + assert_eq!(FactorizationKind::Ldlt.to_string(), "LDLT"); assert_eq!( - err.to_string(), - "exact result requires rounding to fit finite f64" + ArithmeticOperation::MatrixInfinityNorm.to_string(), + "matrix infinity norm" ); - } - - #[test] - fn laerror_display_formats_unrepresentable_requires_rounding_with_index() { - let err = LaError::Unrepresentable { - index: Some(2), - reason: UnrepresentableReason::RequiresRounding, - }; assert_eq!( - err.to_string(), - "exact result requires rounding to fit finite f64 at index 2" + ArithmeticOperation::SymmetryCheck.to_string(), + "symmetry check" ); - } - - #[test] - fn laerror_display_formats_unrepresentable_not_finite() { - let err = LaError::Unrepresentable { - index: None, - reason: UnrepresentableReason::NotFinite, - }; - assert_eq!(err.to_string(), "exact result does not round to finite f64"); - } - - #[test] - fn laerror_display_formats_unrepresentable_not_finite_with_index() { - let err = LaError::Unrepresentable { - index: Some(2), - reason: UnrepresentableReason::NotFinite, - }; assert_eq!( - err.to_string(), - "exact result does not round to finite f64 at index 2" + ArithmeticOperation::LuFactorization.to_string(), + "LU factorization" ); - } - - #[test] - fn laerror_unrepresentable_reason_reports_typed_reason() { - let rounding = LaError::Unrepresentable { - index: Some(2), - reason: UnrepresentableReason::RequiresRounding, - }; - let not_finite = LaError::Unrepresentable { - index: None, - reason: UnrepresentableReason::NotFinite, - }; - assert_eq!( - rounding.unrepresentable_reason(), - Some(UnrepresentableReason::RequiresRounding) + ArithmeticOperation::LdltFactorization.to_string(), + "LDLT factorization" ); + assert_eq!(ArithmeticOperation::LuSolve.to_string(), "LU solve"); + assert_eq!(ArithmeticOperation::LdltSolve.to_string(), "LDLT solve"); + assert_eq!(ArithmeticOperation::Determinant.to_string(), "determinant"); assert_eq!( - not_finite.unrepresentable_reason(), - Some(UnrepresentableReason::NotFinite) + ArithmeticOperation::DeterminantErrorBound.to_string(), + "determinant error bound" ); assert_eq!( - LaError::Singular { pivot_col: 0 }.unrepresentable_reason(), - None + ArithmeticOperation::VectorDotProduct.to_string(), + "vector dot product" + ); + assert_eq!( + ArithmeticOperation::VectorSquaredNorm.to_string(), + "vector squared norm" ); } #[test] - fn laerror_requires_rounding_only_matches_rounding_reason() { - assert!( - LaError::Unrepresentable { - index: Some(2), - reason: UnrepresentableReason::RequiresRounding, + fn singular_constructors_and_displays_preserve_reason() { + let exact = LaError::singular_exact(3); + assert_eq!( + exact, + LaError::Singular { + pivot_col: 3, + reason: SingularityReason::Exact, } - .requires_rounding() ); - assert!( - !LaError::Unrepresentable { - index: None, - reason: UnrepresentableReason::NotFinite, - } - .requires_rounding() + assert_eq!( + exact.to_string(), + "matrix is exactly singular at pivot column 3" ); - assert!(!LaError::Singular { pivot_col: 0 }.requires_rounding()); - } - #[test] - fn laerror_display_formats_determinant_scale_overflow() { - let err = LaError::DeterminantScaleOverflow { - dim: 3, - min_exponent: -1074, - }; + let numerical = LaError::singular_numerical(2, FactorizationKind::Lu, 1e-14, 1e-12); assert_eq!( - err.to_string(), - "exact determinant scale exponent overflows for dimension 3 with minimum entry exponent -1074" + numerical, + LaError::Singular { + pivot_col: 2, + reason: SingularityReason::Numerical { + factorization: FactorizationKind::Lu, + pivot_magnitude: 1e-14, + tolerance: 1e-12, + }, + } ); - } - - #[test] - fn laerror_display_formats_unsupported_dimension() { - let err = LaError::UnsupportedDimension { - requested: 8, - max: crate::MAX_STACK_MATRIX_DISPATCH_DIM, - }; assert_eq!( - err.to_string(), - "unsupported matrix dimension 8; maximum stack-dispatch dimension is 7" + numerical.to_string(), + "matrix is numerically singular during LU factorization at pivot column 2: pivot magnitude 0.00000000000001 <= tolerance 0.000000000001" ); } #[test] - fn laerror_display_formats_index_out_of_bounds() { - let err = LaError::IndexOutOfBounds { - row: 3, - col: 0, - dim: 3, - }; + fn non_finite_constructors_preserve_location_and_origin() { assert_eq!( - err.to_string(), - "matrix index (3, 0) is out of bounds for dimension 3" + LaError::non_finite_input_matrix(1, 2), + LaError::NonFinite { + location: NonFiniteLocation::MatrixCell { row: 1, col: 2 }, + origin: NonFiniteOrigin::Input, + } + ); + assert_eq!( + LaError::non_finite_input_vector(3).to_string(), + "non-finite input value at vector entry 3" + ); + assert_eq!( + LaError::non_finite_input_scalar().to_string(), + "non-finite scalar input" + ); + assert_eq!( + LaError::non_finite_computation_matrix(ArithmeticOperation::LuFactorization, 2, 1) + .to_string(), + "non-finite value computed during LU factorization at matrix cell (2, 1)" + ); + assert_eq!( + LaError::non_finite_computation_step(ArithmeticOperation::LuSolve, 1).to_string(), + "non-finite value computed during LU solve at step 1" + ); + assert_eq!( + LaError::non_finite_computation_scalar(ArithmeticOperation::Determinant).to_string(), + "non-finite scalar result computed during determinant" ); } #[test] - fn laerror_display_formats_invalid_tolerance() { - let err = LaError::InvalidTolerance { value: -1.0 }; + fn unrepresentable_helpers_preserve_recovery_reason() { + let rounding = LaError::unrepresentable(Some(2), UnrepresentableReason::RequiresRounding); + let not_finite = LaError::unrepresentable(None, UnrepresentableReason::NotFinite); assert_eq!( - err.to_string(), - "invalid tolerance -1; expected finite value >= 0" + rounding.unrepresentable_reason(), + Some(UnrepresentableReason::RequiresRounding) + ); + assert!(rounding.requires_rounding()); + assert_eq!( + not_finite.to_string(), + "exact result has no finite f64 representation after rounding" ); + assert!(!not_finite.requires_rounding()); + assert_eq!(LaError::singular_exact(0).unrepresentable_reason(), None); } #[test] - fn validate_tolerance_matches_tolerance_new() { - for value in [0.0, 1e-12, f64::MAX] { - assert_eq!(LaError::validate_tolerance(value), Tolerance::new(value)); - } - + fn invalid_tolerance_classifies_non_finite_before_negative() { assert_eq!( - LaError::validate_tolerance(-1.0), - Err(LaError::InvalidTolerance { value: -1.0 }) - ); - assert_matches!( - LaError::validate_tolerance(f64::NAN), - Err(LaError::InvalidTolerance { value }) if value.is_nan() + LaError::invalid_tolerance(-1.0), + LaError::InvalidTolerance { + value: -1.0, + reason: InvalidToleranceReason::Negative, + } ); assert_eq!( - LaError::validate_tolerance(f64::INFINITY), - Err(LaError::InvalidTolerance { - value: f64::INFINITY, - }) + LaError::invalid_tolerance(f64::NEG_INFINITY), + LaError::InvalidTolerance { + value: f64::NEG_INFINITY, + reason: InvalidToleranceReason::NotFinite, + } ); assert_eq!( - LaError::validate_tolerance(f64::NEG_INFINITY), - Err(LaError::InvalidTolerance { - value: f64::NEG_INFINITY, - }) + LaError::invalid_tolerance(-1.0).to_string(), + "invalid tolerance -1; expected value >= 0" ); } #[test] - fn laerror_display_formats_asymmetric() { - let err = LaError::Asymmetric { - row: 0, - col: 2, - dim: 3, - }; + fn asymmetric_error_retains_observed_values_and_bound() { + let err = LaError::asymmetric(0, 2, 3, 1.0, 1.5, 1e-12); + assert_eq!( + err, + LaError::Asymmetric { + row: 0, + col: 2, + dim: 3, + upper: 1.0, + lower: 1.5, + allowed_abs_diff: 1e-12, + } + ); assert_eq!( err.to_string(), - "matrix is not symmetric for dimension 3: asymmetric pair (0, 2)" + "matrix is not symmetric for dimension 3: entry (0, 2) = 1 and entry (2, 0) = 1.5 differ by more than allowed absolute difference 0.000000000001" ); } #[test] - fn laerror_display_formats_not_positive_semidefinite() { - let err = LaError::NotPositiveSemidefinite { - pivot_col: 1, - value: -3.0, - }; + fn positive_semidefinite_errors_preserve_distinct_violations() { assert_eq!( - err.to_string(), + LaError::not_positive_semidefinite_negative(1, -3.0).to_string(), "matrix is not positive semidefinite at LDLT pivot column 1: diagonal value -3 < 0" ); + assert_eq!( + LaError::not_positive_semidefinite_zero_coupling(0, 1, 2.0).to_string(), + "matrix is not positive semidefinite at LDLT pivot column 0: zero diagonal has non-zero coupling at row 1 with value 2" + ); + } + + #[test] + fn remaining_helpers_and_displays_preserve_fields() { + assert_eq!( + LaError::determinant_scale_overflow(3, -1074).to_string(), + "exact determinant scale exponent overflows for dimension 3 with minimum entry exponent -1074" + ); + assert_eq!( + LaError::unsupported_dimension(8, MAX_STACK_MATRIX_DISPATCH_DIM).to_string(), + "unsupported matrix dimension 8; maximum stack-dispatch dimension is 7" + ); + assert_eq!( + LaError::index_out_of_bounds(3, 0, 3).to_string(), + "matrix index (3, 0) is out of bounds for dimension 3" + ); } #[test] - fn laerror_is_std_error_with_no_source() { - let err = LaError::Singular { pivot_col: 0 }; - let e: &dyn std::error::Error = &err; - assert!(e.source().is_none()); + fn is_std_error_with_no_source() { + let err = LaError::singular_exact(0); + let error: &dyn Error = &err; + assert!(error.source().is_none()); } } diff --git a/src/exact.rs b/src/exact.rs index 76a4bd1..7835e4f 100644 --- a/src/exact.rs +++ b/src/exact.rs @@ -10,13 +10,14 @@ //! //! All determinant methods (`det_exact`, `det_exact_f64`, //! `det_exact_rounded_f64`, and `det_sign_exact`) share the same integer-scaled -//! determinant core. Each f64 entry is decomposed via `f64_decompose` into -//! `mantissa × 2^exponent`, then all entries are scaled to a common `BigInt` +//! determinant core. Each proven-finite f64 entry is decomposed via +//! `decompose_proven_finite_f64` into `mantissa × 2^exponent`, then all entries +//! are scaled to a common `BigInt` //! matrix (shifting by `e - e_min`). D≤4 uses direct integer expansions; larger //! matrices use fraction-free Bareiss elimination entirely in `BigInt` //! arithmetic — no `BigRational`, no GCD, no denominator tracking. The result //! is `(det_int, total_exp)` where `det = det_int × 2^(D × e_min)`. `det_exact` -//! wraps this with `bigint_exp_to_bigrational` to reconstruct a reduced +//! wraps this with `big_int_exp_to_big_rational` to reconstruct a reduced //! `BigRational`; `det_exact_f64` converts the same pair only when the exact //! value is representable as finite binary64; `det_exact_rounded_f64` rounds //! the same exact value to finite binary64; and `det_sign_exact` reads the sign @@ -28,15 +29,17 @@ //! 1. **Fast filter (D ≤ 4)**: compute `det_direct()` and a conservative error //! bound. If `|det| > bound`, the f64 sign is provably correct — return //! immediately without allocating. -//! 2. **Exact fallback**: run integer-only Bareiss for a guaranteed-correct -//! sign. +//! 2. **Exact fallback**: evaluate the scaled `BigInt` matrix directly for +//! D ≤ 4 or with Bareiss elimination for D ≥ 5, yielding a +//! guaranteed-correct sign. //! //! ## Linear system solve //! //! `solve_exact`, `solve_exact_f64`, and `solve_exact_rounded_f64` solve -//! `A x = b` with a hybrid algorithm that reuses the integer-only Bareiss core -//! used for determinants. Matrix and RHS entries are decomposed via -//! `f64_decompose` into `mantissa × 2^exponent`, scaled to a shared +//! `A x = b` with a hybrid algorithm that shares the determinant path's exact +//! integer scaling and then applies Bareiss elimination to the augmented +//! system. Matrix and RHS entries are decomposed via +//! `decompose_proven_finite_f64` into `mantissa × 2^exponent`, scaled to a shared //! base `2^e_min`, and assembled into a `BigInt` augmented system //! `(A | b)`. Forward elimination runs entirely in `BigInt` with //! fraction-free Bareiss updates — no `BigRational`, no GCD @@ -54,10 +57,11 @@ //! ## f64 → integer decomposition //! //! Both the determinant and solve paths share a single conversion -//! primitive, `f64_decompose`, which extracts `(mantissa, exponent, -//! sign)` from the IEEE 754 binary64 bit representation (\[9\]). The -//! determinant path combines those components into a `BigInt` matrix -//! (for Bareiss) and a `2^(D × e_min)` scale factor, while the solve +//! primitive, `decompose_proven_finite_f64`, which parses the IEEE 754 binary64 bit +//! representation into a proof-bearing component (\[9\]). The +//! determinant path combines those components into a `BigInt` matrix for +//! direct expansion or Bareiss elimination and a `2^(D × e_min)` scale factor, +//! while the solve //! path builds a `BigInt` augmented system and lifts the //! upper-triangular result into `BigRational` for back-substitution. //! See Goldberg \[10\] for background on floating-point representation @@ -67,10 +71,9 @@ //! ## Validation //! //! Public `Matrix` / `Vector` values are finite by construction before exact -//! methods reach the integer-Bareiss core. The decomposition helpers for those -//! domain types can then call `f64_decompose` without repeating stored-entry -//! validation; `f64_decompose` itself is therefore never called with non-finite -//! input from the public API. +//! methods reach the integer-scaled exact core. The decomposition helpers consume +//! that proof without repeating stored-entry validation; a fallible raw-f64 +//! decomposition remains only to test rejection at the primitive boundary. use core::hint::cold_path; use core::mem::take; @@ -85,6 +88,109 @@ use crate::matrix::Matrix; use crate::vector::Vector; use crate::{LaError, UnrepresentableReason}; +/// The exact sign of a determinant. +/// +/// Available with the `exact` Cargo feature. +/// +/// This type makes the three possible outcomes explicit instead of exposing a +/// raw integer that could contain values other than −1, 0, or +1. +/// +/// # Examples +/// ``` +/// use la_stack::prelude::*; +/// +/// let sign = Matrix::<2>::identity().det_sign_exact(); +/// assert_eq!(sign, DeterminantSign::Positive); +/// assert_eq!(sign.as_i8(), 1); +/// ``` +#[must_use] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DeterminantSign { + /// The determinant is strictly negative. + Negative, + /// The determinant is exactly zero. + Zero, + /// The determinant is strictly positive. + Positive, +} + +impl DeterminantSign { + /// Return the conventional numeric sign −1, 0, or +1. + #[inline] + #[must_use] + pub const fn as_i8(self) -> i8 { + match self { + Self::Negative => -1, + Self::Zero => 0, + Self::Positive => 1, + } + } +} + +/// Convert an already-computed exact result to finite binary64 output. +/// +/// This extension trait is implemented for [`BigRational`] determinants and +/// `[BigRational; D]` exact solutions. It lets callers retain the exact value, +/// try the strict no-rounding contract, and recover with explicit rounding +/// without repeating determinant evaluation or linear-system elimination. +/// [`BigRational::new_raw`] values are interpreted by their mathematical +/// quotient: denominator signs and common factors do not change the result. A +/// zero denominator is rejected as [`UnrepresentableReason::NotFinite`]. +/// +/// # Examples +/// ``` +/// use la_stack::prelude::*; +/// +/// # fn main() -> Result<(), LaError> { +/// let matrix = Matrix::<2>::try_from_rows([ +/// [1.0 + f64::EPSILON, 0.0], +/// [0.0, 1.0 - f64::EPSILON], +/// ])?; +/// let exact = matrix.det_exact()?; +/// let rounded = match exact.try_to_f64() { +/// Ok(value) => value, +/// Err(error) if error.requires_rounding() => exact.to_rounded_f64()?, +/// Err(error) => return Err(error), +/// }; +/// assert_eq!(rounded.to_bits(), 1.0_f64.to_bits()); +/// +/// let system = Matrix::<1>::try_from_rows([[3.0]])?; +/// let rhs = Vector::<1>::try_new([3.0])?; +/// let exact_solution = system.solve_exact(rhs)?; +/// assert_eq!(exact_solution.try_to_f64()?.into_array(), [1.0]); +/// # Ok(()) +/// # } +/// ``` +pub trait ExactF64Conversion { + /// Finite binary64 output produced by the conversion. + type Output; + + /// Convert only when every exact value already has an exact finite + /// binary64 representation. + /// + /// The candidate conversion follows IEEE 754 round-to-nearest, + /// ties-to-even, but this strict method returns it only when no rounding is + /// required. + /// + /// # Errors + /// Returns [`LaError::Unrepresentable`] with + /// [`UnrepresentableReason::RequiresRounding`] when finite binary64 output + /// would require rounding, or [`UnrepresentableReason::NotFinite`] when + /// rounding cannot produce finite output. Exact solution errors include the + /// first failing component index. + fn try_to_f64(&self) -> Result; + + /// Round the exact value to finite binary64 output. + /// + /// Rounding follows IEEE 754 round-to-nearest, ties-to-even. + /// + /// # Errors + /// Returns [`LaError::Unrepresentable`] with + /// [`UnrepresentableReason::NotFinite`] when rounding cannot produce finite + /// output. Exact solution errors include the first failing component index. + fn to_rounded_f64(&self) -> Result; +} + const F64_SIGNIFICAND_BITS: i64 = 53; const F64_FRACTION_BITS: i64 = 52; const F64_MIN_BINARY_EXPONENT: i64 = -1074; @@ -93,28 +199,20 @@ const F64_MAX_BINARY_EXPONENT: i64 = 1023; const F64_EXPONENT_BIAS: i64 = 1023; const F64_FRACTION_MASK: u64 = (1u64 << 52) - 1; -/// Decompose a finite `f64` into its IEEE 754 components. -/// -/// Returns `None` for ±0.0, or `Some((mantissa, exponent, is_negative))` with a -/// non-zero mantissa where the value is exactly -/// `(-1)^is_negative × mantissa × 2^exponent` and `mantissa` is odd (trailing -/// zeros stripped). See `REFERENCES.md` \[9-10\]. +/// Decompose an `f64` whose finiteness has already been proven into its IEEE +/// 754 components. /// -/// # Errors -/// Returns [`LaError::NonFinite`] if `x` is NaN or infinite. -const fn f64_decompose(x: f64) -> Result, LaError> { +/// This helper is total for every bit pattern so proof-bearing callers never +/// need to recover from a second finiteness check. Its result is meaningful as +/// an exact real value only when `x` is finite. +const fn decompose_proven_finite_f64(x: f64) -> Component { let bits = x.to_bits(); let biased_exp = ((bits >> 52) & 0x7FF) as i32; let fraction = bits & 0x000F_FFFF_FFFF_FFFF; // ±0.0 if biased_exp == 0 && fraction == 0 { - return Ok(None); - } - - if biased_exp == 0x7FF { - cold_path(); - return Err(LaError::non_finite_at(0)); + return Component::Zero; } let (mantissa, raw_exp) = if biased_exp == 0 { @@ -127,25 +225,48 @@ const fn f64_decompose(x: f64) -> Result, LaErro ((1u64 << 52) | fraction, biased_exp - 1075) }; - // Strip trailing zeros so the mantissa is odd. + // Strip trailing zeros so the mantissa is odd. The zero bit patterns + // returned above are the only finite values with a zero mantissa. let tz = mantissa.trailing_zeros(); - let mantissa = mantissa >> tz; - let Some(mantissa) = NonZeroU64::new(mantissa) else { - cold_path(); - return Ok(None); + let Some(mantissa) = NonZeroU64::new(mantissa >> tz) else { + return Component::Zero; }; - let exponent = raw_exp + tz.cast_signed(); - let is_negative = bits >> 63 != 0; - Ok(Some((mantissa, exponent, is_negative))) + Component::NonZero { + mantissa, + exponent: raw_exp + tz.cast_signed(), + is_negative: bits >> 63 != 0, + } } -/// Convert a `BigInt × 2^exp` pair to a reduced `BigRational`. +/// Parse an arbitrary `f64` into its exact IEEE 754 components. +/// +/// Returns [`Component::Zero`] for ±0.0, or [`Component::NonZero`] with a +/// non-zero mantissa where the value is exactly +/// `(-1)^is_negative × mantissa × 2^exponent` and `mantissa` is odd (trailing +/// zeros stripped). See `REFERENCES.md` \[9-10\]. +/// +/// # Errors +/// Returns [`LaError::NonFinite`] if `x` is NaN or infinite. +#[cfg(test)] +const fn decompose_f64(x: f64) -> Result { + let bits = x.to_bits(); + let biased_exp = ((bits >> 52) & 0x7FF) as i32; + + if biased_exp == 0x7FF { + cold_path(); + return Err(LaError::non_finite_input_scalar()); + } + + Ok(decompose_proven_finite_f64(x)) +} + +/// Convert a [`BigInt`] × `2^exp` pair to a reduced [`BigRational`]. /// /// When `exp < 0` (denominator is `2^(-exp)`), shared factors of 2 are /// stripped from `value` to keep the fraction in lowest terms without a /// full GCD computation. -fn bigint_exp_to_bigrational(mut value: BigInt, mut exp: i32) -> BigRational { +fn big_int_exp_to_big_rational(mut value: BigInt, mut exp: i32) -> BigRational { if value == BigInt::from(0) { return BigRational::from_integer(BigInt::from(0)); } @@ -157,14 +278,8 @@ fn bigint_exp_to_bigrational(mut value: BigInt, mut exp: i32) -> BigRational { let exp_abs = exp.unsigned_abs(); let reduce = tz.min(u64::from(exp_abs)); value >>= reduce; - #[allow(clippy::cast_possible_truncation)] - let reduce = reduce as u32; - let remaining_abs = exp_abs - reduce; - exp = match remaining_abs { - 0 => 0, - 2_147_483_648 => i32::MIN, - value => -value.cast_signed(), - }; + let remaining_abs = u64::from(exp_abs) - reduce; + exp = negative_exponent_from_magnitude(remaining_abs); } if exp >= 0 { @@ -174,6 +289,24 @@ fn bigint_exp_to_bigrational(mut value: BigInt, mut exp: i32) -> BigRational { } } +/// Reconstruct a non-positive `i32` exponent from its unsigned magnitude. +/// +/// The exact-conversion path can produce magnitude 2^31 for `i32::MIN`, which +/// is one greater than `i32::MAX` and therefore cannot be converted before +/// negation. Magnitudes derived from an `i32` never exceed that boundary. +#[inline] +fn negative_exponent_from_magnitude(magnitude: u64) -> i32 { + if magnitude == u64::from(i32::MIN.unsigned_abs()) { + return i32::MIN; + } + + let Ok(value) = i32::try_from(magnitude) else { + cold_path(); + unreachable!("negative exponent magnitude exceeds the i32 domain"); + }; + -value +} + /// Convert an exact rational result to `f64` only when the conversion is exact. /// /// This supports the strict `*_exact_f64` public APIs by accepting only dyadic @@ -185,28 +318,58 @@ fn bigint_exp_to_bigrational(mut value: BigInt, mut exp: i32) -> BigRational { /// [`UnrepresentableReason::RequiresRounding`] when the rational denominator is /// not a power of two and the rounded value would still be finite. fn exact_rational_to_finite_f64(exact: &BigRational, index: Option) -> Result { - let numerator = exact.numer(); - if numerator.sign() == Sign::NoSign { - return Ok(0.0); - } - - let denominator = exact.denom(); - let Some(denominator_exp) = denominator.trailing_zeros() else { + if exact.denom().sign() == Sign::NoSign { cold_path(); return Err(LaError::unrepresentable( index, - rounded_rational_unrepresentable_reason(exact), + UnrepresentableReason::NotFinite, )); - }; + } - if denominator.bits().checked_sub(1) != Some(denominator_exp) { + if exact.numer().sign() == Sign::NoSign { + return Ok(0.0); + } + + let denominator = exact.denom(); + if denominator.sign() == Sign::Plus + && let Some(denominator_exp) = positive_power_of_two_exponent(denominator) + && let Ok(denominator_exp) = i32::try_from(denominator_exp) + { + return big_int_exp_ref_to_finite_f64(exact.numer(), -denominator_exp, index, || { + rounded_rational_unrepresentable_reason(exact) + }); + } + + // `BigRational::new_raw` can expose a negative denominator or uncancelled + // common factors. Normalization is necessary before deciding whether the + // mathematical quotient is dyadic. Canonical dyadic values take the + // borrowed fast path above and do not clone. + let reduced = exact.reduced(); + reduced_rational_to_finite_f64(&reduced, index) +} + +/// Return `k` exactly when `value` is the positive integer `2^k`. +fn positive_power_of_two_exponent(value: &BigInt) -> Option { + if value.sign() != Sign::Plus { + return None; + } + + let exponent = value.trailing_zeros()?; + (value.bits().checked_sub(1) == Some(exponent)).then_some(exponent) +} + +/// Strictly convert a reduced rational with a positive denominator. +fn reduced_rational_to_finite_f64( + exact: &BigRational, + index: Option, +) -> Result { + let Some(denominator_exp) = positive_power_of_two_exponent(exact.denom()) else { cold_path(); return Err(LaError::unrepresentable( index, rounded_rational_unrepresentable_reason(exact), )); - } - + }; let Ok(denominator_exp) = i32::try_from(denominator_exp) else { cold_path(); return Err(LaError::unrepresentable( @@ -215,7 +378,9 @@ fn exact_rational_to_finite_f64(exact: &BigRational, index: Option) -> Re )); }; - bigint_exp_to_finite_f64(numerator.clone(), -denominator_exp, index) + big_int_exp_ref_to_finite_f64(exact.numer(), -denominator_exp, index, || { + rounded_rational_unrepresentable_reason(exact) + }) } /// Classify a failed exact-rational-to-`f64` conversion by the rounded result. @@ -230,11 +395,23 @@ fn rounded_rational_unrepresentable_reason(exact: &BigRational) -> Unrepresentab } } -/// Convert an exact rational result to a rounded finite `f64`. +/// Convert an exact rational result to a rounded finite `f64` using IEEE 754 +/// round-to-nearest, ties-to-even. fn exact_rational_to_rounded_f64( exact: &BigRational, index: Option, ) -> Result { + if exact.denom().sign() == Sign::NoSign { + cold_path(); + return Err(LaError::unrepresentable( + index, + UnrepresentableReason::NotFinite, + )); + } + if exact.numer().sign() == Sign::NoSign { + return Ok(0.0); + } + let Some(value) = exact.to_f64() else { cold_path(); return Err(LaError::unrepresentable( @@ -253,6 +430,42 @@ fn exact_rational_to_rounded_f64( } } +impl ExactF64Conversion for BigRational { + type Output = f64; + + #[inline] + fn try_to_f64(&self) -> Result { + exact_rational_to_finite_f64(self, None) + } + + #[inline] + fn to_rounded_f64(&self) -> Result { + exact_rational_to_rounded_f64(self, None) + } +} + +impl ExactF64Conversion for [BigRational; D] { + type Output = Vector; + + #[inline] + fn try_to_f64(&self) -> Result { + let mut result = [0.0; D]; + for (index, value) in self.iter().enumerate() { + result[index] = exact_rational_to_finite_f64(value, Some(index))?; + } + Vector::try_new(result) + } + + #[inline] + fn to_rounded_f64(&self) -> Result { + let mut result = [0.0; D]; + for (index, value) in self.iter().enumerate() { + result[index] = exact_rational_to_rounded_f64(value, Some(index))?; + } + Vector::try_new(result) + } +} + /// Convert a `BigInt × 2^exp` pair to an exactly represented finite `f64`. /// /// This avoids allocating a [`BigRational`] when determinant and solve paths @@ -268,78 +481,89 @@ fn exact_rational_to_rounded_f64( /// Returns [`LaError::Unrepresentable`] with /// [`UnrepresentableReason::NotFinite`] when the exact value cannot be /// represented by any finite `f64`. -fn bigint_exp_to_finite_f64( - mut value: BigInt, +fn shifted_magnitude_to_u64(value: &BigInt, shift: u64) -> Option { + let word_bits = u64::from(u64::BITS); + let word_index = usize::try_from(shift / word_bits).ok()?; + let bit_shift = u32::try_from(shift % word_bits).ok()?; + let mut digits = value.iter_u64_digits().skip(word_index); + let low = digits.next()? >> bit_shift; + if bit_shift == 0 { + Some(low) + } else { + let high = digits.next().unwrap_or(0) << (u64::BITS - bit_shift); + Some(low | high) + } +} + +/// Borrowed core for exact integer-and-exponent conversion. +/// +/// The normalized significand is read directly from the [`BigInt`] digits, so +/// successful strict conversion does not clone an already-computed exact +/// result. `rounded_reason` is evaluated only when finite output would require +/// rounding. +fn big_int_exp_ref_to_finite_f64( + value: &BigInt, exp: i32, index: Option, + rounded_reason: impl FnOnce() -> UnrepresentableReason, ) -> Result { - if value == BigInt::from(0) { + if value.sign() == Sign::NoSign { return Ok(0.0); } let is_negative = value.sign() == Sign::Minus; - if is_negative { - value = -value; - } - let mut exp = i64::from(exp); - if let Some(tz) = value.trailing_zeros() { - value >>= tz; - let Ok(tz) = i64::try_from(tz) else { - cold_path(); - return Err(LaError::unrepresentable( - index, - UnrepresentableReason::NotFinite, - )); - }; - let Some(updated_exp) = exp.checked_add(tz) else { - cold_path(); - return Err(LaError::unrepresentable( - index, - UnrepresentableReason::NotFinite, - )); - }; - exp = updated_exp; - } - - let bit_len = value.bits(); - let Ok(bit_len) = i64::try_from(bit_len) else { + let Some(trailing_zeros) = value.trailing_zeros() else { + cold_path(); + unreachable!("nonzero integer must have a least-significant set bit"); + }; + let Ok(trailing_zeros_i64) = i64::try_from(trailing_zeros) else { cold_path(); return Err(LaError::unrepresentable( index, UnrepresentableReason::NotFinite, )); }; - let Some(top_bit_exp) = exp.checked_add(bit_len - 1) else { + let Some(updated_exp) = exp.checked_add(trailing_zeros_i64) else { cold_path(); return Err(LaError::unrepresentable( index, UnrepresentableReason::NotFinite, )); }; - if exp < F64_MIN_BINARY_EXPONENT { + exp = updated_exp; + + let Some(bit_len) = value.bits().checked_sub(trailing_zeros) else { + cold_path(); + unreachable!("trailing-zero count cannot exceed integer bit length"); + }; + let Ok(bit_len) = i64::try_from(bit_len) else { cold_path(); return Err(LaError::unrepresentable( index, - UnrepresentableReason::RequiresRounding, + UnrepresentableReason::NotFinite, )); - } - if top_bit_exp > F64_MAX_BINARY_EXPONENT { + }; + let Some(top_bit_exp) = exp.checked_add(bit_len - 1) else { cold_path(); return Err(LaError::unrepresentable( index, UnrepresentableReason::NotFinite, )); - } - if bit_len > F64_SIGNIFICAND_BITS { + }; + if top_bit_exp > F64_MAX_BINARY_EXPONENT { cold_path(); return Err(LaError::unrepresentable( index, - UnrepresentableReason::RequiresRounding, + UnrepresentableReason::NotFinite, )); } + if exp < F64_MIN_BINARY_EXPONENT || bit_len > F64_SIGNIFICAND_BITS { + cold_path(); + return Err(LaError::unrepresentable(index, rounded_reason())); + } - let Some(mantissa) = value.to_u64() else { + let Some(mantissa) = shifted_magnitude_to_u64(value, trailing_zeros) else { cold_path(); return Err(LaError::unrepresentable( index, @@ -379,31 +603,40 @@ fn bigint_exp_to_finite_f64( } } +fn big_int_exp_to_finite_f64( + value: &BigInt, + exp: i32, + index: Option, +) -> Result { + big_int_exp_ref_to_finite_f64(value, exp, index, || { + let exact = big_int_exp_to_big_rational(value.clone(), exp); + rounded_rational_unrepresentable_reason(&exact) + }) +} + /// Convert a `BigInt × 2^exp` determinant pair to a rounded finite `f64`. -fn bigint_exp_to_rounded_f64(value: BigInt, exp: i32) -> Result { - let exact = bigint_exp_to_bigrational(value, exp); +fn big_int_exp_to_rounded_f64(value: BigInt, exp: i32) -> Result { + let exact = big_int_exp_to_big_rational(value, exp); exact_rational_to_rounded_f64(&exact, None) } // ----------------------------------------------------------------------- -// Shared integer-Bareiss primitives +// Shared integer-scaling and Bareiss primitives // ----------------------------------------------------------------------- // -// Both `bareiss_det_int` (determinants) and `gauss_solve` (linear system -// solve) follow the same pipeline: decompose every f64 entry into -// `(mantissa, exponent, is_negative)`, track the minimum exponent across -// non-zero entries, scale each entry by `2^(exp − e_min)` to build a -// fully-integer `BigInt` matrix, and run Bareiss fraction-free forward -// elimination. The helpers below factor out each stage so the two -// callers differ only in post-processing (± sign for det, back-sub for -// solve) and in whether they carry a RHS through the elimination. +// Both `exact_det_int_finite` (determinants) and `bareiss_solve_finite` (linear +// systems) parse every f64 entry into a proof-bearing component, track the +// minimum exponent across non-zero entries, and scale each entry by +// `2^(exp − e_min)`. Determinants then use direct expansions for D≤4 and +// fraction-free Bareiss elimination for D≥5; solves use Bareiss elimination on +// the augmented system before rational back-substitution. /// Decomposed finite f64 in the form `(-1)^is_negative · mantissa · 2^exponent`. /// /// `Zero` represents ±0.0. Non-zero entries carry a [`NonZeroU64`] mantissa, so /// the exact-arithmetic paths cannot accidentally combine an absent mantissa /// with active exponent/sign fields after decomposition. -#[derive(Clone, Copy, Default)] +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] enum Component { #[default] Zero, @@ -414,152 +647,245 @@ enum Component { }, } -/// Decompose every entry of a finite `D×D` matrix via `f64_decompose`. -/// -/// Returns the per-entry components and the minimum exponent across non-zero -/// entries. If every entry is zero, the exponent is `i32::MAX`. -fn decompose_finite_matrix( - m: &Matrix, -) -> Result<([[Component; D]; D], i32), LaError> { - let mut components = [[Component::default(); D]; D]; - let mut e_min = i32::MAX; - for (r, row) in m.rows().iter().enumerate() { - for (c, &entry) in row.iter().enumerate() { - if let Some((mantissa, exponent, is_negative)) = - f64_decompose(entry).map_err(|_| LaError::non_finite_cell(r, c))? - { - components[r][c] = Component::NonZero { - mantissa, - exponent, - is_negative, - }; - e_min = e_min.min(exponent); - } +impl Component { + /// Return the exponent carried by a non-zero component. + const fn exponent(self) -> Option { + match self { + Self::Zero => None, + Self::NonZero { exponent, .. } => Some(exponent), } } - Ok((components, e_min)) } -/// Decompose every entry of a finite length-`D` vector via `f64_decompose`. -/// -/// Returns the per-entry components and the minimum exponent across non-zero -/// entries. If every entry is zero, the exponent is `i32::MAX`. -fn decompose_finite_vec(v: &Vector) -> Result<([Component; D], i32), LaError> { - let mut components = [Component::default(); D]; - let mut e_min = i32::MAX; - let data = v.as_array(); - for (i, &entry) in data.iter().enumerate() { - if let Some((mantissa, exponent, is_negative)) = - f64_decompose(entry).map_err(|_| LaError::non_finite_at(i))? - { - components[i] = Component::NonZero { - mantissa, - exponent, - is_negative, +mod decomposition { + use super::Component; + + /// A component collection paired with its proven minimum non-zero exponent. + /// + /// `None` represents an all-zero collection; no sentinel exponent is stored. + /// Private fields prevent callers from supplying components and their proof + /// independently. + #[derive(Clone, Debug, Eq, PartialEq)] + pub(super) struct Decomposed { + components: T, + min_exponent: Option, + } + + impl Decomposed { + /// Borrow the parsed components. + pub(super) const fn components(&self) -> &T { + &self.components + } + + /// Return the minimum exponent, or `None` when every component is zero. + pub(super) const fn min_exponent(&self) -> Option { + self.min_exponent + } + } + + impl Decomposed<[Component; D]> { + /// Derive a vector decomposition and its proof together. + pub(super) fn from_vector_components(components: [Component; D]) -> Self { + let min_exponent = components + .iter() + .filter_map(|component| component.exponent()) + .min(); + Self { + components, + min_exponent, + } + } + } + + impl Decomposed<[[Component; D]; D]> { + /// Derive a matrix decomposition and its proof together. + pub(super) fn from_matrix_components(components: [[Component; D]; D]) -> Self { + let min_exponent = components + .iter() + .flatten() + .filter_map(|component| component.exponent()) + .min(); + Self { + components, + min_exponent, + } + } + } + + /// A shared scaling exponent proven no greater than either input minimum. + /// + /// The private field prevents raw construction outside this proof-owning + /// module. + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + pub(super) struct ScaleExponent { + value: i32, + } + + impl ScaleExponent { + /// Canonical scale for an empty or all-zero component collection. + pub(super) const ZERO: Self = Self { value: 0 }; + + /// Select a scale for one decomposed collection. + pub(super) const fn for_decomposed(decomposed: &Decomposed) -> Self { + let value = match decomposed.min_exponent() { + Some(exponent) => exponent, + None => 0, }; - e_min = e_min.min(exponent); + Self { value } + } + + /// Select a common scale for two decomposed collections. + pub(super) const fn shared(left: &Decomposed, right: &Decomposed) -> Self { + let exponent = match (left.min_exponent(), right.min_exponent()) { + (Some(left), Some(right)) => { + if left < right { + left + } else { + right + } + } + (Some(exponent), None) | (None, Some(exponent)) => exponent, + (None, None) => 0, + }; + Self { value: exponent } + } + + /// Return the proven common exponent. + pub(super) const fn get(self) -> i32 { + self.value + } + + /// Compute a non-negative shift from this proven common exponent. + /// + /// # Panics + /// Panics only if a private decomposition invariant is broken and an entry + /// exponent is lower than the common minimum. + pub(super) fn shift_for(self, exponent: i32) -> u32 { + let Some(shift) = exponent.checked_sub(self.value) else { + unreachable!("finite f64 exponent difference cannot overflow"); + }; + let Ok(shift) = u32::try_from(shift) else { + unreachable!("common exponent cannot exceed a component exponent"); + }; + shift } } - Ok((components, e_min)) +} + +use decomposition::{Decomposed, ScaleExponent}; + +/// Decompose a matrix whose finite-storage invariant has already been proven. +fn decompose_proven_finite_matrix( + m: &Matrix, +) -> Decomposed<[[Component; D]; D]> { + let components = + from_fn(|row| from_fn(|col| decompose_proven_finite_f64(m.as_rows()[row][col]))); + Decomposed::from_matrix_components(components) +} + +/// Decompose a vector whose finite-storage invariant has already been proven. +fn decompose_proven_finite_vector(v: &Vector) -> Decomposed<[Component; D]> { + let components = from_fn(|index| decompose_proven_finite_f64(v.as_array()[index])); + Decomposed::from_vector_components(components) } /// Convert a single decomposed component to its scaled `BigInt` /// representation: `(±mantissa) << (exp − e_min)`. #[inline] -fn component_to_bigint(c: Component, e_min: i32) -> BigInt { - match c { +fn component_to_big_int(component: Component, scale: ScaleExponent) -> BigInt { + match component { Component::Zero => BigInt::from(0), Component::NonZero { mantissa, exponent, is_negative, } => { - let v = BigInt::from(mantissa.get()) << (exponent - e_min).cast_unsigned(); - if is_negative { -v } else { v } + let value = BigInt::from(mantissa.get()) << scale.shift_for(exponent); + if is_negative { -value } else { value } } } } -/// Build a `D×D` integer matrix from a component table, scaled to the -/// shared base `2^e_min`. -fn build_bigint_matrix( +/// Build a `D×D` integer matrix from components, scaled to a shared base. +fn build_big_int_matrix( components: &[[Component; D]; D], - e_min: i32, + scale: ScaleExponent, ) -> [[BigInt; D]; D] { - from_fn(|r| from_fn(|c| component_to_bigint(components[r][c], e_min))) + from_fn(|row| from_fn(|col| component_to_big_int(components[row][col], scale))) } -/// Build a length-`D` integer vector from a component array, scaled to -/// the shared base `2^e_min`. -fn build_bigint_vec(components: &[Component; D], e_min: i32) -> [BigInt; D] { - from_fn(|i| component_to_bigint(components[i], e_min)) +/// Build a length-`D` integer vector from components, scaled to a shared base. +fn build_big_int_vec( + components: &[Component; D], + scale: ScaleExponent, +) -> [BigInt; D] { + from_fn(|index| component_to_big_int(components[index], scale)) } /// Compute a 2×2 determinant from a scaled integer matrix. #[inline] -fn det2_bigint(a: &[[BigInt; D]; D]) -> BigInt { +fn det2_big_int(a: &[[BigInt; D]; D]) -> BigInt { &a[0][0] * &a[1][1] - &a[0][1] * &a[1][0] } -/// Compute a 3×3 determinant from scaled integer entries. +/// Compute an exact 3×3 determinant from borrowed scaled-integer entries. +/// +/// This fixed-shape kernel serves both the direct D=3 determinant path and the +/// 3×3 minors used by the D=4 expansion. Borrowing entries avoids cloning +/// [`BigInt`] values while keeping every operation in exact integer arithmetic. #[inline] -#[allow(clippy::too_many_arguments)] -fn det3_bigint_entries( - a00: &BigInt, - a01: &BigInt, - a02: &BigInt, - a10: &BigInt, - a11: &BigInt, - a12: &BigInt, - a20: &BigInt, - a21: &BigInt, - a22: &BigInt, -) -> BigInt { - let m00 = a11 * a22 - a12 * a21; - let m01 = a10 * a22 - a12 * a20; - let m02 = a10 * a21 - a11 * a20; - a00 * m00 - a01 * m01 + a02 * m02 +fn det3_big_int_entries(a: [[&BigInt; 3]; 3]) -> BigInt { + let m00 = a[1][1] * a[2][2] - a[1][2] * a[2][1]; + let m01 = a[1][0] * a[2][2] - a[1][2] * a[2][0]; + let m02 = a[1][0] * a[2][1] - a[1][1] * a[2][0]; + a[0][0] * m00 - a[0][1] * m01 + a[0][2] * m02 } /// Compute a 3×3 determinant from a scaled integer matrix. #[inline] -fn det3_bigint(a: &[[BigInt; D]; D]) -> BigInt { - det3_bigint_entries( - &a[0][0], &a[0][1], &a[0][2], &a[1][0], &a[1][1], &a[1][2], &a[2][0], &a[2][1], &a[2][2], - ) +fn det3_big_int(a: &[[BigInt; D]; D]) -> BigInt { + det3_big_int_entries([ + [&a[0][0], &a[0][1], &a[0][2]], + [&a[1][0], &a[1][1], &a[1][2]], + [&a[2][0], &a[2][1], &a[2][2]], + ]) } /// Compute a 4×4 determinant from a scaled integer matrix. #[inline] -fn det4_bigint(a: &[[BigInt; D]; D]) -> BigInt { +fn det4_big_int(a: &[[BigInt; D]; D]) -> BigInt { let mut det = BigInt::from(0); if a[0][0].sign() != Sign::NoSign { - let c00 = det3_bigint_entries( - &a[1][1], &a[1][2], &a[1][3], &a[2][1], &a[2][2], &a[2][3], &a[3][1], &a[3][2], - &a[3][3], - ); + let c00 = det3_big_int_entries([ + [&a[1][1], &a[1][2], &a[1][3]], + [&a[2][1], &a[2][2], &a[2][3]], + [&a[3][1], &a[3][2], &a[3][3]], + ]); det += &a[0][0] * c00; } if a[0][1].sign() != Sign::NoSign { - let c01 = det3_bigint_entries( - &a[1][0], &a[1][2], &a[1][3], &a[2][0], &a[2][2], &a[2][3], &a[3][0], &a[3][2], - &a[3][3], - ); + let c01 = det3_big_int_entries([ + [&a[1][0], &a[1][2], &a[1][3]], + [&a[2][0], &a[2][2], &a[2][3]], + [&a[3][0], &a[3][2], &a[3][3]], + ]); det -= &a[0][1] * c01; } if a[0][2].sign() != Sign::NoSign { - let c02 = det3_bigint_entries( - &a[1][0], &a[1][1], &a[1][3], &a[2][0], &a[2][1], &a[2][3], &a[3][0], &a[3][1], - &a[3][3], - ); + let c02 = det3_big_int_entries([ + [&a[1][0], &a[1][1], &a[1][3]], + [&a[2][0], &a[2][1], &a[2][3]], + [&a[3][0], &a[3][1], &a[3][3]], + ]); det += &a[0][2] * c02; } if a[0][3].sign() != Sign::NoSign { - let c03 = det3_bigint_entries( - &a[1][0], &a[1][1], &a[1][2], &a[2][0], &a[2][1], &a[2][2], &a[3][0], &a[3][1], - &a[3][2], - ); + let c03 = det3_big_int_entries([ + [&a[1][0], &a[1][1], &a[1][2]], + [&a[2][0], &a[2][1], &a[2][2]], + [&a[3][0], &a[3][1], &a[3][2]], + ]); det -= &a[0][3] * c03; } @@ -569,9 +895,9 @@ fn det4_bigint(a: &[[BigInt; D]; D]) -> BigInt { /// Outcome of a Bareiss forward-elimination pass. #[derive(Debug)] enum BareissResult { - /// Elimination completed; `sign` is `±1` based on the parity of row + /// Elimination completed; `odd_swaps` records the parity of row /// swaps (relevant for determinants; solves discard it). - Upper { sign: i8 }, + Upper { odd_swaps: bool }, /// Column `pivot_col` has no non-zero pivot at or below its diagonal. Singular { pivot_col: usize }, } @@ -592,7 +918,7 @@ fn bareiss_forward_eliminate( ) -> BareissResult { let zero = BigInt::from(0); let mut prev_pivot = BigInt::from(1); - let mut sign: i8 = 1; + let mut odd_swaps = false; for k in 0..D { // First-non-zero pivot search. @@ -604,7 +930,7 @@ fn bareiss_forward_eliminate( if let Some(r) = &mut rhs { r.swap(k, i); } - sign = -sign; + odd_swaps = !odd_swaps; found = true; break; } @@ -615,6 +941,12 @@ fn bareiss_forward_eliminate( } } + // The final pivot has now been proven non-zero. There are no rows or + // columns left to eliminate, and `prev_pivot` would never be read again. + if k + 1 == D { + break; + } + // Elimination. The Bareiss update reads the current `a[i][k]` // in both the inner `j`-loop and the RHS update, so zero it only // *after* those reads. @@ -634,26 +966,25 @@ fn bareiss_forward_eliminate( // Post-conditions (debug builds only): `a` is upper triangular with // non-zero pivots. These catch future regressions in the inner-loop // update or pivot-search logic without runtime cost in release. - // Indexed iteration is clearer than iterator chains here because the - // checks read disjoint cells across rows and columns at each step. #[cfg(debug_assertions)] - #[allow(clippy::needless_range_loop)] - for k in 0..D { - assert_ne!(a[k][k], zero, "pivot at ({k}, {k}) must be non-zero"); - for i in (k + 1)..D { - assert_eq!(a[i][k], zero, "sub-diagonal at ({i}, {k}) must be zero"); + for (k, row) in a.iter().enumerate() { + assert_ne!(row[k], zero, "pivot at ({k}, {k}) must be non-zero"); + for (i, lower_row) in a.iter().enumerate().skip(k + 1) { + assert_eq!( + lower_row[k], zero, + "sub-diagonal at ({i}, {k}) must be zero" + ); } } - BareissResult::Upper { sign } + BareissResult::Upper { odd_swaps } } /// Compute the determinant scale exponent `D × e_min`. /// -/// This centralizes the scale-overflow classification used by public exact -/// determinant APIs: [`Matrix::det_exact`], [`Matrix::det_exact_f64`], and -/// [`Matrix::det_sign_exact`] all surface failures from this helper as -/// [`LaError::DeterminantScaleOverflow`]. +/// This centralizes the scale-overflow classification used by exact +/// determinant value APIs. Sign-only evaluation deliberately bypasses this +/// bookkeeping because a positive binary scale cannot change determinant sign. /// /// # Errors /// Returns [`LaError::DeterminantScaleOverflow`] if `D` cannot fit in the @@ -670,11 +1001,11 @@ fn determinant_scale_exp(e_min: i32) -> Result { Ok(total_exp) } -/// Compute the exact determinant from integer-scaled entries. +/// Compute the determinant integer and its shared per-entry scale. /// -/// Returns `(det_int, scale_exp)` where the true determinant is -/// `det_int × 2^scale_exp`. Since the scale factor `2^scale_exp` is always -/// positive, `det_int.sign()` gives the sign of the determinant directly. +/// Returns `(det_int, scale)` where the true determinant is +/// `det_int × 2^(D × scale)`. Since that scale factor is always positive, +/// callers interested only in the sign do not need to form `D × scale`. /// /// All arithmetic is in `BigInt` — no `BigRational`, no GCD, no denominator /// tracking. Each f64 entry is decomposed into `mantissa × 2^exponent` and @@ -682,52 +1013,68 @@ fn determinant_scale_exp(e_min: i32) -> Result { /// uses direct determinant expansions; larger matrices use Bareiss elimination /// whose inner-loop division is exact (guaranteed by the algorithm). /// -fn bareiss_det_int_finite(m: &Matrix) -> Result<(BigInt, i32), LaError> { +fn scaled_det_int_finite(m: &Matrix) -> (BigInt, ScaleExponent) { + let decomposed = decompose_proven_finite_matrix(m); + scaled_det_int_decomposed(&decomposed) +} + +/// Compute a determinant integer from a proof-bearing component table. +fn scaled_det_int_decomposed( + decomposed: &Decomposed<[[Component; D]; D]>, +) -> (BigInt, ScaleExponent) { // D == 0 has no `a[D-1][D-1]` to read; shortcut to the empty-product // determinant. if D == 0 { - return Ok((BigInt::from(1), 0)); + return (BigInt::from(1), ScaleExponent::ZERO); } - let (components, e_min) = decompose_finite_matrix(m)?; - - // All entries are zero → singular (det = 0). - if e_min == i32::MAX { - return Ok((BigInt::from(0), 0)); + if decomposed.min_exponent().is_none() { + return (BigInt::from(0), ScaleExponent::ZERO); } - - let mut a = build_bigint_matrix(&components, e_min); + let scale = ScaleExponent::for_decomposed(decomposed); + let mut a = build_big_int_matrix(decomposed.components(), scale); let det_int = match D { - 1 => a[0][0].clone(), - 2 => det2_bigint(&a), - 3 => det3_bigint(&a), - 4 => det4_bigint(&a), + 1 => take(&mut a[0][0]), + 2 => det2_big_int(&a), + 3 => det3_big_int(&a), + 4 => det4_big_int(&a), _ => { - let sign = match bareiss_forward_eliminate(&mut a, None) { - BareissResult::Upper { sign } => sign, + let odd_swaps = match bareiss_forward_eliminate(&mut a, None) { + BareissResult::Upper { odd_swaps } => odd_swaps, BareissResult::Singular { .. } => { cold_path(); - return Ok((BigInt::from(0), 0)); + return (BigInt::from(0), ScaleExponent::ZERO); } }; - if sign < 0 { - -&a[D - 1][D - 1] - } else { - a[D - 1][D - 1].clone() - } + let det = take(&mut a[D - 1][D - 1]); + if odd_swaps { -det } else { det } } }; - let total_exp = determinant_scale_exp::(e_min)?; + (det_int, scale) +} + +/// Compute the exact determinant as an integer plus one total binary scale. +/// +/// Zero determinants use exponent zero because their value is independent of +/// scale. Non-zero determinants validate `D × e_min` for the value-producing +/// exact APIs; sign-only callers use [`scaled_det_int_finite`] directly. +fn exact_det_int_finite(m: &Matrix) -> Result<(BigInt, i32), LaError> { + let (det_int, scale) = scaled_det_int_finite(m); + if det_int.sign() == Sign::NoSign { + return Ok((det_int, 0)); + } + let total_exp = determinant_scale_exp::(scale.get())?; Ok((det_int, total_exp)) } -/// Compute the exact determinant of a `D×D` matrix using integer-only Bareiss -/// elimination and return the result as a `BigRational`. -fn bareiss_det_finite(m: &Matrix) -> Result { - let (det_int, total_exp) = bareiss_det_int_finite(m)?; - Ok(bigint_exp_to_bigrational(det_int, total_exp)) +/// Compute the exact determinant of a `D×D` matrix using direct `BigInt` +/// expansions for D≤4 or integer-only Bareiss elimination for D≥5, then return +/// the result as a `BigRational`. +fn exact_det_finite(m: &Matrix) -> Result { + let (det_int, total_exp) = exact_det_int_finite(m)?; + Ok(big_int_exp_to_big_rational(det_int, total_exp)) } /// Solve `A x = b` exactly after matrix and RHS finiteness has been proven. @@ -738,13 +1085,13 @@ fn bareiss_det_finite(m: &Matrix) -> Result( +fn bareiss_solve_finite( m: &Matrix, b: &Vector, ) -> Result<[BigRational; D], LaError> { - let (m_components, m_e_min) = decompose_finite_matrix(m)?; - let (b_components, b_e_min) = decompose_finite_vec(b)?; - gauss_solve_components(m_components, m_e_min, b_components, b_e_min) + let matrix = decompose_proven_finite_matrix(m); + let rhs = decompose_proven_finite_vector(b); + bareiss_solve_components(&matrix, &rhs) } /// Solve an exact integer-scaled augmented system from decomposed components. @@ -762,25 +1109,19 @@ fn gauss_solve_finite( /// # Errors /// Returns [`LaError::Singular`] if the matrix component table represents an /// exactly singular matrix. -fn gauss_solve_components( - m_components: [[Component; D]; D], - m_e_min: i32, - b_components: [Component; D], - b_e_min: i32, +fn bareiss_solve_components( + matrix: &Decomposed<[[Component; D]; D]>, + rhs: &Decomposed<[Component; D]>, ) -> Result<[BigRational; D], LaError> { - let mut e_min = m_e_min.min(b_e_min); - if e_min == i32::MAX { - e_min = 0; - } - - let mut a = build_bigint_matrix(&m_components, e_min); - let mut rhs = build_bigint_vec(&b_components, e_min); + let scale = ScaleExponent::shared(matrix, rhs); + let mut a = build_big_int_matrix(matrix.components(), scale); + let mut rhs = build_big_int_vec(rhs.components(), scale); match bareiss_forward_eliminate(&mut a, Some(&mut rhs)) { BareissResult::Upper { .. } => {} BareissResult::Singular { pivot_col } => { cold_path(); - return Err(LaError::Singular { pivot_col }); + return Err(LaError::singular_exact(pivot_col)); } } @@ -798,21 +1139,6 @@ fn gauss_solve_components( Ok(x) } -/// Exact determinant for a finite-by-construction matrix. -/// -/// This is the private implementation target for [`Matrix::det_exact`]. Keeping -/// the helper separate from the public method keeps the exact core focused on -/// the Bareiss computation while relying on the public [`Matrix`] finite-storage -/// invariant. -/// -/// # Errors -/// Returns [`LaError::DeterminantScaleOverflow`] if determinant scaling -/// overflows the internal exponent representation. -#[inline] -fn det_exact_finite(m: &Matrix) -> Result { - bareiss_det_finite(m) -} - /// Exact determinant converted to finite `f64` without rounding. /// /// This preserves the strict contract of [`Matrix::det_exact_f64`]: if the exact @@ -829,8 +1155,8 @@ fn det_exact_finite(m: &Matrix) -> Result(m: &Matrix) -> Result { - let (det_int, total_exp) = bareiss_det_int_finite(m)?; - bigint_exp_to_finite_f64(det_int, total_exp, None) + let (det_int, total_exp) = exact_det_int_finite(m)?; + big_int_exp_to_finite_f64(&det_int, total_exp, None) } /// Exact determinant rounded to finite `f64`. @@ -843,114 +1169,38 @@ fn det_exact_f64_finite(m: &Matrix) -> Result { /// overflows the internal exponent representation. /// /// Returns [`LaError::Unrepresentable`] with -/// [`UnrepresentableReason::NotFinite`] if the rounded result would be NaN or -/// infinity. +/// [`UnrepresentableReason::NotFinite`] if rounding cannot produce a finite `f64`. #[inline] fn det_exact_rounded_f64_finite(m: &Matrix) -> Result { - let (det_int, total_exp) = bareiss_det_int_finite(m)?; - bigint_exp_to_rounded_f64(det_int, total_exp) -} - -/// Exact linear solve for finite inputs. -/// -/// This is the private implementation target for [`Matrix::solve_exact`]. -/// Public [`Matrix`] and [`Vector`] values are finite by construction, so this -/// helper can focus on the exact Bareiss/rational solve. -/// -/// # Errors -/// Returns [`LaError::Singular`] if the matrix is exactly singular. -#[inline] -fn solve_exact_finite( - m: &Matrix, - b: Vector, -) -> Result<[BigRational; D], LaError> { - gauss_solve_finite(m, &b) -} - -/// Exact linear solve converted to finite `f64` components without rounding. -/// -/// This preserves the strict contract of [`Matrix::solve_exact_f64`]: each exact -/// component must already be representable as finite binary64. -/// -/// # Errors -/// Returns [`LaError::Singular`] if the matrix is exactly singular. -/// -/// Returns [`LaError::Unrepresentable`] with the failing component index when an -/// exact solution component requires rounding or cannot be represented as a -/// finite `f64`. -#[inline] -fn solve_exact_f64_finite( - m: &Matrix, - b: Vector, -) -> Result, LaError> { - let exact = solve_exact_finite(m, b)?; - let mut result = [0.0f64; D]; - for (i, val) in exact.iter().enumerate() { - result[i] = exact_rational_to_finite_f64(val, Some(i))?; - } - Ok(Vector::new_unchecked(result)) -} - -/// Exact linear solve rounded to finite `f64` components. -/// -/// This is the intentionally lossy counterpart to [`solve_exact_f64_finite`] and -/// the private implementation target for [`Matrix::solve_exact_rounded_f64`]. -/// -/// # Errors -/// Returns [`LaError::Singular`] if the matrix is exactly singular. -/// -/// Returns [`LaError::Unrepresentable`] with -/// [`UnrepresentableReason::NotFinite`] if any rounded component would be NaN or -/// infinity. -#[inline] -fn solve_exact_rounded_f64_finite( - m: &Matrix, - b: Vector, -) -> Result, LaError> { - let exact = solve_exact_finite(m, b)?; - let mut result = [0.0f64; D]; - for (i, val) in exact.iter().enumerate() { - result[i] = exact_rational_to_rounded_f64(val, Some(i))?; - } - Ok(Vector::new_unchecked(result)) + let (det_int, total_exp) = exact_det_int_finite(m)?; + big_int_exp_to_rounded_f64(det_int, total_exp) } /// Exact determinant sign for an already finite matrix. /// -/// The fast `f64` filter may reject overflowed scalar intermediates as -/// inconclusive, then fall back to integer Bareiss sign computation. -/// -/// # Errors -/// Returns [`LaError::NonFinite`] if a direct determinant or error-bound -/// computation detects a non-finite condition that is not an inconclusive scalar -/// overflow. +/// The fast `f64` filter treats overflowed or underflow-sensitive scalar +/// intermediates as inconclusive, then falls back to exact integer sign +/// computation: direct expansion for D≤4 or Bareiss elimination for D≥5. /// -/// Returns [`LaError::DeterminantScaleOverflow`] if determinant scaling -/// overflows the internal exponent representation. #[inline] -fn det_sign_exact_finite(m: &Matrix) -> Result { - match (m.det_direct(), m.det_errbound()) { - (Ok(Some(det_f64)), Ok(Some(err))) => { - if det_f64 > err { - return Ok(1); - } - if det_f64 < -err { - return Ok(-1); - } +fn det_sign_exact_finite(m: &Matrix) -> DeterminantSign { + if let Some((det_f64, error_bound)) = m.det_filter() { + if det_f64 > error_bound { + return DeterminantSign::Positive; + } + if det_f64 < -error_bound { + return DeterminantSign::Negative; } - (Err(LaError::NonFinite { row: None, .. }), _) - | (_, Err(LaError::NonFinite { row: None, .. })) => {} - (Err(err), _) | (_, Err(err)) => return Err(err), - _ => {} } cold_path(); - let (det_int, _) = bareiss_det_int_finite(m)?; - Ok(match det_int.sign() { - Sign::Plus => 1, - Sign::Minus => -1, - Sign::NoSign => 0, - }) + let decomposed = decompose_proven_finite_matrix(m); + let (det_int, _) = scaled_det_int_decomposed(&decomposed); + match det_int.sign() { + Sign::Plus => DeterminantSign::Positive, + Sign::Minus => DeterminantSign::Negative, + Sign::NoSign => DeterminantSign::Zero, + } } impl Matrix { @@ -987,17 +1237,23 @@ impl Matrix { /// overflows the internal exponent representation. #[inline] pub fn det_exact(&self) -> Result { - det_exact_finite(self) + exact_det_finite(self) } /// Exact determinant converted to `f64`. /// /// Requires the `exact` Cargo feature. /// - /// Computes the exact determinant with the same integer Bareiss core used by + /// Computes the exact determinant with the same integer-scaled core used by /// [`det_exact`](Self::det_exact), then converts the exact scaled integer /// result to `f64` only if the result is exactly representable as a finite - /// binary64 value. + /// binary64 value. The candidate conversion follows IEEE 754 + /// round-to-nearest, ties-to-even, but is returned only when no rounding is + /// required. + /// + /// When callers also need the exact value or may recover with explicit + /// rounding, compute [`det_exact`](Self::det_exact) once and use + /// [`ExactF64Conversion`] on the returned [`BigRational`]. /// /// # Examples /// ``` @@ -1026,14 +1282,15 @@ impl Matrix { /// /// Requires the `exact` Cargo feature. /// - /// Computes the exact determinant with the same integer Bareiss core used by + /// Computes the exact determinant with the same integer-scaled core used by /// [`det_exact`](Self::det_exact), then rounds the exact value to a finite - /// binary64 value. Unlike [`det_exact_f64`](Self::det_exact_f64), this method - /// is intentionally lossy and may round non-dyadic or underflowing nonzero - /// exact determinants. + /// binary64 value using IEEE 754 round-to-nearest, ties-to-even. Unlike + /// [`det_exact_f64`](Self::det_exact_f64), this method is intentionally lossy + /// and may round non-dyadic or underflowing nonzero exact determinants. /// /// # Examples /// ``` + /// use core::assert_matches; /// use la_stack::prelude::*; /// /// # fn main() -> Result<(), LaError> { @@ -1042,11 +1299,12 @@ impl Matrix { /// [0.0, 1.0 - f64::EPSILON], /// ])?; /// - /// assert_eq!( + /// assert_matches!( /// m.det_exact_f64(), /// Err(LaError::Unrepresentable { /// index: None, /// reason: UnrepresentableReason::RequiresRounding, + /// .. /// }) /// ); /// assert_eq!(m.det_exact_rounded_f64()?.to_bits(), 1.0f64.to_bits()); @@ -1058,8 +1316,7 @@ impl Matrix { /// Returns [`LaError::DeterminantScaleOverflow`] if determinant scaling /// overflows the internal exponent representation. /// - /// Returns [`LaError::Unrepresentable`] if the rounded determinant would be - /// NaN or infinite. + /// Returns [`LaError::Unrepresentable`] if rounding cannot produce a finite `f64`. #[inline] pub fn det_exact_rounded_f64(&self) -> Result { det_exact_rounded_f64_finite(self) @@ -1111,7 +1368,7 @@ impl Matrix { /// Returns [`LaError::Singular`] if the matrix is exactly singular. #[inline] pub fn solve_exact(&self, b: Vector) -> Result<[BigRational; D], LaError> { - solve_exact_finite(self, b) + bareiss_solve_finite(self, &b) } /// Exact linear system solve converted to `f64`. @@ -1121,7 +1378,12 @@ impl Matrix { /// Computes the exact [`BigRational`] solution via /// [`solve_exact`](Self::solve_exact) and converts each component to `f64` /// only if that component is exactly representable as a finite binary64 - /// value. + /// value. The candidate conversion follows IEEE 754 round-to-nearest, + /// ties-to-even, but is returned only when no rounding is required. + /// + /// When callers also need the exact solution or may recover with explicit + /// rounding, compute [`solve_exact`](Self::solve_exact) once and use + /// [`ExactF64Conversion`] on the returned array. /// /// # Examples /// ``` @@ -1143,7 +1405,7 @@ impl Matrix { /// cannot be represented exactly as a finite `f64`. #[inline] pub fn solve_exact_f64(&self, b: Vector) -> Result, LaError> { - solve_exact_f64_finite(self, b) + self.solve_exact(b)?.try_to_f64() } /// Exact linear system solve rounded to `f64`. @@ -1152,23 +1414,25 @@ impl Matrix { /// /// Computes the exact [`BigRational`] solution via /// [`solve_exact`](Self::solve_exact) and rounds each component to a finite - /// binary64 value. Unlike [`solve_exact_f64`](Self::solve_exact_f64), this - /// method is intentionally lossy and may round non-dyadic or underflowing - /// nonzero exact components. + /// binary64 value using IEEE 754 round-to-nearest, ties-to-even. Unlike + /// [`solve_exact_f64`](Self::solve_exact_f64), this method is intentionally + /// lossy and may round non-dyadic or underflowing nonzero exact components. /// /// # Examples /// ``` + /// use core::assert_matches; /// use la_stack::prelude::*; /// /// # fn main() -> Result<(), LaError> { /// let a = Matrix::<1>::try_from_rows([[3.0]])?; /// let b = Vector::<1>::try_new([1.0])?; /// - /// assert_eq!( + /// assert_matches!( /// a.solve_exact_f64(b), /// Err(LaError::Unrepresentable { /// index: Some(0), /// reason: UnrepresentableReason::RequiresRounding, + /// .. /// }) /// ); /// assert_eq!(a.solve_exact_rounded_f64(b)?.into_array(), [1.0 / 3.0]); @@ -1178,25 +1442,26 @@ impl Matrix { /// /// # Errors /// Returns [`LaError::Singular`] if the matrix is exactly singular. - /// Returns [`LaError::Unrepresentable`] if any rounded component would be - /// NaN or infinite. + /// Returns [`LaError::Unrepresentable`] if rounding any component cannot + /// produce a finite `f64`. #[inline] pub fn solve_exact_rounded_f64(&self, b: Vector) -> Result, LaError> { - solve_exact_rounded_f64_finite(self, b) + self.solve_exact(b)?.to_rounded_f64() } /// Exact determinant sign using adaptive-precision arithmetic. /// /// Requires the `exact` Cargo feature. /// - /// Returns `1` if `det > 0`, `-1` if `det < 0`, and `0` if `det == 0` (singular). + /// Returns [`DeterminantSign::Positive`], [`DeterminantSign::Negative`], or + /// [`DeterminantSign::Zero`] according to the exact determinant. /// /// For D ≤ 4, a fast f64 filter is tried first: `det_direct()` is compared /// against a conservative error bound derived from the matrix permanent. /// If the f64 result clearly exceeds the bound, the sign is returned - /// immediately without allocating. Otherwise (and always for D ≥ 5), - /// integer-only Bareiss elimination (`bareiss_det_int`) computes the exact - /// sign without constructing any `BigRational` values. + /// immediately without allocating. Otherwise, exact integer arithmetic + /// computes the sign without constructing any `BigRational` values: direct + /// `BigInt` expansions for D ≤ 4 and Bareiss elimination for D ≥ 5. /// /// # When to use /// @@ -1215,30 +1480,31 @@ impl Matrix { /// [7.0, 8.0, 9.0], /// ])?; /// // This matrix is singular (row 3 = row 1 + row 2 in exact arithmetic). - /// assert_eq!(m.det_sign_exact()?, 0); + /// assert_eq!(m.det_sign_exact(), DeterminantSign::Zero); /// - /// assert_eq!(Matrix::<3>::identity().det_sign_exact()?, 1); + /// assert_eq!(Matrix::<3>::identity().det_sign_exact(), DeterminantSign::Positive); /// # Ok::<(), LaError>(()) /// ``` - /// - /// # Errors - /// Returns [`LaError::DeterminantScaleOverflow`] if determinant scaling - /// overflows the internal exponent representation. #[inline] - pub fn det_sign_exact(&self) -> Result { + pub fn det_sign_exact(&self) -> DeterminantSign { det_sign_exact_finite(self) } } #[cfg(test)] mod tests { - use super::*; - use crate::DEFAULT_SINGULAR_TOL; - use core::assert_matches; + use std::array::from_fn; + use num_traits::Signed; use pastey::paste; - use std::array::from_fn; + use proptest::prelude::*; + + use super::*; + use crate::{ + ArithmeticOperation, DEFAULT_SINGULAR_TOL, NonFiniteLocation, NonFiniteOrigin, + SingularityReason, + }; // ----------------------------------------------------------------------- // Test helpers @@ -1246,22 +1512,26 @@ mod tests { /// Build an exact `BigRational` from an `f64` via IEEE 754 bit decomposition. /// - /// Thin wrapper over [`f64_decompose`] that packs the mantissa/exponent + /// Thin wrapper over [`decompose_f64`] that packs the mantissa/exponent /// pair into a fully-formed `BigRational` of the form `±m · 2^e`. The - /// production code paths (`bareiss_det_int`, `gauss_solve`) instead + /// production code paths (`exact_det_int_finite`, `bareiss_solve_finite`) instead /// decompose every entry into a shared-scale `BigInt` matrix, which /// avoids per-entry GCD work in the elimination loops — so this helper /// is not used by them and lives here to keep test assertions concise - /// (e.g. `assert_eq!(x[0], f64_to_bigrational(3.0))`). + /// (e.g. `assert_eq!(x[0], f64_to_big_rational(3.0))`). /// /// See `REFERENCES.md` \[9-10\] for the IEEE 754 standard and Goldberg's /// survey of floating-point representation. /// /// # Panics /// Panics if `x` is NaN or infinite. - fn f64_to_bigrational(x: f64) -> BigRational { - let Some((mantissa, exponent, is_negative)) = - f64_decompose(x).expect("test helper requires finite f64 input") + fn f64_to_big_rational(x: f64) -> BigRational { + let component = decompose_f64(x).expect("test helper requires finite f64 input"); + let Component::NonZero { + mantissa, + exponent, + is_negative, + } = component else { return BigRational::from_integer(BigInt::from(0)); }; @@ -1279,77 +1549,63 @@ mod tests { } } - // ----------------------------------------------------------------------- - // Macro-generated per-dimension tests (D=2..5) - // ----------------------------------------------------------------------- - - macro_rules! gen_internal_matrix_exact_tests { - ($d:literal) => { - paste! { - #[test] - fn []() { - let a = Matrix::<$d>::identity(); - let b = Vector::<$d>::new([1.0; $d]); - - assert_eq!( - det_exact_finite(&a).unwrap(), - BigRational::from_integer(BigInt::from(1)) - ); - assert!((det_exact_f64_finite(&a).unwrap() - 1.0).abs() <= f64::EPSILON); - assert_eq!(det_sign_exact_finite(&a).unwrap(), 1); - - let exact = solve_exact_finite(&a, b).unwrap(); - for value in exact { - assert_eq!(value, BigRational::from_integer(BigInt::from(1))); - } - - let exact_f64 = a.solve_exact_f64(b).unwrap(); - for value in exact_f64.into_array() { - assert!((value - 1.0).abs() <= f64::EPSILON); - } - } - } + fn assert_non_finite_input_scalar(result: &Result) { + let Err(error) = result else { + panic!("expected a non-finite scalar-input error"); }; + assert!(matches!( + *error, + LaError::NonFinite { + location: NonFiniteLocation::Scalar, + origin: NonFiniteOrigin::Input, + .. + } + )); } - gen_internal_matrix_exact_tests!(2); - gen_internal_matrix_exact_tests!(3); - gen_internal_matrix_exact_tests!(4); - gen_internal_matrix_exact_tests!(5); - - macro_rules! gen_det_exact_tests { - ($d:literal) => { - paste! { - #[test] - fn []() { - let det = Matrix::<$d>::identity().det_exact().unwrap(); - assert_eq!(det, BigRational::from_integer(BigInt::from(1))); - } - } + fn assert_unrepresentable( + result: &Result, + expected_index: Option, + expected_reason: UnrepresentableReason, + ) { + let Err(error) = result else { + panic!("expected an exact-to-f64 conversion error"); }; + assert!(matches!( + *error, + LaError::Unrepresentable { index, reason, .. } + if index == expected_index && reason == expected_reason + )); } - gen_det_exact_tests!(2); - gen_det_exact_tests!(3); - gen_det_exact_tests!(4); - gen_det_exact_tests!(5); + // ----------------------------------------------------------------------- + // Macro-generated per-dimension tests (D=2..5) + // ----------------------------------------------------------------------- - macro_rules! gen_det_exact_f64_tests { + macro_rules! gen_exact_identity_tests { ($d:literal) => { paste! { #[test] - fn []() { - let det = Matrix::<$d>::identity().det_exact_f64().unwrap(); - assert!((det - 1.0).abs() <= f64::EPSILON); + fn []() { + let matrix = Matrix::<$d>::identity(); + let one = BigRational::from_integer(BigInt::from(1)); + + assert_eq!(matrix.det_exact().unwrap(), one); + assert_eq!(matrix.det_exact_f64().unwrap().to_bits(), 1.0_f64.to_bits()); + assert_eq!( + matrix.det_exact_rounded_f64().unwrap().to_bits(), + 1.0_f64.to_bits() + ); + assert_eq!(matrix.det_sign_exact(), DeterminantSign::Positive); } } }; } - gen_det_exact_f64_tests!(2); - gen_det_exact_f64_tests!(3); - gen_det_exact_f64_tests!(4); - gen_det_exact_f64_tests!(5); + gen_exact_identity_tests!(2); + gen_exact_identity_tests!(3); + gen_exact_identity_tests!(4); + gen_exact_identity_tests!(5); /// For D ≤ 4, `det_exact_f64` should agree with `det_direct` on matrices /// whose exact determinant is representable in f64. @@ -1381,45 +1637,28 @@ mod tests { #[test] fn det_sign_exact_d0_is_positive() { - assert_eq!(Matrix::<0>::zero().det_sign_exact().unwrap(), 1); - } - - #[test] - fn det_sign_exact_d1_positive() { - let m = Matrix::<1>::try_from_rows([[42.0]]).unwrap(); - assert_eq!(m.det_sign_exact().unwrap(), 1); - } - - #[test] - fn det_sign_exact_d1_negative() { - let m = Matrix::<1>::try_from_rows([[-3.5]]).unwrap(); - assert_eq!(m.det_sign_exact().unwrap(), -1); - } - - #[test] - fn det_sign_exact_d1_zero() { - let m = Matrix::<1>::try_from_rows([[0.0]]).unwrap(); - assert_eq!(m.det_sign_exact().unwrap(), 0); - } - - #[test] - fn det_sign_exact_identity_2d() { - assert_eq!(Matrix::<2>::identity().det_sign_exact().unwrap(), 1); + assert_eq!( + Matrix::<0>::zero().det_sign_exact(), + DeterminantSign::Positive + ); } #[test] - fn det_sign_exact_identity_3d() { - assert_eq!(Matrix::<3>::identity().det_sign_exact().unwrap(), 1); + fn det_sign_exact_d1_positive() { + let m = Matrix::<1>::try_from_rows([[42.0]]).unwrap(); + assert_eq!(m.det_sign_exact(), DeterminantSign::Positive); } #[test] - fn det_sign_exact_identity_4d() { - assert_eq!(Matrix::<4>::identity().det_sign_exact().unwrap(), 1); + fn det_sign_exact_d1_negative() { + let m = Matrix::<1>::try_from_rows([[-3.5]]).unwrap(); + assert_eq!(m.det_sign_exact(), DeterminantSign::Negative); } #[test] - fn det_sign_exact_identity_5d() { - assert_eq!(Matrix::<5>::identity().det_sign_exact().unwrap(), 1); + fn det_sign_exact_d1_zero() { + let m = Matrix::<1>::try_from_rows([[0.0]]).unwrap(); + assert_eq!(m.det_sign_exact(), DeterminantSign::Zero); } #[test] @@ -1430,7 +1669,7 @@ mod tests { [1.0, 2.0, 3.0], // duplicate of row 0 ]) .unwrap(); - assert_eq!(m.det_sign_exact().unwrap(), 0); + assert_eq!(m.det_sign_exact(), DeterminantSign::Zero); } #[test] @@ -1438,7 +1677,7 @@ mod tests { // Row 2 = row 0 + row 1 in exact arithmetic. let m = Matrix::<3>::try_from_rows([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [5.0, 7.0, 9.0]]) .unwrap(); - assert_eq!(m.det_sign_exact().unwrap(), 0); + assert_eq!(m.det_sign_exact(), DeterminantSign::Zero); } #[test] @@ -1446,14 +1685,14 @@ mod tests { // Swapping two rows of the identity negates the determinant. let m = Matrix::<3>::try_from_rows([[0.0, 1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]]) .unwrap(); - assert_eq!(m.det_sign_exact().unwrap(), -1); + assert_eq!(m.det_sign_exact(), DeterminantSign::Negative); } #[test] fn det_sign_exact_negative_det_known() { // det([[1,2],[3,4]]) = 1*4 - 2*3 = -2 let m = Matrix::<2>::try_from_rows([[1.0, 2.0], [3.0, 4.0]]).unwrap(); - assert_eq!(m.det_sign_exact().unwrap(), -1); + assert_eq!(m.det_sign_exact(), DeterminantSign::Negative); } #[test] @@ -1461,7 +1700,7 @@ mod tests { // SPD matrix → positive determinant. let m = Matrix::<3>::try_from_rows([[4.0, 2.0, 0.0], [2.0, 5.0, 1.0], [0.0, 1.0, 3.0]]) .unwrap(); - assert_eq!(m.det_sign_exact().unwrap(), 1); + assert_eq!(m.det_sign_exact(), DeterminantSign::Positive); assert!(m.det().unwrap() > 0.0); } @@ -1481,7 +1720,7 @@ mod tests { ]) .unwrap(); // Exact: det = perturbation × (5×9 − 6×8) = perturbation × (−3) < 0. - assert_eq!(m.det_sign_exact().unwrap(), -1); + assert_eq!(m.det_sign_exact(), DeterminantSign::Negative); } /// For D ≤ 4, well-conditioned matrices should hit the fast filter @@ -1497,7 +1736,7 @@ mod tests { ]) .unwrap(); // SPD tridiagonal → positive det. - assert_eq!(m.det_sign_exact().unwrap(), 1); + assert_eq!(m.det_sign_exact(), DeterminantSign::Positive); } #[test] @@ -1510,7 +1749,7 @@ mod tests { [0.0, 0.0, 1.0, 5.0], ]) .unwrap(); - assert_eq!(m.det_sign_exact().unwrap(), -1); + assert_eq!(m.det_sign_exact(), DeterminantSign::Negative); } #[test] @@ -1521,83 +1760,53 @@ mod tests { let m = Matrix::<2>::try_from_rows([[tiny, 0.0], [0.0, tiny]]).unwrap(); // det = tiny^2 > 0 - assert_eq!(m.det_sign_exact().unwrap(), 1); - } - - #[test] - fn det_sign_exact_returns_err_on_nan() { - assert_eq!( - Matrix::<2>::try_from_rows([[f64::NAN, 0.0], [0.0, 1.0]]), - Err(LaError::NonFinite { - row: Some(0), - col: 0 - }) - ); - } - - #[test] - fn det_sign_exact_returns_err_on_infinity() { - assert_eq!( - Matrix::<2>::try_from_rows([[f64::INFINITY, 0.0], [0.0, 1.0]]), - Err(LaError::NonFinite { - row: Some(0), - col: 0 - }) - ); - } - - #[test] - fn exact_public_methods_reject_unchecked_nonfinite_matrix_before_computation() { - let m = Matrix::<2>::from_rows_unchecked([[1.0, 0.0], [f64::NAN, 1.0]]); - let b = Vector::<2>::new([1.0, 1.0]); - let expected = Err(LaError::NonFinite { - row: Some(1), - col: 0, - }); - - assert_eq!(m.det_exact().map(|_| ()), expected); - assert_eq!(m.det_exact_f64().map(|_| ()), expected); - assert_eq!(m.det_exact_rounded_f64().map(|_| ()), expected); - assert_eq!(m.det_sign_exact().map(|_| ()), expected); - assert_eq!(m.solve_exact(b).map(|_| ()), expected); - assert_eq!(m.solve_exact_f64(b).map(|_| ()), expected); - assert_eq!(m.solve_exact_rounded_f64(b).map(|_| ()), expected); + assert_eq!(m.det_sign_exact(), DeterminantSign::Positive); } #[test] - fn exact_solve_public_methods_reject_unchecked_nonfinite_rhs_before_computation() { - let a = Matrix::<2>::identity(); - let b = Vector::<2>::new_unchecked([1.0, f64::INFINITY]); - let expected = Err(LaError::NonFinite { row: None, col: 1 }); - - assert_eq!(a.solve_exact(b).map(|_| ()), expected); - assert_eq!(a.solve_exact_f64(b).map(|_| ()), expected); - assert_eq!(a.solve_exact_rounded_f64(b).map(|_| ()), expected); - } + fn det_sign_exact_falls_back_when_subnormal_rounding_reverses_direct_sign() { + let scale = 2.0_f64.powi(-360); + let matrix = Matrix::<3>::try_from_rows([ + [-5.0 * scale, 3.0 * scale, 6.0 * scale], + [0.0, -7.0 * scale, -7.0 * scale], + [2.0 * scale, -3.0 * scale, -4.0 * scale], + ]) + .unwrap(); - #[test] - fn det_sign_exact_returns_err_on_nan_5x5() { - // D ≥ 5 bypasses the fast filter, exercising the bareiss_det path. - let mut m = Matrix::<5>::identity(); assert_eq!( - m.set(2, 3, f64::NAN), - Err(LaError::NonFinite { - row: Some(2), - col: 3 - }) + matrix.det_direct().unwrap().unwrap().to_bits(), + (-f64::from_bits(1)).to_bits() ); - } + assert_eq!(matrix.det_errbound(), Ok(None)); + assert!(matrix.det_exact().unwrap().is_positive()); + assert_eq!(matrix.det_sign_exact(), DeterminantSign::Positive); + } + + #[test] + fn det_sign_exact_falls_back_for_bit_exact_underflow_counterexample() { + let matrix = Matrix::<3>::try_from_rows([ + [ + f64::from_bits(9_218_868_437_227_405_311), + f64::from_bits(13_830_554_455_654_793_216), + 0.0, + ], + [ + f64::from_bits(6_790_500_848_393_242_208), + f64::from_bits(2_184_621_143_747_520_227), + f64::from_bits(2_187_555_472_467_513_745), + ], + [ + 0.0, + f64::from_bits(2_184_859_204_554_904_434), + f64::from_bits(2_184_762_736_385_916_910), + ], + ]) + .unwrap(); - #[test] - fn det_sign_exact_returns_err_on_infinity_5x5() { - let mut m = Matrix::<5>::identity(); - assert_eq!( - m.set(0, 0, f64::INFINITY), - Err(LaError::NonFinite { - row: Some(0), - col: 0 - }) - ); + assert!(matrix.det_direct().unwrap().unwrap().is_sign_positive()); + assert_eq!(matrix.det_errbound(), Ok(None)); + assert!(matrix.det_exact().unwrap().is_negative()); + assert_eq!(matrix.det_sign_exact(), DeterminantSign::Negative); } #[test] @@ -1612,7 +1821,7 @@ mod tests { [0.0, 0.0, 0.0, 0.0, 1.0], ]) .unwrap(); - assert_eq!(m.det_sign_exact().unwrap(), -1); + assert_eq!(m.det_sign_exact(), DeterminantSign::Negative); } #[test] @@ -1627,7 +1836,7 @@ mod tests { ]) .unwrap(); // Two transpositions → even permutation → det = +1 - assert_eq!(m.det_sign_exact().unwrap(), 1); + assert_eq!(m.det_sign_exact(), DeterminantSign::Positive); } // ----------------------------------------------------------------------- @@ -1647,22 +1856,6 @@ mod tests { ); } - #[test] - fn det_errbound_d2_positive() { - let m = Matrix::<2>::try_from_rows([[1.0, 2.0], [3.0, 4.0]]).unwrap(); - let bound = m.det_errbound().unwrap().unwrap(); - assert!(bound > 0.0); - // bound = ERR_COEFF_2 * (|1*4| + |2*3|) = ERR_COEFF_2 * 10 - assert!(crate::ERR_COEFF_2.mul_add(-10.0, bound).abs() < 1e-30); - } - - #[test] - fn det_errbound_d3_positive() { - let m = Matrix::<3>::identity(); - let bound = m.det_errbound().unwrap().unwrap(); - assert!(bound > 0.0); - } - #[test] fn det_errbound_d3_non_identity() { // Non-identity matrix to exercise all code paths in D=3 case @@ -1672,13 +1865,6 @@ mod tests { assert!(bound > 0.0); } - #[test] - fn det_errbound_d4_positive() { - let m = Matrix::<4>::identity(); - let bound = m.det_errbound().unwrap().unwrap(); - assert!(bound > 0.0); - } - #[test] fn det_errbound_d4_non_identity() { // Non-identity matrix to exercise all code paths in D=4 case @@ -1693,84 +1879,180 @@ mod tests { assert!(bound > 0.0); } - #[test] - fn det_errbound_d5_is_none() { - assert_eq!(Matrix::<5>::identity().det_errbound(), Ok(None)); - } - // ----------------------------------------------------------------------- - // f64_decompose tests + // decompose_f64 tests // ----------------------------------------------------------------------- #[test] - fn f64_decompose_zero() { - assert!(f64_decompose(0.0).unwrap().is_none()); - assert!(f64_decompose(-0.0).unwrap().is_none()); + fn decompose_f64_zero() { + assert_eq!(decompose_f64(0.0), Ok(Component::Zero)); + assert_eq!(decompose_f64(-0.0), Ok(Component::Zero)); } #[test] - fn f64_decompose_one() { - let (mant, exp, neg) = f64_decompose(1.0).unwrap().unwrap(); - assert_eq!(mant.get(), 1); - assert_eq!(exp, 0); - assert!(!neg); + fn decompose_f64_one() { + assert_eq!( + decompose_f64(1.0), + Ok(Component::NonZero { + mantissa: NonZeroU64::new(1).unwrap(), + exponent: 0, + is_negative: false, + }) + ); } #[test] - fn f64_decompose_negative() { - let (mant, exp, neg) = f64_decompose(-3.5).unwrap().unwrap(); - // -3.5 = -7 × 2^(-1), mantissa is 7 (odd after stripping) - assert_eq!(mant.get(), 7); - assert_eq!(exp, -1); - assert!(neg); + fn decompose_f64_negative() { + assert_eq!( + decompose_f64(-3.5), + Ok(Component::NonZero { + mantissa: NonZeroU64::new(7).unwrap(), + exponent: -1, + is_negative: true, + }) + ); } #[test] - fn f64_decompose_subnormal() { - let tiny = 5e-324_f64; + fn decompose_f64_subnormal() { + let tiny = f64::from_bits(1); assert!(tiny.is_subnormal()); - let (mant, exp, neg) = f64_decompose(tiny).unwrap().unwrap(); - assert_eq!(mant.get(), 1); - assert_eq!(exp, -1074); - assert!(!neg); + assert_eq!( + decompose_f64(tiny), + Ok(Component::NonZero { + mantissa: NonZeroU64::new(1).unwrap(), + exponent: -1074, + is_negative: false, + }) + ); } #[test] - fn f64_decompose_power_of_two() { - let (mant, exp, neg) = f64_decompose(1024.0).unwrap().unwrap(); - assert_eq!(mant.get(), 1); - assert_eq!(exp, 10); // 1024 = 2^10 - assert!(!neg); + fn decompose_f64_power_of_two() { + assert_eq!( + decompose_f64(1024.0), + Ok(Component::NonZero { + mantissa: NonZeroU64::new(1).unwrap(), + exponent: 10, + is_negative: false, + }) + ); } #[test] - fn f64_decompose_rejects_nan() { - assert_eq!( - f64_decompose(f64::NAN), - Err(LaError::NonFinite { row: None, col: 0 }) - ); + fn decompose_f64_rejects_nan() { + assert_non_finite_input_scalar(&decompose_f64(f64::NAN)); + } + + proptest! { + #[test] + fn finite_f64_round_trips_through_exact_decomposition(bits in any::()) { + let value = f64::from_bits(bits); + prop_assume!(value.is_finite()); + + let exact = f64_to_big_rational(value); + let reconstructed = exact_rational_to_finite_f64(&exact, None); + + prop_assert_eq!(reconstructed, Ok(value)); + } } #[test] - fn component_to_bigint_distinguishes_zero_from_nonzero_mantissa() { - assert_eq!( - component_to_bigint(Component::default(), -10), - BigInt::from(0) - ); + fn wide_low_exponent_value_reports_non_finite_rounded_result() { + // (2^2099 - 1) × 2^-1075 lies just below 2^1024 and rounds to +∞. + let value = (BigInt::from(1_u8) << 2099_u32) - BigInt::from(1_u8); + let result = big_int_exp_to_finite_f64(&value, -1075, None); + + assert!(!result.as_ref().unwrap_err().requires_rounding()); + assert_unrepresentable(&result, None, UnrepresentableReason::NotFinite); + } + #[test] + fn component_to_big_int_distinguishes_zero_from_nonzero_mantissa() { + let baseline = Component::NonZero { + mantissa: NonZeroU64::new(1).unwrap(), + exponent: 1, + is_negative: false, + }; let positive = Component::NonZero { mantissa: NonZeroU64::new(3).unwrap(), exponent: 4, is_negative: false, }; - assert_eq!(component_to_bigint(positive, 1), BigInt::from(24)); - let negative = Component::NonZero { mantissa: NonZeroU64::new(5).unwrap(), exponent: 3, is_negative: true, }; - assert_eq!(component_to_bigint(negative, 1), BigInt::from(-20)); + + let decomposed = + Decomposed::from_vector_components([Component::Zero, baseline, positive, negative]); + let scale = ScaleExponent::for_decomposed(&decomposed); + + assert_eq!( + component_to_big_int(Component::Zero, scale), + BigInt::from(0) + ); + assert_eq!(component_to_big_int(positive, scale), BigInt::from(24)); + assert_eq!(component_to_big_int(negative, scale), BigInt::from(-20)); + } + + #[test] + fn decomposed_all_zero_uses_no_sentinel_exponent() { + let decomposed = decompose_proven_finite_matrix(&Matrix::<2>::zero()); + assert_eq!(decomposed.min_exponent(), None); + + let scale = ScaleExponent::for_decomposed(&decomposed); + assert_eq!(scale, ScaleExponent::ZERO); + assert_eq!(scale.get(), 0); + assert_eq!( + build_big_int_matrix(decomposed.components(), scale), + [ + [BigInt::from(0), BigInt::from(0)], + [BigInt::from(0), BigInt::from(0)] + ] + ); + } + + #[test] + fn shared_scale_is_no_greater_than_each_component_exponent() { + let tiny = f64::from_bits(1); + let matrix = Matrix::<2>::try_from_rows([[f64::MAX, 0.0], [0.0, 1.0]]).unwrap(); + let rhs = Vector::<2>::try_new([tiny, 0.0]).unwrap(); + let matrix = decompose_proven_finite_matrix(&matrix); + let rhs = decompose_proven_finite_vector(&rhs); + + assert_eq!(matrix.min_exponent(), Some(0)); + assert_eq!(rhs.min_exponent(), Some(-1074)); + + let scale = ScaleExponent::shared(&matrix, &rhs); + assert_eq!(scale.get(), -1074); + assert_eq!(scale.shift_for(-1074), 0); + assert_eq!(scale.shift_for(0), 1074); + } + + proptest! { + #[test] + fn derived_scale_yields_nonnegative_shifts(bits in any::<[u64; 4]>()) { + let values = bits.map(f64::from_bits); + prop_assume!(values.iter().all(|value| value.is_finite())); + let matrix = Matrix::<2>::try_from_rows([ + [values[0], values[1]], + [values[2], values[3]], + ]).unwrap(); + let decomposed = decompose_proven_finite_matrix(&matrix); + let scale = ScaleExponent::for_decomposed(&decomposed); + + for component in decomposed.components().iter().flatten() { + if let Some(exponent) = component.exponent() { + prop_assert!(exponent >= scale.get()); + prop_assert_eq!( + scale.shift_for(exponent), + u32::try_from(exponent - scale.get()).unwrap(), + ); + } + } + } } #[test] @@ -1800,14 +2082,34 @@ mod tests { ); } + #[test] + fn negative_exponent_from_magnitude_covers_i32_domain_boundaries() { + assert_eq!(negative_exponent_from_magnitude(0), 0); + assert_eq!(negative_exponent_from_magnitude(1), -1); + assert_eq!( + negative_exponent_from_magnitude(i32::MAX.cast_unsigned().into()), + -i32::MAX + ); + assert_eq!( + negative_exponent_from_magnitude(i32::MIN.unsigned_abs().into()), + i32::MIN + ); + } + + #[test] + #[should_panic(expected = "negative exponent magnitude exceeds the i32 domain")] + fn negative_exponent_from_magnitude_rejects_values_above_i32_domain() { + let _ = negative_exponent_from_magnitude(u64::from(i32::MIN.unsigned_abs()) + 1); + } + // ----------------------------------------------------------------------- - // bareiss_det_int tests + // Exact scaled-integer determinant tests // ----------------------------------------------------------------------- #[test] - fn bareiss_det_int_d0() { + fn exact_det_int_d0() { let m = Matrix::<0>::zero(); - let (det, exp) = bareiss_det_int_finite(&m).unwrap(); + let (det, exp) = exact_det_int_finite(&m).unwrap(); assert_eq!(det, BigInt::from(1)); assert_eq!(exp, 0); } @@ -1818,7 +2120,7 @@ mod tests { /// combinations that exercise the sign handling, the all-zero early /// return, trailing-zero stripping, and negative exponent scaling. #[test] - fn bareiss_det_int_d1_cases() { + fn exact_det_int_d1_cases() { let cases: &[(f64, i64, i32)] = &[ // (input, expected_det_int, expected_exp) (7.0, 7, 0), // integer → (7, 0) @@ -1828,7 +2130,7 @@ mod tests { ]; for &(input, expected_det_int, expected_exp) in cases { let m = Matrix::<1>::try_from_rows([[input]]).unwrap(); - let (det, exp) = bareiss_det_int_finite(&m).unwrap(); + let (det, exp) = exact_det_int_finite(&m).unwrap(); assert_eq!( det, BigInt::from(expected_det_int), @@ -1839,142 +2141,113 @@ mod tests { } #[test] - fn bareiss_det_int_d2_known() { + fn exact_det_int_d2_known() { // det([[1,2],[3,4]]) = -2 let m = Matrix::<2>::try_from_rows([[1.0, 2.0], [3.0, 4.0]]).unwrap(); - let (det_int, total_exp) = bareiss_det_int_finite(&m).unwrap(); + let (det_int, total_exp) = exact_det_int_finite(&m).unwrap(); // Reconstruct and verify. - let det = bigint_exp_to_bigrational(det_int, total_exp); + let det = big_int_exp_to_big_rational(det_int, total_exp); assert_eq!(det, BigRational::from_integer(BigInt::from(-2))); } #[test] - fn bareiss_det_int_all_zeros() { + fn exact_det_int_all_zeros() { let m = Matrix::<3>::zero(); - let (det, _) = bareiss_det_int_finite(&m).unwrap(); + let (det, _) = exact_det_int_finite(&m).unwrap(); assert_eq!(det, BigInt::from(0)); } #[test] - fn bareiss_det_int_sign_matches_det_sign_exact() { - // The sign of det_int should match det_sign_exact for various matrices. - let m = Matrix::<3>::try_from_rows([[0.0, 1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]]) - .unwrap(); - let (det_int, _) = bareiss_det_int_finite(&m).unwrap(); - assert_eq!(det_int.sign(), Sign::Minus); // det = -1 - } - - #[test] - fn bareiss_det_int_fractional_entries() { + fn exact_det_int_fractional_entries() { // Entries with negative exponents: 0.5 = 1×2^(-1), 0.25 = 1×2^(-2). // det([[0.5, 0.25], [1.0, 1.0]]) = 0.5×1.0 − 0.25×1.0 = 0.25 let m = Matrix::<2>::try_from_rows([[0.5, 0.25], [1.0, 1.0]]).unwrap(); - let (det_int, total_exp) = bareiss_det_int_finite(&m).unwrap(); - let det = bigint_exp_to_bigrational(det_int, total_exp); + let (det_int, total_exp) = exact_det_int_finite(&m).unwrap(); + let det = big_int_exp_to_big_rational(det_int, total_exp); assert_eq!(det, BigRational::new(BigInt::from(1), BigInt::from(4))); } #[test] - fn bareiss_det_int_d3_with_pivoting() { - // Zero on diagonal → exercises pivot swap inside bareiss_det_int. + fn exact_det_int_d3_direct_expansion_handles_zero_diagonal() { + // A zero diagonal entry does not require pivoting in the direct D=3 expansion. let m = Matrix::<3>::try_from_rows([[0.0, 1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]]) .unwrap(); - let (det_int, total_exp) = bareiss_det_int_finite(&m).unwrap(); - let det = bigint_exp_to_bigrational(det_int, total_exp); + let (det_int, total_exp) = exact_det_int_finite(&m).unwrap(); + let det = big_int_exp_to_big_rational(det_int, total_exp); assert_eq!(det, BigRational::from_integer(BigInt::from(-1))); } - /// Per AGENTS.md: dimension-generic tests must cover D=2–5. - macro_rules! gen_bareiss_det_int_identity_tests { - ($d:literal) => { - paste! { - #[test] - fn []() { - let m = Matrix::<$d>::identity(); - let (det_int, total_exp) = bareiss_det_int_finite(&m).unwrap(); - let det = bigint_exp_to_bigrational(det_int, total_exp); - assert_eq!(det, BigRational::from_integer(BigInt::from(1))); - } - } - }; - } - - gen_bareiss_det_int_identity_tests!(2); - gen_bareiss_det_int_identity_tests!(3); - gen_bareiss_det_int_identity_tests!(4); - gen_bareiss_det_int_identity_tests!(5); - // ----------------------------------------------------------------------- - // bigint_exp_to_bigrational tests + // big_int_exp_to_big_rational tests // ----------------------------------------------------------------------- #[test] - fn bigint_exp_to_bigrational_zero() { - let r = bigint_exp_to_bigrational(BigInt::from(0), -50); + fn big_int_exp_to_big_rational_zero() { + let r = big_int_exp_to_big_rational(BigInt::from(0), -50); assert_eq!(r, BigRational::from_integer(BigInt::from(0))); } #[test] - fn bigint_exp_to_bigrational_positive_exp() { + fn big_int_exp_to_big_rational_positive_exp() { // 3 × 2^2 = 12 - let r = bigint_exp_to_bigrational(BigInt::from(3), 2); + let r = big_int_exp_to_big_rational(BigInt::from(3), 2); assert_eq!(r, BigRational::from_integer(BigInt::from(12))); } #[test] - fn bigint_exp_to_bigrational_negative_exp_reduced() { + fn big_int_exp_to_big_rational_negative_exp_reduced() { // 6 × 2^(-2) = 6/4 → reduced to 3/2 (strip one shared factor of 2) - let r = bigint_exp_to_bigrational(BigInt::from(6), -2); + let r = big_int_exp_to_big_rational(BigInt::from(6), -2); assert_eq!(*r.numer(), BigInt::from(3)); assert_eq!(*r.denom(), BigInt::from(2)); } #[test] - fn bigint_exp_to_bigrational_negative_exp_reduces_to_integer() { + fn big_int_exp_to_big_rational_negative_exp_reduces_to_integer() { // 8 × 2^(-3) = 1 after stripping every denominator factor. - let r = bigint_exp_to_bigrational(BigInt::from(8), -3); + let r = big_int_exp_to_big_rational(BigInt::from(8), -3); assert_eq!(r, BigRational::from_integer(BigInt::from(1))); } #[test] - fn bigint_exp_to_bigrational_negative_exp_already_odd() { + fn big_int_exp_to_big_rational_negative_exp_already_odd() { // 3 × 2^(-2) = 3/4 (already in lowest terms since 3 is odd) - let r = bigint_exp_to_bigrational(BigInt::from(3), -2); + let r = big_int_exp_to_big_rational(BigInt::from(3), -2); assert_eq!(*r.numer(), BigInt::from(3)); assert_eq!(*r.denom(), BigInt::from(4)); } #[test] - fn bigint_exp_to_bigrational_negative_value() { + fn big_int_exp_to_big_rational_negative_value() { // -5 × 2^1 = -10 - let r = bigint_exp_to_bigrational(BigInt::from(-5), 1); + let r = big_int_exp_to_big_rational(BigInt::from(-5), 1); assert_eq!(r, BigRational::from_integer(BigInt::from(-10))); } #[test] - fn bigint_exp_to_bigrational_negative_value_with_denominator() { + fn big_int_exp_to_big_rational_negative_value_with_denominator() { // -3 × 2^(-2) = -3/4 - let r = bigint_exp_to_bigrational(BigInt::from(-3), -2); + let r = big_int_exp_to_big_rational(BigInt::from(-3), -2); assert_eq!(*r.numer(), BigInt::from(-3)); assert_eq!(*r.denom(), BigInt::from(4)); } // ----------------------------------------------------------------------- - // bareiss_det (wrapper) tests + // Public exact determinant wrapper tests // ----------------------------------------------------------------------- #[test] - fn bareiss_det_d1_returns_entry() { + fn det_exact_d1_returns_entry() { let det = Matrix::<1>::try_from_rows([[7.0]]) .unwrap() .det_exact() .unwrap(); - assert_eq!(det, f64_to_bigrational(7.0)); + assert_eq!(det, f64_to_big_rational(7.0)); } #[test] - fn bareiss_det_d3_with_pivoting() { - // First column has zero on diagonal → exercises pivot swap + break. + fn det_exact_d3_direct_expansion_handles_zero_diagonal() { + // Direct D=3 expansion handles a zero diagonal entry without pivoting. let m = Matrix::<3>::try_from_rows([[0.0, 1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]]) .unwrap(); let det = m.det_exact().unwrap(); @@ -1983,8 +2256,8 @@ mod tests { } #[test] - fn bareiss_det_singular_all_zeros_in_column() { - // Column 1 is all zeros below diagonal after elimination → singular. + fn det_exact_d3_singular_zero_column_returns_zero() { + // A zero column makes the direct D=3 determinant exactly zero. let m = Matrix::<3>::try_from_rows([[1.0, 0.0, 0.0], [0.0, 0.0, 0.0], [0.0, 0.0, 1.0]]) .unwrap(); let det = m.det_exact().unwrap(); @@ -1994,14 +2267,14 @@ mod tests { #[test] fn det_sign_exact_overflow_determinant_finite_entries() { // Entries near f64::MAX are finite, but the f64 determinant overflows - // to infinity. The fast filter should be skipped and Bareiss should - // compute the correct positive sign. + // to infinity. The fast filter is inconclusive and the direct `BigInt` + // expansion computes the correct positive sign. let big = f64::MAX / 2.0; assert!(big.is_finite()); let m = Matrix::<3>::try_from_rows([[0.0, 0.0, 1.0], [big, 0.0, 1.0], [0.0, big, 1.0]]) .unwrap(); // det = big^2 > 0 - assert_eq!(m.det_sign_exact().unwrap(), 1); + assert_eq!(m.det_sign_exact(), DeterminantSign::Positive); } // ----------------------------------------------------------------------- @@ -2096,13 +2369,7 @@ mod tests { let m = Matrix::<3>::try_from_rows([[0.0, 0.0, 1.0], [big, 0.0, 1.0], [0.0, big, 1.0]]) .unwrap(); // det = big^2, which overflows f64. - assert_eq!( - m.det_exact_f64(), - Err(LaError::Unrepresentable { - index: None, - reason: UnrepresentableReason::NotFinite, - }) - ); + assert_unrepresentable(&m.det_exact_f64(), None, UnrepresentableReason::NotFinite); } #[test] @@ -2111,12 +2378,10 @@ mod tests { let m = Matrix::<3>::try_from_rows([[0.0, 0.0, 1.0], [big, 0.0, 1.0], [0.0, big, 1.0]]) .unwrap(); - assert_eq!( - m.det_exact_rounded_f64(), - Err(LaError::Unrepresentable { - index: None, - reason: UnrepresentableReason::NotFinite, - }) + assert_unrepresentable( + &m.det_exact_rounded_f64(), + None, + UnrepresentableReason::NotFinite, ); } @@ -2126,12 +2391,10 @@ mod tests { let m = Matrix::<2>::try_from_rows([[tiny, 0.0], [0.0, tiny]]).unwrap(); assert!(m.det_exact().unwrap().is_positive()); - assert_eq!( - m.det_exact_f64(), - Err(LaError::Unrepresentable { - index: None, - reason: UnrepresentableReason::RequiresRounding, - }) + assert_unrepresentable( + &m.det_exact_f64(), + None, + UnrepresentableReason::RequiresRounding, ); } @@ -2147,23 +2410,13 @@ mod tests { BigInt::from(1_u128 << 104), )) ); - assert_eq!( - m.det_exact_f64(), - Err(LaError::Unrepresentable { - index: None, - reason: UnrepresentableReason::RequiresRounding, - }) + assert_unrepresentable( + &m.det_exact_f64(), + None, + UnrepresentableReason::RequiresRounding, ); } - #[test] - fn det_exact_f64_accepts_min_positive_subnormal() { - let tiny = f64::from_bits(1); - let m = Matrix::<1>::try_from_rows([[tiny]]).unwrap(); - - assert_eq!(m.det_exact_f64().unwrap().to_bits(), tiny.to_bits()); - } - #[test] fn det_exact_f64_accepts_max_finite_binary64() { let m = Matrix::<1>::try_from_rows([[f64::MAX]]).unwrap(); @@ -2171,17 +2424,6 @@ mod tests { assert_eq!(m.det_exact_f64().unwrap().to_bits(), f64::MAX.to_bits()); } - #[test] - fn det_exact_rounded_f64_rounds_inexact_result() { - let m = Matrix::<2>::try_from_rows([[1.0 + f64::EPSILON, 0.0], [0.0, 1.0 - f64::EPSILON]]) - .unwrap(); - - assert_eq!( - m.det_exact_rounded_f64().unwrap().to_bits(), - 1.0f64.to_bits() - ); - } - // ----------------------------------------------------------------------- // solve_exact: macro-generated per-dimension tests (D=2..5) // ----------------------------------------------------------------------- @@ -2200,12 +2442,15 @@ mod tests { ($d:literal) => { paste! { #[test] - fn []() { + fn []() { let a = Matrix::<$d>::identity(); let b = arbitrary_rhs::<$d>(); - let x = a.solve_exact(b).unwrap(); - for (i, xi) in x.iter().enumerate() { - assert_eq!(*xi, f64_to_bigrational(b.as_array()[i])); + let exact = a.solve_exact(b).unwrap(); + let strict_f64 = a.solve_exact_f64(b).unwrap().into_array(); + + for i in 0..$d { + assert_eq!(exact[i], f64_to_big_rational(b.as_array()[i])); + assert_eq!(strict_f64[i].to_bits(), b.as_array()[i].to_bits()); } } @@ -2214,7 +2459,14 @@ mod tests { // Zero matrix is singular. let a = Matrix::<$d>::zero(); let b = arbitrary_rhs::<$d>(); - assert_eq!(a.solve_exact(b), Err(LaError::Singular { pivot_col: 0 })); + assert_matches!( + a.solve_exact(b), + Err(LaError::Singular { + pivot_col: 0, + reason: SingularityReason::Exact, + .. + }) + ); } } }; @@ -2225,27 +2477,6 @@ mod tests { gen_solve_exact_tests!(4); gen_solve_exact_tests!(5); - macro_rules! gen_solve_exact_f64_tests { - ($d:literal) => { - paste! { - #[test] - fn []() { - let a = Matrix::<$d>::identity(); - let b = arbitrary_rhs::<$d>(); - let x = a.solve_exact_f64(b).unwrap().into_array(); - for i in 0..$d { - assert!((x[i] - b.as_array()[i]).abs() <= f64::EPSILON); - } - } - } - }; - } - - gen_solve_exact_f64_tests!(2); - gen_solve_exact_f64_tests!(3); - gen_solve_exact_f64_tests!(4); - gen_solve_exact_f64_tests!(5); - /// For D ≤ 4, `solve_exact_f64` should agree with `Lu::solve` on /// well-conditioned matrices. macro_rules! gen_solve_exact_f64_agrees_with_lu { @@ -2310,7 +2541,10 @@ mod tests { ($d:literal) => { paste! { #[test] - #[allow(clippy::cast_precision_loss)] + #[expect( + clippy::cast_precision_loss, + reason = "dimensions and indices are at most five and exactly representable as f64" + )] fn []() { // A = D * I + J (diag = D+1, off-diag = 1). Invertible // for any D >= 1 and cheap to multiply by hand. @@ -2346,7 +2580,7 @@ mod tests { let x = a.solve_exact(b).unwrap(); for i in 0..$d { - assert_eq!(x[i], f64_to_bigrational(x0[i])); + assert_eq!(x[i], f64_to_big_rational(x0[i])); } } } @@ -2388,9 +2622,9 @@ mod tests { let b = Vector::<3>::new([2.0, 3.0, 4.0]); let x = a.solve_exact(b).unwrap(); // x = [3, 2, 4] - assert_eq!(x[0], f64_to_bigrational(3.0)); - assert_eq!(x[1], f64_to_bigrational(2.0)); - assert_eq!(x[2], f64_to_bigrational(4.0)); + assert_eq!(x[0], f64_to_big_rational(3.0)); + assert_eq!(x[1], f64_to_big_rational(2.0)); + assert_eq!(x[2], f64_to_big_rational(4.0)); } #[test] @@ -2408,7 +2642,13 @@ mod tests { let a = Matrix::<3>::try_from_rows([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [1.0, 2.0, 3.0]]) .unwrap(); let b = Vector::<3>::new([1.0, 2.0, 3.0]); - assert_matches!(a.solve_exact(b), Err(LaError::Singular { .. })); + assert_matches!( + a.solve_exact(b), + Err(LaError::Singular { + reason: SingularityReason::Exact, + .. + }) + ); } #[test] @@ -2424,11 +2664,11 @@ mod tests { .unwrap(); let b = Vector::<5>::new([10.0, 20.0, 30.0, 40.0, 50.0]); let x = a.solve_exact(b).unwrap(); - assert_eq!(x[0], f64_to_bigrational(20.0)); - assert_eq!(x[1], f64_to_bigrational(10.0)); - assert_eq!(x[2], f64_to_bigrational(30.0)); - assert_eq!(x[3], f64_to_bigrational(40.0)); - assert_eq!(x[4], f64_to_bigrational(50.0)); + assert_eq!(x[0], f64_to_big_rational(20.0)); + assert_eq!(x[1], f64_to_big_rational(10.0)); + assert_eq!(x[2], f64_to_big_rational(30.0)); + assert_eq!(x[3], f64_to_big_rational(40.0)); + assert_eq!(x[4], f64_to_big_rational(50.0)); } /// Entries near `f64::MAX / 2` are finite but their product would @@ -2513,7 +2753,10 @@ mod tests { ($d:literal) => { paste! { #[test] - #[allow(clippy::cast_precision_loss)] + #[expect( + clippy::cast_precision_loss, + reason = "indices are at most five and exactly representable as f64" + )] fn []() { let tiny = 5e-324_f64; // smallest positive subnormal assert!(tiny.is_subnormal()); @@ -2527,7 +2770,7 @@ mod tests { let b = Vector::<$d>::new(b_arr); let x = a.solve_exact(b).unwrap(); for i in 0..$d { - assert_eq!(x[i], f64_to_bigrational((i + 1) as f64 * tiny)); + assert_eq!(x[i], f64_to_big_rational((i + 1) as f64 * tiny)); } } } @@ -2552,10 +2795,10 @@ mod tests { ($d:literal) => { paste! { #[test] - #[allow(clippy::cast_precision_loss)] - // `2..$d` is empty when D=2 (no padded rows); that is the - // intended behaviour of the macro, not a bug. - #[allow(clippy::reversed_empty_ranges)] + #[expect( + clippy::cast_precision_loss, + reason = "indices and test offsets are small integers exactly representable as f64" + )] fn []() { // Top-left 2×2: A = [[0, 1], [2, 1]]. After swap: // [[2, 1], [0, 1]], rhs = [4, 3] → x[1] = 3, x[0] = 1/2. @@ -2564,8 +2807,8 @@ mod tests { rows[1][0] = 2.0; rows[1][1] = 1.0; // Identity padding for the remaining rows. - for i in 2..$d { - rows[i][i] = 1.0; + for (i, row) in rows.iter_mut().enumerate().skip(2) { + row[i] = 1.0; } let a = Matrix::<$d>::try_from_rows(rows).unwrap(); // b = [3, 4, 12, 13, …]; padded entries are arbitrary @@ -2573,15 +2816,15 @@ mod tests { let mut b_arr = [0.0f64; $d]; b_arr[0] = 3.0; b_arr[1] = 4.0; - for i in 2..$d { - b_arr[i] = (i + 10) as f64; + for (i, value) in b_arr.iter_mut().enumerate().skip(2) { + *value = (i + 10) as f64; } let b = Vector::<$d>::new(b_arr); let x = a.solve_exact(b).unwrap(); assert_eq!(x[0], BigRational::new(BigInt::from(1), BigInt::from(2))); assert_eq!(x[1], BigRational::from_integer(BigInt::from(3))); - for i in 2..$d { - assert_eq!(x[i], f64_to_bigrational((i + 10) as f64)); + for (i, value) in x.iter().enumerate().skip(2) { + assert_eq!(value, &f64_to_big_rational((i + 10) as f64)); } } } @@ -2605,10 +2848,10 @@ mod tests { ($d:literal) => { paste! { #[test] - #[allow(clippy::cast_precision_loss)] - // `3..$d` is empty when D=3 (no padded rows); that is the - // intended behaviour of the macro, not a bug. - #[allow(clippy::reversed_empty_ranges)] + #[expect( + clippy::cast_precision_loss, + reason = "indices and test offsets are small integers exactly representable as f64" + )] fn []() { let mut rows = [[0.0f64; $d]; $d]; rows[0][0] = 1.0; rows[0][1] = 2.0; rows[0][2] = 3.0; @@ -2616,16 +2859,16 @@ mod tests { rows[1][2] = 4.0; rows[2][1] = 5.0; rows[2][2] = 6.0; // Identity padding for the remaining rows. - for i in 3..$d { - rows[i][i] = 1.0; + for (i, row) in rows.iter_mut().enumerate().skip(3) { + row[i] = 1.0; } let a = Matrix::<$d>::try_from_rows(rows).unwrap(); let mut b_arr = [0.0f64; $d]; b_arr[0] = 6.0; b_arr[1] = 7.0; b_arr[2] = 8.0; - for i in 3..$d { - b_arr[i] = (i + 10) as f64; + for (i, value) in b_arr.iter_mut().enumerate().skip(3) { + *value = (i + 10) as f64; } let b = Vector::<$d>::new(b_arr); let x = a.solve_exact(b).unwrap(); @@ -2633,8 +2876,8 @@ mod tests { assert_eq!(x[0], BigRational::new(BigInt::from(7), BigInt::from(4))); assert_eq!(x[1], BigRational::new(BigInt::from(-1), BigInt::from(2))); assert_eq!(x[2], BigRational::new(BigInt::from(7), BigInt::from(4))); - for i in 3..$d { - assert_eq!(x[i], f64_to_bigrational((i + 10) as f64)); + for (i, value) in x.iter().enumerate().skip(3) { + assert_eq!(value, &f64_to_big_rational((i + 10) as f64)); } } } @@ -2651,7 +2894,7 @@ mod tests { /// column. The matrix is identity in the top-left `(D-1)×(D-1)` with /// a row of ones as the last row (and an all-zero last column), so the /// rank is exactly `D-1`. `solve_exact` must return - /// `LaError::Singular { pivot_col: D - 1 }`. + /// exact singularity at `pivot_col = D - 1`. macro_rules! gen_solve_exact_singular_rank_deficient_tests { ($d:literal) => { paste! { @@ -2665,9 +2908,13 @@ mod tests { // Last column is left all-zero → rank exactly D-1. let a = Matrix::<$d>::try_from_rows(rows).unwrap(); let b = Vector::<$d>::new([1.0; $d]); - assert_eq!( + assert_matches!( a.solve_exact(b), - Err(LaError::Singular { pivot_col: $d - 1 }) + Err(LaError::Singular { + pivot_col, + reason: SingularityReason::Exact, + .. + }) if pivot_col == $d - 1 ); } } @@ -2719,14 +2966,17 @@ mod tests { // `exact_hilbert_{4x4,5x5}`) so a regression would be caught even // when benchmarks are not running. - /// Multiply `A · x` entirely in `BigRational`, using `f64_to_bigrational` + /// Multiply `A · x` entirely in `BigRational`, using `f64_to_big_rational` /// to lift each matrix entry. Used by residual assertions for inputs /// whose exact solution has no closed form we can easily type out. - fn bigrational_matvec(a: &Matrix, x: &[BigRational; D]) -> [BigRational; D] { + fn big_rational_matvec( + a: &Matrix, + x: &[BigRational; D], + ) -> [BigRational; D] { from_fn(|i| { let mut sum = BigRational::from_integer(BigInt::from(0)); - for (aij, xj) in a.rows()[i].iter().zip(x.iter()) { - sum += f64_to_bigrational(*aij) * xj; + for (aij, xj) in a.as_rows()[i].iter().zip(x.iter()) { + sum += f64_to_big_rational(*aij) * xj; } sum }) @@ -2761,30 +3011,6 @@ mod tests { assert_eq!(x[2], one); } - #[test] - fn solve_exact_f64_near_singular_benchmark_rhs_is_rejection_path() { - let perturbation = f64::from_bits(0x3CD0_0000_0000_0000); // 2^-50 - let a = Matrix::<3>::try_from_rows([ - [1.0 + perturbation, 2.0, 3.0], - [4.0, 5.0, 6.0], - [7.0, 8.0, 9.0], - ]) - .unwrap(); - let b = Vector::<3>::new([1.0, 2.0, 3.0]); - - assert_eq!( - a.solve_exact_f64(b), - Err(LaError::Unrepresentable { - index: Some(2), - reason: UnrepresentableReason::RequiresRounding, - }) - ); - assert_eq!( - a.solve_exact_rounded_f64(b).unwrap().into_array()[2].to_bits(), - (1.0f64 / 3.0).to_bits() - ); - } - /// Large-entry 3×3 solve (matches the `exact_large_entries_3x3` /// bench). `A = big · I + (1 - I)` with `big = f64::MAX / 2` and /// `b = [big, 1, 1] = A · [1, 0, 0]`. The `BigInt` augmented system @@ -2809,32 +3035,37 @@ mod tests { /// Determinant of the large-entry 3×3 is roughly `big^3`, which /// overflows `f64`. `det_direct()` therefore reports a computed /// [`LaError::NonFinite`], the fast filter inside `det_sign_exact` - /// treats that as inconclusive, and the Bareiss fallback resolves the - /// positive sign correctly. `det_exact_f64` must report `Unrepresentable`. + /// treats that as inconclusive, and the direct `BigInt` fallback resolves + /// the positive sign correctly. `det_exact_f64` must report `Unrepresentable`. #[test] fn det_sign_exact_large_entries_3x3_positive() { let big = f64::MAX / 2.0; let a = Matrix::<3>::try_from_rows([[big, 1.0, 1.0], [1.0, big, 1.0], [1.0, 1.0, big]]) .unwrap(); // Fast filter is inconclusive (big^3 overflows f64 to +∞), so - // this exercises the Bareiss cold path. - assert_matches!(a.det_direct(), Err(LaError::NonFinite { row: None, .. })); - assert_eq!(a.det_sign_exact().unwrap(), 1); + // this exercises the direct `BigInt` cold path. + assert_matches!( + a.det_direct(), + Err(LaError::NonFinite { + location: NonFiniteLocation::Scalar, + origin: NonFiniteOrigin::Computation { + operation: ArithmeticOperation::Determinant, + .. + }, + .. + }) + ); + assert_eq!(a.det_sign_exact(), DeterminantSign::Positive); // Cross-validate: the exact `BigRational` determinant must agree // on sign with `det_sign_exact`, and `det_exact_f64` must reject the // conversion (the value is representable in BigRational but far exceeds f64). assert!(a.det_exact().unwrap().is_positive()); - assert_eq!( - a.det_exact_f64(), - Err(LaError::Unrepresentable { - index: None, - reason: UnrepresentableReason::NotFinite, - }) - ); + assert_unrepresentable(&a.det_exact_f64(), None, UnrepresentableReason::NotFinite); } /// Hilbert matrices are symmetric positive-definite, so - /// `det_sign_exact` must return `1` for every D. For D=2..=4 the + /// `det_sign_exact` must return [`DeterminantSign::Positive`] for every D. + /// For D=2..=4 the /// fast f64 filter resolves the positive sign without falling /// through (Hilbert's determinant is tiny but still well above the /// `det_errbound` cushion); for D=5 the filter is skipped entirely @@ -2846,7 +3077,7 @@ mod tests { #[test] fn []() { let h = hilbert::<$d>(); - assert_eq!(h.det_sign_exact().unwrap(), 1); + assert_eq!(h.det_sign_exact(), DeterminantSign::Positive); } } }; @@ -2857,30 +3088,6 @@ mod tests { gen_det_sign_exact_hilbert_positive_tests!(4); gen_det_sign_exact_hilbert_positive_tests!(5); - macro_rules! gen_solve_exact_f64_hilbert_benchmark_rhs_tests { - ($d:literal) => { - paste! { - #[test] - fn []() { - let h = hilbert::<$d>(); - let b = Vector::<$d>::new([1.0; $d]); - - assert_matches!( - h.solve_exact_f64(b), - Err(LaError::Unrepresentable { - reason: UnrepresentableReason::RequiresRounding, - .. - }) - ); - assert_matches!(h.solve_exact_rounded_f64(b), Ok(_)); - } - } - }; - } - - gen_solve_exact_f64_hilbert_benchmark_rhs_tests!(4); - gen_solve_exact_f64_hilbert_benchmark_rhs_tests!(5); - /// `solve_exact` on a Hilbert matrix must produce a solution whose /// residual `A · x - b` is *exactly* zero in `BigRational` arithmetic. /// Hilbert entries (`1/3`, `1/5`, `1/6`, `1/7`, …) are non-terminating @@ -2902,9 +3109,9 @@ mod tests { } let b = Vector::<$d>::new(b_arr); let x = h.solve_exact(b).unwrap(); - let ax = bigrational_matvec(&h, &x); + let ax = big_rational_matvec(&h, &x); for i in 0..$d { - assert_eq!(ax[i], f64_to_bigrational(b_arr[i])); + assert_eq!(ax[i], f64_to_big_rational(b_arr[i])); } } } @@ -2936,12 +3143,10 @@ mod tests { let big = f64::MAX / 2.0; let a = Matrix::<2>::try_from_rows([[1.0 / big, 0.0], [0.0, 1.0 / big]]).unwrap(); let b = Vector::<2>::new([big, big]); - assert_eq!( - a.solve_exact_f64(b), - Err(LaError::Unrepresentable { - index: Some(0), - reason: UnrepresentableReason::NotFinite, - }) + assert_unrepresentable( + &a.solve_exact_f64(b), + Some(0), + UnrepresentableReason::NotFinite, ); } @@ -2950,12 +3155,10 @@ mod tests { let a = Matrix::<1>::try_from_rows([[3.0 * f64::MIN_POSITIVE]]).unwrap(); let b = Vector::<1>::new([f64::MAX]); - assert_eq!( - a.solve_exact_f64(b), - Err(LaError::Unrepresentable { - index: Some(0), - reason: UnrepresentableReason::NotFinite, - }) + assert_unrepresentable( + &a.solve_exact_f64(b), + Some(0), + UnrepresentableReason::NotFinite, ); } @@ -2965,12 +3168,10 @@ mod tests { let a = Matrix::<2>::try_from_rows([[1.0 / big, 0.0], [0.0, 1.0 / big]]).unwrap(); let b = Vector::<2>::new([big, big]); - assert_eq!( - a.solve_exact_rounded_f64(b), - Err(LaError::Unrepresentable { - index: Some(0), - reason: UnrepresentableReason::NotFinite, - }) + assert_unrepresentable( + &a.solve_exact_rounded_f64(b), + Some(0), + UnrepresentableReason::NotFinite, ); } @@ -2980,12 +3181,10 @@ mod tests { let a = Matrix::<1>::try_from_rows([[2.0]]).unwrap(); let b = Vector::<1>::new([tiny]); - assert_eq!( - a.solve_exact_f64(b), - Err(LaError::Unrepresentable { - index: Some(0), - reason: UnrepresentableReason::RequiresRounding, - }) + assert_unrepresentable( + &a.solve_exact_f64(b), + Some(0), + UnrepresentableReason::RequiresRounding, ); } @@ -3001,115 +3200,97 @@ mod tests { ); } - #[test] - fn solve_exact_f64_rejects_non_dyadic_component() { - let a = Matrix::<1>::try_from_rows([[3.0]]).unwrap(); - let b = Vector::<1>::new([1.0]); - - assert_eq!( - a.solve_exact_f64(b), - Err(LaError::Unrepresentable { - index: Some(0), - reason: UnrepresentableReason::RequiresRounding, - }) - ); - } - - #[test] - fn solve_exact_rounded_f64_rounds_non_dyadic_component() { - let a = Matrix::<1>::try_from_rows([[3.0]]).unwrap(); - let b = Vector::<1>::new([1.0]); - - assert_eq!( - a.solve_exact_rounded_f64(b).unwrap().into_array()[0].to_bits(), - (1.0f64 / 3.0).to_bits() - ); - } - // ----------------------------------------------------------------------- // exact solve boundary tests // ----------------------------------------------------------------------- #[test] - fn gauss_solve_d1() { + fn bareiss_solve_d1() { let a = Matrix::<1>::try_from_rows([[2.0]]).unwrap(); let b = Vector::<1>::new([6.0]); let x = a.solve_exact(b).unwrap(); - assert_eq!(x[0], f64_to_bigrational(3.0)); + assert_eq!(x[0], f64_to_big_rational(3.0)); } #[test] - fn gauss_solve_singular_column_all_zero() { + fn bareiss_solve_singular_column_all_zero() { let a = Matrix::<3>::try_from_rows([[1.0, 0.0, 0.0], [0.0, 0.0, 0.0], [0.0, 0.0, 1.0]]) .unwrap(); let b = Vector::<3>::new([1.0, 2.0, 3.0]); - assert_eq!(a.solve_exact(b), Err(LaError::Singular { pivot_col: 1 })); + assert_matches!( + a.solve_exact(b), + Err(LaError::Singular { + pivot_col: 1, + reason: SingularityReason::Exact, + .. + }) + ); } // ----------------------------------------------------------------------- - // f64_to_bigrational tests + // f64_to_big_rational tests // ----------------------------------------------------------------------- #[test] - fn f64_to_bigrational_positive_zero() { - let r = f64_to_bigrational(0.0); + fn f64_to_big_rational_positive_zero() { + let r = f64_to_big_rational(0.0); assert_eq!(r, BigRational::from_integer(BigInt::from(0))); } #[test] - fn f64_to_bigrational_negative_zero() { - let r = f64_to_bigrational(-0.0); + fn f64_to_big_rational_negative_zero() { + let r = f64_to_big_rational(-0.0); assert_eq!(r, BigRational::from_integer(BigInt::from(0))); } #[test] - fn f64_to_bigrational_one() { - let r = f64_to_bigrational(1.0); + fn f64_to_big_rational_one() { + let r = f64_to_big_rational(1.0); assert_eq!(r, BigRational::from_integer(BigInt::from(1))); } #[test] - fn f64_to_bigrational_negative_one() { - let r = f64_to_bigrational(-1.0); + fn f64_to_big_rational_negative_one() { + let r = f64_to_big_rational(-1.0); assert_eq!(r, BigRational::from_integer(BigInt::from(-1))); } #[test] - fn f64_to_bigrational_half() { - let r = f64_to_bigrational(0.5); + fn f64_to_big_rational_half() { + let r = f64_to_big_rational(0.5); assert_eq!(r, BigRational::new(BigInt::from(1), BigInt::from(2))); } #[test] - fn f64_to_bigrational_quarter() { - let r = f64_to_bigrational(0.25); + fn f64_to_big_rational_quarter() { + let r = f64_to_big_rational(0.25); assert_eq!(r, BigRational::new(BigInt::from(1), BigInt::from(4))); } #[test] - fn f64_to_bigrational_negative_three_and_a_half() { + fn f64_to_big_rational_negative_three_and_a_half() { // -3.5 = -7/2 - let r = f64_to_bigrational(-3.5); + let r = f64_to_big_rational(-3.5); assert_eq!(r, BigRational::new(BigInt::from(-7), BigInt::from(2))); } #[test] - fn f64_to_bigrational_integer() { - let r = f64_to_bigrational(42.0); + fn f64_to_big_rational_integer() { + let r = f64_to_big_rational(42.0); assert_eq!(r, BigRational::from_integer(BigInt::from(42))); } #[test] - fn f64_to_bigrational_power_of_two() { - let r = f64_to_bigrational(1024.0); + fn f64_to_big_rational_power_of_two() { + let r = f64_to_big_rational(1024.0); assert_eq!(r, BigRational::from_integer(BigInt::from(1024))); } #[test] - fn f64_to_bigrational_subnormal() { + fn f64_to_big_rational_subnormal() { let tiny = 5e-324_f64; // smallest positive subnormal assert!(tiny.is_subnormal()); - let r = f64_to_bigrational(tiny); + let r = f64_to_big_rational(tiny); // 5e-324 = 1 × 2^(-1074) assert_eq!( r, @@ -3118,17 +3299,17 @@ mod tests { } #[test] - fn f64_to_bigrational_already_lowest_terms() { + fn f64_to_big_rational_already_lowest_terms() { // 0.5 should produce numer=1, denom=2 (already reduced). - let r = f64_to_bigrational(0.5); + let r = f64_to_big_rational(0.5); assert_eq!(*r.numer(), BigInt::from(1)); assert_eq!(*r.denom(), BigInt::from(2)); } #[test] - fn f64_to_bigrational_round_trip() { + fn f64_to_big_rational_round_trip() { // -0.0 is excluded: it maps to BigRational(0) which round-trips - // to +0.0 (correct; tested separately in f64_to_bigrational_negative_zero). + // to +0.0 (correct; tested separately in f64_to_big_rational_negative_zero). let values = [ 0.0, 1.0, @@ -3145,7 +3326,7 @@ mod tests { 5e-324, ]; for &v in &values { - let r = f64_to_bigrational(v); + let r = f64_to_big_rational(v); let back = r.to_f64().expect("round-trip to_f64 failed"); assert!( v.to_bits() == back.to_bits(), @@ -3155,12 +3336,9 @@ mod tests { } #[test] - fn f64_decompose_rejects_nonfinite_inputs() { + fn decompose_f64_rejects_non_finite_inputs() { for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { - assert_eq!( - f64_decompose(value), - Err(LaError::NonFinite { row: None, col: 0 }) - ); + assert_non_finite_input_scalar(&decompose_f64(value)); } } } diff --git a/src/ldlt.rs b/src/ldlt.rs index 8b51731..e562b9a 100644 --- a/src/ldlt.rs +++ b/src/ldlt.rs @@ -9,15 +9,16 @@ //! # Preconditions //! The input matrix must be **symmetric**. This is a correctness contract, not a hint: //! the factorization algorithm reads only the lower triangle and implicitly assumes the -//! upper triangle mirrors it. Asymmetric inputs return [`LaError::Asymmetric`] -//! before factorization starts. Callers who know their matrices may not be -//! symmetric at all should use [`crate::Lu`] instead. +//! upper triangle mirrors it exactly. Asymmetric inputs return [`LaError::Asymmetric`] +//! with an allowed absolute difference of `0.0` before factorization starts. IEEE-754 +//! signed zeros compare equal and are accepted. Callers who know their matrices may +//! not be symmetric at all should use [`crate::Lu`] instead. use core::hint::cold_path; -use crate::matrix::{Matrix, SymmetricMatrix}; +use crate::scaled_product::{RangeCheckedProduct, ScaledProduct, range_checked_product}; use crate::vector::Vector; -use crate::{LaError, Tolerance}; +use crate::{ArithmeticOperation, FactorizationKind, LaError, SymmetricMatrix, Tolerance}; /// LDLT factorization (`A = L D Lᵀ`) for symmetric positive (semi)definite matrices. /// @@ -29,12 +30,12 @@ use crate::{LaError, Tolerance}; /// /// # Preconditions /// The source matrix passed to [`Matrix::ldlt`](crate::Matrix::ldlt) must be -/// symmetric (`A[i][j] == A[j][i]` within rounding). Asymmetric inputs return -/// [`LaError::Asymmetric`] before factorization starts; see +/// exactly symmetric (`A[i][j] == A[j][i]` for every mirrored pair). Asymmetric +/// inputs return [`LaError::Asymmetric`] before factorization starts; see /// [`Matrix::ldlt`](crate::Matrix::ldlt) for details and alternatives. /// /// # Storage -/// The factors are stored in a single [`Matrix`]: +/// The factors are stored in one inline row-major array: /// - `D` is stored on the diagonal. /// - The strict lower triangle stores the multipliers of `L`. /// - The diagonal of `L` is implicit ones. @@ -50,13 +51,13 @@ pub struct Ldlt { /// finite and every diagonal satisfies the factorization tolerance. #[derive(Clone, Copy, Debug, PartialEq)] struct LdltFactors { - storage: Matrix, + storage: [[f64; D]; D], } impl LdltFactors { - /// Construct factors after LDLT factorization has proven the storage invariant. + /// Store rows after the factorization loop has proven all factor invariants. #[inline] - const fn new_unchecked(storage: Matrix) -> Self { + const fn from_proven_rows(storage: [[f64; D]; D]) -> Self { Self { storage } } @@ -64,59 +65,88 @@ impl LdltFactors { #[inline] #[must_use] const fn row(&self, index: usize) -> &[f64; D] { - &self.storage.rows()[index] - } - - /// Return a factor entry. - #[inline] - #[must_use] - const fn entry(&self, row: usize, col: usize) -> f64 { - self.storage.rows()[row][col] + &self.storage[index] } /// Return a diagonal entry of `D`. #[inline] #[must_use] const fn diag(&self, index: usize) -> f64 { - self.storage.rows()[index][index] + self.storage[index][index] } } impl Ldlt { - /// Factor a matrix that has already passed LDLT symmetry validation. + /// Factor a finite, symmetry-proven matrix for + /// [`Matrix::ldlt`](crate::Matrix::ldlt). + /// + /// Consuming [`SymmetricMatrix`] lets the factorization read only the lower + /// triangle without revalidating symmetry. A successful result contains + /// only finite factor storage with diagonals above `tol`. + /// + /// # Errors + /// Returns [`LaError::NotPositiveSemidefinite`] for a negative pivot or a + /// zero pivot with non-zero coupling, [`LaError::Singular`] for an uncoupled + /// zero pivot or a positive pivot at or below `tol`, and [`LaError::NonFinite`] + /// when a pivot, multiplier, or update is not finite. #[inline] - #[allow(clippy::needless_range_loop)] pub(crate) fn factor_symmetric(a: SymmetricMatrix, tol: Tolerance) -> Result { - let mut f = a.into_matrix(); - let tol = tol.get(); + let mut rows = a.into_matrix().into_rows(); + let tolerance = tol.get(); { - let rows = f.rows_mut_unchecked(); + let rows = &mut rows; // LDLT via symmetric rank-1 updates, using only the lower triangle. for j in 0..D { let d = rows[j][j]; if !d.is_finite() { cold_path(); - return Err(LaError::non_finite_cell(j, j)); + return Err(LaError::non_finite_computation_matrix( + ArithmeticOperation::LdltFactorization, + j, + j, + )); } if d < 0.0 { cold_path(); - return Err(LaError::not_positive_semidefinite(j, d)); + if let Some(error) = Self::non_finite_factor_error(rows) { + return Err(error); + } + return Err(LaError::not_positive_semidefinite_negative(j, d)); } - if d <= tol { + if d == 0.0 { cold_path(); - return Err(LaError::Singular { pivot_col: j }); + return Err(Self::zero_pivot_failure(rows, j, tolerance)); + } + if d <= tolerance { + cold_path(); + if let Some(error) = Self::non_finite_factor_error(rows) { + return Err(error); + } + return Err(LaError::singular_numerical( + j, + FactorizationKind::Ldlt, + d, + tolerance, + )); } - if D <= 5 { // Tiny matrices benchmark better when column normalization stays // separate from the trailing update. + #[expect( + clippy::needless_range_loop, + reason = "the row index identifies the lower-triangle entry and any reported non-finite coordinate" + )] for i in (j + 1)..D { let l = rows[i][j] / d; if !l.is_finite() { cold_path(); - return Err(LaError::non_finite_cell(i, j)); + return Err(LaError::non_finite_computation_matrix( + ArithmeticOperation::LdltFactorization, + i, + j, + )); } rows[i][j] = l; } @@ -125,6 +155,10 @@ impl Ldlt { let l_i = rows[i][j]; let l_i_d = l_i * d; + #[expect( + clippy::needless_range_loop, + reason = "the triangular column index coordinates multiplier reads with in-place trailing-row writes" + )] for k in (j + 1)..=i { let l_k = rows[k][j]; let new_val = (-l_i_d).mul_add(l_k, rows[i][k]); @@ -138,12 +172,20 @@ impl Ldlt { let l_i = rows[i][j] / d; if !l_i.is_finite() { cold_path(); - return Err(LaError::non_finite_cell(i, j)); + return Err(LaError::non_finite_computation_matrix( + ArithmeticOperation::LdltFactorization, + i, + j, + )); } rows[i][j] = l_i; let l_i_d = l_i * d; + #[expect( + clippy::needless_range_loop, + reason = "the triangular column index coordinates normalized-column reads with the fused in-place update" + )] for k in (j + 1)..=i { let l_k = rows[k][j]; let new_val = (-l_i_d).mul_add(l_k, rows[i][k]); @@ -157,10 +199,40 @@ impl Ldlt { // Every computed lower-triangular entry is checked when it becomes a // pivot or multiplier; the untouched upper triangle remains finite input. Ok(Self { - factors: LdltFactors::new_unchecked(f), + factors: LdltFactors::from_proven_rows(rows), }) } + /// Return the first non-finite factor cell in row-major order. + fn non_finite_factor_error(rows: &[[f64; D]; D]) -> Option { + for (row, values) in rows.iter().enumerate() { + for (col, value) in values.iter().enumerate() { + if !value.is_finite() { + return Some(LaError::non_finite_computation_matrix( + ArithmeticOperation::LdltFactorization, + row, + col, + )); + } + } + } + None + } + + /// Classify a zero pivot after checking factor storage and every coupling. + fn zero_pivot_failure(rows: &[[f64; D]; D], pivot_col: usize, tolerance: f64) -> LaError { + if let Some(error) = Self::non_finite_factor_error(rows) { + return error; + } + for (row, values) in rows.iter().enumerate().skip(pivot_col + 1) { + let coupling = values[pivot_col]; + if coupling != 0.0 { + return LaError::not_positive_semidefinite_zero_coupling(pivot_col, row, coupling); + } + } + LaError::singular_numerical(pivot_col, FactorizationKind::Ldlt, 0.0, tolerance) + } + /// Determinant of the original matrix. /// /// For SPD/PSD matrices, this is the product of the diagonal terms of `D`. @@ -179,24 +251,55 @@ impl Ldlt { /// # } /// ``` /// + /// Diagonal pivots are multiplied directly while each non-zero running + /// product remains finite and normal. If direct accumulation detects range + /// loss, all pivots are recomputed with power-of-two scaling before a + /// premature overflow or underflow can affect the returned determinant. + /// The final product is rounded to `f64`; a non-zero magnitude below the + /// binary64 range may round to zero. No certified absolute error bound is + /// provided. + /// /// # Errors - /// Returns [`LaError::NonFinite`] if the determinant product overflows to - /// NaN or infinity. + /// Returns [`LaError::NonFinite`] if the final scaled determinant cannot be + /// represented as a finite `f64`. #[inline] pub const fn det(&self) -> Result { let mut det = 1.0; let mut i = 0; while i < D { - det *= self.factors.diag(i); - if !det.is_finite() { - cold_path(); - return Err(LaError::non_finite_at(i)); + let factor = self.factors.diag(i); + match range_checked_product(det, factor) { + RangeCheckedProduct::Safe(next) => det = next, + RangeCheckedProduct::NeedsScaling => { + cold_path(); + return self.scaled_det(); + } } i += 1; } Ok(det) } + /// Recompute the determinant with normalized mantissa/exponent scaling. + #[cold] + const fn scaled_det(&self) -> Result { + let mut product = ScaledProduct::new(false); + let mut i = 0; + while i < D { + product.multiply(self.factors.diag(i)); + i += 1; + } + + if let Some(det) = product.finish() { + Ok(det) + } else { + Err(LaError::non_finite_computation_step( + ArithmeticOperation::Determinant, + D.saturating_sub(1), + )) + } + } + /// Solve `A x = b` using this LDLT factorization. /// /// [`Vector`] is finite by construction, so this method only checks computed @@ -226,19 +329,6 @@ impl Ldlt { /// overflows to NaN or infinity. #[inline] pub const fn solve(&self, b: Vector) -> Result, LaError> { - self.solve_finite(b) - } - - /// Solve `A x = b` using this LDLT factorization and a finite right-hand side. - /// - /// The right-hand side entries and stored factors are known finite, so this - /// path only checks computed substitution overflows. - /// - /// # Errors - /// Returns [`LaError::NonFinite`] if a computed substitution intermediate - /// overflows to NaN or infinity. - #[inline] - pub(crate) const fn solve_finite(&self, b: Vector) -> Result, LaError> { let mut x = b.into_array(); // Forward substitution: L y = b (L has unit diagonal). @@ -253,7 +343,10 @@ impl Ldlt { } if !sum.is_finite() { cold_path(); - return Err(LaError::non_finite_at(i)); + return Err(LaError::non_finite_computation_step( + ArithmeticOperation::LdltSolve, + i, + )); } x[i] = sum; i += 1; @@ -267,7 +360,10 @@ impl Ldlt { let quotient = x[i] / diag; if !quotient.is_finite() { cold_path(); - return Err(LaError::non_finite_at(i)); + return Err(LaError::non_finite_computation_step( + ArithmeticOperation::LdltSolve, + i, + )); } x[i] = quotient; i += 1; @@ -282,12 +378,15 @@ impl Ldlt { let mut sum = x[i]; let mut j = i + 1; while j < D { - sum = (-self.factors.entry(j, i)).mul_add(x[j], sum); + sum = (-self.factors.row(j)[i]).mul_add(x[j], sum); j += 1; } if !sum.is_finite() { cold_path(); - return Err(LaError::non_finite_at(i)); + return Err(LaError::non_finite_computation_step( + ArithmeticOperation::LdltSolve, + i, + )); } x[i] = sum; ii += 1; @@ -303,7 +402,10 @@ impl Ldlt { let x_j = x[jj]; if !x_j.is_finite() { cold_path(); - return Err(LaError::non_finite_at(jj)); + return Err(LaError::non_finite_computation_step( + ArithmeticOperation::LdltSolve, + jj, + )); } let row = self.factors.row(jj); @@ -315,25 +417,31 @@ impl Ldlt { } } - Ok(Vector::new_unchecked(x)) + Vector::from_computation(x, ArithmeticOperation::LdltSolve) } } #[cfg(test)] mod tests { - use super::*; - - use crate::DEFAULT_SINGULAR_TOL; use core::hint::black_box; use approx::assert_abs_diff_eq; use pastey::paste; - macro_rules! gen_public_api_ldlt_identity_tests { + use super::*; + use crate::DEFAULT_SINGULAR_TOL; + use crate::matrix::Matrix; + + const TWO_NEG_800: f64 = f64::from_bits(223_u64 << 52); + const TWO_NEG_38: f64 = f64::from_bits(985_u64 << 52); + const TWO_POS_43: f64 = f64::from_bits(1066_u64 << 52); + const TWO_POS_800: f64 = f64::from_bits(1823_u64 << 52); + + macro_rules! gen_ldlt_identity_tests { ($d:literal) => { paste! { #[test] - fn []() { + fn []() { let a = Matrix::<$d>::identity(); let ldlt = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap(); @@ -358,16 +466,16 @@ mod tests { }; } - gen_public_api_ldlt_identity_tests!(2); - gen_public_api_ldlt_identity_tests!(3); - gen_public_api_ldlt_identity_tests!(4); - gen_public_api_ldlt_identity_tests!(5); + gen_ldlt_identity_tests!(2); + gen_ldlt_identity_tests!(3); + gen_ldlt_identity_tests!(4); + gen_ldlt_identity_tests!(5); - macro_rules! gen_public_api_ldlt_diagonal_tests { + macro_rules! gen_ldlt_diagonal_tests { ($d:literal) => { paste! { #[test] - fn []() { + fn []() { let diag = { let mut arr = [0.0f64; $d]; let values = [1.0f64, 2.0, 3.0, 4.0, 5.0]; @@ -414,10 +522,10 @@ mod tests { }; } - gen_public_api_ldlt_diagonal_tests!(2); - gen_public_api_ldlt_diagonal_tests!(3); - gen_public_api_ldlt_diagonal_tests!(4); - gen_public_api_ldlt_diagonal_tests!(5); + gen_ldlt_diagonal_tests!(2); + gen_ldlt_diagonal_tests!(3); + gen_ldlt_diagonal_tests!(4); + gen_ldlt_diagonal_tests!(5); #[test] fn solve_0x0_returns_empty_vector_and_unit_det() { @@ -446,6 +554,23 @@ mod tests { assert_abs_diff_eq!(ldlt.det().unwrap(), 8.0, epsilon = 1e-12); } + #[test] + fn det_ordinary_factors_matches_direct_product_bits() { + let diagonal = [1.5, 2.0, 0.25, 8.0]; + let mut rows = [[0.0; 4]; 4]; + let mut expected = 1.0; + for (i, factor) in diagonal.into_iter().enumerate() { + rows[i][i] = factor; + expected *= factor; + } + + let ldlt = Matrix::<4>::try_from_rows(rows) + .unwrap() + .ldlt(DEFAULT_SINGULAR_TOL) + .unwrap(); + assert_eq!(ldlt.det().unwrap().to_bits(), expected.to_bits()); + } + #[test] fn solve_3x3_spd_tridiagonal_smoke() { let a = Matrix::<3>::try_from_rows(black_box([ @@ -470,75 +595,100 @@ mod tests { // Rank-1 Gram-like matrix. let a = Matrix::<2>::try_from_rows(black_box([[1.0, 1.0], [1.0, 1.0]])).unwrap(); let err = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap_err(); - assert_eq!(err, LaError::Singular { pivot_col: 1 }); + assert_eq!( + err, + LaError::singular_numerical( + 1, + FactorizationKind::Ldlt, + 0.0, + DEFAULT_SINGULAR_TOL.get() + ) + ); } #[test] - fn negative_initial_diagonal_reports_not_positive_semidefinite() { - let a = Matrix::<2>::try_from_rows(black_box([[-1.0, 0.0], [0.0, 1.0]])).unwrap(); + fn zero_pivot_with_nonzero_coupling_is_not_reported_as_singular() { + let a = Matrix::<2>::try_from_rows(black_box([[0.0, 1.0], [1.0, 0.0]])).unwrap(); let err = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap_err(); assert_eq!( err, - LaError::NotPositiveSemidefinite { - pivot_col: 0, - value: -1.0, - } + LaError::not_positive_semidefinite_zero_coupling(0, 1, 1.0) ); } #[test] - fn negative_updated_diagonal_reports_not_positive_semidefinite() { - let a = Matrix::<2>::try_from_rows(black_box([[1.0, 2.0], [2.0, 1.0]])).unwrap(); + fn zero_pivot_reports_non_finite_coupling_before_domain_violation() { + let a = Matrix::<3>::try_from_rows(black_box([ + [1.0, 1.0, f64::MAX], + [1.0, 1.0, -f64::MAX], + [f64::MAX, -f64::MAX, 1.0], + ])) + .unwrap(); let err = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap_err(); assert_eq!( err, - LaError::NotPositiveSemidefinite { - pivot_col: 1, - value: -3.0, - } + LaError::non_finite_computation_matrix(ArithmeticOperation::LdltFactorization, 2, 1,) ); } #[test] - fn matrix_constructor_rejects_nonfinite_diagonal() { - let err = Matrix::<2>::try_from_rows([[f64::NAN, 0.0], [0.0, 1.0]]).unwrap_err(); + fn small_positive_pivot_reports_numerical_singularity() { + let a = Matrix::<2>::try_from_rows(black_box([[1e-13, 0.0], [0.0, 1.0]])).unwrap(); + let err = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap_err(); assert_eq!( err, - LaError::NonFinite { - row: Some(0), - col: 0 - } + LaError::singular_numerical( + 0, + FactorizationKind::Ldlt, + 1e-13, + DEFAULT_SINGULAR_TOL.get() + ) ); } #[test] - fn matrix_constructor_rejects_nonfinite_offdiagonal_before_asymmetry() { - let err = Matrix::<2>::try_from_rows([[1.0, f64::NAN], [0.0, 1.0]]).unwrap_err(); + fn negative_initial_diagonal_reports_not_positive_semidefinite() { + let a = Matrix::<2>::try_from_rows(black_box([[-1.0, 0.0], [0.0, 1.0]])).unwrap(); + let err = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap_err(); + assert_eq!(err, LaError::not_positive_semidefinite_negative(0, -1.0)); + } + + #[test] + fn negative_updated_diagonal_reports_not_positive_semidefinite() { + let a = Matrix::<2>::try_from_rows(black_box([[1.0, 2.0], [2.0, 1.0]])).unwrap(); + let err = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap_err(); + assert_eq!(err, LaError::not_positive_semidefinite_negative(1, -3.0)); + } + + #[test] + fn negative_pivot_does_not_mask_earlier_non_finite_update() { + let a = Matrix::<3>::try_from_rows(black_box([ + [1.0, 2.0, f64::MAX], + [2.0, 1.0, 0.0], + [f64::MAX, 0.0, 1.0], + ])) + .unwrap(); + + let err = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap_err(); assert_eq!( err, - LaError::NonFinite { - row: Some(0), - col: 1, - } + LaError::non_finite_computation_matrix(ArithmeticOperation::LdltFactorization, 2, 1,) ); } #[test] - fn nonfinite_l_multiplier_overflow() { + fn non_finite_l_multiplier_overflow() { // d = 1e-11 > tol, but l = 1e300 / 1e-11 = 1e311 overflows f64. let a = Matrix::<2>::try_from_rows([[1e-11, 1e300], [1e300, 1.0]]).unwrap(); let err = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap_err(); assert_eq!( err, - LaError::NonFinite { - row: Some(1), - col: 0 - } + LaError::non_finite_computation_matrix(ArithmeticOperation::LdltFactorization, 1, 0) ); } #[test] - fn nonfinite_l_multiplier_overflow_fused_branch_6d() { + fn non_finite_l_multiplier_overflow_fused_branch_6d() { // D > 5 uses the fused LDLT update path. Keep the same overflow shape // as the 2D test while forcing that branch. let mut rows = [[0.0; 6]; 6]; @@ -553,30 +703,24 @@ mod tests { let err = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap_err(); assert_eq!( err, - LaError::NonFinite { - row: Some(5), - col: 0 - } + LaError::non_finite_computation_matrix(ArithmeticOperation::LdltFactorization, 5, 0) ); } #[test] - fn nonfinite_trailing_submatrix_overflow() { + fn non_finite_trailing_submatrix_overflow() { // L multiplier is finite (1e200), but the rank-1 update // (-1e200 * 1.0) * 1e200 + 1.0 overflows. let a = Matrix::<2>::try_from_rows([[1.0, 1e200], [1e200, 1.0]]).unwrap(); let err = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap_err(); assert_eq!( err, - LaError::NonFinite { - row: Some(1), - col: 1 - } + LaError::non_finite_computation_matrix(ArithmeticOperation::LdltFactorization, 1, 1) ); } #[test] - fn nonfinite_trailing_submatrix_overflow_fused_branch_6d() { + fn non_finite_trailing_submatrix_overflow_fused_branch_6d() { // D > 5 uses the fused LDLT update path. The overflowing trailing // diagonal is detected when it later becomes a pivot. let mut rows = [[0.0; 6]; 6]; @@ -590,15 +734,12 @@ mod tests { let err = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap_err(); assert_eq!( err, - LaError::NonFinite { - row: Some(5), - col: 5 - } + LaError::non_finite_computation_matrix(ArithmeticOperation::LdltFactorization, 5, 5) ); } #[test] - fn nonfinite_solve_forward_substitution_overflow() { + fn non_finite_solve_forward_substitution_overflow() { // SPD matrix with large L multiplier: L[1,0] = 1e153. // Forward substitution overflows: y[1] = 0 - 1e153 * 1e156 = -inf. let a = Matrix::<3>::try_from_rows([ @@ -611,11 +752,14 @@ mod tests { let b = Vector::<3>::new([1e156, 0.0, 0.0]); let err = ldlt.solve(b).unwrap_err(); - assert_eq!(err, LaError::NonFinite { row: None, col: 1 }); + assert_eq!( + err, + LaError::non_finite_computation_step(ArithmeticOperation::LdltSolve, 1) + ); } #[test] - fn nonfinite_solve_back_substitution_overflow() { + fn non_finite_solve_back_substitution_overflow() { // SPD matrix: [[1,0,0],[0,1,2],[0,2,5]] has LDLT factors // D=[1,1,1], L[2,1]=2. Forward sub and diagonal solve produce // z=[0,0,1e308]. Back-substitution: x[2]=1e308 then @@ -626,11 +770,14 @@ mod tests { let b = Vector::<3>::new([0.0, 0.0, 1e308]); let err = ldlt.solve(b).unwrap_err(); - assert_eq!(err, LaError::NonFinite { row: None, col: 1 }); + assert_eq!( + err, + LaError::non_finite_computation_step(ArithmeticOperation::LdltSolve, 1) + ); } #[test] - fn nonfinite_solve_back_substitution_overflow_scatter_branch_5d() { + fn non_finite_solve_back_substitution_overflow_scatter_branch_5d() { // Exercises the D >= 5 row-prefix scatter branch with the same // bottom-right 2x2 SPD block used by the D3 back-substitution test. let a = Matrix::<5>::try_from_rows([ @@ -645,11 +792,14 @@ mod tests { let b = Vector::<5>::new([0.0, 0.0, 0.0, 0.0, 1e308]); let err = ldlt.solve(b).unwrap_err(); - assert_eq!(err, LaError::NonFinite { row: None, col: 3 }); + assert_eq!( + err, + LaError::non_finite_computation_step(ArithmeticOperation::LdltSolve, 3) + ); } #[test] - fn nonfinite_solve_diagonal_solve_overflow() { + fn non_finite_solve_diagonal_solve_overflow() { // Diagonal SPD matrix with a tiny diagonal entry just above the // singularity tolerance. Forward substitution passes through the // large RHS unchanged, then the diagonal solve z[1] = y[1] / D[1] @@ -660,7 +810,10 @@ mod tests { let b = Vector::<2>::new([0.0, 1.0e300]); let err = ldlt.solve(b).unwrap_err(); - assert_eq!(err, LaError::NonFinite { row: None, col: 1 }); + assert_eq!( + err, + LaError::non_finite_computation_step(ArithmeticOperation::LdltSolve, 1) + ); } #[test] @@ -674,50 +827,195 @@ mod tests { ]) .unwrap(); let ldlt = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap(); - assert_eq!(ldlt.det(), Err(LaError::NonFinite { row: None, col: 3 })); + assert_eq!( + ldlt.det(), + Err(LaError::non_finite_computation_step( + ArithmeticOperation::Determinant, + 4 + )) + ); } #[test] - fn asymmetric_input_returns_typed_error() { - // a[0][1] = 2.0 but a[1][0] = -2.0 → clearly asymmetric. - let a = Matrix::<3>::try_from_rows([[4.0, 2.0, 0.0], [-2.0, 5.0, 1.0], [0.0, 1.0, 3.0]]) + fn det_balances_extreme_diagonals_independently_of_storage_order() { + let zero_tolerance = Tolerance::try_new(0.0).unwrap(); + for diagonal in [ + [TWO_NEG_800, TWO_NEG_800, TWO_POS_800, TWO_POS_800], + [TWO_POS_800, TWO_POS_800, TWO_NEG_800, TWO_NEG_800], + ] { + let mut rows = [[0.0; 4]; 4]; + for (i, value) in diagonal.into_iter().enumerate() { + rows[i][i] = value; + } + + let ldlt = Matrix::<4>::try_from_rows(rows) + .unwrap() + .ldlt(zero_tolerance) + .unwrap(); + assert_eq!(ldlt.det(), Ok(1.0)); + } + } + + #[test] + fn det_balances_extreme_diagonals_in_large_dimension() { + let zero_tolerance = Tolerance::try_new(0.0).unwrap(); + for diagonal in [ + [TWO_NEG_800, TWO_NEG_800, TWO_POS_800, TWO_POS_800, 1.0, 1.0], + [TWO_POS_800, TWO_POS_800, TWO_NEG_800, TWO_NEG_800, 1.0, 1.0], + ] { + let mut rows = [[0.0; 6]; 6]; + for (i, value) in diagonal.into_iter().enumerate() { + rows[i][i] = value; + } + + let ldlt = Matrix::<6>::try_from_rows(rows) + .unwrap() + .ldlt(zero_tolerance) + .unwrap(); + assert_eq!(ldlt.det(), Ok(1.0)); + } + } + + #[test] + fn det_rounds_final_tiny_magnitude_to_zero() { + let zero_tolerance = Tolerance::try_new(0.0).unwrap(); + let matrix = Matrix::<2>::try_from_rows([[TWO_NEG_800, 0.0], [0.0, TWO_NEG_800]]).unwrap(); + let det = matrix.ldlt(zero_tolerance).unwrap().det().unwrap(); + + assert_eq!(det.to_bits(), 0.0f64.to_bits()); + } + + #[test] + fn ldlt_d1_classifies_positive_zero_and_negative_inputs() { + let positive = Matrix::<1>::try_from_rows([[2.0]]) + .unwrap() + .ldlt(DEFAULT_SINGULAR_TOL) .unwrap(); + assert_eq!(positive.det(), Ok(2.0)); + assert_abs_diff_eq!( + positive + .solve(Vector::<1>::new([6.0])) + .unwrap() + .into_array()[0], + 3.0, + epsilon = 0.0 + ); + + let zero = Matrix::<1>::try_from_rows([[0.0]]) + .unwrap() + .ldlt(DEFAULT_SINGULAR_TOL); assert_eq!( - a.ldlt(DEFAULT_SINGULAR_TOL), - Err(LaError::Asymmetric { - row: 0, - col: 1, - dim: 3, - }) + zero, + Err(LaError::singular_numerical( + 0, + FactorizationKind::Ldlt, + 0.0, + DEFAULT_SINGULAR_TOL.get() + )) + ); + + let negative = Matrix::<1>::try_from_rows([[-1.0]]) + .unwrap() + .ldlt(DEFAULT_SINGULAR_TOL); + assert_eq!( + negative, + Err(LaError::not_positive_semidefinite_negative(0, -1.0)) ); } - macro_rules! gen_solve_boundary_tests { + /// Construct an exactly representable tridiagonal SPD system from a unit + /// lower-bidiagonal `L` and positive integer diagonal `D`. + fn nontrivial_spd_system() -> (Matrix, Vector, [f64; D], f64) { + let mut rows = [[0.0_f64; D]; D]; + let mut expected_det = 1.0_f64; + let mut diagonal = 1.0_f64; + let mut k = 0; + while k < D { + rows[k][k] += diagonal; + expected_det *= diagonal; + if k + 1 < D { + let off_diagonal = 0.5 * diagonal; + rows[k][k + 1] += off_diagonal; + rows[k + 1][k] += off_diagonal; + rows[k + 1][k + 1] = 0.25_f64.mul_add(diagonal, rows[k + 1][k + 1]); + } + diagonal += 1.0; + k += 1; + } + + let mut expected_x = [0.0_f64; D]; + let mut value = 1.0_f64; + for entry in &mut expected_x { + *entry = value; + value += 1.0; + } + let rhs = core::array::from_fn(|row| { + rows[row] + .iter() + .zip(expected_x.iter()) + .fold(0.0_f64, |sum, (&coefficient, &x)| { + coefficient.mul_add(x, sum) + }) + }); + + ( + Matrix::::try_from_rows(rows).unwrap(), + Vector::::try_new(rhs).unwrap(), + expected_x, + expected_det, + ) + } + + macro_rules! gen_nontrivial_large_ldlt_tests { ($d:literal) => { paste! { - /// Raw non-finite right-hand sides are rejected before a - /// public caller can construct a `Vector`. #[test] - fn []() { - let mut rhs = [1.0; $d]; - rhs[$d - 1] = f64::NAN; - - assert_eq!( - Vector::<$d>::try_new(rhs), - Err(LaError::NonFinite { - row: None, - col: $d - 1, - }) - ); + fn []() { + let (matrix, rhs, expected_x, expected_det) = + nontrivial_spd_system::<$d>(); + let ldlt = matrix.ldlt(DEFAULT_SINGULAR_TOL).unwrap(); + let solution = ldlt.solve(rhs).unwrap().into_array(); + + for (actual, expected) in solution.into_iter().zip(expected_x) { + assert_abs_diff_eq!(actual, expected, epsilon = 1e-12); + } + assert_abs_diff_eq!(ldlt.det().unwrap(), expected_det, epsilon = 1e-12); + assert_abs_diff_eq!(matrix.det().unwrap(), expected_det, epsilon = 1e-10); } } }; } - gen_solve_boundary_tests!(2); - gen_solve_boundary_tests!(3); - gen_solve_boundary_tests!(4); - gen_solve_boundary_tests!(5); + gen_nontrivial_large_ldlt_tests!(6); + gen_nontrivial_large_ldlt_tests!(8); + + #[test] + fn asymmetric_input_returns_typed_error() { + // a[0][1] = 2.0 but a[1][0] = -2.0 → clearly asymmetric. + let a = Matrix::<3>::try_from_rows([[4.0, 2.0, 0.0], [-2.0, 5.0, 1.0], [0.0, 1.0, 3.0]]) + .unwrap(); + assert_eq!( + a.ldlt(DEFAULT_SINGULAR_TOL), + Err(LaError::asymmetric(0, 1, 3, 2.0, -2.0, 0.0)) + ); + } + + #[test] + fn approximately_symmetric_input_is_rejected_before_factoring_another_operator() { + // The tolerance-based diagnostic accepts this exact power-of-two + // counterexample because 4 <= 1e-12 * 2^43. Factoring only its lower + // triangle would instead replace the upper zero with 4: the original + // determinant is 32, while that projected matrix has determinant 16. + let matrix = Matrix::<2>::try_from_rows([[TWO_POS_43, 0.0], [4.0, TWO_NEG_38]]).unwrap(); + let diagnostic_tolerance = Tolerance::try_new(1e-12).unwrap(); + + assert_eq!(matrix.det(), Ok(32.0)); + assert_eq!(matrix.is_symmetric(diagnostic_tolerance), Ok(true)); + assert_eq!( + matrix.ldlt(DEFAULT_SINGULAR_TOL), + Err(LaError::asymmetric(0, 1, 2, 0.0, 4.0, 0.0)) + ); + } // ----------------------------------------------------------------------- // Const-evaluability tests. @@ -746,10 +1044,8 @@ mod tests { i += 1; } rows[0][0] = 2.0; - let factors = Matrix::<$d>::from_rows_unchecked(rows); - let ldlt = Ldlt::<$d> { - factors: LdltFactors::new_unchecked(factors), - }; + let factors = LdltFactors::from_proven_rows(rows); + let ldlt = Ldlt::<$d> { factors }; ldlt.det() }; assert_eq!(DET, Ok(2.0)); @@ -761,11 +1057,15 @@ mod tests { /// / back sub pipeline inside a `const { … }` initializer. #[test] fn []() { - #[allow(clippy::cast_precision_loss)] - const X: [f64; $d] = { - let ldlt = Ldlt::<$d> { - factors: LdltFactors::new_unchecked(Matrix::<$d>::identity()), - }; + #[expect( + clippy::cast_precision_loss, + reason = "test indices are at most five and exactly representable as f64" + )] + const X: Result, LaError> = { + let factors = LdltFactors::from_proven_rows( + Matrix::<$d>::identity().into_rows() + ); + let ldlt = Ldlt::<$d> { factors }; let mut b_arr = [0.0f64; $d]; let mut i = 0; while i < $d { @@ -773,15 +1073,16 @@ mod tests { i += 1; } let b = Vector::<$d>::new(b_arr); - match ldlt.solve(b) { - Ok(v) => v.into_array(), - Err(_) => [0.0f64; $d], - } + ldlt.solve(b) }; - #[allow(clippy::cast_precision_loss)] + let x = X.unwrap().into_array(); + #[expect( + clippy::cast_precision_loss, + reason = "test indices are at most five and exactly representable as f64" + )] for i in 0..$d { let expected = i as f64 + 1.0; - assert!((X[i] - expected).abs() <= 1e-12); + assert!((x[i] - expected).abs() <= 1e-12); } } } diff --git a/src/lib.rs b/src/lib.rs index 0ea0a8f..ff2db88 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -94,11 +94,11 @@ mod readme_doctests { /// [4.0, 5.0, 6.0], /// [7.0, 8.0, 9.0], /// ])?; - /// assert_eq!(m.det_sign_exact()?, 0); // exactly singular + /// assert_eq!(m.det_sign_exact(), DeterminantSign::Zero); // exactly singular /// /// let det = m.det_exact()?; /// assert_eq!(det, BigRational::from_integer(0.into())); // exact zero - /// let det_f64 = m.det_exact_f64()?; + /// let det_f64 = det.try_to_f64()?; /// assert_eq!(det_f64, 0.0); /// /// // If strict exact-to-f64 conversion would require rounding, opt in @@ -107,9 +107,10 @@ mod readme_doctests { /// [1.0 + f64::EPSILON, 0.0], /// [0.0, 1.0 - f64::EPSILON], /// ])?; - /// let rounded_det = match inexact.det_exact_f64() { + /// let exact_det = inexact.det_exact()?; + /// let rounded_det = match exact_det.try_to_f64() { /// Ok(det) => det, - /// Err(err) if err.requires_rounding() => inexact.det_exact_rounded_f64()?, + /// Err(err) if err.requires_rounding() => exact_det.to_rounded_f64()?, /// Err(err) => return Err(err), /// }; /// assert_eq!(rounded_det.to_bits(), 1.0f64.to_bits()); @@ -123,7 +124,8 @@ mod readme_doctests { /// ])?; /// let huge_det = huge.det_exact()?; /// assert_eq!( - /// huge.det_exact_f64() + /// huge_det + /// .try_to_f64() /// .err() /// .and_then(|err| err.unrepresentable_reason()), /// Some(UnrepresentableReason::NotFinite) @@ -133,13 +135,65 @@ mod readme_doctests { /// // Exact linear system solve /// let a = Matrix::<2>::try_from_rows([[1.0, 2.0], [3.0, 4.0]])?; /// let b = Vector::<2>::try_new([5.0, 11.0])?; - /// let x = a.solve_exact_f64(b)?.into_array(); + /// let exact_x = a.solve_exact(b)?; + /// let x = exact_x.try_to_f64()?.into_array(); /// assert!((x[0] - 1.0).abs() <= f64::EPSILON); /// assert!((x[1] - 2.0).abs() <= f64::EPSILON); /// # Ok(()) /// # } /// ``` fn exact_arithmetic_example() {} + + #[cfg(feature = "exact")] + /// ```rust + /// use la_stack::prelude::*; + /// + /// fn adaptive_det_sign( + /// matrix: &Matrix, + /// ) -> DeterminantSign { + /// if let (Ok(Some(bound)), Ok(Some(det))) = + /// (matrix.det_errbound(), matrix.det_direct()) + /// { + /// if det.abs() > bound { + /// return if det > 0.0 { + /// DeterminantSign::Positive + /// } else { + /// DeterminantSign::Negative + /// }; + /// } + /// } + /// + /// matrix.det_sign_exact() + /// } + /// + /// # fn main() -> Result<(), LaError> { + /// let identity = Matrix::<3>::identity(); + /// assert_eq!( + /// adaptive_det_sign(&identity), + /// DeterminantSign::Positive + /// ); + /// + /// let singular = Matrix::<3>::try_from_rows([ + /// [1.0, 2.0, 3.0], + /// [4.0, 5.0, 6.0], + /// [7.0, 8.0, 9.0], + /// ])?; + /// assert_eq!(adaptive_det_sign(&singular), DeterminantSign::Zero); + /// + /// let big = f64::MAX / 2.0; + /// let overflowing = Matrix::<3>::try_from_rows([ + /// [0.0, 0.0, 1.0], + /// [big, 0.0, 1.0], + /// [0.0, big, 1.0], + /// ])?; + /// assert_eq!( + /// adaptive_det_sign(&overflowing), + /// DeterminantSign::Positive + /// ); + /// # Ok(()) + /// # } + /// ``` + fn adaptive_precision_example() {} } mod error; @@ -148,9 +202,12 @@ mod exact; mod ldlt; mod lu; mod matrix; +mod scaled_product; mod tolerance; mod vector; +#[cfg(feature = "exact")] +pub use exact::{DeterminantSign, ExactF64Conversion}; #[cfg(feature = "exact")] pub use num_bigint::BigInt; #[cfg(feature = "exact")] @@ -163,8 +220,9 @@ pub use num_traits::{FromPrimitive, Signed, ToPrimitive}; // // For `D ∈ {2, 3, 4}`, `Matrix::det_direct()` evaluates the Leibniz expansion // of the determinant as a tree of f64 multiplies and fused multiply-adds -// (FMAs). Following Shewchuk's error-analysis methodology (REFERENCES.md -// [8]), the absolute error of that computation is bounded by +// (FMAs). When every rounded intermediate is normal or an exact structural +// zero, Shewchuk's error-analysis methodology (REFERENCES.md [8]) bounds the +// absolute error of that computation by // // |det_direct(A) - det_exact(A)| ≤ ERR_COEFF_D · p(|A|) // @@ -172,10 +230,10 @@ pub use num_traits::{FromPrimitive, Signed, ToPrimitive}; // // p(|A|) = Σ_σ ∏ᵢ |A[i, σ(i)]|, // -// i.e. the same cofactor-expansion tree as `det_direct` but with each -// entry replaced by its magnitude. Note that `p(|A|)` is *not* the -// combinatorial matrix permanent — the name "permanent" appears in the -// source for brevity and to match the cited literature. +// i.e. exactly the combinatorial matrix permanent `perm(|A|)`. The +// implementation evaluates the corresponding fixed-size expansion in f64, so +// the computed `permanent` value used by the bound may itself be rounded even +// though the mathematical quantity above is exact. // // Each constant has the shape `a · EPS + b · EPS²`: the linear term bounds // the first-order rounding and the quadratic term absorbs the interaction @@ -201,7 +259,8 @@ const EPS: f64 = f64::EPSILON; // 2^-52 /// multiplier that turns the matrix's absolute Leibniz sum into a conservative /// bound on floating-point roundoff in the closed-form 2×2 determinant formula. /// -/// For any 2×2 matrix `A = [[a, b], [c, d]]` with finite f64 entries, +/// For a 2×2 matrix `A = [[a, b], [c, d]]` whose closed-form determinant +/// intermediates do not undergo gradual underflow, /// /// ```text /// |A.det_direct() - det_exact(A)| ≤ ERR_COEFF_2 · (|a·d| + |b·c|) @@ -220,7 +279,7 @@ const EPS: f64 = f64::EPSILON; // 2^-52 /// /// # Example /// ``` -/// use la_stack::prelude::*; +/// use la_stack::{prelude::*, ERR_COEFF_2}; /// /// # fn main() -> Result<(), LaError> { /// let m = Matrix::<2>::try_from_rows([[1.0, 2.0], [3.0, 4.0]])?; @@ -246,7 +305,8 @@ pub const ERR_COEFF_2: f64 = 3.0 * EPS + 16.0 * EPS * EPS; /// multiplier that turns the matrix's absolute Leibniz sum into a conservative /// bound on floating-point roundoff in the closed-form 3×3 determinant formula. /// -/// For any 3×3 matrix `A` with finite f64 entries, +/// For a 3×3 matrix `A` whose closed-form determinant intermediates do not +/// undergo gradual underflow, /// /// ```text /// |A.det_direct() - det_exact(A)| ≤ ERR_COEFF_3 · p(|A|) @@ -268,15 +328,16 @@ pub const ERR_COEFF_3: f64 = 8.0 * EPS + 64.0 * EPS * EPS; /// multiplier that turns the matrix's absolute Leibniz sum into a conservative /// bound on floating-point roundoff in the closed-form 4×4 determinant formula. /// -/// For any 4×4 matrix `A` with finite f64 entries, +/// For a 4×4 matrix `A` whose closed-form determinant intermediates do not +/// undergo gradual underflow, /// /// ```text /// |A.det_direct() - det_exact(A)| ≤ ERR_COEFF_4 · p(|A|) /// ``` /// -/// where `p(|A|)` is the absolute Leibniz sum. `det_direct` for D=4 -/// hoists six 2×2 minors, combines them into four 3×3 cofactors, then -/// reduces those with an FMA row combination, yielding the +/// where `p(|A|)` is the absolute Leibniz sum. `det_direct` for D=4 +/// evaluates four nested 3×3 cofactors and reduces them with an FMA row +/// combination, yielding the /// `12·EPS + 128·EPS²` bound. See `REFERENCES.md` \[8\] for the /// Shewchuk framework these bounds follow. /// @@ -292,14 +353,34 @@ pub const ERR_COEFF_4: f64 = 12.0 * EPS + 128.0 * EPS * EPS; /// dispatch surface explicit. pub const MAX_STACK_MATRIX_DISPATCH_DIM: usize = 7; -pub use error::{LaError, UnrepresentableReason}; +pub use error::{ + ArithmeticOperation, FactorizationKind, InvalidToleranceReason, LaError, NonFiniteLocation, + NonFiniteOrigin, PositiveSemidefiniteViolation, SingularityReason, UnrepresentableReason, +}; pub use ldlt::Ldlt; pub use lu::Lu; pub use matrix::Matrix; -pub(crate) use tolerance::LDLT_SYMMETRY_REL_TOL; pub use tolerance::{DEFAULT_SINGULAR_TOL, Tolerance}; pub use vector::Vector; +/// A finite [`Matrix`] proven exactly symmetric for LDLT factorization. +/// +/// Mirrored entries have equal numeric values; IEEE-754 signed zeros may have +/// different bit patterns because `+0.0 == -0.0`. +#[must_use] +#[derive(Clone, Copy, Debug, PartialEq)] +struct SymmetricMatrix { + matrix: Matrix, +} + +impl SymmetricMatrix { + /// Consume the wrapper and return the underlying matrix. + #[inline] + const fn into_matrix(self) -> Matrix { + self.matrix + } +} + /// Fallibly dispatch a runtime dimension to a concrete stack-allocated matrix. /// /// The macro creates a zero matrix with type `Matrix` for the selected @@ -322,8 +403,8 @@ pub use vector::Vector; /// # fn main() -> Result<(), LaError> { /// let requested = 2usize; /// let det = try_with_stack_matrix!(requested, |mut m| -> Result { -/// m.set_checked(0, 0, 1.0)?; -/// m.set_checked(1, 1, 1.0)?; +/// m.set(0, 0, 1.0)?; +/// m.set(1, 1, 1.0)?; /// m.det() /// })?; /// @@ -383,76 +464,60 @@ macro_rules! try_with_stack_matrix { /// Common imports for ergonomic usage. /// -/// This prelude re-exports the primary types and constants: [`Matrix`], -/// [`Vector`], [`Lu`], [`Ldlt`], [`Tolerance`], [`LaError`], -/// [`UnrepresentableReason`], [`DEFAULT_SINGULAR_TOL`], and the determinant -/// error bound coefficients [`ERR_COEFF_2`], [`ERR_COEFF_3`], and -/// [`ERR_COEFF_4`]. It also re-exports [`MAX_STACK_MATRIX_DISPATCH_DIM`] and -/// [`try_with_stack_matrix!`] for runtime-to-const matrix dispatch. +/// This prelude re-exports the primary types and common constants: [`Matrix`], +/// [`Vector`], [`Lu`], [`Ldlt`], [`Tolerance`], and [`LaError`]. Its typed +/// error categories include [`ArithmeticOperation`], [`FactorizationKind`], +/// [`InvalidToleranceReason`], [`NonFiniteLocation`], [`NonFiniteOrigin`], +/// [`PositiveSemidefiniteViolation`], [`SingularityReason`], and +/// [`UnrepresentableReason`]. It also re-exports [`DEFAULT_SINGULAR_TOL`], +/// [`MAX_STACK_MATRIX_DISPATCH_DIM`], and [`try_with_stack_matrix!`] for +/// runtime-to-const matrix dispatch. Advanced custom-filter code should import +/// [`ERR_COEFF_2`], [`ERR_COEFF_3`], and [`ERR_COEFF_4`] explicitly from the +/// crate root; those raw coefficients intentionally stay out of the prelude. /// -/// When the `exact` feature is enabled, `BigInt` and `BigRational` are also -/// re-exported so callers can construct exact values (e.g. as the expected -/// result of `Matrix::det_exact`) without adding `num-bigint` / `num-rational` -/// to their own dependencies. The most commonly needed `num-traits` items are -/// re-exported alongside them: `FromPrimitive` for `BigRational::from_f64` / -/// `from_i64`, `ToPrimitive` for `BigRational::to_f64` / `to_i64`, and `Signed` -/// for `.is_positive()` / `.is_negative()` / `.abs()`. +/// When the `exact` feature is enabled, `DeterminantSign`, +/// `ExactF64Conversion`, `BigInt`, and `BigRational` are also re-exported. +/// `ExactF64Conversion` converts an already-computed exact determinant or +/// solution under either the strict or explicitly rounded binary64 contract, +/// without repeating exact elimination. The number types let callers construct +/// expected exact values without adding `num-bigint` / `num-rational` to their +/// own dependencies. The most commonly needed `num-traits` items are re-exported +/// alongside them: `FromPrimitive` for `BigRational::from_f64` / `from_i64`, +/// `ToPrimitive` for `BigRational::to_f64` / `to_i64`, and `Signed` for +/// `.is_positive()` / `.is_negative()` / `.abs()`. pub mod prelude { pub use crate::{ - DEFAULT_SINGULAR_TOL, ERR_COEFF_2, ERR_COEFF_3, ERR_COEFF_4, LaError, Ldlt, Lu, - MAX_STACK_MATRIX_DISPATCH_DIM, Matrix, Tolerance, UnrepresentableReason, Vector, - try_with_stack_matrix, + ArithmeticOperation, DEFAULT_SINGULAR_TOL, FactorizationKind, InvalidToleranceReason, + LaError, Ldlt, Lu, MAX_STACK_MATRIX_DISPATCH_DIM, Matrix, NonFiniteLocation, + NonFiniteOrigin, PositiveSemidefiniteViolation, SingularityReason, Tolerance, + UnrepresentableReason, Vector, try_with_stack_matrix, }; #[cfg(feature = "exact")] - pub use crate::{BigInt, BigRational, FromPrimitive, Signed, ToPrimitive}; + pub use crate::{ + BigInt, BigRational, DeterminantSign, ExactF64Conversion, FromPrimitive, Signed, + ToPrimitive, + }; } #[cfg(test)] mod tests { - use super::*; - use approx::assert_abs_diff_eq; + use pastey::paste; - mod prelude_tests { - use approx::assert_abs_diff_eq; - - use crate::prelude::*; - - #[test] - fn prelude_reexports_compile_and_work() -> Result<(), LaError> { - // Use the items so we know they are in scope and usable. - let m = Matrix::<2>::identity(); - let v = Vector::<2>::try_new([1.0, 2.0])?; - let tol = Tolerance::new(0.0)?; - assert_abs_diff_eq!(tol.get(), 0.0, epsilon = 0.0); - assert_abs_diff_eq!(m.inf_norm()?, 1.0, epsilon = 0.0); - assert_abs_diff_eq!(v.norm2_sq()?, 5.0, epsilon = 0.0); - let _ = m.lu(DEFAULT_SINGULAR_TOL)?.solve(v)?; - let _ = m.ldlt(DEFAULT_SINGULAR_TOL)?.solve(v)?; - assert_eq!( - LaError::unrepresentable(None, UnrepresentableReason::RequiresRounding), - LaError::Unrepresentable { - index: None, - reason: UnrepresentableReason::RequiresRounding, - } - ); - assert_eq!(MAX_STACK_MATRIX_DISPATCH_DIM, 7); - Ok(()) - } - } + use super::*; macro_rules! gen_stack_matrix_dispatch_tests { ($d:literal) => { - pastey::paste! { + paste! { #[test] fn []() { let requested = $d; let got = try_with_stack_matrix!(requested, |mut m| -> Result { if $d > 0 { - m.set_checked($d - 1, $d - 1, f64::from($d))?; + m.set($d - 1, $d - 1, f64::from($d))?; assert_abs_diff_eq!( - m.get_checked($d - 1, $d - 1)?, + m.try_get($d - 1, $d - 1)?, f64::from($d), epsilon = 0.0 ); @@ -466,6 +531,7 @@ mod tests { }; } + gen_stack_matrix_dispatch_tests!(1); gen_stack_matrix_dispatch_tests!(2); gen_stack_matrix_dispatch_tests!(3); gen_stack_matrix_dispatch_tests!(4); @@ -482,6 +548,21 @@ mod tests { assert_eq!(got, Ok(Some(1.0))); } + #[test] + fn try_with_stack_matrix_evaluates_dimension_once() { + let mut evaluations = 0; + let got = try_with_stack_matrix!( + { + evaluations += 1; + 2usize + }, + |matrix| -> Result { matrix.try_get(1, 1) }, + ); + + assert_eq!(evaluations, 1); + assert_eq!(got, Ok(0.0)); + } + #[test] fn try_with_stack_matrix_reports_unsupported_dimension() { let got = try_with_stack_matrix!(8usize, |m| -> Result { m.det() }); @@ -519,39 +600,4 @@ mod tests { })) ); } - - /// Exercise every exact-feature re-export via the prelude so a future - /// refactor that drops one (e.g. removing `Signed` from the prelude - /// list) fails to compile rather than silently breaking downstream. - #[cfg(feature = "exact")] - #[test] - fn prelude_exact_reexports_compile_and_work() { - use crate::prelude::*; - - // `BigInt` and `BigRational` constructors. - let n = BigInt::from(7); - let r = BigRational::from_integer(n.clone()); - assert_eq!(*r.numer(), n); - - // `FromPrimitive::from_f64` / `from_i64` on `BigRational`. - let half = BigRational::new(BigInt::from(1), BigInt::from(2)); - let two = BigRational::from_integer(BigInt::from(2)); - assert_eq!(BigRational::from_f64(0.5), Some(half.clone())); - assert_eq!(BigRational::from_i64(2), Some(two.clone())); - assert_eq!( - half.clone() + half.clone(), - BigRational::from_integer(BigInt::from(1)) - ); - - // `Signed::is_positive` / `is_negative` / `abs`. - assert!(half.is_positive()); - assert!(!half.is_negative()); - let neg = -half.clone(); - assert!(neg.is_negative()); - assert_eq!(neg.abs(), half); - - // `ToPrimitive::to_f64` / `to_i64`. - assert_eq!(half.to_f64(), Some(0.5)); - assert_eq!(two.to_i64(), Some(2)); - } } diff --git a/src/lu.rs b/src/lu.rs index 8508cd9..f18f3dd 100644 --- a/src/lu.rs +++ b/src/lu.rs @@ -5,8 +5,9 @@ use core::hint::cold_path; use crate::matrix::Matrix; +use crate::scaled_product::{RangeCheckedProduct, ScaledProduct, range_checked_product}; use crate::vector::Vector; -use crate::{LaError, Tolerance}; +use crate::{ArithmeticOperation, FactorizationKind, LaError, Tolerance}; /// LU decomposition (PA = LU) with partial pivoting. /// @@ -16,38 +17,100 @@ use crate::{LaError, Tolerance}; #[derive(Clone, Copy, Debug, PartialEq)] pub struct Lu { factors: LuFactors, - piv: [usize; D], - piv_sign: f64, + permutation: RowPermutation, } -/// In-place LU factor storage whose `U` diagonal is finite and usable. +/// Finite LU factor storage. /// -/// Construction through [`Lu::factor_finite`] proves every stored entry is -/// finite and every `U[i,i]` satisfies the factorization tolerance. +/// [`Lu::factor_finite`] separately proves that every `U[i,i]` satisfies the +/// factorization tolerance before this storage becomes part of a [`Lu`]. #[derive(Clone, Copy, Debug, PartialEq)] struct LuFactors { - storage: Matrix, + storage: [[f64; D]; D], } impl LuFactors { - /// Construct factors after LU factorization has proven the storage invariant. + /// Validate and finalize raw factorization work storage as finite factors. #[inline] - const fn new_unchecked(storage: Matrix) -> Self { - Self { storage } + const fn try_from_computation(storage: [[f64; D]; D]) -> Result { + let mut row = 0; + while row < D { + let mut col = 0; + while col < D { + if !storage[row][col].is_finite() { + return Err(LaError::non_finite_computation_matrix( + ArithmeticOperation::LuFactorization, + row, + col, + )); + } + col += 1; + } + row += 1; + } + + Ok(Self { storage }) } /// Borrow a factor row. #[inline] #[must_use] const fn row(&self, index: usize) -> &[f64; D] { - &self.storage.rows()[index] + &self.storage[index] } /// Return a diagonal entry of `U`. #[inline] #[must_use] const fn diag(&self, index: usize) -> f64 { - self.storage.rows()[index][index] + self.storage[index][index] + } +} + +/// Source-row permutation and its determinant parity. +/// +/// Starting from identity and permitting only synchronized swaps makes every +/// stored source row in-bounds and unique while keeping parity inseparable from +/// the index mapping. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct RowPermutation { + source_rows: [usize; D], + odd: bool, +} + +impl RowPermutation { + /// Construct the identity permutation. + const fn identity() -> Self { + let mut source_rows = [0; D]; + let mut row = 0; + while row < D { + source_rows[row] = row; + row += 1; + } + Self { + source_rows, + odd: false, + } + } + + /// Apply one row swap and update parity atomically. + const fn swap(&mut self, left: usize, right: usize) { + if left != right { + let source_row = self.source_rows[left]; + self.source_rows[left] = self.source_rows[right]; + self.source_rows[right] = source_row; + self.odd = !self.odd; + } + } + + /// Return the original source row now occupying `row`. + const fn source_row(&self, row: usize) -> usize { + self.source_rows[row] + } + + /// Return whether the permutation contains an odd number of swaps. + const fn is_odd(&self) -> bool { + self.odd } } @@ -61,26 +124,23 @@ impl Lu { /// checked before return so successful factors do not contain a non-finite /// value produced during elimination. #[inline] - #[allow(clippy::needless_range_loop)] pub(crate) fn factor_finite(a: Matrix, tol: Tolerance) -> Result { - let mut lu = a; - let tol = tol.get(); - - let mut piv = [0usize; D]; - for (i, p) in piv.iter_mut().enumerate() { - *p = i; - } - - let mut piv_sign = 1.0; + let mut rows = a.into_rows(); + let tolerance = tol.get(); + let mut permutation = RowPermutation::identity(); { - let rows = lu.rows_mut_unchecked(); + let rows = &mut rows; for k in 0..D { // Choose pivot row. let mut pivot_row = k; let mut pivot_abs = rows[k][k].abs(); + #[expect( + clippy::needless_range_loop, + reason = "the row index identifies the pivot later used for synchronized matrix and permutation swaps" + )] for r in (k + 1)..D { let v = rows[r][k].abs(); if v > pivot_abs { @@ -89,15 +149,35 @@ impl Lu { } } - if pivot_abs <= tol { + if pivot_abs <= tolerance { cold_path(); - return Err(LaError::Singular { pivot_col: k }); + + // A non-finite value produced in an earlier update does not + // participate in `v > pivot_abs` comparisons. Scan only on + // this cold failure path so it cannot be masked as singular. + for (row, values) in rows.iter().enumerate() { + for (col, value) in values.iter().enumerate() { + if !value.is_finite() { + return Err(LaError::non_finite_computation_matrix( + ArithmeticOperation::LuFactorization, + row, + col, + )); + } + } + } + + return Err(LaError::singular_numerical( + k, + FactorizationKind::Lu, + pivot_abs, + tolerance, + )); } if pivot_row != k { rows.swap(k, pivot_row); - piv.swap(k, pivot_row); - piv_sign = -piv_sign; + permutation.swap(k, pivot_row); } let pivot = rows[k][k]; @@ -107,6 +187,10 @@ impl Lu { let mult = rows[r][k] / pivot; rows[r][k] = mult; + #[expect( + clippy::needless_range_loop, + reason = "the column index pairs pivot-row reads with eliminated-row writes in the in-place update" + )] for c in (k + 1)..D { let updated = (-mult).mul_add(rows[k][c], rows[r][c]); rows[r][c] = updated; @@ -115,12 +199,11 @@ impl Lu { } } - let lu = lu.validate_finite()?; + let factors = LuFactors::try_from_computation(rows)?; Ok(Self { - factors: LuFactors::new_unchecked(lu), - piv, - piv_sign, + factors, + permutation, }) } @@ -153,26 +236,13 @@ impl Lu { /// overflows to NaN or infinity. #[inline] pub const fn solve(&self, b: Vector) -> Result, LaError> { - self.solve_finite(b) - } - - /// Solve `A x = b` using this LU factorization and a finite right-hand side. - /// - /// The right-hand side entries and stored factors are known finite, so this - /// path only checks computed substitution overflows. - /// - /// # Errors - /// Returns [`LaError::NonFinite`] if a computed substitution intermediate - /// overflows to NaN or infinity. - #[inline] - pub(crate) const fn solve_finite(&self, b: Vector) -> Result, LaError> { let mut x = [0.0; D]; let b = b.as_array(); let mut i = 0; if D <= 4 { while i < D { - x[i] = b[self.piv[i]]; + x[i] = b[self.permutation.source_row(i)]; i += 1; } @@ -189,7 +259,10 @@ impl Lu { } if !sum.is_finite() { cold_path(); - return Err(LaError::non_finite_at(i)); + return Err(LaError::non_finite_computation_step( + ArithmeticOperation::LuSolve, + i, + )); } x[i] = sum; i += 1; @@ -198,7 +271,7 @@ impl Lu { // Larger fixed dimensions avoid an extra pass by reading the // pivoted right-hand side directly into forward substitution. while i < D { - let mut sum = b[self.piv[i]]; + let mut sum = b[self.permutation.source_row(i)]; let row = self.factors.row(i); let mut j = 0; while j < i { @@ -207,7 +280,10 @@ impl Lu { } if !sum.is_finite() { cold_path(); - return Err(LaError::non_finite_at(i)); + return Err(LaError::non_finite_computation_step( + ArithmeticOperation::LuSolve, + i, + )); } x[i] = sum; i += 1; @@ -229,19 +305,25 @@ impl Lu { let diag = row[i]; if !sum.is_finite() { cold_path(); - return Err(LaError::non_finite_at(i)); + return Err(LaError::non_finite_computation_step( + ArithmeticOperation::LuSolve, + i, + )); } let quotient = sum / diag; if !quotient.is_finite() { cold_path(); - return Err(LaError::non_finite_at(i)); + return Err(LaError::non_finite_computation_step( + ArithmeticOperation::LuSolve, + i, + )); } x[i] = quotient; ii += 1; } - Ok(Vector::new_unchecked(x)) + Vector::from_computation(x, ArithmeticOperation::LuSolve) } /// Determinant of the original matrix. @@ -260,40 +342,98 @@ impl Lu { /// # } /// ``` /// + /// Diagonal pivots are multiplied directly while each non-zero running + /// product remains finite and normal. If direct accumulation detects range + /// loss, all pivots are recomputed with power-of-two scaling before a + /// premature overflow or underflow can affect the returned determinant. + /// The final product is rounded to `f64`; a non-zero magnitude below the + /// binary64 range may round to zero. No certified absolute error bound is + /// provided. + /// /// # Errors - /// Returns [`LaError::NonFinite`] if the determinant product overflows to - /// NaN or infinity. + /// Returns [`LaError::NonFinite`] if the final scaled determinant cannot be + /// represented as a finite `f64`. #[inline] pub const fn det(&self) -> Result { - let mut det = self.piv_sign; + let mut det = if self.permutation.is_odd() { -1.0 } else { 1.0 }; let mut i = 0; while i < D { - det *= self.factors.diag(i); - if !det.is_finite() { - cold_path(); - return Err(LaError::non_finite_at(i)); + let factor = self.factors.diag(i); + match range_checked_product(det, factor) { + RangeCheckedProduct::Safe(next) => det = next, + RangeCheckedProduct::NeedsScaling => { + cold_path(); + return self.scaled_det(); + } } i += 1; } Ok(det) } + + /// Recompute the determinant with normalized mantissa/exponent scaling. + #[cold] + const fn scaled_det(&self) -> Result { + let mut product = ScaledProduct::new(self.permutation.is_odd()); + let mut i = 0; + while i < D { + product.multiply(self.factors.diag(i)); + i += 1; + } + + if let Some(det) = product.finish() { + Ok(det) + } else { + Err(LaError::non_finite_computation_step( + ArithmeticOperation::Determinant, + D.saturating_sub(1), + )) + } + } } #[cfg(test)] mod tests { - use super::*; - use crate::DEFAULT_SINGULAR_TOL; - use core::hint::black_box; use approx::assert_abs_diff_eq; use pastey::paste; - macro_rules! gen_public_api_pivoting_solve_and_det_tests { + use super::*; + use crate::DEFAULT_SINGULAR_TOL; + + const TWO_NEG_800: f64 = f64::from_bits(223_u64 << 52); + const TWO_POS_800: f64 = f64::from_bits(1823_u64 << 52); + + #[test] + fn row_permutation_keeps_mapping_and_parity_synchronized() { + let mut permutation = RowPermutation::<4>::identity(); + assert_eq!( + core::array::from_fn(|row| permutation.source_row(row)), + [0, 1, 2, 3] + ); + assert!(!permutation.is_odd()); + + permutation.swap(0, 3); + assert_eq!( + core::array::from_fn(|row| permutation.source_row(row)), + [3, 1, 2, 0] + ); + assert!(permutation.is_odd()); + + permutation.swap(1, 2); + assert_eq!( + core::array::from_fn(|row| permutation.source_row(row)), + [3, 2, 1, 0] + ); + assert!(!permutation.is_odd()); + } + + macro_rules! gen_pivoting_solve_and_det_tests { ($d:literal) => { paste! { #[test] - fn []() { + fn []() { // Public API path under test: // Matrix::lu (pub) -> Lu::solve (pub). @@ -334,7 +474,7 @@ mod tests { } #[test] - fn []() { + fn []() { // Public API path under test: // Matrix::lu (pub) -> Lu::det (pub). @@ -359,21 +499,21 @@ mod tests { }; } - gen_public_api_pivoting_solve_and_det_tests!(2); - gen_public_api_pivoting_solve_and_det_tests!(3); - gen_public_api_pivoting_solve_and_det_tests!(4); - gen_public_api_pivoting_solve_and_det_tests!(5); + gen_pivoting_solve_and_det_tests!(2); + gen_pivoting_solve_and_det_tests!(3); + gen_pivoting_solve_and_det_tests!(4); + gen_pivoting_solve_and_det_tests!(5); - macro_rules! gen_public_api_tridiagonal_smoke_solve_and_det_tests { - ($d:literal) => { + macro_rules! gen_tridiagonal_smoke_solve_and_det_tests { + ($d:literal $(, #[$stack_array_expectation:meta])?) => { paste! { #[test] - fn []() { + fn []() { // Public API path under test: // Matrix::lu (pub) -> Lu::solve (pub). // Classic SPD tridiagonal: 2 on diagonal, -1 on sub/super-diagonals. - #[allow(clippy::large_stack_arrays)] + $(#[$stack_array_expectation])? let mut rows = [[0.0f64; $d]; $d]; for i in 0..$d { rows[i][i] = 2.0; @@ -406,13 +546,13 @@ mod tests { } #[test] - fn []() { + fn []() { // Public API path under test: // Matrix::lu (pub) -> Lu::det (pub). // Classic SPD tridiagonal: 2 on diagonal, -1 on sub/super-diagonals. // Determinant is known exactly: det = D + 1. - #[allow(clippy::large_stack_arrays)] + $(#[$stack_array_expectation])? let mut rows = [[0.0f64; $d]; $d]; for i in 0..$d { rows[i][i] = 2.0; @@ -437,9 +577,15 @@ mod tests { }; } - gen_public_api_tridiagonal_smoke_solve_and_det_tests!(16); - gen_public_api_tridiagonal_smoke_solve_and_det_tests!(32); - gen_public_api_tridiagonal_smoke_solve_and_det_tests!(64); + gen_tridiagonal_smoke_solve_and_det_tests!(16); + gen_tridiagonal_smoke_solve_and_det_tests!(32); + gen_tridiagonal_smoke_solve_and_det_tests!( + 64, + #[expect( + clippy::large_stack_arrays, + reason = "the test deliberately exercises the crate's stack-allocated matrix storage" + )] + ); #[test] fn solve_0x0_returns_empty_vector_and_unit_det() { @@ -494,34 +640,30 @@ mod tests { } #[test] - fn det_requires_pivot_sign() { - // Row swap ⇒ determinant sign flip. - let a = Matrix::<2>::try_from_rows(black_box([[0.0, 1.0], [1.0, 0.0]])).unwrap(); - let lu = a.lu(DEFAULT_SINGULAR_TOL).unwrap(); - - let det_fn: fn(&Lu<2>) -> Result = black_box(Lu::<2>::det); - assert_abs_diff_eq!(det_fn(&lu).unwrap(), -1.0, epsilon = 0.0); - } - - #[test] - fn solve_requires_pivoting() { - let a = Matrix::<2>::try_from_rows(black_box([[0.0, 1.0], [1.0, 0.0]])).unwrap(); - let lu = a.lu(DEFAULT_SINGULAR_TOL).unwrap(); - let b = Vector::<2>::new(black_box([1.0, 2.0])); - - let solve_fn: fn(&Lu<2>, Vector<2>) -> Result, LaError> = - black_box(Lu::<2>::solve); - let x = solve_fn(&lu, b).unwrap().into_array(); + fn det_ordinary_factors_matches_direct_product_bits() { + let diagonal = [1.5, -2.0, 0.25, 8.0]; + let mut rows = [[0.0; 4]; 4]; + let mut expected = 1.0; + for (i, factor) in diagonal.into_iter().enumerate() { + rows[i][i] = factor; + expected *= factor; + } - assert_abs_diff_eq!(x[0], 2.0, epsilon = 1e-12); - assert_abs_diff_eq!(x[1], 1.0, epsilon = 1e-12); + let lu = Matrix::<4>::try_from_rows(rows) + .unwrap() + .lu(DEFAULT_SINGULAR_TOL) + .unwrap(); + assert_eq!(lu.det().unwrap().to_bits(), expected.to_bits()); } #[test] fn singular_detected() { let a = Matrix::<2>::try_from_rows(black_box([[1.0, 2.0], [2.0, 4.0]])).unwrap(); let err = a.lu(DEFAULT_SINGULAR_TOL).unwrap_err(); - assert_eq!(err, LaError::Singular { pivot_col: 1 }); + assert_eq!( + err, + LaError::singular_numerical(1, FactorizationKind::Lu, 0.0, DEFAULT_SINGULAR_TOL.get()) + ); } #[test] @@ -529,54 +671,54 @@ mod tests { // Not exactly singular, but below DEFAULT_SINGULAR_TOL. let a = Matrix::<2>::try_from_rows(black_box([[1e-13, 0.0], [0.0, 1.0]])).unwrap(); let err = a.lu(DEFAULT_SINGULAR_TOL).unwrap_err(); - assert_eq!(err, LaError::Singular { pivot_col: 0 }); - } - - #[test] - fn matrix_constructor_rejects_nonfinite_pivot_entry() { - let err = Matrix::<2>::try_from_rows([[f64::NAN, 0.0], [0.0, 1.0]]).unwrap_err(); assert_eq!( err, - LaError::NonFinite { - row: Some(0), - col: 0 - } + LaError::singular_numerical( + 0, + FactorizationKind::Lu, + 1e-13, + DEFAULT_SINGULAR_TOL.get() + ) ); } #[test] - fn matrix_constructor_rejects_nonfinite_pivot_column_entry() { - let err = Matrix::<2>::try_from_rows([[1.0, 0.0], [f64::INFINITY, 1.0]]).unwrap_err(); + fn non_finite_detected_in_trailing_update() { + let a = Matrix::<3>::try_from_rows([ + [1.0, f64::MAX, 0.0], + [-1.0, f64::MAX, 0.0], + [0.0, 0.0, 1.0], + ]) + .unwrap(); + + let err = a.lu(DEFAULT_SINGULAR_TOL).unwrap_err(); assert_eq!( err, - LaError::NonFinite { - row: Some(1), - col: 0 - } + LaError::non_finite_computation_matrix(ArithmeticOperation::LuFactorization, 1, 1) ); } #[test] - fn nonfinite_detected_in_trailing_update() { - let a = Matrix::<3>::try_from_rows([ - [1.0, f64::MAX, 0.0], - [-1.0, f64::MAX, 0.0], - [0.0, 0.0, 1.0], + fn generated_non_finite_takes_precedence_over_later_singular_pivot() { + // The first update generates infinities, and the next generates NaN. + // NaN does not win a pivot comparison and must not be masked as singular. + let a = Matrix::<4>::try_from_rows([ + [1.0, f64::MAX, 0.0, 0.0], + [1.0, f64::MAX, 0.0, 0.0], + [-1.0, f64::MAX, 0.0, 0.0], + [-1.0, f64::MAX, 0.0, 0.0], ]) .unwrap(); let err = a.lu(DEFAULT_SINGULAR_TOL).unwrap_err(); assert_eq!( err, - LaError::NonFinite { - row: Some(1), - col: 1, - } + LaError::non_finite_computation_matrix(ArithmeticOperation::LuFactorization, 1, 1,) ); } #[test] - fn solve_nonfinite_forward_substitution_overflow() { + fn solve_non_finite_forward_substitution_overflow() { // L has a -1 multiplier, and a large RHS makes forward substitution overflow. let a = Matrix::<3>::try_from_rows([[1.0, 0.0, 0.0], [-1.0, 1.0, 0.0], [0.0, 0.0, 1.0]]) .unwrap(); @@ -584,11 +726,14 @@ mod tests { let b = Vector::<3>::new([1.0e308, 1.0e308, 0.0]); let err = lu.solve(b).unwrap_err(); - assert_eq!(err, LaError::NonFinite { row: None, col: 1 }); + assert_eq!( + err, + LaError::non_finite_computation_step(ArithmeticOperation::LuSolve, 1) + ); } #[test] - fn solve_nonfinite_forward_substitution_overflow_fused_branch_5d() { + fn solve_non_finite_forward_substitution_overflow_fused_branch_5d() { // Exercises the D >= 5 fused pivot/forward-substitution branch with the // same overflowing L multiplier as the D3 test. let a = Matrix::<5>::try_from_rows([ @@ -603,22 +748,28 @@ mod tests { let b = Vector::<5>::new([1.0e308, 1.0e308, 0.0, 0.0, 0.0]); let err = lu.solve(b).unwrap_err(); - assert_eq!(err, LaError::NonFinite { row: None, col: 1 }); + assert_eq!( + err, + LaError::non_finite_computation_step(ArithmeticOperation::LuSolve, 1) + ); } #[test] - fn solve_nonfinite_back_substitution_overflow() { + fn solve_non_finite_back_substitution_overflow() { // Make x[1] overflow during back substitution, then ensure it is detected on the next row. let a = Matrix::<2>::try_from_rows([[1.0, 1.0], [0.0, 2.0e-12]]).unwrap(); let lu = a.lu(DEFAULT_SINGULAR_TOL).unwrap(); let b = Vector::<2>::new([0.0, 1.0e300]); let err = lu.solve(b).unwrap_err(); - assert_eq!(err, LaError::NonFinite { row: None, col: 1 }); + assert_eq!( + err, + LaError::non_finite_computation_step(ArithmeticOperation::LuSolve, 1) + ); } #[test] - fn solve_nonfinite_back_substitution_sum_overflow() { + fn solve_non_finite_back_substitution_sum_overflow() { // Upper-triangular U with a very large off-diagonal in row 1 and a // very large x[2] produced by the RHS. The back-substitution // accumulator `sum = (-row[j]).mul_add(x[j], sum)` overflows while @@ -631,7 +782,10 @@ mod tests { let b = Vector::<3>::new([0.0, 0.0, 1.0e200]); let err = lu.solve(b).unwrap_err(); - assert_eq!(err, LaError::NonFinite { row: None, col: 1 }); + assert_eq!( + err, + LaError::non_finite_computation_step(ArithmeticOperation::LuSolve, 1) + ); } #[test] @@ -645,35 +799,66 @@ mod tests { ]) .unwrap(); let lu = a.lu(DEFAULT_SINGULAR_TOL).unwrap(); - assert_eq!(lu.det(), Err(LaError::NonFinite { row: None, col: 3 })); + assert_eq!( + lu.det(), + Err(LaError::non_finite_computation_step( + ArithmeticOperation::Determinant, + 4 + )) + ); } - macro_rules! gen_solve_boundary_tests { - ($d:literal) => { - paste! { - /// Raw non-finite right-hand sides are rejected before a - /// public caller can construct a `Vector`. - #[test] - fn []() { - let mut rhs = [1.0; $d]; - rhs[$d - 1] = f64::NAN; - - assert_eq!( - Vector::<$d>::try_new(rhs), - Err(LaError::NonFinite { - row: None, - col: $d - 1, - }) - ); - } + #[test] + fn det_balances_extreme_diagonals_independently_of_storage_order() { + let zero_tolerance = Tolerance::try_new(0.0).unwrap(); + for diagonal in [ + [TWO_NEG_800, TWO_NEG_800, TWO_POS_800, TWO_POS_800], + [TWO_POS_800, TWO_POS_800, TWO_NEG_800, TWO_NEG_800], + ] { + let mut rows = [[0.0; 4]; 4]; + for (i, value) in diagonal.into_iter().enumerate() { + rows[i][i] = value; } - }; + + let lu = Matrix::<4>::try_from_rows(rows) + .unwrap() + .lu(zero_tolerance) + .unwrap(); + assert_eq!(lu.det(), Ok(1.0)); + } + } + + #[test] + fn matrix_det_fallback_inherits_balanced_extreme_accumulation() { + let zero_tolerance = Tolerance::try_new(0.0).unwrap(); + for diagonal in [ + [TWO_NEG_800, TWO_NEG_800, TWO_POS_800, TWO_POS_800, 1.0, 1.0], + [TWO_POS_800, TWO_POS_800, TWO_NEG_800, TWO_NEG_800, 1.0, 1.0], + ] { + let mut rows = [[0.0; 6]; 6]; + for (i, value) in diagonal.into_iter().enumerate() { + rows[i][i] = value; + } + + let matrix = Matrix::<6>::try_from_rows(rows).unwrap(); + assert_eq!(matrix.det(), Ok(1.0)); + assert_eq!(matrix.lu(zero_tolerance).unwrap().det(), Ok(1.0)); + } } - gen_solve_boundary_tests!(2); - gen_solve_boundary_tests!(3); - gen_solve_boundary_tests!(4); - gen_solve_boundary_tests!(5); + #[test] + fn det_rounds_final_tiny_magnitude_to_zero() { + let zero_tolerance = Tolerance::try_new(0.0).unwrap(); + let positive = + Matrix::<2>::try_from_rows([[TWO_NEG_800, 0.0], [0.0, TWO_NEG_800]]).unwrap(); + let positive_det = positive.lu(zero_tolerance).unwrap().det().unwrap(); + assert_eq!(positive_det.to_bits(), 0.0f64.to_bits()); + + let negative = + Matrix::<2>::try_from_rows([[-TWO_NEG_800, 0.0], [0.0, TWO_NEG_800]]).unwrap(); + let negative_det = negative.lu(zero_tolerance).unwrap().det().unwrap(); + assert_eq!(negative_det.to_bits(), (-0.0f64).to_bits()); + } // ----------------------------------------------------------------------- // Const-evaluability tests. @@ -688,11 +873,12 @@ mod tests { fn lu_det_const_eval_d2() { const DET: Result = { // Triangular factors with diag [2.0, 3.0] and no row swaps. - let factors = Matrix::<2>::from_rows_unchecked([[2.0, 0.0], [0.0, 3.0]]); + let Ok(factors) = LuFactors::try_from_computation([[2.0, 0.0], [0.0, 3.0]]) else { + panic!("LU test factors must be finite"); + }; let lu = Lu::<2> { - factors: LuFactors::new_unchecked(factors), - piv: [0, 1], - piv_sign: 1.0, + factors, + permutation: RowPermutation::identity(), }; lu.det() }; @@ -702,12 +888,17 @@ mod tests { #[test] fn lu_det_const_eval_d3_row_swap() { const DET: Result = { - // Identity factors but `piv_sign = -1.0` encoding a single row swap; + // Identity factors with odd row-swap parity; // the determinant magnitude is 1 but the sign flips. + let Ok(factors) = LuFactors::try_from_computation(Matrix::<3>::identity().into_rows()) + else { + panic!("LU test factors must be usable"); + }; + let mut permutation = RowPermutation::identity(); + permutation.swap(0, 1); let lu = Lu::<3> { - factors: LuFactors::new_unchecked(Matrix::<3>::identity()), - piv: [1, 0, 2], - piv_sign: -1.0, + factors, + permutation, }; lu.det() }; @@ -717,19 +908,20 @@ mod tests { #[test] fn lu_solve_const_eval_d2() { // Identity LU ⇒ solve returns the permuted RHS untouched. - const X: [f64; 2] = { + const X: Result, LaError> = { + let Ok(factors) = LuFactors::try_from_computation(Matrix::<2>::identity().into_rows()) + else { + panic!("LU test factors must be usable"); + }; let lu = Lu::<2> { - factors: LuFactors::new_unchecked(Matrix::<2>::identity()), - piv: [0, 1], - piv_sign: 1.0, + factors, + permutation: RowPermutation::identity(), }; let b = Vector::<2>::new([1.0, 2.0]); - match lu.solve(b) { - Ok(v) => v.into_array(), - Err(_) => [0.0, 0.0], - } + lu.solve(b) }; - assert!((X[0] - 1.0).abs() <= 1e-12); - assert!((X[1] - 2.0).abs() <= 1e-12); + let x = X.unwrap().into_array(); + assert!((x[0] - 1.0).abs() <= 1e-12); + assert!((x[1] - 2.0).abs() <= 1e-12); } } diff --git a/src/matrix.rs b/src/matrix.rs index 61b22c6..6ee7735 100644 --- a/src/matrix.rs +++ b/src/matrix.rs @@ -6,7 +6,9 @@ use core::hint::cold_path; use crate::ldlt::Ldlt; use crate::lu::Lu; -use crate::{ERR_COEFF_2, ERR_COEFF_3, ERR_COEFF_4, LDLT_SYMMETRY_REL_TOL, LaError, Tolerance}; +use crate::{ + ArithmeticOperation, ERR_COEFF_2, ERR_COEFF_3, ERR_COEFF_4, LaError, SymmetricMatrix, Tolerance, +}; /// Finite fixed-size square matrix `D×D`, stored inline. /// @@ -17,8 +19,8 @@ use crate::{ERR_COEFF_2, ERR_COEFF_3, ERR_COEFF_4, LDLT_SYMMETRY_REL_TOL, LaErro /// [`faer`](https://crates.io/crates/faer). /// /// Public construction and mutation reject NaN and infinity through -/// [`try_from_rows`](Self::try_from_rows), [`set`](Self::set), and -/// [`set_checked`](Self::set_checked). The storage field is private, so a +/// [`try_from_rows`](Self::try_from_rows) and [`set`](Self::set). The storage +/// field is private, so a /// `Matrix` value carries the invariant that every stored entry is finite. /// Algorithms therefore do not re-scan stored entries at every use; user-visible /// non-finite errors come from construction/mutation boundaries or from values @@ -40,51 +42,121 @@ pub struct Matrix { rows: [[f64; D]; D], } -/// Matrix proven finite and symmetric under the crate's LDLT symmetry tolerance. -#[must_use] +/// Rounded arithmetic result together with proof that gradual underflow could +/// not have changed that operation's result. +/// +/// The determinant filter may only use its relative-error coefficients while +/// every rounded operation in both the determinant and absolute-Leibniz trees +/// stays in the normal range. Exact structural zeros are safe; cancellation to +/// zero is conservatively treated as inconclusive. #[derive(Clone, Copy, Debug, PartialEq)] -#[allow(clippy::redundant_pub_crate)] -pub(crate) struct SymmetricMatrix { - matrix: Matrix, +struct FilterArithmetic { + value: f64, + underflow_safe: bool, +} + +impl FilterArithmetic { + /// Return whether a rounded result is normal or non-finite. + /// + /// A single exponent-field test keeps the overwhelmingly common normal + /// path cheap. Callers inspect operands only when the result is zero or + /// subnormal so they can distinguish structural zero from range loss. + #[expect( + clippy::inline_always, + reason = "determinant hot-path specialization must eliminate unused safety state" + )] + #[inline(always)] + const fn has_nonzero_exponent(value: f64) -> bool { + value.to_bits() & 0x7ff0_0000_0000_0000 != 0 + } + + /// Ordinary floating-point multiplication. + #[expect( + clippy::inline_always, + reason = "determinant hot-path specialization must eliminate unused safety state" + )] + #[inline(always)] + const fn multiply(lhs: f64, rhs: f64) -> Self { + let value = lhs * rhs; + Self { + value, + underflow_safe: !TRACK_UNDERFLOW + || Self::has_nonzero_exponent(value) + || lhs == 0.0 + || rhs == 0.0, + } + } + + /// Ordinary addition of the non-negative terms used by the error-bound tree. + #[expect( + clippy::inline_always, + reason = "determinant hot-path specialization must eliminate unused safety state" + )] + #[inline(always)] + const fn add_non_negative(lhs: f64, rhs: f64) -> Self { + let value = lhs + rhs; + Self { + value, + underflow_safe: !TRACK_UNDERFLOW + || Self::has_nonzero_exponent(value) + || (lhs == 0.0 && rhs == 0.0), + } + } + + /// Fused multiply-add. + #[expect( + clippy::inline_always, + reason = "determinant hot-path specialization must eliminate unused safety state" + )] + #[inline(always)] + const fn mul_add(lhs: f64, rhs: f64, addend: f64) -> Self { + let value = lhs.mul_add(rhs, addend); + Self { + value, + underflow_safe: !TRACK_UNDERFLOW + || Self::has_nonzero_exponent(value) + || ((lhs == 0.0 || rhs == 0.0) && addend == 0.0), + } + } } impl SymmetricMatrix { /// Construct a symmetric matrix proof without checking the invariant. /// - /// This constructor is only for paths that have already validated finite - /// entries and LDLT symmetry with the same predicate as - /// [`try_new`](Self::try_new). + /// This constructor is only for paths that have already validated exact + /// mirrored-entry equality with the same predicate as + /// [`try_new`](Self::try_new). Finiteness is carried by [`Matrix`]. #[inline] - pub(crate) const fn new_unchecked(matrix: Matrix) -> Self { + const fn new_unchecked(matrix: Matrix) -> Self { Self { matrix } } - /// Validate that a matrix is symmetric under the LDLT symmetry tolerance. + /// Validate that every mirrored pair has exactly the same finite value. /// - /// The predicate is the same one used by [`Matrix::ldlt`]: - /// `|A[i][j] - A[j][i]| <= 1e-12 * max(1, inf_norm(A))`, with scaling that - /// preserves strict tolerances when an unscaled row sum would overflow. + /// IEEE-754 signed zeros compare equal, so `+0.0` and `-0.0` satisfy this + /// mathematical-symmetry proof even though their bit patterns differ. /// /// # Errors - /// Returns [`LaError::Asymmetric`] when the first off-diagonal pair violates - /// the LDLT symmetry predicate. - /// - /// Returns [`LaError::NonFinite`] when computing the scaled symmetry - /// tolerance overflows to NaN or infinity. + /// Returns [`LaError::Asymmetric`] with `allowed_abs_diff == 0.0` when the + /// first off-diagonal pair is not exactly equal. #[inline] - pub(crate) fn try_new(matrix: Matrix) -> Result { - if let Some((row, col)) = matrix.first_asymmetry(LDLT_SYMMETRY_REL_TOL)? { - cold_path(); - Err(LaError::asymmetric(row, col, D)) - } else { - Ok(Self::new_unchecked(matrix)) + #[expect( + clippy::float_cmp, + reason = "LDLT requires exact mirrored-entry equality to factor the supplied operator" + )] + fn try_new(matrix: Matrix) -> Result { + for row in 0..D { + for col in (row + 1)..D { + let upper = matrix.rows[row][col]; + let lower = matrix.rows[col][row]; + if upper != lower { + cold_path(); + return Err(LaError::asymmetric(row, col, D, upper, lower, 0.0)); + } + } } - } - /// Consume the wrapper and return the underlying matrix. - #[inline] - pub(crate) const fn into_matrix(self) -> Matrix { - self.matrix + Ok(Self::new_unchecked(matrix)) } } @@ -110,8 +182,8 @@ impl Matrix { /// offending entry in row-major order when `rows` contains NaN or infinity. #[inline] pub const fn try_from_rows(rows: [[f64; D]; D]) -> Result { - if let Some((row, col)) = Self::first_non_finite_cell_in(&rows) { - Err(LaError::non_finite_cell(row, col)) + if let Some((row, col)) = Self::first_non_finite_cell(&rows) { + Err(LaError::non_finite_input_matrix(row, col)) } else { Ok(Self::from_rows_unchecked(rows)) } @@ -119,31 +191,64 @@ impl Matrix { /// Construct a matrix without checking that entries are finite. /// - /// This crate-internal escape hatch is reserved for finite literals and + /// This module-private escape hatch is reserved for finite literals and /// algorithm outputs whose finite invariant is visible at the call site. /// Computed outputs must be validated before becoming observable API values. #[inline] - pub(crate) const fn from_rows_unchecked(rows: [[f64; D]; D]) -> Self { + const fn from_rows_unchecked(rows: [[f64; D]; D]) -> Self { Self { rows } } - /// Borrow finite row-major storage. + /// Borrow the finite row-major backing array. /// - /// This accessor exposes the already validated backing array to internal - /// algorithms without giving them mutable access that could invalidate the - /// [`Matrix`] invariant. + /// The returned view is tied to this [`Matrix`], so callers can inspect the + /// canonical storage without copying it or bypassing the finite-value + /// invariant. + /// + /// # Examples + /// ``` + /// use la_stack::prelude::*; + /// + /// # fn main() -> Result<(), LaError> { + /// let matrix = Matrix::<2>::try_from_rows([[1.0, 2.0], [3.0, 4.0]])?; + /// assert_eq!(matrix.as_rows(), &[[1.0, 2.0], [3.0, 4.0]]); + /// # Ok(()) + /// # } + /// ``` + /// + /// A live view keeps the matrix immutably borrowed, so validated mutation + /// cannot occur until the view is no longer used: + /// + /// ```compile_fail + /// use la_stack::Matrix; + /// + /// let mut matrix = Matrix::<2>::identity(); + /// let rows = matrix.as_rows(); + /// assert!(matrix.set(0, 0, 5.0).is_ok()); + /// assert_eq!(rows[0][0], 1.0); + /// ``` #[inline] - pub(crate) const fn rows(&self) -> &[[f64; D]; D] { + #[must_use] + pub const fn as_rows(&self) -> &[[f64; D]; D] { &self.rows } - /// Mutably borrow raw row-major storage without preserving the finite invariant. + /// Consume this matrix and return its finite row-major backing array. + /// + /// # Examples + /// ``` + /// use la_stack::prelude::*; /// - /// This is reserved for internal factorization temporaries whose results are - /// validated or otherwise proven finite before becoming observable API values. + /// # fn main() -> Result<(), LaError> { + /// let matrix = Matrix::<2>::try_from_rows([[1.0, 2.0], [3.0, 4.0]])?; + /// assert_eq!(matrix.into_rows(), [[1.0, 2.0], [3.0, 4.0]]); + /// # Ok(()) + /// # } + /// ``` #[inline] - pub(crate) const fn rows_mut_unchecked(&mut self) -> &mut [[f64; D]; D] { - &mut self.rows + #[must_use] + pub const fn into_rows(self) -> [[f64; D]; D] { + self.rows } /// All-zeros finite matrix. @@ -199,9 +304,9 @@ impl Matrix { /// ``` #[inline] #[must_use] - pub const fn get(&self, r: usize, c: usize) -> Option { - if r < D && c < D { - Some(self.rows[r][c]) + pub const fn get(&self, row: usize, col: usize) -> Option { + if row < D && col < D { + Some(self.rows[row][col]) } else { None } @@ -215,17 +320,19 @@ impl Matrix { /// /// # Examples /// ``` + /// use core::assert_matches; /// use la_stack::prelude::*; /// /// # fn main() -> Result<(), LaError> { /// let m = Matrix::<2>::try_from_rows([[1.0, 2.0], [3.0, 4.0]])?; - /// assert_eq!(m.get_checked(1, 0)?, 3.0); - /// assert_eq!( - /// m.get_checked(2, 0), + /// assert_eq!(m.try_get(1, 0)?, 3.0); + /// assert_matches!( + /// m.try_get(2, 0), /// Err(LaError::IndexOutOfBounds { /// row: 2, /// col: 0, /// dim: 2, + /// .. /// }) /// ); /// # Ok(()) @@ -235,7 +342,7 @@ impl Matrix { /// # Errors /// Returns [`LaError::IndexOutOfBounds`] when either index is not `< D`. #[inline] - pub const fn get_checked(&self, row: usize, col: usize) -> Result { + pub const fn try_get(&self, row: usize, col: usize) -> Result { if row < D && col < D { Ok(self.rows[row][col]) } else { @@ -247,18 +354,20 @@ impl Matrix { /// /// # Examples /// ``` + /// use core::assert_matches; /// use la_stack::prelude::*; /// /// # fn main() -> Result<(), LaError> { /// let mut m = Matrix::<2>::zero(); /// assert_eq!(m.set(0, 1, 2.5), Ok(())); /// assert_eq!(m.get(0, 1), Some(2.5)); - /// assert_eq!( + /// assert_matches!( /// m.set(10, 0, 1.0), /// Err(LaError::IndexOutOfBounds { /// row: 10, /// col: 0, /// dim: 2, + /// .. /// }) /// ); /// # Ok(()) @@ -270,45 +379,11 @@ impl Matrix { /// Returns [`LaError::NonFinite`] when `value` is NaN or infinity. #[inline] pub const fn set(&mut self, row: usize, col: usize, value: f64) -> Result<(), LaError> { - self.set_checked(row, col, value) - } - - /// Set an element, preserving index context on failure. - /// - /// The matrix is mutated only when `(row, col)` is in bounds and `value` is - /// finite. - /// - /// # Examples - /// ``` - /// use la_stack::prelude::*; - /// - /// # fn main() -> Result<(), LaError> { - /// let mut m = Matrix::<2>::zero(); - /// m.set_checked(0, 1, 2.5)?; - /// assert_eq!(m.get_checked(0, 1)?, 2.5); - /// - /// assert_eq!( - /// m.set_checked(10, 0, 1.0), - /// Err(LaError::IndexOutOfBounds { - /// row: 10, - /// col: 0, - /// dim: 2, - /// }) - /// ); - /// # Ok(()) - /// # } - /// ``` - /// - /// # Errors - /// Returns [`LaError::IndexOutOfBounds`] when either index is not `< D`. - /// Returns [`LaError::NonFinite`] when `value` is NaN or infinity. - #[inline] - pub const fn set_checked(&mut self, row: usize, col: usize, value: f64) -> Result<(), LaError> { if row >= D || col >= D { return Err(LaError::index_out_of_bounds(row, col, D)); } if !value.is_finite() { - return Err(LaError::non_finite_cell(row, col)); + return Err(LaError::non_finite_input_matrix(row, col)); } self.rows[row][col] = value; Ok(()) @@ -328,6 +403,7 @@ impl Matrix { /// /// # Examples /// ``` + /// use core::assert_matches; /// use la_stack::prelude::*; /// /// # fn main() -> Result<(), LaError> { @@ -335,11 +411,12 @@ impl Matrix { /// assert!((m.inf_norm()? - 7.0).abs() <= 1e-12); /// /// // Raw NaN entries are rejected with coordinates. - /// assert_eq!( + /// assert_matches!( /// Matrix::<2>::try_from_rows([[f64::NAN, 1.0], [2.0, 3.0]]), /// Err(LaError::NonFinite { - /// row: Some(0), - /// col: 0, + /// location: NonFiniteLocation::MatrixCell { row: 0, col: 0, .. }, + /// origin: NonFiniteOrigin::Input, + /// .. /// }) /// ); /// # Ok(()) @@ -362,7 +439,11 @@ impl Matrix { row_sum += row[c].abs(); if !row_sum.is_finite() { cold_path(); - return Err(LaError::non_finite_cell(r, c)); + return Err(LaError::non_finite_computation_matrix( + ArithmeticOperation::MatrixInfinityNorm, + r, + c, + )); } c += 1; } @@ -375,21 +456,23 @@ impl Matrix { Ok(max_row_sum) } - /// Returns `true` if the matrix is symmetric within a relative tolerance. + /// Returns `true` if the matrix is approximately symmetric within a relative tolerance. /// /// Two entries `self[r][c]` and `self[c][r]` are considered equal (for the /// purposes of symmetry) when /// `|self[r][c] - self[c][r]| <= rel_tol * max(1.0, inf_norm(self))`. - /// This mirrors the predicate used internally by [`ldlt`](Self::ldlt), so - /// callers can pre-validate matrices that may come from untrusted sources. + /// This is a diagnostic predicate for applications that have an + /// approximation-specific symmetry threshold. It is not the precondition + /// used by [`ldlt`](Self::ldlt), which requires exact mirrored-entry + /// equality so the returned factors represent the original matrix. /// /// Use [`first_asymmetry`](Self::first_asymmetry) to locate the first /// offending pair when this returns `Ok(false)`. /// /// The `rel_tol` argument is a [`Tolerance`], so raw caller input must be /// finite and non-negative before it can reach this predicate. Use - /// [`Tolerance::new`] or [`LaError::validate_tolerance`] when accepting a - /// raw `f64`; negative, NaN, and infinite tolerances return + /// [`Tolerance::try_new`] when accepting a raw `f64`; negative, NaN, and + /// infinite tolerances return /// [`LaError::InvalidTolerance`]. /// /// # Overflow handling @@ -404,7 +487,7 @@ impl Matrix { /// /// # fn main() -> Result<(), LaError> { /// let a = Matrix::<2>::try_from_rows([[4.0, 2.0], [2.0, 3.0]])?; - /// let tol = Tolerance::new(1e-12)?; + /// let tol = Tolerance::try_new(1e-12)?; /// assert!(a.is_symmetric(tol)?); /// /// let b = Matrix::<2>::try_from_rows([[4.0, 2.0], [3.0, 3.0]])?; @@ -422,13 +505,15 @@ impl Matrix { } /// Returns the indices `(r, c)` (with `r < c`) of the first off-diagonal - /// pair that violates symmetry, or `None` if the matrix is symmetric - /// within `rel_tol`. + /// pair that violates approximate symmetry, or `None` if the matrix is + /// symmetric within `rel_tol`. /// /// Iteration order is row-major over the strict upper triangle, so the /// returned indices are the lexicographically smallest such pair. The /// predicate is the same as [`is_symmetric`](Self::is_symmetric): /// `|self[r][c] - self[c][r]| <= rel_tol * max(1.0, inf_norm(self))`. + /// It is intentionally distinct from the exact equality required by + /// [`ldlt`](Self::ldlt). /// /// A finite matrix can return [`LaError::NonFinite`] with matrix coordinates /// if computing the scaled symmetry tolerance overflows to NaN or infinity. @@ -437,8 +522,8 @@ impl Matrix { /// /// The `rel_tol` argument is a [`Tolerance`], so raw caller input must be /// finite and non-negative before it can reach this predicate. Use - /// [`Tolerance::new`] or [`LaError::validate_tolerance`] when accepting a - /// raw `f64`; negative, NaN, and infinite tolerances return + /// [`Tolerance::try_new`] when accepting a raw `f64`; negative, NaN, and + /// infinite tolerances return /// [`LaError::InvalidTolerance`]. /// /// # Examples @@ -451,7 +536,7 @@ impl Matrix { /// [2.0, 4.0, 5.0], /// [0.0, 6.0, 9.0], // 6.0 breaks symmetry with a[1][2] = 5.0 /// ])?; - /// let tol = Tolerance::new(1e-12)?; + /// let tol = Tolerance::try_new(1e-12)?; /// assert_eq!(a.first_asymmetry(tol)?, Some((1, 2))); /// assert_eq!(Matrix::<3>::identity().first_asymmetry(tol)?, None); /// # Ok(()) @@ -518,8 +603,8 @@ impl Matrix { /// /// The `tol` argument is a [`Tolerance`], so raw caller input must be /// finite and non-negative before it can reach factorization. Use - /// [`Tolerance::new`] or [`LaError::validate_tolerance`] when accepting a - /// raw `f64`; negative, NaN, and infinite tolerances return + /// [`Tolerance::try_new`] when accepting a raw `f64`; negative, NaN, and + /// infinite tolerances return /// [`LaError::InvalidTolerance`]. /// /// # Errors @@ -542,18 +627,24 @@ impl Matrix { /// matrices such as Gram matrices. /// /// # Symmetry validation - /// The input matrix `self` must be symmetric — that is, - /// `self[i][j] == self[j][i]` within the crate's LDLT symmetry tolerance - /// (`1e-12`, scaled like [`is_symmetric`](Self::is_symmetric)). This is a - /// correctness invariant, not merely a performance hint, so asymmetric inputs return - /// [`LaError::Asymmetric`] before factorization starts. If you need a - /// general-purpose factorization that tolerates non-symmetric inputs, use - /// [`lu`](Self::lu) instead. + /// The input matrix `self` must be exactly symmetric: every mirrored pair + /// must satisfy `self[i][j] == self[j][i]`. IEEE-754 signed zeros compare + /// equal and are therefore accepted. Exact equality is a correctness + /// invariant, not merely a performance hint: LDLT reads only the lower + /// triangle, so accepting an approximate mismatch would factor a different + /// operator than the matrix supplied by the caller. Asymmetric inputs return + /// [`LaError::Asymmetric`] with an allowed absolute difference of `0.0` + /// before factorization starts. + /// + /// [`is_symmetric`](Self::is_symmetric) remains available as a + /// tolerance-based diagnostic, but `Ok(true)` from that method does not + /// establish this exact LDLT precondition. If you need a general-purpose + /// factorization for a non-symmetric matrix, use [`lu`](Self::lu) instead. /// /// The `tol` argument is a [`Tolerance`], so raw caller input must be /// finite and non-negative before it can reach factorization. Use - /// [`Tolerance::new`] or [`LaError::validate_tolerance`] when accepting a - /// raw `f64`; negative, NaN, and infinite tolerances return + /// [`Tolerance::try_new`] when accepting a raw `f64`; negative, NaN, and + /// infinite tolerances return /// [`LaError::InvalidTolerance`]. /// /// # Examples @@ -592,10 +683,11 @@ impl Matrix { /// ``` /// /// # Errors - /// Returns [`LaError::NotPositiveSemidefinite`] if, for some step `k`, the required - /// diagonal entry `d = D[k,k]` is negative. - /// Returns [`LaError::Singular`] if `0 <= d <= tol`, treating PSD degeneracy - /// as singular/degenerate. + /// Returns [`LaError::NotPositiveSemidefinite`] if a pivot is negative or a + /// zero pivot retains a non-zero coupling below it. + /// Returns [`LaError::Singular`] if a zero pivot has no remaining coupling, + /// or if a positive pivot satisfies `d <= tol`, treating PSD degeneracy as + /// singular. /// Returns [`LaError::NonFinite`] if factorization computes a non-finite /// intermediate. /// Returns [`LaError::Asymmetric`] if the input matrix is not symmetric. @@ -605,7 +697,7 @@ impl Matrix { } /// Return the first non-finite stored cell in row-major order. - const fn first_non_finite_cell_in(rows: &[[f64; D]; D]) -> Option<(usize, usize)> { + const fn first_non_finite_cell(rows: &[[f64; D]; D]) -> Option<(usize, usize)> { let mut r = 0; while r < D { let mut c = 0; @@ -620,28 +712,33 @@ impl Matrix { None } - /// Validate storage after unchecked internal construction or mutation. + /// Compute the approximate-symmetry tolerance scale for a finite matrix. /// - /// Public constructors and setters already maintain this invariant. This - /// helper is reserved for internal factorization temporaries and test - /// fixtures that intentionally bypass those boundaries. - #[inline] - pub(crate) const fn validate_finite(self) -> Result { - if let Some((row, col)) = Self::first_non_finite_cell_in(&self.rows) { - Err(LaError::non_finite_cell(row, col)) - } else { - Ok(self) - } - } - - /// Compute the symmetry tolerance scale for a finite matrix. - /// - /// This helper protects the public [`is_symmetric`](Self::is_symmetric), - /// [`first_asymmetry`](Self::first_asymmetry), and [`ldlt`](Self::ldlt) - /// error contracts: an overflowed row-scale accumulator is reported with - /// the matrix cell whose contribution made it non-finite. + /// This helper protects the public [`is_symmetric`](Self::is_symmetric) and + /// [`first_asymmetry`](Self::first_asymmetry) diagnostic contracts: the + /// documented norm-first formula is used whenever its intermediate is + /// representable, while an overflow-safe termwise fallback reports the + /// matrix cell that makes the scaled tolerance non-finite. fn symmetry_epsilon(&self, rel_tol: Tolerance) -> Result { let rel_tol = rel_tol.get(); + + if rel_tol == 0.0 { + return Ok(rel_tol); + } + + if let Ok(norm) = self.inf_norm() { + let scale = if norm > 1.0 { norm } else { 1.0 }; + let eps = rel_tol * scale; + if eps.is_finite() { + return Ok(eps); + } + } + + // If the unscaled row sum or the final multiplication overflows, apply + // the tolerance to each non-negative contribution before summing. A row + // can overflow only at magnitudes where multiplication by the smallest + // positive tolerance is normal, so this fallback cannot introduce the + // gradual-underflow discrepancy avoided by the direct path above. let mut eps = rel_tol; for r in 0..D { @@ -650,7 +747,11 @@ impl Matrix { row_eps = rel_tol.mul_add(self.rows[r][c].abs(), row_eps); if !row_eps.is_finite() { cold_path(); - return Err(LaError::non_finite_cell(r, c)); + return Err(LaError::non_finite_computation_matrix( + ArithmeticOperation::SymmetryCheck, + r, + c, + )); } } if row_eps > eps { @@ -693,69 +794,111 @@ impl Matrix { /// to NaN or infinity. #[inline] pub const fn det_direct(&self) -> Result, LaError> { + let Some(det) = self.det_direct_arithmetic::() else { + cold_path(); + return Ok(None); + }; + + Self::computed_scalar_result(ArithmeticOperation::Determinant, det.value) + } + + /// Evaluate the closed-form determinant while certifying every rounded + /// operation against gradual underflow. + #[expect( + clippy::inline_always, + reason = "det_direct callers must eliminate unused filter-safety bookkeeping" + )] + #[inline(always)] + const fn det_direct_arithmetic( + &self, + ) -> Option> { match D { - 0 => Ok(Some(1.0)), - 1 => Self::computed_scalar_result(self.rows[0][0]), + 0 => Some(FilterArithmetic { + value: 1.0, + underflow_safe: true, + }), + 1 => Some(FilterArithmetic { + value: self.rows[0][0], + underflow_safe: true, + }), 2 => { - let det = if self.rows[0][1] == 0.0 { - self.rows[0][0] * self.rows[1][1] + let a = self.rows[0][0]; + let b = self.rows[0][1]; + let c = self.rows[1][0]; + let d = self.rows[1][1]; + if b == 0.0 { + Some(FilterArithmetic::::multiply(a, d)) } else { - self.rows[0][0].mul_add(self.rows[1][1], -(self.rows[0][1] * self.rows[1][0])) - }; - Self::computed_scalar_result(det) - } - 3 => { - let det = Self::det3_elements( - [self.rows[0][0], self.rows[0][1], self.rows[0][2]], - [self.rows[1][0], self.rows[1][1], self.rows[1][2]], - [self.rows[2][0], self.rows[2][1], self.rows[2][2]], - ); - Self::computed_scalar_result(det) + let subtrahend = FilterArithmetic::::multiply(b, c); + let mut det = + FilterArithmetic::::mul_add(a, d, -subtrahend.value); + det.underflow_safe &= subtrahend.underflow_safe; + Some(det) + } } + 3 => Some(Self::det3_elements::( + [self.rows[0][0], self.rows[0][1], self.rows[0][2]], + [self.rows[1][0], self.rows[1][1], self.rows[1][2]], + [self.rows[2][0], self.rows[2][1], self.rows[2][2]], + )), 4 => { let r = &self.rows; let mut det = if r[0][3] == 0.0 { - 0.0 + FilterArithmetic { + value: 0.0, + underflow_safe: true, + } } else { - let c03 = Self::det3_elements( + let c03 = Self::det3_elements::( [r[1][0], r[1][1], r[1][2]], [r[2][0], r[2][1], r[2][2]], [r[3][0], r[3][1], r[3][2]], ); - -(r[0][3] * c03) + let mut term = + FilterArithmetic::::multiply(r[0][3], c03.value); + term.value = -term.value; + term.underflow_safe &= c03.underflow_safe; + term }; if r[0][2] != 0.0 { - let c02 = Self::det3_elements( + let c02 = Self::det3_elements::( [r[1][0], r[1][1], r[1][3]], [r[2][0], r[2][1], r[2][3]], [r[3][0], r[3][1], r[3][3]], ); - det = r[0][2].mul_add(c02, det); + let prior_safe = det.underflow_safe && c02.underflow_safe; + det = + FilterArithmetic::::mul_add(r[0][2], c02.value, det.value); + det.underflow_safe &= prior_safe; } if r[0][1] != 0.0 { - let c01 = Self::det3_elements( + let c01 = Self::det3_elements::( [r[1][0], r[1][2], r[1][3]], [r[2][0], r[2][2], r[2][3]], [r[3][0], r[3][2], r[3][3]], ); - det = (-r[0][1]).mul_add(c01, det); + let prior_safe = det.underflow_safe && c01.underflow_safe; + det = FilterArithmetic::::mul_add( + -r[0][1], c01.value, det.value, + ); + det.underflow_safe &= prior_safe; } if r[0][0] != 0.0 { - let c00 = Self::det3_elements( + let c00 = Self::det3_elements::( [r[1][1], r[1][2], r[1][3]], [r[2][1], r[2][2], r[2][3]], [r[3][1], r[3][2], r[3][3]], ); - det = r[0][0].mul_add(c00, det); + let prior_safe = det.underflow_safe && c00.underflow_safe; + det = + FilterArithmetic::::mul_add(r[0][0], c00.value, det.value); + det.underflow_safe &= prior_safe; } - Self::computed_scalar_result(det) - } - _ => { - cold_path(); - Ok(None) + Some(det) } + _ => None, } } @@ -765,15 +908,17 @@ impl Matrix { /// For D ∈ {1, 2, 3, 4}, this bypasses LU factorization entirely for a significant /// speedup (see [`det_direct`](Self::det_direct)). /// - /// Finite inputs return a floating-point determinant estimate in every dimension; - /// this method does not surface [`LaError::Singular`]. Because it mixes - /// closed-form paths from [`det_direct`](Self::det_direct) with an LU fallback, - /// the returned value has no certified absolute error bound. Use + /// Because this method mixes closed-form paths from + /// [`det_direct`](Self::det_direct) with an LU fallback, the returned value has + /// no certified absolute error bound. Use /// [`det_errbound`](Self::det_errbound) for D ≤ 4 bounds, or the exact /// determinant APIs when exact singularity classification or certified values - /// matter. For D ≥ 5, the LU fallback only maps an exactly zero pivot to - /// `Ok(0.0)`. Use [`lu`](Self::lu) directly when you need tolerance-aware - /// singularity detection or the pivot column. + /// matter. For D ≥ 5, the zero-tolerance LU fallback surfaces + /// [`LaError::Singular`] when elimination cannot produce a non-zero pivot. + /// Floating-point elimination cannot in general distinguish an exactly + /// singular matrix from a non-singular matrix whose intermediate pivot + /// rounded to zero, so this method never converts that numerical failure into + /// an exact `0.0` result. /// /// # Examples /// ``` @@ -786,25 +931,33 @@ impl Matrix { /// # } /// ``` /// + /// The LU fallback accumulates its diagonal product with power-of-two + /// scaling, so factor order cannot cause premature overflow or underflow in + /// the final product. Elimination intermediates remain subject to binary64 + /// rounding and range limits. + /// /// # Errors - /// Returns [`LaError::NonFinite`] if the LU fallback computes a non-finite - /// factorization cell, or the determinant product overflows to NaN or infinity. + /// Returns [`LaError::Singular`] if the D ≥ 5 LU fallback cannot produce a + /// non-zero pivot, including when a non-zero mathematical intermediate rounds + /// to zero during elimination. Returns [`LaError::NonFinite`] if a D ≤ 4 + /// closed-form result is non-finite, if the LU fallback computes a + /// non-finite factorization cell, or if its final scaled determinant cannot + /// be represented as a finite `f64`. #[inline] pub fn det(self) -> Result { if let Some(d) = self.det_direct()? { return Ok(d); } - match self.lu(Tolerance::new_unchecked(0.0)) { - Ok(lu) => lu.det(), - Err(LaError::Singular { .. }) => Ok(0.0), - Err(err) => Err(err), - } + self.lu(Tolerance::ZERO)?.det() } /// Conservative absolute error bound for `det_direct()`. /// - /// Returns `Ok(Some(bound))` such that `|det_direct() - det_exact| ≤ bound`, - /// or `Ok(None)` for D ≥ 5 where no fast bound is available. + /// Returns `Ok(Some(bound))` such that `|det_direct() - det_exact| ≤ bound` + /// when every rounded intermediate used by the closed-form determinant and + /// bound is normal (or an exact structural zero). Returns `Ok(None)` when + /// gradual underflow could invalidate the relative-error analysis, or for + /// D ≥ 5 where no fast bound is available. /// /// For D ≤ 4, the bound is derived from the absolute Leibniz sum using /// Shewchuk-style error analysis (see `REFERENCES.md` \[8\] and the @@ -818,8 +971,9 @@ impl Matrix { /// /// # When to use /// - /// Use this to build adaptive-precision logic: if `|det_direct()| > bound`, - /// the f64 sign is provably correct. Otherwise fall back to exact arithmetic. + /// Use this to build adaptive-precision logic: when a bound is available and + /// `|det_direct()| > bound`, the f64 sign is provably correct. Otherwise fall + /// back to exact arithmetic. /// /// # Examples /// ``` @@ -844,81 +998,216 @@ impl Matrix { /// ```ignore /// use la_stack::prelude::*; /// - /// let m = Matrix::<3>::identity(); - /// if let Some(bound) = m.det_errbound()? { - /// if let Some(det) = m.det_direct()? { + /// fn adaptive_det_sign( + /// matrix: &Matrix, + /// ) -> DeterminantSign { + /// if let (Ok(Some(bound)), Ok(Some(det))) = + /// (matrix.det_errbound(), matrix.det_direct()) + /// { /// if det.abs() > bound { - /// // f64 sign is guaranteed correct - /// let sign = det.signum() as i8; - /// } else { - /// // Fall back to exact arithmetic (requires `exact` feature) - /// let sign = m.det_sign_exact()?; + /// return if det > 0.0 { + /// DeterminantSign::Positive + /// } else { + /// DeterminantSign::Negative + /// }; /// } /// } - /// } else { - /// // D ≥ 5: no fast filter, use exact directly - /// let sign = m.det_sign_exact()?; + /// + /// matrix.det_sign_exact() + /// } + /// + /// fn main() -> Result<(), LaError> { + /// assert_eq!( + /// adaptive_det_sign(&Matrix::<3>::identity()), + /// DeterminantSign::Positive + /// ); + /// + /// let big = f64::MAX / 2.0; + /// let overflowing = Matrix::<3>::try_from_rows([ + /// [0.0, 0.0, 1.0], + /// [big, 0.0, 1.0], + /// [0.0, big, 1.0], + /// ])?; + /// assert_eq!( + /// adaptive_det_sign(&overflowing), + /// DeterminantSign::Positive + /// ); + /// Ok(()) /// } /// ``` /// /// # Errors /// Returns [`LaError::NonFinite`] when the bound computation overflows to - /// NaN or infinity. + /// NaN or infinity. Underflow-sensitive finite computations return + /// `Ok(None)` instead because they are valid inputs for an exact fallback. #[inline] pub const fn det_errbound(&self) -> Result, LaError> { + let Some(det) = self.det_direct_arithmetic::() else { + cold_path(); + return Ok(None); + }; + self.det_errbound_from_arithmetic(det) + } + + /// Evaluate the determinant and its certified error bound with one shared + /// traversal of the determinant arithmetic tree. + #[cfg(feature = "exact")] + pub(crate) const fn det_filter(&self) -> Option<(f64, f64)> { + if self.det_filter_inputs_have_wide_exponent_margin() { + let Some(det) = self.det_direct_arithmetic::() else { + cold_path(); + return None; + }; + return self.det_filter_from_arithmetic(det); + } + + let Some(det) = self.det_direct_arithmetic::() else { + cold_path(); + return None; + }; + self.det_filter_from_arithmetic(det) + } + + /// Return whether every non-zero entry is large enough that the complete + /// D≤4 determinant and permanent trees cannot gradually underflow. + /// + /// The `2^-16` threshold leaves hundreds of binary exponent bits of margin + /// even after the D=4 tree's products, FMAs, and binary64 rounding steps. + /// Overflow remains possible and is classified after evaluation. Inputs + /// below this conservative threshold use per-operation tracking instead. + #[cfg(feature = "exact")] + const fn det_filter_inputs_have_wide_exponent_margin(&self) -> bool { + const MIN_MAGNITUDE_BITS: u64 = 1007_u64 << 52; // 2^-16 + const MAGNITUDE_MASK: u64 = !(1_u64 << 63); + + if D > 4 { + return false; + } + + let mut row = 0; + while row < D { + let mut col = 0; + while col < D { + let magnitude_bits = self.rows[row][col].to_bits() & MAGNITUDE_MASK; + if magnitude_bits != 0 && magnitude_bits < MIN_MAGNITUDE_BITS { + return false; + } + col += 1; + } + row += 1; + } + true + } + + /// Classify a completed determinant tree and construct its matching bound. + #[cfg(feature = "exact")] + const fn det_filter_from_arithmetic( + &self, + det: FilterArithmetic, + ) -> Option<(f64, f64)> { + if !det.value.is_finite() { + return None; + } + + let Ok(Some(bound)) = self.det_errbound_from_arithmetic(det) else { + return None; + }; + Some((det.value, bound)) + } + + /// Compute a bound after the matching determinant tree has been evaluated. + const fn det_errbound_from_arithmetic( + &self, + det: FilterArithmetic, + ) -> Result, LaError> { + if !det.underflow_safe { + cold_path(); + return Ok(None); + } + match D { - 0 | 1 => Self::computed_scalar_result(0.0), + 0 | 1 => Self::computed_scalar_result(ArithmeticOperation::DeterminantErrorBound, 0.0), 2 => { let r = &self.rows; - let permanent = (r[0][0] * r[1][1]).abs() + (r[0][1] * r[1][0]).abs(); - Self::computed_scalar_result(ERR_COEFF_2 * permanent) + let product_0 = FilterArithmetic::::multiply(r[0][0], r[1][1]); + let product_1 = FilterArithmetic::::multiply(r[0][1], r[1][0]); + let mut permanent = FilterArithmetic::::add_non_negative( + product_0.value.abs(), + product_1.value.abs(), + ); + permanent.underflow_safe &= product_0.underflow_safe && product_1.underflow_safe; + Self::certified_error_bound(ERR_COEFF_2, permanent) } 3 => { let r = &self.rows; - let permanent = Self::det3_abs_permanent_elements( + let permanent = Self::det3_abs_permanent_elements::( [r[0][0], r[0][1], r[0][2]], [r[1][0], r[1][1], r[1][2]], [r[2][0], r[2][1], r[2][2]], ); - Self::computed_scalar_result(ERR_COEFF_3 * permanent) + Self::certified_error_bound(ERR_COEFF_3, permanent) } 4 => { let r = &self.rows; let mut permanent = if r[0][3] == 0.0 { - 0.0 + FilterArithmetic { + value: 0.0, + underflow_safe: true, + } } else { - let pc3 = Self::det3_abs_permanent_elements( + let pc3 = Self::det3_abs_permanent_elements::( [r[1][0], r[1][1], r[1][2]], [r[2][0], r[2][1], r[2][2]], [r[3][0], r[3][1], r[3][2]], ); - r[0][3].abs() * pc3 + let mut term = + FilterArithmetic::::multiply(r[0][3].abs(), pc3.value); + term.underflow_safe &= pc3.underflow_safe; + term }; if r[0][2] != 0.0 { - let pc2 = Self::det3_abs_permanent_elements( + let pc2 = Self::det3_abs_permanent_elements::( [r[1][0], r[1][1], r[1][3]], [r[2][0], r[2][1], r[2][3]], [r[3][0], r[3][1], r[3][3]], ); - permanent = r[0][2].abs().mul_add(pc2, permanent); + let prior_safe = permanent.underflow_safe && pc2.underflow_safe; + permanent = FilterArithmetic::::mul_add( + r[0][2].abs(), + pc2.value, + permanent.value, + ); + permanent.underflow_safe &= prior_safe; } if r[0][1] != 0.0 { - let pc1 = Self::det3_abs_permanent_elements( + let pc1 = Self::det3_abs_permanent_elements::( [r[1][0], r[1][2], r[1][3]], [r[2][0], r[2][2], r[2][3]], [r[3][0], r[3][2], r[3][3]], ); - permanent = r[0][1].abs().mul_add(pc1, permanent); + let prior_safe = permanent.underflow_safe && pc1.underflow_safe; + permanent = FilterArithmetic::::mul_add( + r[0][1].abs(), + pc1.value, + permanent.value, + ); + permanent.underflow_safe &= prior_safe; } if r[0][0] != 0.0 { - let pc0 = Self::det3_abs_permanent_elements( + let pc0 = Self::det3_abs_permanent_elements::( [r[1][1], r[1][2], r[1][3]], [r[2][1], r[2][2], r[2][3]], [r[3][1], r[3][2], r[3][3]], ); - permanent = r[0][0].abs().mul_add(pc0, permanent); + let prior_safe = permanent.underflow_safe && pc0.underflow_safe; + permanent = FilterArithmetic::::mul_add( + r[0][0].abs(), + pc0.value, + permanent.value, + ); + permanent.underflow_safe &= prior_safe; } - Self::computed_scalar_result(ERR_COEFF_4 * permanent) + Self::certified_error_bound(ERR_COEFF_4, permanent) } _ => { cold_path(); @@ -933,20 +1222,47 @@ impl Matrix { /// contract: a mathematically absent term must not compute an overflowing /// minor and poison the determinant with `0.0 * inf == NaN`. Nonzero terms /// keep the same fused multiply-add ordering as the closed-form expansion. - const fn det3_elements(r0: [f64; 3], r1: [f64; 3], r2: [f64; 3]) -> f64 { + #[expect( + clippy::inline_always, + reason = "det_direct callers must eliminate unused filter-safety bookkeeping" + )] + #[inline(always)] + const fn det3_elements( + r0: [f64; 3], + r1: [f64; 3], + r2: [f64; 3], + ) -> FilterArithmetic { let mut det = if r0[2] == 0.0 { - 0.0 + FilterArithmetic { + value: 0.0, + underflow_safe: true, + } } else { - let m02 = r1[0].mul_add(r2[1], -(r1[1] * r2[0])); - r0[2] * m02 + let subtrahend = FilterArithmetic::::multiply(r1[1], r2[0]); + let mut m02 = + FilterArithmetic::::mul_add(r1[0], r2[1], -subtrahend.value); + m02.underflow_safe &= subtrahend.underflow_safe; + let mut term = FilterArithmetic::::multiply(r0[2], m02.value); + term.underflow_safe &= m02.underflow_safe; + term }; if r0[1] != 0.0 { - let m01 = r1[0].mul_add(r2[2], -(r1[2] * r2[0])); - det = (-r0[1]).mul_add(m01, det); + let subtrahend = FilterArithmetic::::multiply(r1[2], r2[0]); + let mut m01 = + FilterArithmetic::::mul_add(r1[0], r2[2], -subtrahend.value); + m01.underflow_safe &= subtrahend.underflow_safe; + let prior_safe = det.underflow_safe && m01.underflow_safe; + det = FilterArithmetic::::mul_add(-r0[1], m01.value, det.value); + det.underflow_safe &= prior_safe; } if r0[0] != 0.0 { - let m00 = r1[1].mul_add(r2[2], -(r1[2] * r2[1])); - det = r0[0].mul_add(m00, det); + let subtrahend = FilterArithmetic::::multiply(r1[2], r2[1]); + let mut m00 = + FilterArithmetic::::mul_add(r1[1], r2[2], -subtrahend.value); + m00.underflow_safe &= subtrahend.underflow_safe; + let prior_safe = det.underflow_safe && m00.underflow_safe; + det = FilterArithmetic::::mul_add(r0[0], m00.value, det.value); + det.underflow_safe &= prior_safe; } det } @@ -956,30 +1272,93 @@ impl Matrix { /// This mirrors [`det3_elements`](Self::det3_elements) for error-bound /// computation: absent determinant terms should not force evaluation of an /// overflowing absolute minor. - const fn det3_abs_permanent_elements(r0: [f64; 3], r1: [f64; 3], r2: [f64; 3]) -> f64 { + #[expect( + clippy::inline_always, + reason = "error-bound call-site specialization avoids tracked-helper overhead" + )] + #[inline(always)] + const fn det3_abs_permanent_elements( + r0: [f64; 3], + r1: [f64; 3], + r2: [f64; 3], + ) -> FilterArithmetic { let mut permanent = if r0[2] == 0.0 { - 0.0 + FilterArithmetic { + value: 0.0, + underflow_safe: true, + } } else { - let pm02 = (r1[0] * r2[1]).abs() + (r1[1] * r2[0]).abs(); - r0[2].abs() * pm02 + let product_0 = FilterArithmetic::::multiply(r1[0], r2[1]); + let product_1 = FilterArithmetic::::multiply(r1[1], r2[0]); + let mut pm02 = FilterArithmetic::::add_non_negative( + product_0.value.abs(), + product_1.value.abs(), + ); + pm02.underflow_safe &= product_0.underflow_safe && product_1.underflow_safe; + let mut term = FilterArithmetic::::multiply(r0[2].abs(), pm02.value); + term.underflow_safe &= pm02.underflow_safe; + term }; if r0[1] != 0.0 { - let pm01 = (r1[0] * r2[2]).abs() + (r1[2] * r2[0]).abs(); - permanent = r0[1].abs().mul_add(pm01, permanent); + let product_0 = FilterArithmetic::::multiply(r1[0], r2[2]); + let product_1 = FilterArithmetic::::multiply(r1[2], r2[0]); + let mut pm01 = FilterArithmetic::::add_non_negative( + product_0.value.abs(), + product_1.value.abs(), + ); + pm01.underflow_safe &= product_0.underflow_safe && product_1.underflow_safe; + let prior_safe = permanent.underflow_safe && pm01.underflow_safe; + permanent = FilterArithmetic::::mul_add( + r0[1].abs(), + pm01.value, + permanent.value, + ); + permanent.underflow_safe &= prior_safe; } if r0[0] != 0.0 { - let pm00 = (r1[1] * r2[2]).abs() + (r1[2] * r2[1]).abs(); - permanent = r0[0].abs().mul_add(pm00, permanent); + let product_0 = FilterArithmetic::::multiply(r1[1], r2[2]); + let product_1 = FilterArithmetic::::multiply(r1[2], r2[1]); + let mut pm00 = FilterArithmetic::::add_non_negative( + product_0.value.abs(), + product_1.value.abs(), + ); + pm00.underflow_safe &= product_0.underflow_safe && product_1.underflow_safe; + let prior_safe = permanent.underflow_safe && pm00.underflow_safe; + permanent = FilterArithmetic::::mul_add( + r0[0].abs(), + pm00.value, + permanent.value, + ); + permanent.underflow_safe &= prior_safe; } permanent } + /// Finish a determinant error bound only when its full arithmetic tree is + /// outside the gradual-underflow regime. + const fn certified_error_bound( + coefficient: f64, + permanent: FilterArithmetic, + ) -> Result, LaError> { + let mut bound = FilterArithmetic::::multiply(coefficient, permanent.value); + bound.underflow_safe &= permanent.underflow_safe; + if bound.underflow_safe { + Self::computed_scalar_result(ArithmeticOperation::DeterminantErrorBound, bound.value) + } else { + cold_path(); + Ok(None) + } + } + /// Return a computed scalar result for a matrix with finite stored entries. - const fn computed_scalar_result(value: f64) -> Result, LaError> { + const fn computed_scalar_result( + operation: ArithmeticOperation, + value: f64, + ) -> Result, LaError> { if value.is_finite() { Ok(Some(value)) } else { - Err(LaError::non_finite_at(0)) + Err(LaError::non_finite_computation_scalar(operation)) } } } @@ -991,21 +1370,74 @@ impl Default for Matrix { } } +#[cfg(all(doc, feature = "exact"))] +mod det_errbound_doctests { + /// ```rust + /// use la_stack::prelude::*; + /// + /// fn adaptive_det_sign( + /// matrix: &Matrix, + /// ) -> DeterminantSign { + /// if let (Ok(Some(bound)), Ok(Some(det))) = + /// (matrix.det_errbound(), matrix.det_direct()) + /// { + /// if det.abs() > bound { + /// return if det > 0.0 { + /// DeterminantSign::Positive + /// } else { + /// DeterminantSign::Negative + /// }; + /// } + /// } + /// + /// matrix.det_sign_exact() + /// } + /// + /// # fn main() -> Result<(), LaError> { + /// let identity = Matrix::<3>::identity(); + /// assert_eq!( + /// adaptive_det_sign(&identity), + /// DeterminantSign::Positive + /// ); + /// + /// let singular = Matrix::<3>::try_from_rows([ + /// [1.0, 2.0, 3.0], + /// [4.0, 5.0, 6.0], + /// [7.0, 8.0, 9.0], + /// ])?; + /// assert_eq!(adaptive_det_sign(&singular), DeterminantSign::Zero); + /// + /// let big = f64::MAX / 2.0; + /// let overflowing = Matrix::<3>::try_from_rows([ + /// [0.0, 0.0, 1.0], + /// [big, 0.0, 1.0], + /// [0.0, big, 1.0], + /// ])?; + /// assert_eq!( + /// adaptive_det_sign(&overflowing), + /// DeterminantSign::Positive + /// ); + /// # Ok(()) + /// # } + /// ``` + fn adaptive_precision_pattern() {} +} + #[cfg(test)] mod tests { - use super::*; - use crate::DEFAULT_SINGULAR_TOL; - use crate::vector::Vector; + use core::hint::black_box; use approx::assert_abs_diff_eq; use pastey::paste; - use std::hint::black_box; - macro_rules! gen_public_api_matrix_tests { + use super::*; + use crate::{DEFAULT_SINGULAR_TOL, FactorizationKind, Vector}; + + macro_rules! gen_matrix_tests { ($d:literal) => { paste! { #[test] - fn []() { + fn []() { let mut rows = [[0.0f64; $d]; $d]; rows[0][0] = 1.0; rows[$d - 1][$d - 1] = -2.0; @@ -1014,13 +1446,13 @@ mod tests { assert_eq!(m.get(0, 0), Some(1.0)); assert_eq!(m.get($d - 1, $d - 1), Some(-2.0)); - assert_eq!(m.get_checked(0, 0), Ok(1.0)); - assert_eq!(m.get_checked($d - 1, $d - 1), Ok(-2.0)); + assert_eq!(m.try_get(0, 0), Ok(1.0)); + assert_eq!(m.try_get($d - 1, $d - 1), Ok(-2.0)); // Out-of-bounds is None. assert_eq!(m.get($d, 0), None); assert_eq!( - m.get_checked($d, 0), + m.try_get($d, 0), Err(LaError::IndexOutOfBounds { row: $d, col: 0, @@ -1040,16 +1472,7 @@ mod tests { ); assert_eq!(m, before_failed_set); assert_eq!( - m.set_checked($d, 0, 3.0), - Err(LaError::IndexOutOfBounds { - row: $d, - col: 0, - dim: $d, - }) - ); - assert_eq!(m, before_failed_set); - assert_eq!( - m.set_checked(0, $d, 3.0), + m.set(0, $d, 3.0), Err(LaError::IndexOutOfBounds { row: 0, col: $d, @@ -1062,12 +1485,45 @@ mod tests { // In-bounds set works. assert_eq!(m.set(0, $d - 1, 3.0), Ok(())); assert_eq!(m.get(0, $d - 1), Some(3.0)); - assert_eq!(m.set_checked($d - 1, 0, 4.0), Ok(())); - assert_eq!(m.get_checked($d - 1, 0), Ok(4.0)); + assert_eq!(m.set($d - 1, 0, 4.0), Ok(())); + assert_eq!(m.try_get($d - 1, 0), Ok(4.0)); } #[test] - fn []() { + fn []() { + for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + let mut m = Matrix::<$d>::identity(); + let before = m; + assert_eq!( + m.set($d - 1, 0, value), + Err(LaError::non_finite_input_matrix($d - 1, 0)) + ); + assert_eq!(m, before); + } + } + + #[test] + fn []() { + for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + let mut rows = [[0.0f64; $d]; $d]; + rows[$d - 1][$d - 1] = value; + assert_eq!( + Matrix::<$d>::try_from_rows(rows), + Err(LaError::non_finite_input_matrix($d - 1, $d - 1)) + ); + } + + let mut rows = [[0.0f64; $d]; $d]; + rows[0][$d - 1] = f64::INFINITY; + rows[$d - 1][0] = f64::NAN; + assert_eq!( + Matrix::<$d>::try_from_rows(rows), + Err(LaError::non_finite_input_matrix(0, $d - 1)) + ); + } + + #[test] + fn []() { let z = Matrix::<$d>::zero(); assert_abs_diff_eq!(z.inf_norm().unwrap(), 0.0, epsilon = 0.0); @@ -1076,7 +1532,7 @@ mod tests { } #[test] - fn []() { + fn []() { let mut rows = [[0.0f64; $d]; $d]; // Row 0 has absolute row sum = D. @@ -1094,7 +1550,7 @@ mod tests { } #[test] - fn []() { + fn []() { let m = Matrix::<$d>::identity(); // Identity has ones on diag and zeros off diag. @@ -1129,29 +1585,15 @@ mod tests { } } - #[test] - fn []() { - let mut rows = [[1.0f64; $d]; $d]; - rows[$d - 1][0] = f64::NAN; - let raw = Matrix::<$d>::from_rows_unchecked(rows); - - assert_eq!( - raw.validate_finite(), - Err(LaError::NonFinite { - row: Some($d - 1), - col: 0, - }) - ); - } } }; } // Mirror delaunay-style multi-dimension tests. - gen_public_api_matrix_tests!(2); - gen_public_api_matrix_tests!(3); - gen_public_api_matrix_tests!(4); - gen_public_api_matrix_tests!(5); + gen_matrix_tests!(2); + gen_matrix_tests!(3); + gen_matrix_tests!(4); + gen_matrix_tests!(5); // === det_direct tests === @@ -1207,12 +1649,6 @@ mod tests { assert_eq!(m.det_direct(), Ok(Some(1.0e300))); } - #[test] - fn det_direct_d4_identity() { - let m = black_box(Matrix::<4>::identity()); - assert_abs_diff_eq!(m.det_direct().unwrap().unwrap(), 1.0, epsilon = 1e-15); - } - #[test] fn det_direct_d4_known_value() { // Diagonal matrix: det = product of diagonal entries. @@ -1258,40 +1694,19 @@ mod tests { assert_eq!(Matrix::<5>::identity().det_direct(), Ok(None)); } - #[test] - fn det_direct_d5_rejects_nonfinite_before_returning_none() { - let mut m = Matrix::<5>::identity(); - assert_eq!( - m.set(3, 4, f64::NAN), - Err(LaError::NonFinite { - row: Some(3), - col: 4, - }) - ); - } - #[test] fn det_direct_d8_returns_none() { assert_eq!(Matrix::<8>::zero().det_direct(), Ok(None)); } - #[test] - fn det_direct_rejects_nonfinite_entry_with_coordinates() { - assert_eq!( - Matrix::<3>::try_from_rows([[1.0, 0.0, 0.0], [0.0, f64::NAN, 0.0], [0.0, 0.0, 1.0]]), - Err(LaError::NonFinite { - row: Some(1), - col: 1, - }) - ); - } - #[test] fn det_direct_rejects_computed_overflow() { let m = Matrix::<2>::try_from_rows([[1e300, 0.0], [0.0, 1e300]]).unwrap(); assert_eq!( m.det_direct(), - Err(LaError::NonFinite { row: None, col: 0 }) + Err(LaError::non_finite_computation_scalar( + ArithmeticOperation::Determinant + )) ); } @@ -1305,7 +1720,13 @@ mod tests { [0.0, 0.0, 0.0, 0.0, 1.0e100], ]) .unwrap(); - assert_eq!(m.det(), Err(LaError::NonFinite { row: None, col: 3 })); + assert_eq!( + m.det(), + Err(LaError::non_finite_computation_step( + ArithmeticOperation::Determinant, + 4 + )) + ); } #[test] @@ -1321,10 +1742,11 @@ mod tests { assert_eq!( m.det(), - Err(LaError::NonFinite { - row: Some(1), - col: 1, - }) + Err(LaError::non_finite_computation_matrix( + ArithmeticOperation::LuFactorization, + 1, + 1 + )) ); } @@ -1332,7 +1754,10 @@ mod tests { ($d:literal) => { paste! { #[test] - #[allow(clippy::cast_precision_loss)] // r, c, D are tiny integers + #[expect( + clippy::cast_precision_loss, + reason = "r, c, and D are tiny test integers exactly representable as f64" + )] fn []() { // Well-conditioned matrix: diagonally dominant. let mut rows = [[0.0f64; $d]; $d]; @@ -1421,7 +1846,43 @@ mod tests { gen_det_singular_zero_matrix_tests!(2); gen_det_singular_zero_matrix_tests!(3); gen_det_singular_zero_matrix_tests!(4); - gen_det_singular_zero_matrix_tests!(5); + + #[test] + fn det_singular_zero_matrix_d5_preserves_lu_error() { + assert_eq!( + Matrix::<5>::zero().det(), + Err(LaError::singular_numerical( + 0, + FactorizationKind::Lu, + 0.0, + 0.0 + )) + ); + } + + #[test] + fn det_d5_does_not_turn_elimination_underflow_into_exact_zero() { + let min_subnormal = f64::from_bits(1); + let two_pow_800 = f64::from_bits(1823_u64 << 52); + let m = Matrix::<5>::try_from_rows([ + [2.0, min_subnormal, 0.0, 0.0, 0.0], + [1.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, two_pow_800, 0.0, 0.0], + [0.0, 0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 1.0], + ]) + .unwrap(); + + assert_eq!( + m.det(), + Err(LaError::singular_numerical( + 1, + FactorizationKind::Lu, + 0.0, + 0.0 + )) + ); + } #[test] fn det_d5_ignores_pivot_tolerance_for_tiny_nonsingular_matrix() { @@ -1440,44 +1901,28 @@ mod tests { assert_abs_diff_eq!(m.det().unwrap(), 1e-13, epsilon = 0.0); assert_eq!( m.lu(DEFAULT_SINGULAR_TOL), - Err(LaError::Singular { pivot_col: 0 }) - ); - } - - #[test] - fn det_returns_nonfinite_error_for_nan_d2() { - assert_eq!( - Matrix::<2>::try_from_rows([[f64::NAN, 1.0], [1.0, 1.0]]), - Err(LaError::NonFinite { - row: Some(0), - col: 0 - }) + Err(LaError::singular_numerical( + 0, + FactorizationKind::Lu, + 1e-13, + DEFAULT_SINGULAR_TOL.get() + )) ); } #[test] - fn det_returns_nonfinite_error_for_inf_d3() { - assert_eq!( - Matrix::<3>::try_from_rows([ - [f64::INFINITY, 0.0, 0.0], - [0.0, 1.0, 0.0], - [0.0, 0.0, 1.0] - ]), - Err(LaError::NonFinite { - row: Some(0), - col: 0 - }) - ); - } - - #[test] - fn det_returns_nonfinite_error_for_overflow_with_finite_entries() { + fn det_returns_non_finite_error_for_overflow_with_finite_entries() { // det_direct produces an overflowing f64 (1e300 * 1e300 = ∞) even - // though every matrix entry is finite. The entry scan in `det` - // falls through and returns NonFinite { row: None, col: 0 } to signal - // a computed overflow rather than a NaN/∞ input. + // though every matrix entry is finite. The entry scan in `det` + // falls through and reports a computed determinant overflow rather + // than a NaN/∞ input. let m = Matrix::<2>::try_from_rows([[1e300, 0.0], [0.0, 1e300]]).unwrap(); - assert_eq!(m.det(), Err(LaError::NonFinite { row: None, col: 0 })); + assert_eq!( + m.det(), + Err(LaError::non_finite_computation_scalar( + ArithmeticOperation::Determinant + )) + ); } // === det_direct const-evaluability tests (D = 2..=5) === @@ -1514,15 +1959,6 @@ mod tests { // === det_errbound tests (no `exact` feature required) === - #[test] - fn det_errbound_available_without_exact_feature() { - // Verify det_errbound is accessible without exact feature - let m = Matrix::<3>::identity(); - let bound = m.det_errbound().unwrap(); - assert!(bound.is_some()); - assert!(bound.unwrap() > 0.0); - } - #[test] fn det_errbound_matches_documented_coefficient_scale() { let m2 = Matrix::<2>::try_from_rows([[1.0, 2.0], [3.0, 4.0]]).unwrap(); @@ -1576,38 +2012,26 @@ mod tests { assert_eq!(Matrix::<5>::identity().det_errbound(), Ok(None)); } + #[cfg(feature = "exact")] #[test] - fn det_errbound_d1_rejects_nonfinite_even_with_zero_bound() { - assert_eq!( - Matrix::<1>::try_from_rows([[f64::INFINITY]]), - Err(LaError::NonFinite { - row: Some(0), - col: 0, - }) - ); - } + fn det_filter_wide_exponent_fast_path_matches_tracked_arithmetic() { + let threshold = f64::from_bits(1007_u64 << 52); // 2^-16 + let at_threshold = Matrix::<2>::try_from_rows([[threshold, 0.0], [0.0, 2.0]]).unwrap(); + assert!(at_threshold.det_filter_inputs_have_wide_exponent_margin()); + + let tracked = at_threshold + .det_filter_from_arithmetic( + at_threshold + .det_direct_arithmetic::() + .expect("D=2 has direct arithmetic"), + ) + .unwrap(); + assert_eq!(at_threshold.det_filter().unwrap(), tracked); - #[test] - fn det_errbound_d5_rejects_nonfinite_before_returning_none() { - let mut m = Matrix::<5>::identity(); - assert_eq!( - m.set(4, 1, f64::NAN), - Err(LaError::NonFinite { - row: Some(4), - col: 1, - }) - ); - } - - #[test] - fn det_errbound_rejects_nonfinite_entry_with_coordinates() { - assert_eq!( - Matrix::<2>::try_from_rows([[1.0, f64::INFINITY], [0.0, 1.0]]), - Err(LaError::NonFinite { - row: Some(0), - col: 1, - }) - ); + let just_below = f64::from_bits(threshold.to_bits() - 1); + let below_threshold = Matrix::<2>::try_from_rows([[just_below, 0.0], [0.0, 2.0]]).unwrap(); + assert!(!below_threshold.det_filter_inputs_have_wide_exponent_margin()); + assert!(!Matrix::<5>::identity().det_filter_inputs_have_wide_exponent_margin()); } #[test] @@ -1615,7 +2039,9 @@ mod tests { let m = Matrix::<2>::try_from_rows([[1e300, 0.0], [0.0, 1e300]]).unwrap(); assert_eq!( m.det_errbound(), - Err(LaError::NonFinite { row: None, col: 0 }) + Err(LaError::non_finite_computation_scalar( + ArithmeticOperation::DeterminantErrorBound + )) ); } @@ -1670,61 +2096,6 @@ mod tests { gen_inf_norm_const_eval_tests!(4); gen_inf_norm_const_eval_tests!(5); - // === inf_norm NaN / Inf rejection (regression tests for #85) === - - macro_rules! gen_inf_norm_nonfinite_tests { - ($d:literal) => { - paste! { - #[test] - fn []() { - // Before the fix, `NaN > max_row_sum` was always false, so a - // matrix full of NaN silently produced inf_norm == 0.0. - assert_eq!( - Matrix::<$d>::try_from_rows([[f64::NAN; $d]; $d]), - Err(LaError::NonFinite { - row: Some(0), - col: 0, - }) - ); - } - - #[test] - fn []() { - // A single NaN entry must surface with its source coordinates. - let mut rows = [[0.0f64; $d]; $d]; - rows[0][0] = f64::NAN; - rows[$d - 1][$d - 1] = 1.0; - assert_eq!( - Matrix::<$d>::try_from_rows(rows), - Err(LaError::NonFinite { - row: Some(0), - col: 0, - }) - ); - } - - #[test] - fn []() { - // Infinity entries should be rejected with their source coordinates. - let mut rows = [[0.0f64; $d]; $d]; - rows[0][0] = f64::INFINITY; - assert_eq!( - Matrix::<$d>::try_from_rows(rows), - Err(LaError::NonFinite { - row: Some(0), - col: 0, - }) - ); - } - } - }; - } - - gen_inf_norm_nonfinite_tests!(2); - gen_inf_norm_nonfinite_tests!(3); - gen_inf_norm_nonfinite_tests!(4); - gen_inf_norm_nonfinite_tests!(5); - // === is_symmetric / first_asymmetry (public LDLT preconditions helpers) === macro_rules! gen_is_symmetric_tests { @@ -1733,15 +2104,15 @@ mod tests { #[test] fn []() { let m = Matrix::<$d>::identity(); - assert!(m.is_symmetric(Tolerance::new(1e-12).unwrap()).unwrap()); - assert_eq!(m.first_asymmetry(Tolerance::new(1e-12).unwrap()).unwrap(), None); + assert!(m.is_symmetric(Tolerance::try_new(1e-12).unwrap()).unwrap()); + assert_eq!(m.first_asymmetry(Tolerance::try_new(1e-12).unwrap()).unwrap(), None); } #[test] fn []() { let m = Matrix::<$d>::zero(); - assert!(m.is_symmetric(Tolerance::new(1e-12).unwrap()).unwrap()); - assert_eq!(m.first_asymmetry(Tolerance::new(1e-12).unwrap()).unwrap(), None); + assert!(m.is_symmetric(Tolerance::try_new(1e-12).unwrap()).unwrap()); + assert_eq!(m.first_asymmetry(Tolerance::try_new(1e-12).unwrap()).unwrap(), None); } #[test] @@ -1750,7 +2121,10 @@ mod tests { let mut m = [[0.0f64; $d]; $d]; for r in 0..$d { for c in 0..$d { - #[allow(clippy::cast_precision_loss)] + #[expect( + clippy::cast_precision_loss, + reason = "matrix test indices are at most five and exactly representable as f64" + )] { m[r][c] = (r * $d + c) as f64; } @@ -1763,8 +2137,8 @@ mod tests { } } let a = Matrix::<$d>::try_from_rows(sym).unwrap(); - assert!(a.is_symmetric(Tolerance::new(1e-12).unwrap()).unwrap()); - assert_eq!(a.first_asymmetry(Tolerance::new(1e-12).unwrap()).unwrap(), None); + assert!(a.is_symmetric(Tolerance::try_new(1e-12).unwrap()).unwrap()); + assert_eq!(a.first_asymmetry(Tolerance::try_new(1e-12).unwrap()).unwrap(), None); } #[test] @@ -1777,31 +2151,13 @@ mod tests { rows[0][$d - 1] = 1.0; rows[$d - 1][0] = -1.0; // breaks symmetry let a = Matrix::<$d>::try_from_rows(rows).unwrap(); - assert!(!a.is_symmetric(Tolerance::new(1e-12).unwrap()).unwrap()); + assert!(!a.is_symmetric(Tolerance::try_new(1e-12).unwrap()).unwrap()); assert_eq!( - a.first_asymmetry(Tolerance::new(1e-12).unwrap()).unwrap(), + a.first_asymmetry(Tolerance::try_new(1e-12).unwrap()).unwrap(), Some((0, $d - 1)) ); } - #[test] - fn []() { - // A NaN off-diagonal is a stored non-finite matrix value, - // not merely a symmetry mismatch. - let mut rows = [[0.0f64; $d]; $d]; - for i in 0..$d { - rows[i][i] = 1.0; - } - rows[0][1] = f64::NAN; - rows[1][0] = f64::NAN; - assert_eq!( - Matrix::<$d>::try_from_rows(rows), - Err(LaError::NonFinite { - row: Some(0), - col: 1, - }) - ); - } } }; } @@ -1815,9 +2171,24 @@ mod tests { ($d:literal) => { paste! { #[test] - fn []() { - let ldlt = Matrix::<$d>::identity().ldlt(DEFAULT_SINGULAR_TOL).unwrap(); - assert_abs_diff_eq!(ldlt.det().unwrap(), 1.0, epsilon = 0.0); + fn []() { + // This exactly mirrored, strictly diagonally dominant + // tridiagonal matrix is positive definite. + let mut rows = [[0.0_f64; $d]; $d]; + for (index, row) in rows.iter_mut().enumerate() { + row[index] = 2.0; + } + for index in 1..$d { + rows[index - 1][index] = 0.5; + rows[index][index - 1] = 0.5; + } + + let matrix = Matrix::<$d>::try_from_rows(rows).unwrap(); + let symmetric = SymmetricMatrix::try_new(matrix).unwrap(); + assert_eq!(symmetric.into_matrix(), matrix); + + let ldlt = matrix.ldlt(DEFAULT_SINGULAR_TOL).unwrap(); + assert!(ldlt.det().unwrap() > 0.0); } #[test] @@ -1831,11 +2202,7 @@ mod tests { assert_eq!( Matrix::<$d>::try_from_rows(rows).and_then(SymmetricMatrix::try_new), - Err(LaError::Asymmetric { - row: 0, - col: $d - 1, - dim: $d, - }) + Err(LaError::asymmetric(0, $d - 1, $d, 1.0, -1.0, 0.0)) ); } } @@ -1847,17 +2214,6 @@ mod tests { gen_ldlt_symmetry_proof_tests!(4); gen_ldlt_symmetry_proof_tests!(5); - #[test] - fn matrix_ldlt_rejects_nonfinite_before_asymmetry() { - assert_eq!( - Matrix::<2>::try_from_rows([[1.0, f64::NAN], [0.0, 1.0]]), - Err(LaError::NonFinite { - row: Some(0), - col: 1, - }) - ); - } - #[test] fn symmetric_matrix_into_matrix_roundtrips_storage_internally() { let a = Matrix::<2>::try_from_rows([[2.0, 1.0], [1.0, 3.0]]).unwrap(); @@ -1866,14 +2222,44 @@ mod tests { assert_eq!(symmetric.into_matrix(), a); } + #[test] + fn matrix_ldlt_accepts_opposite_signed_zero_mirrors() { + let matrix = Matrix::<2>::try_from_rows([[2.0, 0.0], [-0.0, 2.0]]).unwrap(); + let ldlt = matrix.ldlt(DEFAULT_SINGULAR_TOL).unwrap(); + + assert_eq!(ldlt.det(), Ok(4.0)); + } + #[test] fn is_symmetric_tolerance_scales_with_inf_norm() { // Off-diagonal entries differ by 1e-6. With inf_norm ≈ 2e6, the // relative tolerance 1e-12 yields eps ≈ 2e-6, which accepts the gap; // a stricter tol of 1e-15 rejects it. let a = Matrix::<2>::try_from_rows([[1.0e6, 1.0e6 + 1.0e-6], [1.0e6, 1.0e6]]).unwrap(); - assert!(a.is_symmetric(Tolerance::new(1e-12).unwrap()).unwrap()); - assert!(!a.is_symmetric(Tolerance::new(1e-15).unwrap()).unwrap()); + assert!(a.is_symmetric(Tolerance::try_new(1e-12).unwrap()).unwrap()); + assert!(!a.is_symmetric(Tolerance::try_new(1e-15).unwrap()).unwrap()); + } + + #[test] + fn symmetry_epsilon_multiplies_after_row_sum_near_subnormal_boundary() { + let min_subnormal = f64::from_bits(1); + let mut rows = [[0.0; 5]; 5]; + let mut col = 0; + while col < 4 { + rows[0][col] = 0.4; + rows[col][0] = 0.4; + col += 1; + } + rows[0][4] = 2.0 * min_subnormal; + rows[4][0] = 0.0; + + let matrix = Matrix::<5>::try_from_rows(rows).unwrap(); + let tolerance = Tolerance::try_new(min_subnormal).unwrap(); + let expected_epsilon = tolerance.get() * matrix.inf_norm().unwrap().max(1.0); + + assert_eq!(expected_epsilon.to_bits(), 2); + assert_eq!(matrix.first_asymmetry(tolerance), Ok(None)); + assert_eq!(matrix.is_symmetric(tolerance), Ok(true)); } #[test] @@ -1882,33 +2268,12 @@ mod tests { let a = Matrix::<3>::try_from_rows([[1.0, 0.0, 2.0], [0.0, 1.0, 3.0], [-2.0, -3.0, 1.0]]) .unwrap(); assert_eq!( - a.first_asymmetry(Tolerance::new(1e-12).unwrap()).unwrap(), + a.first_asymmetry(Tolerance::try_new(1e-12).unwrap()) + .unwrap(), Some((0, 2)) ); } - #[test] - fn first_asymmetry_rejects_infinite_offdiagonal() { - assert_eq!( - Matrix::<2>::try_from_rows([[1.0, f64::INFINITY], [0.0, 1.0]]), - Err(LaError::NonFinite { - row: Some(0), - col: 1, - }) - ); - } - - #[test] - fn first_asymmetry_rejects_nan_diagonal() { - assert_eq!( - Matrix::<2>::try_from_rows([[f64::NAN, 1.0], [1.0, 1.0]]), - Err(LaError::NonFinite { - row: Some(0), - col: 0, - }) - ); - } - #[test] fn first_asymmetry_strict_tol_survives_row_sum_overflow() { let a = Matrix::<3>::try_from_rows([ @@ -1920,36 +2285,39 @@ mod tests { assert_eq!( a.inf_norm(), - Err(LaError::NonFinite { - row: Some(1), - col: 2 - }) + Err(LaError::non_finite_computation_matrix( + ArithmeticOperation::MatrixInfinityNorm, + 1, + 2 + )) ); assert_eq!( - a.first_asymmetry(Tolerance::new(0.0).unwrap()).unwrap(), + a.first_asymmetry(Tolerance::try_new(0.0).unwrap()).unwrap(), Some((0, 1)) ); - assert!(!a.is_symmetric(Tolerance::new(0.0).unwrap()).unwrap()); + assert!(!a.is_symmetric(Tolerance::try_new(0.0).unwrap()).unwrap()); } #[test] fn first_asymmetry_rejects_scaled_epsilon_overflow() { let a = Matrix::<2>::try_from_rows([[0.0, 0.0], [2.0, 1.0]]).unwrap(); - let tol = Tolerance::new(f64::MAX).unwrap(); + let tol = Tolerance::try_new(f64::MAX).unwrap(); assert_eq!( a.first_asymmetry(tol), - Err(LaError::NonFinite { - row: Some(1), - col: 0 - }) + Err(LaError::non_finite_computation_matrix( + ArithmeticOperation::SymmetryCheck, + 1, + 0 + )) ); assert_eq!( a.is_symmetric(tol), - Err(LaError::NonFinite { - row: Some(1), - col: 0 - }) + Err(LaError::non_finite_computation_matrix( + ArithmeticOperation::SymmetryCheck, + 1, + 0 + )) ); } @@ -1957,9 +2325,10 @@ mod tests { fn first_asymmetry_flags_overflowed_finite_difference() { let a = Matrix::<2>::try_from_rows([[1.0, f64::MAX], [-f64::MAX, 1.0]]).unwrap(); assert_eq!( - a.first_asymmetry(Tolerance::new(1e-12).unwrap()).unwrap(), + a.first_asymmetry(Tolerance::try_new(1e-12).unwrap()) + .unwrap(), Some((0, 1)) ); - assert!(!a.is_symmetric(Tolerance::new(1e-12).unwrap()).unwrap()); + assert!(!a.is_symmetric(Tolerance::try_new(1e-12).unwrap()).unwrap()); } } diff --git a/src/scaled_product.rs b/src/scaled_product.rs new file mode 100644 index 0000000..aa12b5f --- /dev/null +++ b/src/scaled_product.rs @@ -0,0 +1,352 @@ +#![forbid(unsafe_code)] +#![expect( + clippy::redundant_pub_crate, + reason = "the helper is shared by sibling modules through this private module" +)] + +//! Allocation-free scaled products for floating-point factor diagonals. + +const SIGN_MASK: u64 = 1_u64 << 63; +const FRACTION_BITS: u32 = 52; +const FRACTION_MASK: u64 = (1_u64 << FRACTION_BITS) - 1; +const EXPONENT_MASK: u64 = 0x7ff; +const EXPONENT_BIAS: i128 = 1023; +const MIN_NORMAL_EXPONENT: i128 = -1022; +const MIN_SUBNORMAL_EXPONENT: i128 = -1074; + +/// Result of multiplying one direct product step with its range proof attached. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) enum RangeCheckedProduct { + /// The value is finite and normal, or is an exact zero caused by a zero + /// operand, so direct accumulation may continue. + Safe(f64), + /// Direct multiplication overflowed or lost range through gradual + /// underflow, so all factors must be recomputed with scaling. + NeedsScaling, +} + +/// Multiply one direct product step and retain only values proven safe for +/// sequential accumulation. +/// +/// The common normal case needs only the result's binary exponent field. Zero +/// operands are checked only when that field is zero, preserving signed-zero +/// multiplication without treating underflow from two non-zero operands as an +/// exact zero. +#[inline] +pub(crate) const fn range_checked_product(accumulator: f64, factor: f64) -> RangeCheckedProduct { + let product = accumulator * factor; + let product_exponent = (product.to_bits() >> FRACTION_BITS) & EXPONENT_MASK; + // Subtracting one maps the valid normal fields 1..=0x7fe to + // 0..=0x7fd. Zero wraps high and 0x7ff maps to the exclusive upper + // bound, so the common normal path needs one unsigned comparison. + if product_exponent.wrapping_sub(1) < EXPONENT_MASK - 1 { + return RangeCheckedProduct::Safe(product); + } + + if product_exponent == 0 && (accumulator == 0.0 || factor == 0.0) { + RangeCheckedProduct::Safe(product) + } else { + RangeCheckedProduct::NeedsScaling + } +} + +/// One non-zero finite factor normalized as `mantissa × 2^exponent`. +#[derive(Clone, Copy)] +struct NormalizedFactor { + mantissa: f64, + exponent: i128, +} + +/// A finite product kept as `(-1)^negative × mantissa × 2^exponent`. +/// +/// Non-zero finite factors are normalized to `1 ≤ mantissa < 2` before +/// multiplication. Consequently, no intermediate mantissa multiplication can +/// underflow or overflow; only [`Self::finish`] decides whether the final +/// rounded result is finite. The most recent factor stays deferred so a final +/// subnormal product can be formed directly in the binary64 destination range +/// without first rounding it as a normal mantissa. +pub(crate) struct ScaledProduct { + mantissa: f64, + exponent: i128, + pending_factor: Option, + negative: bool, + zero: bool, + non_finite: bool, +} + +impl ScaledProduct { + /// Start an empty product with the requested initial sign. + #[inline] + pub(crate) const fn new(negative: bool) -> Self { + Self { + mantissa: 1.0, + exponent: 0, + pending_factor: None, + negative, + zero: false, + non_finite: false, + } + } + + /// Multiply by one factor while retaining a normalized mantissa. + #[inline] + pub(crate) const fn multiply(&mut self, factor: f64) { + let bits = factor.to_bits(); + self.negative ^= bits & SIGN_MASK != 0; + + let magnitude = bits & !SIGN_MASK; + let biased_exponent = (magnitude >> FRACTION_BITS) & EXPONENT_MASK; + let fraction = magnitude & FRACTION_MASK; + + if biased_exponent == EXPONENT_MASK { + self.non_finite = true; + return; + } + if biased_exponent == 0 && fraction == 0 { + self.zero = true; + return; + } + if self.zero { + return; + } + + let (factor_mantissa, factor_exponent) = if biased_exponent == 0 { + // A subnormal value is `fraction × 2^-1074`. Move its highest set + // bit to the binary64 hidden-bit position to obtain a mantissa in + // [1, 2), and compensate in the exponent. + let highest_bit = fraction.ilog2(); + let shift = FRACTION_BITS - highest_bit; + let significand = fraction << shift; + ( + f64::from_bits((1023_u64 << FRACTION_BITS) | (significand & FRACTION_MASK)), + (highest_bit as i128) + MIN_SUBNORMAL_EXPONENT, + ) + } else { + ( + f64::from_bits((1023_u64 << FRACTION_BITS) | fraction), + (biased_exponent as i128) - EXPONENT_BIAS, + ) + }; + + if let Some(pending) = self.pending_factor { + self.absorb_factor(pending); + } + self.pending_factor = Some(NormalizedFactor { + mantissa: factor_mantissa, + exponent: factor_exponent, + }); + } + + /// Fold one normalized factor into the running product. + #[inline] + const fn absorb_factor(&mut self, factor: NormalizedFactor) { + self.mantissa *= factor.mantissa; + self.exponent += factor.exponent; + + // Both operands were in [1, 2), so the exact product is below 4.0. + // Rounding can produce 4.0 at the upper boundary; normalize twice in + // that exceptional case to preserve `mantissa < 2`. + if self.mantissa >= 2.0 { + self.mantissa *= 0.5; + self.exponent += 1; + if self.mantissa >= 2.0 { + self.mantissa *= 0.5; + self.exponent += 1; + } + } + } + + /// Round the accumulated product to binary64. + /// + /// Returns `None` only when the accumulated result rounds outside the + /// finite binary64 range. Magnitudes below that range round to a signed + /// zero or subnormal value with round-to-nearest, ties-to-even semantics. + #[inline] + #[expect( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "the preceding bounds prove the normal and deferred-final biased exponents fit u64" + )] + pub(crate) const fn finish(mut self) -> Option { + if self.non_finite { + return None; + } + + let sign = if self.negative { SIGN_MASK } else { 0 }; + if self.zero { + return Some(f64::from_bits(sign)); + } + let Some(pending) = self.pending_factor else { + return Some(f64::from_bits(sign | (1023_u64 << FRACTION_BITS))); + }; + + let final_exponent = self.exponent + pending.exponent; + + // The product of two mantissas in [1, 2) is strictly below 4. Values + // below this exponent are therefore strictly below half the least + // subnormal and round to signed zero. + if final_exponent < MIN_SUBNORMAL_EXPONENT - 2 { + return Some(f64::from_bits(sign)); + } + + if final_exponent < MIN_NORMAL_EXPONENT { + // Scale both normal operands so their single multiplication lands + // directly in the final subnormal range. Multiplying normalized + // mantissas first would round once to 53 bits here and a second time + // to the much coarser subnormal grid below. + let left = f64::from_bits( + (1_u64 << FRACTION_BITS) | (self.mantissa.to_bits() & FRACTION_MASK), + ); + let right_biased_exponent = + (final_exponent - MIN_NORMAL_EXPONENT + EXPONENT_BIAS) as u64; + let right = f64::from_bits( + (right_biased_exponent << FRACTION_BITS) + | (pending.mantissa.to_bits() & FRACTION_MASK), + ); + let magnitude = left * right; + return Some(f64::from_bits(sign | magnitude.to_bits())); + } + + self.absorb_factor(pending); + if self.exponent > EXPONENT_BIAS { + return None; + } + + let mantissa_bits = self.mantissa.to_bits(); + let fraction = mantissa_bits & FRACTION_MASK; + let biased_exponent = (self.exponent + EXPONENT_BIAS) as u64; + Some(f64::from_bits( + sign | (biased_exponent << FRACTION_BITS) | fraction, + )) + } +} + +#[cfg(test)] +mod tests { + use super::{RangeCheckedProduct, SIGN_MASK, ScaledProduct, range_checked_product}; + + const TWO_NEG_800: f64 = f64::from_bits(223_u64 << 52); + const TWO_POS_800: f64 = f64::from_bits(1823_u64 << 52); + const SUBNORMAL_ROUNDING_LEFT: f64 = f64::from_bits(0x3cb2_e219_27ac_435a); + const SUBNORMAL_ROUNDING_RIGHT: f64 = f64::from_bits(0x0014_55e5_f80b_50eb); + + /// Return the exact bits produced by a two-factor scaled product. + fn scaled_product_bits(left: f64, right: f64) -> Option { + let mut product = ScaledProduct::new(false); + product.multiply(left); + product.multiply(right); + product.finish().map(f64::to_bits) + } + + #[test] + fn balanced_extreme_factors_do_not_depend_on_storage_order() { + let mut forward = ScaledProduct::new(false); + for factor in [TWO_NEG_800, TWO_NEG_800, TWO_POS_800, TWO_POS_800] { + forward.multiply(factor); + } + + let mut reverse = ScaledProduct::new(false); + for factor in [TWO_POS_800, TWO_POS_800, TWO_NEG_800, TWO_NEG_800] { + reverse.multiply(factor); + } + + assert_eq!(forward.finish(), Some(1.0)); + assert_eq!(reverse.finish(), Some(1.0)); + } + + #[test] + fn final_range_decision_distinguishes_underflow_and_overflow() { + let mut underflow = ScaledProduct::new(true); + underflow.multiply(TWO_NEG_800); + underflow.multiply(TWO_NEG_800); + assert_eq!(underflow.finish().map(f64::to_bits), Some(1_u64 << 63)); + + let mut overflow = ScaledProduct::new(false); + overflow.multiply(TWO_POS_800); + overflow.multiply(TWO_POS_800); + assert_eq!(overflow.finish(), None); + } + + #[test] + fn final_subnormal_product_is_rounded_once_in_const_evaluation() { + const POSITIVE: Option = { + let mut product = ScaledProduct::new(false); + product.multiply(SUBNORMAL_ROUNDING_LEFT); + product.multiply(SUBNORMAL_ROUNDING_RIGHT); + product.finish() + }; + const NEGATIVE: Option = { + let mut product = ScaledProduct::new(true); + product.multiply(SUBNORMAL_ROUNDING_LEFT); + product.multiply(SUBNORMAL_ROUNDING_RIGHT); + product.finish() + }; + + assert_eq!( + (SUBNORMAL_ROUNDING_LEFT * SUBNORMAL_ROUNDING_RIGHT).to_bits(), + 1 + ); + assert_eq!(POSITIVE.map(f64::to_bits), Some(1)); + assert_eq!(NEGATIVE.map(f64::to_bits), Some(SIGN_MASK | 1)); + } + + #[test] + fn final_subnormal_product_is_rounded_once_after_earlier_range_loss() { + let factors = [ + TWO_NEG_800, + TWO_NEG_800, + TWO_POS_800, + TWO_POS_800, + SUBNORMAL_ROUNDING_LEFT, + SUBNORMAL_ROUNDING_RIGHT, + ]; + let mut product = ScaledProduct::new(false); + for factor in factors { + product.multiply(factor); + } + + assert_eq!( + range_checked_product(TWO_NEG_800, TWO_NEG_800), + RangeCheckedProduct::NeedsScaling + ); + assert_eq!(product.finish().map(f64::to_bits), Some(1)); + } + + #[test] + fn final_subnormal_product_preserves_ties_to_even_at_range_boundaries() { + let least_subnormal = f64::from_bits(1); + let three_subnormals = f64::from_bits(3); + let largest_below_one = f64::from_bits(0x3fef_ffff_ffff_ffff); + + assert_eq!(scaled_product_bits(least_subnormal, 0.5), Some(0)); + assert_eq!(scaled_product_bits(three_subnormals, 0.5), Some(2)); + assert_eq!( + scaled_product_bits(f64::MIN_POSITIVE, largest_below_one), + Some(f64::MIN_POSITIVE.to_bits()) + ); + } + + #[test] + fn direct_product_range_check_distinguishes_exact_zero_from_range_loss() { + assert_eq!( + range_checked_product(1.5, 2.0), + RangeCheckedProduct::Safe(3.0) + ); + assert_eq!( + range_checked_product(-0.0, -2.0), + RangeCheckedProduct::Safe(0.0) + ); + assert_eq!( + range_checked_product(TWO_NEG_800, TWO_NEG_800), + RangeCheckedProduct::NeedsScaling + ); + assert_eq!( + range_checked_product(f64::MIN_POSITIVE, 0.5), + RangeCheckedProduct::NeedsScaling + ); + assert_eq!( + range_checked_product(TWO_POS_800, TWO_POS_800), + RangeCheckedProduct::NeedsScaling + ); + } +} diff --git a/src/tolerance.rs b/src/tolerance.rs index b85dfea..012a581 100644 --- a/src/tolerance.rs +++ b/src/tolerance.rs @@ -6,7 +6,7 @@ use crate::LaError; /// Finite, non-negative tolerance used by numerical predicates and factorizations. /// -/// Construct with [`Tolerance::new`] when accepting raw caller input. Once +/// Construct with [`Tolerance::try_new`] when accepting raw caller input. Once /// constructed, the stored value is guaranteed to be finite and `>= 0`, so /// downstream algorithms do not need to revalidate the tolerance. /// @@ -19,16 +19,14 @@ pub struct Tolerance { } impl Tolerance { - /// Construct a tolerance without checking the raw value. - /// - /// This crate-internal escape hatch is only for constants whose finite, - /// non-negative value is visible at the call site. Public callers should - /// use [`Tolerance::new`] so the returned value carries the validation - /// proof. - pub(crate) const fn new_unchecked(value: f64) -> Self { + /// Construct a tolerance for a finite, non-negative module-local literal. + const fn new_unchecked(value: f64) -> Self { Self { value } } + /// Exact zero tolerance for crate-internal algorithms. + pub(crate) const ZERO: Self = Self::new_unchecked(0.0); + /// Construct a finite, non-negative tolerance. /// /// # Examples @@ -36,17 +34,19 @@ impl Tolerance { /// use la_stack::prelude::*; /// /// # fn main() -> Result<(), LaError> { - /// let tol = Tolerance::new(1e-12)?; + /// let tol = Tolerance::try_new(1e-12)?; /// assert_eq!(tol.get(), 1e-12); /// # Ok(()) /// # } /// ``` /// /// # Errors - /// Returns [`LaError::InvalidTolerance`] when `value` is NaN, infinite, or - /// negative. + /// Returns [`LaError::InvalidTolerance`] with + /// [`crate::InvalidToleranceReason::NotFinite`] for NaN/infinity or + /// [`crate::InvalidToleranceReason::Negative`] for a finite negative + /// value. Both signed-zero representations are accepted and preserved. #[inline] - pub const fn new(value: f64) -> Result { + pub const fn try_new(value: f64) -> Result { if value >= 0.0 && value.is_finite() { Ok(Self::new_unchecked(value)) } else { @@ -61,7 +61,7 @@ impl Tolerance { /// use la_stack::prelude::*; /// /// # fn main() -> Result<(), LaError> { - /// let tol = Tolerance::new(0.0)?; + /// let tol = Tolerance::try_new(0.0)?; /// assert_eq!(tol.get(), 0.0); /// # Ok(()) /// # } @@ -92,14 +92,6 @@ impl Tolerance { /// ``` pub const DEFAULT_SINGULAR_TOL: Tolerance = Tolerance::new_unchecked(1e-12); -/// Relative tolerance used to validate matrices for LDLT factorization. -/// -/// This is crate-internal because LDLT callers provide the factorization -/// tolerance separately; the symmetry tolerance is a fixed domain check used to -/// parse a public [`Matrix`](crate::Matrix) into the internal symmetric proof -/// type before factorization. -pub const LDLT_SYMMETRY_REL_TOL: Tolerance = Tolerance::new_unchecked(1e-12); - #[cfg(test)] mod tests { use core::assert_matches; @@ -111,45 +103,53 @@ mod tests { #[test] fn default_singular_tol_is_expected() { assert_abs_diff_eq!(DEFAULT_SINGULAR_TOL.get(), 1e-12, epsilon = 0.0); + assert_eq!(Tolerance::ZERO.get().to_bits(), 0.0f64.to_bits()); } #[test] - fn tolerance_new_accepts_finite_non_negative_values() { + fn try_new_accepts_finite_non_negative_values() { assert_eq!( - Tolerance::new(0.0).unwrap().get().to_bits(), + Tolerance::try_new(0.0).unwrap().get().to_bits(), 0.0f64.to_bits() ); assert_eq!( - Tolerance::new(1e-12).unwrap().get().to_bits(), + Tolerance::try_new(1e-12).unwrap().get().to_bits(), 1e-12f64.to_bits() ); assert_eq!( - Tolerance::new(f64::MAX).unwrap().get().to_bits(), + Tolerance::try_new(f64::MAX).unwrap().get().to_bits(), f64::MAX.to_bits() ); } #[test] - fn tolerance_new_rejects_negative_nan_and_infinity() { - assert_eq!( - Tolerance::new(-1.0), - Err(LaError::InvalidTolerance { value: -1.0 }) - ); - assert_matches!( - Tolerance::new(f64::NAN), - Err(LaError::InvalidTolerance { value }) if value.is_nan() - ); - assert_eq!( - Tolerance::new(f64::INFINITY), - Err(LaError::InvalidTolerance { - value: f64::INFINITY, - }) - ); + fn try_new_accepts_and_preserves_negative_zero() { + let tolerance = Tolerance::try_new(-0.0).unwrap(); + + assert_eq!(tolerance.get().to_bits(), (-0.0f64).to_bits()); + } + + #[test] + fn try_new_rejects_negative_finite_values() { assert_eq!( - Tolerance::new(f64::NEG_INFINITY), + Tolerance::try_new(-1.0), Err(LaError::InvalidTolerance { - value: f64::NEG_INFINITY, + value: -1.0, + reason: crate::InvalidToleranceReason::Negative, }) ); } + + #[test] + fn try_new_rejects_non_finite_values_with_structured_reason() { + for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + assert_matches!( + Tolerance::try_new(value), + Err(LaError::InvalidTolerance { + value: observed, + reason: crate::InvalidToleranceReason::NotFinite, + }) if observed.to_bits() == value.to_bits() + ); + } + } } diff --git a/src/vector.rs b/src/vector.rs index 6ae6496..2649f47 100644 --- a/src/vector.rs +++ b/src/vector.rs @@ -4,7 +4,7 @@ use core::hint::cold_path; -use crate::LaError; +use crate::{ArithmeticOperation, LaError}; /// Finite fixed-size vector of length `D`, stored inline. /// @@ -62,30 +62,35 @@ impl Vector { /// `data` contains NaN or infinity. #[inline] pub const fn try_new(data: [f64; D]) -> Result { - if let Some(index) = Self::first_non_finite_entry_in(&data) { - Err(LaError::non_finite_at(index)) + if let Some(index) = Self::first_non_finite_entry(&data) { + Err(LaError::non_finite_input_vector(index)) } else { - Ok(Self::new_unchecked(data)) + Ok(Self { data }) } } - /// Construct a vector without checking that entries are finite. + /// Finalize vector storage produced by an arithmetic operation. /// - /// This crate-internal escape hatch is reserved for finite literals and - /// algorithm outputs whose finite invariant is visible at the call site. - /// Computed outputs must check their intermediates before using this - /// constructor to create an observable [`Vector`]. + /// Keeping this validation in the type that owns the finite-storage + /// invariant prevents a new computation path from accidentally turning raw + /// non-finite storage into a [`Vector`]. #[inline] - pub(crate) const fn new_unchecked(data: [f64; D]) -> Self { - Self { data } + pub(crate) const fn from_computation( + data: [f64; D], + operation: ArithmeticOperation, + ) -> Result { + if let Some(index) = Self::first_non_finite_entry(&data) { + Err(LaError::non_finite_computation_step(operation, index)) + } else { + Ok(Self { data }) + } } /// Return the first non-finite stored entry in index order. /// - /// Shared by the public raw-storage boundary and crate-internal reparsing - /// paths so both report the same first offending index with - /// [`LaError::NonFinite`]. - const fn first_non_finite_entry_in(data: &[f64; D]) -> Option { + /// Used by the public raw-storage boundary to report the first offending + /// index with [`LaError::NonFinite`]. + const fn first_non_finite_entry(data: &[f64; D]) -> Option { let mut i = 0; while i < D { if !data[i].is_finite() { @@ -107,7 +112,7 @@ impl Vector { /// ``` #[inline] pub const fn zero() -> Self { - Self::new_unchecked([0.0; D]) + Self { data: [0.0; D] } } /// Borrow the finite backing array. @@ -162,7 +167,7 @@ impl Vector { /// # fn main() -> Result<(), LaError> { /// let a = Vector::<3>::try_new([1.0, 2.0, 3.0])?; /// let b = Vector::<3>::try_new([-2.0, 0.5, 4.0])?; - /// assert!((a.dot(b)? - 11.0).abs() <= 1e-12); + /// assert!((a.dot(&b)? - 11.0).abs() <= 1e-12); /// # Ok(()) /// # } /// ``` @@ -171,7 +176,16 @@ impl Vector { /// Returns [`LaError::NonFinite`] when the accumulated dot product overflows /// to NaN or infinity. #[inline] - pub const fn dot(self, other: Self) -> Result { + pub const fn dot(&self, other: &Self) -> Result { + self.dot_with_operation(other, ArithmeticOperation::VectorDotProduct) + } + + /// Accumulate a dot product while retaining the public operation that owns it. + const fn dot_with_operation( + &self, + other: &Self, + operation: ArithmeticOperation, + ) -> Result { let lhs = self.as_array(); let rhs = other.as_array(); let mut acc = 0.0; @@ -180,7 +194,7 @@ impl Vector { acc = lhs[i].mul_add(rhs[i], acc); if !acc.is_finite() { cold_path(); - return Err(LaError::non_finite_at(i)); + return Err(LaError::non_finite_computation_step(operation, i)); } i += 1; } @@ -211,8 +225,8 @@ impl Vector { /// Returns [`LaError::NonFinite`] when the accumulated norm overflows to NaN /// or infinity. #[inline] - pub const fn norm2_sq(self) -> Result { - self.dot(self) + pub const fn norm2_sq(&self) -> Result { + self.dot_with_operation(self, ArithmeticOperation::VectorSquaredNorm) } } @@ -225,18 +239,18 @@ impl Default for Vector { #[cfg(test)] mod tests { - use super::*; - use core::hint::black_box; use approx::assert_abs_diff_eq; use pastey::paste; - macro_rules! gen_public_api_vector_tests { + use super::*; + + macro_rules! gen_vector_tests { ($d:literal) => { paste! { #[test] - fn []() { + fn []() { let arr = { let mut arr = [0.0f64; $d]; let values = [1.0f64, 2.0, 3.0, 4.0, 5.0]; @@ -259,7 +273,7 @@ mod tests { } #[test] - fn []() { + fn []() { let z = Vector::<$d>::zero(); for &x in z.as_array() { assert_abs_diff_eq!(x, 0.0, epsilon = 0.0); @@ -275,7 +289,7 @@ mod tests { } #[test] - fn []() { + fn []() { // Use black_box to avoid constant-folding/inlining eliminating the actual dot loop, // which can make coverage tools report the mul_add line as uncovered. @@ -320,50 +334,62 @@ mod tests { // Call via (black_boxed) fn pointers to discourage inlining, improving line-level coverage // attribution for the loop body. - let dot_fn: fn(Vector<$d>, Vector<$d>) -> Result = + let dot_fn: fn(&Vector<$d>, &Vector<$d>) -> Result = black_box(Vector::<$d>::dot); - let norm2_sq_fn: fn(Vector<$d>) -> Result = + let norm2_sq_fn: fn(&Vector<$d>) -> Result = black_box(Vector::<$d>::norm2_sq); assert_abs_diff_eq!( - dot_fn(black_box(a), black_box(b)).unwrap(), + dot_fn(black_box(&a), black_box(&b)).unwrap(), expected_dot, epsilon = 1e-14 ); assert_abs_diff_eq!( - norm2_sq_fn(black_box(a)).unwrap(), + norm2_sq_fn(black_box(&a)).unwrap(), expected_norm2_sq, epsilon = 1e-14 ); } #[test] - fn []() { - let mut a_arr = [1.0f64; $d]; - a_arr[$d - 1] = f64::NAN; + fn []() { + for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + let mut data = [1.0f64; $d]; + data[$d - 1] = value; + assert_eq!( + Vector::<$d>::try_new(data), + Err(LaError::non_finite_input_vector($d - 1)) + ); + } + let mut data = [1.0f64; $d]; + data[0] = f64::INFINITY; + data[$d - 1] = f64::NAN; assert_eq!( - Vector::<$d>::try_new(a_arr), - Err(LaError::NonFinite { - row: None, - col: $d - 1, - }) + Vector::<$d>::try_new(data), + Err(LaError::non_finite_input_vector(0)) ); } #[test] - fn []() { - let mut b_arr = [1.0f64; $d]; - b_arr[0] = f64::INFINITY; + fn []() { + let mut data = [1.0f64; $d]; + data[$d - 1] = f64::INFINITY; assert_eq!( - Vector::<$d>::try_new(b_arr), - Err(LaError::NonFinite { row: None, col: 0 }) + Vector::<$d>::from_computation( + data, + ArithmeticOperation::LuSolve, + ), + Err(LaError::non_finite_computation_step( + ArithmeticOperation::LuSolve, + $d - 1, + )) ); } #[test] - fn []() { + fn []() { let mut a_arr = [1.0f64; $d]; a_arr[0] = f64::MAX; let a = Vector::<$d>::new(a_arr); @@ -372,8 +398,20 @@ mod tests { b_arr[0] = 2.0; let b = Vector::<$d>::new(b_arr); - assert_eq!(a.dot(b), Err(LaError::NonFinite { row: None, col: 0 })); - assert_eq!(a.norm2_sq(), Err(LaError::NonFinite { row: None, col: 0 })); + assert_eq!( + a.dot(&b), + Err(LaError::non_finite_computation_step( + ArithmeticOperation::VectorDotProduct, + 0, + )) + ); + assert_eq!( + a.norm2_sq(), + Err(LaError::non_finite_computation_step( + ArithmeticOperation::VectorSquaredNorm, + 0, + )) + ); } } @@ -381,8 +419,8 @@ mod tests { } // Mirror delaunay-style multi-dimension tests. - gen_public_api_vector_tests!(2); - gen_public_api_vector_tests!(3); - gen_public_api_vector_tests!(4); - gen_public_api_vector_tests!(5); + gen_vector_tests!(2); + gen_vector_tests!(3); + gen_vector_tests!(4); + gen_vector_tests!(5); } diff --git a/tests/exact_bench_config.rs b/tests/exact_bench_config.rs index 9f94634..93c6668 100644 --- a/tests/exact_bench_config.rs +++ b/tests/exact_bench_config.rs @@ -4,15 +4,39 @@ #![forbid(unsafe_code)] #[path = "../benches/common/exact.rs"] -mod exact_bench; +pub mod exact_bench; use core::array::from_fn; +use std::error::Error; -use exact_bench::{ExactBenchConfigError, I16Range, SplitMix64}; +use exact_bench::{ + ExactBenchConfigError, ExactInput, I16Range, SplitMix64, ValidatedExactInput, hilbert_input, + large_entries_3x3_input, make_matrix_rows, make_random_input_corpus, make_vector_array, + near_singular_3x3_input, validate_exact_fixture, +}; +use la_stack::{Matrix, Vector}; +use pastey::paste; + +fn baseline_input() -> ExactInput { + let Ok(matrix) = Matrix::::try_from_rows(make_matrix_rows::()) else { + panic!("baseline benchmark matrix should be finite"); + }; + let Ok(rhs) = Vector::::try_new(make_vector_array::()) else { + panic!("baseline benchmark RHS should be finite"); + }; + ExactInput { matrix, rhs } +} + +fn validate_baseline_and_random_corpus() { + let _ = validate_exact_fixture(baseline_input::()); + for input in make_random_input_corpus::() { + let _ = validate_exact_fixture(input); + } +} #[test] fn i16_range_rejects_unordered_bounds() { - let Err(err) = I16Range::new(5, 4) else { + let Err(err) = I16Range::try_new(5, 4) else { panic!("unordered range should be rejected"); }; assert_eq!( @@ -21,18 +45,10 @@ fn i16_range_rejects_unordered_bounds() { ); } -#[test] -fn empty_corpus_error_message_names_requirement() { - assert_eq!( - ExactBenchConfigError::EmptyCorpus.to_string(), - "random input corpus must be nonempty" - ); -} - #[test] fn exact_bench_config_error_is_std_error() { let err = ExactBenchConfigError::UnorderedRange { min: 5, max: 4 }; - let as_error: &dyn std::error::Error = &err; + let as_error: &dyn Error = &err; assert_eq!( as_error.to_string(), @@ -43,7 +59,7 @@ fn exact_bench_config_error_is_std_error() { #[test] fn i16_range_single_value_always_draws_that_value() { - let Ok(range) = I16Range::new(7, 7) else { + let Ok(range) = I16Range::try_new(7, 7) else { panic!("single-value range should be valid"); }; let mut rng = SplitMix64::new(0); @@ -55,7 +71,7 @@ fn i16_range_single_value_always_draws_that_value() { #[test] fn i16_range_draws_stay_inside_inclusive_bounds() { - let Ok(range) = I16Range::new(-10, 10) else { + let Ok(range) = I16Range::try_new(-10, 10) else { panic!("ordered range should be valid"); }; let mut rng = SplitMix64::new(0xCAFE_F00D); @@ -65,9 +81,23 @@ fn i16_range_draws_stay_inside_inclusive_bounds() { } } +#[test] +fn i16_range_full_domain_reaches_both_endpoints() { + let Ok(range) = I16Range::try_new(i16::MIN, i16::MAX) else { + panic!("the full i16 domain should be a valid range"); + }; + + // These seeds make the first modulo offsets 0 and 65,535, respectively. + let mut minimum_rng = SplitMix64::new(59_587); + let mut maximum_rng = SplitMix64::new(16_165); + + assert_eq!(minimum_rng.next_i16(range), i16::MIN); + assert_eq!(maximum_rng.next_i16(range), i16::MAX); +} + #[test] fn splitmix64_sequence_is_stable_for_benchmark_seed() { - let Ok(range) = I16Range::new(-10, 10) else { + let Ok(range) = I16Range::try_new(-10, 10) else { panic!("ordered range should be valid"); }; let mut rng = SplitMix64::new(0xCAFE_F00D); @@ -79,3 +109,51 @@ fn splitmix64_sequence_is_stable_for_benchmark_seed() { [-8, 4, -3, -8, 8, -4, 4, -6, -9, -8, 6, 1, 0, 3, -8, -1] ); } + +macro_rules! gen_exact_benchmark_fixture_tests { + ($d:literal) => { + paste! { + #[test] + fn []() { + validate_baseline_and_random_corpus::<$d>(); + } + } + }; +} + +gen_exact_benchmark_fixture_tests!(2); +gen_exact_benchmark_fixture_tests!(3); +gen_exact_benchmark_fixture_tests!(4); +gen_exact_benchmark_fixture_tests!(5); + +#[test] +fn exact_adversarial_benchmark_fixtures_are_correct() { + for input in [near_singular_3x3_input(), large_entries_3x3_input()] { + let _ = validate_exact_fixture(input); + } + let _ = validate_exact_fixture(hilbert_input::<4>()); + let _ = validate_exact_fixture(hilbert_input::<5>()); +} + +#[test] +fn validated_fixture_exposes_only_checked_inputs() { + let raw = baseline_input::<3>(); + let expected_matrix = raw.matrix; + let expected_rhs = raw.rhs; + + let validated: ValidatedExactInput<3> = validate_exact_fixture(raw); + + assert_eq!(validated.matrix(), &expected_matrix); + assert_eq!(validated.rhs(), expected_rhs); +} + +#[test] +#[should_panic(expected = "exact solve oracle check failed")] +fn fixture_validation_rejects_singular_solve_input() { + let matrix = Matrix::<2>::try_from_rows([[1.0, 2.0], [2.0, 4.0]]) + .unwrap_or_else(|error| panic!("singular fixture must still be finite: {error}")); + let rhs = Vector::<2>::try_new([1.0, 2.0]) + .unwrap_or_else(|error| panic!("singular fixture RHS must be finite: {error}")); + + let _ = validate_exact_fixture(ExactInput { matrix, rhs }); +} diff --git a/tests/exact_conversion_boundaries.rs b/tests/exact_conversion_boundaries.rs new file mode 100644 index 0000000..48e8c0e --- /dev/null +++ b/tests/exact_conversion_boundaries.rs @@ -0,0 +1,237 @@ +//! Independent bit-pattern coverage for exact-to-binary64 conversion boundaries. + +#![forbid(unsafe_code)] +#![cfg(feature = "exact")] + +use la_stack::prelude::*; + +const POSITIVE_ZERO_BITS: u64 = 0; +const NEGATIVE_ZERO_BITS: u64 = 1_u64 << 63; +const BELOW_OVERFLOW_MIDPOINT_INCREMENT: f64 = f64::from_bits(1992_u64 << 52); // 2^969 +const AT_OVERFLOW_MIDPOINT_INCREMENT: f64 = f64::from_bits(1993_u64 << 52); // 2^970 + +fn assert_unrepresentable( + result: &Result, + index: Option, + reason: UnrepresentableReason, +) { + assert!(matches!( + result, + Err(LaError::Unrepresentable { + index: actual_index, + reason: actual_reason, + .. + }) if *actual_index == index && *actual_reason == reason + )); +} + +fn diagonal(values: [f64; D]) -> Matrix { + let mut rows = [[0.0; D]; D]; + for (index, value) in values.into_iter().enumerate() { + rows[index][index] = value; + } + Matrix::try_from_rows(rows).expect("diagonal fixture is finite") +} + +fn determinant_near_overflow(increment: f64, negative: bool) -> Matrix<2> { + let rows = if negative { + [[-f64::MAX, increment], [1.0, 1.0]] + } else { + [[f64::MAX, -increment], [1.0, 1.0]] + }; + Matrix::try_from_rows(rows).expect("overflow-boundary fixture is finite") +} + +fn raw_rational(numerator: i32, denominator: i32) -> BigRational { + BigRational::new_raw(BigInt::from(numerator), BigInt::from(denominator)) +} + +#[test] +fn raw_rational_conversion_uses_the_mathematical_quotient() { + let cases = [ + (raw_rational(1, -2), (-0.5_f64).to_bits()), + (raw_rational(-1, -2), 0.5_f64.to_bits()), + (raw_rational(3, 6), 0.5_f64.to_bits()), + ]; + + for (exact, expected_bits) in &cases { + assert_eq!(exact.try_to_f64().unwrap().to_bits(), *expected_bits); + assert_eq!(exact.to_rounded_f64().unwrap().to_bits(), *expected_bits); + } + + let exact = cases.map(|(value, _)| value); + let strict = exact.try_to_f64().unwrap().into_array().map(f64::to_bits); + let rounded = exact + .to_rounded_f64() + .unwrap() + .into_array() + .map(f64::to_bits); + let expected = [(-0.5_f64).to_bits(), 0.5_f64.to_bits(), 0.5_f64.to_bits()]; + assert_eq!(strict, expected); + assert_eq!(rounded, expected); +} + +#[test] +fn raw_zero_denominator_is_not_finite_and_arrays_report_its_first_index() { + for exact in [raw_rational(0, 0), raw_rational(1, 0)] { + assert_unrepresentable(&exact.try_to_f64(), None, UnrepresentableReason::NotFinite); + assert_unrepresentable( + &exact.to_rounded_f64(), + None, + UnrepresentableReason::NotFinite, + ); + } + + let exact = [ + raw_rational(1, -2), + raw_rational(-1, -2), + raw_rational(3, 6), + raw_rational(0, 0), + raw_rational(1, 0), + ]; + assert_unrepresentable( + &exact.try_to_f64(), + Some(3), + UnrepresentableReason::NotFinite, + ); + assert_unrepresentable( + &exact.to_rounded_f64(), + Some(3), + UnrepresentableReason::NotFinite, + ); +} + +#[test] +fn d0_exact_strict_and_rounded_outputs_follow_empty_product_conventions() { + let matrix = Matrix::<0>::zero(); + let rhs = Vector::<0>::zero(); + + let determinant = matrix.det_exact().unwrap(); + assert_eq!(determinant.try_to_f64(), Ok(1.0)); + assert_eq!(determinant.to_rounded_f64(), Ok(1.0)); + assert_eq!(matrix.det_exact_f64(), Ok(1.0)); + assert_eq!(matrix.det_exact_rounded_f64(), Ok(1.0)); + + let solution = matrix.solve_exact(rhs).unwrap(); + assert!(solution.try_to_f64().unwrap().as_array().is_empty()); + assert!(solution.to_rounded_f64().unwrap().as_array().is_empty()); + assert!(matrix.solve_exact_f64(rhs).unwrap().as_array().is_empty()); + assert!( + matrix + .solve_exact_rounded_f64(rhs) + .unwrap() + .as_array() + .is_empty() + ); +} + +#[test] +fn overflow_midpoint_classification_is_symmetric_and_bit_exact() { + for negative in [false, true] { + let below = determinant_near_overflow(BELOW_OVERFLOW_MIDPOINT_INCREMENT, negative); + let exact_below = below.det_exact().unwrap(); + assert_unrepresentable( + &exact_below.try_to_f64(), + None, + UnrepresentableReason::RequiresRounding, + ); + let expected = if negative { -f64::MAX } else { f64::MAX }; + assert_eq!( + exact_below.to_rounded_f64().unwrap().to_bits(), + expected.to_bits() + ); + assert_eq!( + below.det_exact_rounded_f64().unwrap().to_bits(), + expected.to_bits() + ); + + let midpoint = determinant_near_overflow(AT_OVERFLOW_MIDPOINT_INCREMENT, negative); + let exact_midpoint = midpoint.det_exact().unwrap(); + assert_unrepresentable( + &exact_midpoint.try_to_f64(), + None, + UnrepresentableReason::NotFinite, + ); + assert_unrepresentable( + &exact_midpoint.to_rounded_f64(), + None, + UnrepresentableReason::NotFinite, + ); + assert_unrepresentable( + &midpoint.det_exact_rounded_f64(), + None, + UnrepresentableReason::NotFinite, + ); + } +} + +#[test] +fn subnormal_halfway_cases_round_to_even_with_signed_zero() { + let tiny = f64::from_bits(1); + let cases = [ + ([tiny, 0.5], POSITIVE_ZERO_BITS), + ([-tiny, 0.5], NEGATIVE_ZERO_BITS), + ([f64::from_bits(3), 0.5], 2), + ([-f64::from_bits(3), 0.5], NEGATIVE_ZERO_BITS | 2), + ]; + + for (diagonal_values, expected_bits) in cases { + let matrix = diagonal(diagonal_values); + let exact = matrix.det_exact().unwrap(); + assert_unrepresentable( + &exact.try_to_f64(), + None, + UnrepresentableReason::RequiresRounding, + ); + assert_eq!(exact.to_rounded_f64().unwrap().to_bits(), expected_bits); + assert_eq!( + matrix.det_exact_rounded_f64().unwrap().to_bits(), + expected_bits + ); + } +} + +#[test] +fn solution_conversion_preserves_first_index_and_negative_underflow_zero() { + let tiny = f64::from_bits(1); + let matrix = diagonal([1.0, 2.0]); + let rhs = Vector::<2>::try_new([0.0, -tiny]).unwrap(); + let exact = matrix.solve_exact(rhs).unwrap(); + + assert_unrepresentable( + &exact.try_to_f64(), + Some(1), + UnrepresentableReason::RequiresRounding, + ); + let rounded = exact.to_rounded_f64().unwrap().into_array(); + assert_eq!(rounded[0].to_bits(), POSITIVE_ZERO_BITS); + assert_eq!(rounded[1].to_bits(), NEGATIVE_ZERO_BITS); + + assert_unrepresentable( + &matrix.solve_exact_f64(rhs), + Some(1), + UnrepresentableReason::RequiresRounding, + ); + assert_eq!( + matrix.solve_exact_rounded_f64(rhs).unwrap().as_array()[1].to_bits(), + NEGATIVE_ZERO_BITS + ); +} + +#[test] +fn d5_exact_sign_and_conversions_handle_final_underflow() { + let tiny = f64::from_bits(1); + let matrix = diagonal([tiny, tiny, 1.0, 1.0, 1.0]); + + assert_eq!(matrix.det_sign_exact(), DeterminantSign::Positive); + let exact = matrix.det_exact().unwrap(); + assert_unrepresentable( + &exact.try_to_f64(), + None, + UnrepresentableReason::RequiresRounding, + ); + assert_eq!( + exact.to_rounded_f64().unwrap().to_bits(), + POSITIVE_ZERO_BITS + ); +} diff --git a/tests/prelude_exports.rs b/tests/prelude_exports.rs new file mode 100644 index 0000000..59c2aba --- /dev/null +++ b/tests/prelude_exports.rs @@ -0,0 +1,90 @@ +#![forbid(unsafe_code)] + +//! Downstream-style contract tests for the public prelude and explicit root exports. + +use core::assert_matches; + +use approx::assert_abs_diff_eq; + +use la_stack::prelude::*; +use la_stack::{ERR_COEFF_2, ERR_COEFF_3, ERR_COEFF_4}; + +const _: [f64; 3] = [ERR_COEFF_2, ERR_COEFF_3, ERR_COEFF_4]; + +#[test] +fn common_prelude_supports_downstream_composition() -> Result<(), LaError> { + let matrix = Matrix::<2>::identity(); + let vector = Vector::<2>::try_new([1.0, 2.0])?; + let tolerance = Tolerance::try_new(0.0)?; + + let lu: Lu<2> = matrix.lu(tolerance)?; + let ldlt: Ldlt<2> = matrix.ldlt(tolerance)?; + let lu_solution = lu.solve(vector)?.into_array(); + let ldlt_solution = ldlt.solve(vector)?.into_array(); + for (actual, expected) in lu_solution.into_iter().zip([1.0, 2.0]) { + assert_abs_diff_eq!(actual, expected, epsilon = 1e-12); + } + for (actual, expected) in ldlt_solution.into_iter().zip([1.0, 2.0]) { + assert_abs_diff_eq!(actual, expected, epsilon = 1e-12); + } + + assert_abs_diff_eq!(DEFAULT_SINGULAR_TOL.get(), 1e-12, epsilon = 0.0); + assert_eq!(ArithmeticOperation::LuSolve.to_string(), "LU solve"); + assert_eq!(FactorizationKind::Lu.to_string(), "LU"); + + assert_matches!( + LaError::invalid_tolerance(-1.0), + LaError::InvalidTolerance { + reason: InvalidToleranceReason::Negative, + .. + } + ); + assert_matches!( + LaError::non_finite_input_vector(1), + LaError::NonFinite { + location: NonFiniteLocation::VectorEntry { index: 1, .. }, + origin: NonFiniteOrigin::Input, + .. + } + ); + assert_matches!( + LaError::not_positive_semidefinite_negative(0, -1.0), + LaError::NotPositiveSemidefinite { + violation: PositiveSemidefiniteViolation::NegativePivot { value: -1.0, .. }, + .. + } + ); + assert_matches!( + LaError::singular_exact(0), + LaError::Singular { + reason: SingularityReason::Exact, + .. + } + ); + assert!( + LaError::unrepresentable(None, UnrepresentableReason::RequiresRounding).requires_rounding() + ); + + let dispatched = try_with_stack_matrix!(2usize, |mut dynamic| -> Result { + dynamic.set(0, 0, 1.0)?; + dynamic.set(1, 1, 1.0)?; + dynamic.det() + })?; + assert_abs_diff_eq!(dispatched, 1.0, epsilon = 0.0); + assert_eq!(MAX_STACK_MATRIX_DISPATCH_DIM, 7); + + Ok(()) +} + +#[cfg(feature = "exact")] +#[test] +fn exact_prelude_supports_downstream_composition() { + let half = BigRational::new(BigInt::from(1), BigInt::from(2)); + let two = BigRational::from_integer(BigInt::from(2)); + + assert_eq!(BigRational::from_f64(0.5).as_ref(), Some(&half)); + assert!(half.is_positive()); + assert_eq!(half.to_f64(), Some(0.5)); + assert_eq!(two.to_i64(), Some(2)); + assert_eq!(DeterminantSign::Positive.as_i8(), 1); +} diff --git a/tests/proptest_exact.rs b/tests/proptest_exact.rs index 9f7db4d..1cd72e8 100644 --- a/tests/proptest_exact.rs +++ b/tests/proptest_exact.rs @@ -1,3 +1,5 @@ +#![forbid(unsafe_code)] + //! Property-based tests for the exact-arithmetic APIs //! (requires `exact` feature). //! @@ -5,17 +7,19 @@ //! - `det_sign_exact` on diagonal and full small-integer matrices //! - `det_exact` on full small-integer matrices against an independent //! `BigRational` Leibniz-expansion oracle +//! - determinant sign and error-bound filtering across independently mixed +//! binary64 exponent regimes //! - `solve_exact` round-trip with integer inputs (`A · x0` in f64 is //! exact for small integers, so `solve(A, A · x0) == x0`) //! - `solve_exact` residual property (`A · solve(A, b) == b` in -//! `BigRational` arithmetic) on random RHS vectors +//! `BigRational` arithmetic) on random integer and mixed-exponent inputs #![cfg(feature = "exact")] use std::array::from_fn; use pastey::paste; -use proptest::prelude::*; +use proptest::{array, prelude::*}; use la_stack::prelude::*; @@ -37,6 +41,25 @@ fn small_int_f64() -> impl Strategy { (-10i32..=10i32).prop_map(f64::from) } +/// Construct the exactly representable finite binary64 value `2^exponent`. +/// +/// Building subnormal powers from their bit representation avoids the +/// intermediate underflow that `f64::powi` can incur for exponents below +/// -1023 on some targets. +fn exact_power_of_two(exponent: i32) -> f64 { + assert!( + (-1074..=1023).contains(&exponent), + "binary64 power-of-two exponent must be finite" + ); + if exponent < -1022 { + f64::from_bits(1_u64 << (exponent + 1074).cast_unsigned()) + } else { + let biased_exponent = + u64::try_from(exponent + 1023).expect("normal exponent bias is non-negative"); + f64::from_bits(biased_exponent << 52) + } +} + fn mixed_scale_finite_f64() -> impl Strategy { prop_oneof![ Just(0.0), @@ -54,16 +77,65 @@ fn mixed_scale_finite_f64() -> impl Strategy { ] } +/// Finite binary64 values whose exponent regime is selected independently for +/// each generated entry. +/// +/// Non-zero values are a small integer coefficient times an exact power of +/// two. The ranges deliberately cover subnormal, tiny normal, ordinary, and +/// large finite values without approaching overflow during value generation. +fn mixed_exponent_finite_f64() -> impl Strategy { + prop_oneof![ + 1 => Just(0.0), + 1 => Just(-0.0), + 3 => (small_nonzero_int_f64(), -1074i32..=-1023i32) + .prop_map(|(coefficient, exponent)| coefficient * exact_power_of_two(exponent)), + 3 => (small_nonzero_int_f64(), -1022i32..=-900i32) + .prop_map(|(coefficient, exponent)| coefficient * exact_power_of_two(exponent)), + 4 => (small_nonzero_int_f64(), -20i32..=20i32) + .prop_map(|(coefficient, exponent)| coefficient * exact_power_of_two(exponent)), + 3 => (small_nonzero_int_f64(), 900i32..=1018i32) + .prop_map(|(coefficient, exponent)| coefficient * exact_power_of_two(exponent)), + ] +} + +/// Independent exact power-of-two row scales used by the dense solve corpus. +/// +/// Exponents leave ample upper-range headroom for the small integer diagonal +/// while spanning subnormal, tiny normal, ordinary, and large finite rows. +fn solve_row_exponent() -> impl Strategy { + prop_oneof![ + 2 => -1074i32..=-1022i32, + 3 => -900i32..=-300i32, + 3 => -20i32..=20i32, + 3 => 300i32..=900i32, + ] +} + +fn is_unrepresentable( + result: &Result, + expected_index: Option, + expected_reason: UnrepresentableReason, +) -> bool { + matches!( + result, + Err(LaError::Unrepresentable { index, reason, .. }) + if *index == expected_index && *reason == expected_reason + ) +} + /// Multiply `A · x` entirely in `BigRational`, lifting each f64 matrix /// entry via `BigRational::from_f64`. Used by residual assertions. /// -/// All f64 inputs in the proptests are small exact integers, so -/// `from_f64` always succeeds with an exact rational reconstruction. -fn bigrational_matvec(a: &[[f64; D]; D], x: &[BigRational; D]) -> [BigRational; D] { +/// Every accepted finite f64 has an exact rational reconstruction, including +/// subnormal values and values with large binary exponents. +fn big_rational_matvec( + a: &[[f64; D]; D], + x: &[BigRational; D], +) -> [BigRational; D] { from_fn(|i| { let mut sum = BigRational::from_integer(BigInt::from(0)); for (aij, xj) in a[i].iter().zip(x.iter()) { - let entry = BigRational::from_f64(*aij).expect("small int fits in BigRational"); + let entry = BigRational::from_f64(*aij).expect("finite f64 converts exactly"); sum += entry * xj; } sum @@ -75,14 +147,14 @@ fn bigrational_matvec(a: &[[f64; D]; D], x: &[BigRational; D]) - /// This is intentionally independent from the production Bareiss core. It is /// factorial-time, but the proptests only use D=2..=5, so it stays tiny while /// giving `det_exact` a separate dense-matrix oracle. -fn bigrational_det_leibniz(a: &[[f64; D]; D]) -> BigRational { +fn big_rational_det_leibniz(a: &[[f64; D]; D]) -> BigRational { let mut det = BigRational::from_integer(BigInt::from(0)); let mut perm: [usize; D] = from_fn(|i| i); loop { let mut term = BigRational::from_integer(BigInt::from(1)); for (row, &col) in perm.iter().enumerate() { - let entry = BigRational::from_f64(a[row][col]).expect("small int fits in BigRational"); + let entry = BigRational::from_f64(a[row][col]).expect("finite f64 converts exactly"); term *= entry; } @@ -100,6 +172,16 @@ fn bigrational_det_leibniz(a: &[[f64; D]; D]) -> BigRational { det } +fn determinant_sign(value: &BigRational) -> DeterminantSign { + if value.is_positive() { + DeterminantSign::Positive + } else if value.is_negative() { + DeterminantSign::Negative + } else { + DeterminantSign::Zero + } +} + fn permutation_is_even(perm: &[usize]) -> bool { let mut inversions = 0usize; for i in 0..perm.len() { @@ -157,7 +239,8 @@ fn make_diagonally_dominant( let mut rows = offdiag; // Must track `small_int_f64`'s `max_off_diag = 10`: `D · 10 + 1` // strictly dominates the worst-case row sum of `10 (D - 1)`. - let shift = f64::from(u8::try_from(D).unwrap_or(u8::MAX)).mul_add(10.0, 1.0); + let dimension = u32::try_from(D).expect("proptest matrix dimension must fit in u32"); + let shift = f64::from(dimension).mul_add(10.0, 1.0); for i in 0..D { rows[i][i] = if diag[i] >= 0.0 { diag[i] + shift @@ -168,6 +251,70 @@ fn make_diagonally_dominant( rows } +/// Build a dense, strictly diagonally-dominant matrix, then scale each row by +/// an independently generated exact power of two. +/// +/// Every off-diagonal entry is a non-zero small integer. Before scaling, the +/// diagonal is one greater than the row's off-diagonal absolute sum, so +/// `|A[i][i]| > Σ_{j≠i} |A[i][j]|`. Positive row scaling preserves that +/// inequality and therefore invertibility (Levy–Desplanques). The selected +/// exponent ranges keep every scaled entry finite and exactly representable, +/// including at the subnormal boundary. +fn make_dense_mixed_exponent_matrix( + offdiag: [[f64; D]; D], + row_exponents: [i32; D], +) -> [[f64; D]; D] { + let mut rows = offdiag; + for i in 0..D { + let offdiag_abs_sum = rows[i] + .iter() + .enumerate() + .filter(|&(j, _)| j != i) + .map(|(_, value)| value.abs()) + .sum::(); + rows[i][i] = offdiag_abs_sum + 1.0; + + let row_scale = exact_power_of_two(row_exponents[i]); + for value in &mut rows[i] { + *value *= row_scale; + } + } + rows +} + +#[test] +fn solve_exact_handles_bit_exact_subnormal_row_scales() { + let row_0_scale = exact_power_of_two(-1023); + let row_1_scale = exact_power_of_two(-1024); + assert_eq!(exact_power_of_two(-1074).to_bits(), 1); + assert_eq!(row_1_scale.to_bits(), 1_u64 << 50); + assert_eq!(row_0_scale.to_bits(), 1_u64 << 51); + + let rows = [ + [3.0 * row_0_scale, -2.0 * row_0_scale], + [-2.0 * row_1_scale, 3.0 * row_1_scale], + ]; + let determinant = big_rational_det_leibniz(&rows); + let expected_determinant = BigRational::new(BigInt::from(5), BigInt::from(1_u8) << 2047_u32); + assert_eq!(determinant, expected_determinant); + assert!(determinant.is_positive()); + + let matrix = Matrix::<2>::try_from_rows(rows).unwrap(); + let rhs = Vector::<2>::try_new([row_0_scale, row_1_scale]).unwrap(); + let solution = matrix.solve_exact(rhs).unwrap(); + let one = BigRational::from_integer(BigInt::from(1)); + assert_eq!(solution, [one.clone(), one]); + + let residual = big_rational_matvec(&rows, &solution); + assert_eq!( + residual, + [ + BigRational::from_f64(row_0_scale).unwrap(), + BigRational::from_f64(row_1_scale).unwrap(), + ] + ); +} + macro_rules! gen_det_sign_exact_proptests { ($d:literal) => { paste! { @@ -175,8 +322,8 @@ macro_rules! gen_det_sign_exact_proptests { #![proptest_config(ProptestConfig::with_cases(64))] #[test] - fn []( - diag in proptest::array::[](small_nonzero_f64()), + fn []( + diag in array::[](small_nonzero_f64()), ) { // Diagonal matrix: determinant sign = product of diagonal signs. let mut rows = [[0.0f64; $d]; $d]; @@ -185,38 +332,27 @@ macro_rules! gen_det_sign_exact_proptests { } let m = Matrix::<$d>::try_from_rows(rows).unwrap(); - let exact_sign = m.det_sign_exact().unwrap(); + let exact_sign = m.det_sign_exact(); // Expected sign from the product of diagonal entries. let neg_count = diag.iter().filter(|&&x| x < 0.0).count(); - let expected_sign: i8 = if neg_count % 2 == 0 { 1 } else { -1 }; + let expected_sign = if neg_count % 2 == 0 { + DeterminantSign::Positive + } else { + DeterminantSign::Negative + }; prop_assert_eq!(exact_sign, expected_sign); - } - - #[test] - fn []( - diag in proptest::array::[](small_nonzero_f64()), - ) { - // For well-conditioned diagonal matrices, det().signum() - // should agree with det_sign_exact(). - let mut rows = [[0.0f64; $d]; $d]; - for i in 0..$d { - rows[i][i] = diag[i]; - } - let m = Matrix::<$d>::try_from_rows(rows).unwrap(); - - let exact_sign = m.det_sign_exact().unwrap(); let fp_det = m.det().unwrap(); - let fp_sign: i8 = if fp_det > 0.0 { - 1 + let fp_sign = if fp_det > 0.0 { + DeterminantSign::Positive } else if fp_det < 0.0 { - -1 + DeterminantSign::Negative } else { - 0 + DeterminantSign::Zero }; - prop_assert_eq!(exact_sign, fp_sign); + prop_assert_eq!(fp_sign, expected_sign); } } } @@ -243,11 +379,11 @@ macro_rules! gen_solve_exact_roundtrip_proptests { #[test] fn []( - offdiag in proptest::array::[]( - proptest::array::[](small_int_f64()), + offdiag in array::[]( + array::[](small_int_f64()), ), - diag in proptest::array::[](small_nonzero_int_f64()), - x0 in proptest::array::[](small_int_f64()), + diag in array::[](small_nonzero_int_f64()), + x0 in array::[](small_int_f64()), ) { let rows = make_diagonally_dominant::<$d>(offdiag, diag); let a = Matrix::<$d>::try_from_rows(rows).unwrap(); @@ -296,18 +432,18 @@ macro_rules! gen_solve_exact_residual_proptests { #[test] fn []( - offdiag in proptest::array::[]( - proptest::array::[](small_int_f64()), + offdiag in array::[]( + array::[](small_int_f64()), ), - diag in proptest::array::[](small_nonzero_int_f64()), - b_arr in proptest::array::[](small_int_f64()), + diag in array::[](small_nonzero_int_f64()), + b_arr in array::[](small_int_f64()), ) { let rows = make_diagonally_dominant::<$d>(offdiag, diag); let a = Matrix::<$d>::try_from_rows(rows).unwrap(); let b = Vector::<$d>::try_new(b_arr).unwrap(); let x = a.solve_exact(b).expect("diagonally-dominant A is non-singular"); - let ax = bigrational_matvec::<$d>(&rows, &x); + let ax = big_rational_matvec::<$d>(&rows, &x); for i in 0..$d { let b_rat = BigRational::from_f64(b_arr[i]) .expect("small int fits in BigRational"); @@ -324,85 +460,87 @@ gen_solve_exact_residual_proptests!(3); gen_solve_exact_residual_proptests!(4); gen_solve_exact_residual_proptests!(5); -/// Dense determinant value oracle: random small-integer matrices should match -/// an independent `BigRational` Leibniz expansion, not just the sign read back -/// from the same Bareiss determinant core. -macro_rules! gen_det_exact_leibniz_oracle_proptests { +/// Mixed-exponent residual property: dense matrices remain exactly solvable +/// when every row has an independent power-of-two scale and every RHS entry +/// independently ranges from zero/subnormal through large finite values. +/// +/// The matrix constructor guarantees strict diagonal dominance. The residual +/// oracle independently reconstructs the original f64 inputs as rationals and +/// verifies `A · solve_exact(A, b) == b` without reusing the Bareiss core. +macro_rules! gen_solve_exact_mixed_exponent_residual_proptests { ($d:literal) => { paste! { proptest! { - #![proptest_config(ProptestConfig::with_cases(32))] + #![proptest_config(ProptestConfig::with_cases(24))] #[test] - fn []( - entries in proptest::array::[]( - proptest::array::[](small_int_f64()), + fn []( + offdiag in array::[]( + array::[](small_nonzero_int_f64()), ), + row_exponents in array::[](solve_row_exponent()), + b_arr in array::[](mixed_exponent_finite_f64()), ) { - let m = Matrix::<$d>::try_from_rows(entries).unwrap(); - let expected = bigrational_det_leibniz::<$d>(&entries); + let rows = make_dense_mixed_exponent_matrix::<$d>(offdiag, row_exponents); + let a = Matrix::<$d>::try_from_rows(rows).unwrap(); + let b = Vector::<$d>::try_new(b_arr).unwrap(); + let x = a + .solve_exact(b) + .expect("strict diagonal dominance guarantees invertibility"); - prop_assert_eq!(m.det_exact().unwrap(), expected); + let ax = big_rational_matvec::<$d>(&rows, &x); + for i in 0..$d { + let b_rat = BigRational::from_f64(b_arr[i]) + .expect("finite f64 converts exactly"); + prop_assert_eq!(&ax[i], &b_rat); + } } } } }; } -gen_det_exact_leibniz_oracle_proptests!(2); -gen_det_exact_leibniz_oracle_proptests!(3); -gen_det_exact_leibniz_oracle_proptests!(4); -gen_det_exact_leibniz_oracle_proptests!(5); - -/// On full (non-diagonal) random small-integer matrices, -/// `det_sign_exact()` must agree with `det_exact().signum()`. This -/// exercises the adaptive fast-filter / Bareiss-fallback boundary on -/// inputs the existing diagonal-only proptests don't touch (e.g. -/// matrices where the f64 det is near its error bound and the filter -/// must defer to Bareiss). -macro_rules! gen_det_sign_agrees_with_det_exact_proptests { +gen_solve_exact_mixed_exponent_residual_proptests!(2); +gen_solve_exact_mixed_exponent_residual_proptests!(3); +gen_solve_exact_mixed_exponent_residual_proptests!(4); +gen_solve_exact_mixed_exponent_residual_proptests!(5); + +/// Dense determinant oracle: both the exact value and adaptive sign must match +/// one independent `BigRational` Leibniz expansion. +macro_rules! gen_det_exact_and_sign_leibniz_oracle_proptests { ($d:literal) => { paste! { proptest! { #![proptest_config(ProptestConfig::with_cases(64))] #[test] - fn []( - entries in proptest::array::[]( - proptest::array::[](small_int_f64()), + fn []( + entries in array::[]( + array::[](small_int_f64()), ), ) { let m = Matrix::<$d>::try_from_rows(entries).unwrap(); - let sign = m.det_sign_exact().unwrap(); - let det = m.det_exact().unwrap(); - let expected: i8 = if det.is_positive() { - 1 - } else if det.is_negative() { - -1 - } else { - 0 - }; - prop_assert_eq!(sign, expected); + let expected = big_rational_det_leibniz::<$d>(&entries); + let expected_sign = determinant_sign(&expected); + + prop_assert_eq!(m.det_exact().unwrap(), expected); + prop_assert_eq!(m.det_sign_exact(), expected_sign); } } } }; } -gen_det_sign_agrees_with_det_exact_proptests!(2); -gen_det_sign_agrees_with_det_exact_proptests!(3); -gen_det_sign_agrees_with_det_exact_proptests!(4); -gen_det_sign_agrees_with_det_exact_proptests!(5); +gen_det_exact_and_sign_leibniz_oracle_proptests!(2); +gen_det_exact_and_sign_leibniz_oracle_proptests!(3); +gen_det_exact_and_sign_leibniz_oracle_proptests!(4); +gen_det_exact_and_sign_leibniz_oracle_proptests!(5); /// Fast-filter invariant: whenever `|det_direct()| > det_errbound()`, -/// the f64 sign is provably correct — so -/// `det_direct().signum() == det_sign_exact()`. This is the -/// correctness guarantee the Shewchuk-style filter inside -/// `det_sign_exact` relies on. The proptest cross-checks that the -/// fast-filter boundary itself is honoured, independent of whether -/// `det_sign_exact` ended up using the filter or the Bareiss fallback -/// on any particular input. Only D=2..=4 have a closed-form -/// `det_direct` / `det_errbound` pair. +/// the f64 sign is provably correct. The expected sign comes directly from an +/// independent `BigRational` Leibniz expansion rather than `det_sign_exact`, +/// avoiding a self-referential comparison with the filter's own consumer. +/// Only D=2..=4 have a closed-form `det_direct` / `det_errbound` pair. macro_rules! gen_det_sign_fast_filter_boundary_proptests { ($d:literal) => { paste! { @@ -410,9 +548,9 @@ macro_rules! gen_det_sign_fast_filter_boundary_proptests { #![proptest_config(ProptestConfig::with_cases(64))] #[test] - fn []( - entries in proptest::array::[]( - proptest::array::[](small_int_f64()), + fn []( + entries in array::[]( + array::[](small_int_f64()), ), ) { let m = Matrix::<$d>::try_from_rows(entries).unwrap(); @@ -420,25 +558,24 @@ macro_rules! gen_det_sign_fast_filter_boundary_proptests { .det_direct() .unwrap() .expect("D<=4 has closed-form det_direct"); - let bound = m - .det_errbound() - .unwrap() - .expect("D<=4 has a det_errbound"); - let sign = m.det_sign_exact().unwrap(); + let exact = big_rational_det_leibniz::<$d>(&entries); + let exact_sign = determinant_sign(&exact); // Only assert when the filter is conclusive. When - // `|det| <= bound` the f64 sign may disagree with the - // exact sign; that case is covered by the other - // proptests via the Bareiss fallback. - if det.abs() > bound { - let direct_sign: i8 = if det > 0.0 { - 1 - } else if det < 0.0 { - -1 - } else { - 0 - }; - prop_assert_eq!(direct_sign, sign); + // `det_errbound` is unavailable or `|det| <= bound`, the + // f64 sign may disagree with the exact sign; those cases + // fall through to direct exact-integer evaluation. + if let Some(bound) = m.det_errbound().unwrap() { + if det.abs() > bound { + let direct_sign = if det > 0.0 { + DeterminantSign::Positive + } else if det < 0.0 { + DeterminantSign::Negative + } else { + DeterminantSign::Zero + }; + prop_assert_eq!(direct_sign, exact_sign); + } } } } @@ -464,8 +601,8 @@ macro_rules! gen_det_errbound_leibniz_oracle_proptests { #[test] fn []( - entries in proptest::array::[]( - proptest::array::[]( + entries in array::[]( + array::[]( (-50i16..=50i16).prop_map(|x| f64::from(x) / 10.0) ), ), @@ -475,23 +612,20 @@ macro_rules! gen_det_errbound_leibniz_oracle_proptests { .det_direct() .unwrap() .expect("D<=4 has closed-form det_direct"); - let bound = m - .det_errbound() - .unwrap() - .expect("D<=4 has a det_errbound"); - - let exact = bigrational_det_leibniz::<$d>(&entries); - let direct_exact = BigRational::from_f64(det_direct) - .expect("det_direct returned finite f64"); - let bound_exact = BigRational::from_f64(bound) - .expect("det_errbound returned finite f64"); - let error = (direct_exact - exact).abs(); - - prop_assert!( - error <= bound_exact, - "det_direct error exceeded det_errbound for D={}: error={error}, bound={bound_exact}", - $d - ); + let exact = big_rational_det_leibniz::<$d>(&entries); + if let Some(bound) = m.det_errbound().unwrap() { + let direct_exact = BigRational::from_f64(det_direct) + .expect("det_direct returned finite f64"); + let bound_exact = BigRational::from_f64(bound) + .expect("det_errbound returned finite f64"); + let error = (direct_exact - exact).abs(); + + prop_assert!( + error <= bound_exact, + "det_direct error exceeded det_errbound for D={}: error={error}, bound={bound_exact}", + $d + ); + } } } } @@ -502,6 +636,57 @@ gen_det_errbound_leibniz_oracle_proptests!(2); gen_det_errbound_leibniz_oracle_proptests!(3); gen_det_errbound_leibniz_oracle_proptests!(4); +/// Exercise the determinant certificate with independently mixed per-entry +/// exponents spanning zero, subnormal, tiny normal, ordinary, and large finite +/// regimes. `det_sign_exact` must always match the independent Leibniz oracle; +/// whenever the filter publishes a bound, the same oracle must confirm it. +/// Inconclusive or overflowed scalar filter arithmetic defers to direct +/// exact-integer evaluation for these D≤4 cases. +macro_rules! gen_extreme_exponent_det_filter_proptests { + ($d:literal) => { + paste! { + proptest! { + #![proptest_config(ProptestConfig::with_cases(32))] + + #[test] + fn []( + entries in array::[]( + array::[](mixed_exponent_finite_f64()), + ), + ) { + let matrix = Matrix::<$d>::try_from_rows(entries).unwrap(); + let exact = big_rational_det_leibniz::<$d>(&entries); + let expected_sign = determinant_sign(&exact); + + prop_assert_eq!(matrix.det_sign_exact(), expected_sign); + + if let Ok(Some(bound)) = matrix.det_errbound() { + let direct = matrix + .det_direct() + .unwrap() + .expect("D<=4 has closed-form det_direct"); + let direct_exact = BigRational::from_f64(direct) + .expect("det_direct returned finite f64"); + let bound_exact = BigRational::from_f64(bound) + .expect("det_errbound returned finite f64"); + let error = (direct_exact - exact).abs(); + + prop_assert!( + error <= bound_exact, + "mixed-exponent determinant error exceeded bound for D={}: error={error}, bound={bound_exact}", + $d, + ); + } + } + } + } + }; +} + +gen_extreme_exponent_det_filter_proptests!(2); +gen_extreme_exponent_det_filter_proptests!(3); +gen_extreme_exponent_det_filter_proptests!(4); + /// Mixed-scale diagonal matrices stress the shared-exponent conversion path: /// zeros, subnormals, tiny normal values, ordinary values, and very large /// finite values can all appear in the same determinant. The independent @@ -514,7 +699,7 @@ macro_rules! gen_mixed_scale_diagonal_exact_det_proptests { #[test] fn []( - diag in proptest::array::[](mixed_scale_finite_f64()), + diag in array::[](mixed_scale_finite_f64()), ) { let mut rows = [[0.0f64; $d]; $d]; let mut expected = BigRational::from_integer(BigInt::from(1)); @@ -525,16 +710,10 @@ macro_rules! gen_mixed_scale_diagonal_exact_det_proptests { } let m = Matrix::<$d>::try_from_rows(rows).unwrap(); - let expected_sign = if expected.is_positive() { - 1 - } else if expected.is_negative() { - -1 - } else { - 0 - }; + let expected_sign = determinant_sign(&expected); let expected_f64 = expected.to_f64(); - prop_assert_eq!(m.det_sign_exact().unwrap(), expected_sign); + prop_assert_eq!(m.det_sign_exact(), expected_sign); match expected_f64 { Some(expected_f64) @@ -548,22 +727,20 @@ macro_rules! gen_mixed_scale_diagonal_exact_det_proptests { ); } Some(expected_f64) if expected_f64.is_finite() => { - prop_assert_eq!( - m.det_exact_f64(), - Err(LaError::Unrepresentable { - index: None, - reason: UnrepresentableReason::RequiresRounding, - }) - ); + let result = m.det_exact_f64(); + prop_assert!(is_unrepresentable( + &result, + None, + UnrepresentableReason::RequiresRounding + )); } _ => { - prop_assert_eq!( - m.det_exact_f64(), - Err(LaError::Unrepresentable { - index: None, - reason: UnrepresentableReason::NotFinite, - }) - ); + let result = m.det_exact_f64(); + prop_assert!(is_unrepresentable( + &result, + None, + UnrepresentableReason::NotFinite + )); } } diff --git a/tests/proptest_factorizations.rs b/tests/proptest_factorizations.rs index a2d8fc8..0d730b2 100644 --- a/tests/proptest_factorizations.rs +++ b/tests/proptest_factorizations.rs @@ -1,3 +1,5 @@ +#![forbid(unsafe_code)] + //! Property-based tests for LU/LDLT factorization APIs. //! //! These tests construct matrices from known factors so we have a reliable oracle for @@ -5,7 +7,7 @@ use approx::assert_abs_diff_eq; use pastey::paste; -use proptest::prelude::*; +use proptest::{array, prelude::*}; use la_stack::prelude::*; @@ -36,11 +38,11 @@ macro_rules! gen_factorization_proptests { #[test] fn []( - l_raw in proptest::array::[]( - proptest::array::[](small_factor_entry()), + l_raw in array::[]( + array::[](small_factor_entry()), ), - d_diag in proptest::array::[](positive_diag_entry()), - x_true in proptest::array::[](small_f64()), + d_diag in array::[](positive_diag_entry()), + x_true in array::[](small_f64()), ) { // Construct A = L * diag(D) * L^T, where L is unit-lower-triangular. let mut l = [[0.0f64; $d]; $d]; @@ -89,7 +91,10 @@ macro_rules! gen_factorization_proptests { let a = Matrix::<$d>::try_from_rows(a_rows).unwrap(); let ldlt = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap(); - assert_abs_diff_eq!(ldlt.det().unwrap(), expected_det, epsilon = 1e-8); + let det_ldlt = ldlt.det().unwrap(); + let det_lu = a.lu(DEFAULT_SINGULAR_TOL).unwrap().det().unwrap(); + assert_abs_diff_eq!(det_ldlt, expected_det, epsilon = 1e-8); + assert_abs_diff_eq!(det_ldlt, det_lu, epsilon = 1e-8); let b = Vector::<$d>::try_new(b_arr).unwrap(); let x = ldlt.solve(b).unwrap().into_array(); @@ -100,14 +105,14 @@ macro_rules! gen_factorization_proptests { #[test] fn []( - l_raw in proptest::array::[]( - proptest::array::[](small_factor_entry()), + l_raw in array::[]( + array::[](small_factor_entry()), ), - u_raw in proptest::array::[]( - proptest::array::[](small_factor_entry()), + u_raw in array::[]( + array::[](small_factor_entry()), ), - u_diag in proptest::array::[](nonzero_diag_entry()), - x_true in proptest::array::[](small_f64()), + u_diag in array::[](nonzero_diag_entry()), + x_true in array::[](small_f64()), ) { // Construct A = L * U, where L is unit-lower-triangular and U is upper-triangular. let mut l = [[0.0f64; $d]; $d]; @@ -180,14 +185,14 @@ macro_rules! gen_factorization_proptests { #[test] fn []( - l_raw in proptest::array::[]( - proptest::array::[](small_factor_entry()), + l_raw in array::[]( + array::[](small_factor_entry()), ), - u_raw in proptest::array::[]( - proptest::array::[](small_factor_entry()), + u_raw in array::[]( + array::[](small_factor_entry()), ), - u_diag in proptest::array::[](nonzero_diag_entry()), - x_true in proptest::array::[](small_f64()), + u_diag in array::[](nonzero_diag_entry()), + x_true in array::[](small_f64()), ) { // Construct A = P^{-1} * L * U, where P swaps the first two rows. // This ensures det(A) has an extra sign flip vs det(LU). @@ -273,3 +278,5 @@ gen_factorization_proptests!(2); gen_factorization_proptests!(3); gen_factorization_proptests!(4); gen_factorization_proptests!(5); +// Exercise the D > 5 factorization and solve branches with randomized inputs. +gen_factorization_proptests!(8); diff --git a/tests/proptest_matrix.rs b/tests/proptest_matrix.rs index bd79e3a..1f7a9b3 100644 --- a/tests/proptest_matrix.rs +++ b/tests/proptest_matrix.rs @@ -1,8 +1,10 @@ +#![forbid(unsafe_code)] + //! Property-based tests for the `Matrix` public API. use approx::assert_abs_diff_eq; use pastey::paste; -use proptest::prelude::*; +use proptest::{array, prelude::*}; use la_stack::prelude::*; @@ -14,56 +16,55 @@ fn small_nonzero_f64() -> impl Strategy { prop_oneof![(-1000i16..=-1i16), (1i16..=1000i16)].prop_map(|x| f64::from(x) / 10.0) } -fn small_ldlt_l_entry() -> impl Strategy { - // Keep entries small so SPD construction stays well-conditioned. - (-50i16..=50i16).prop_map(|x| f64::from(x) / 100.0) -} - -fn positive_ldlt_diag() -> impl Strategy { - // Positive diagonal, comfortably above DEFAULT_SINGULAR_TOL. - (1i16..=20i16).prop_map(|x| f64::from(x) / 10.0) -} - -macro_rules! gen_public_api_matrix_proptests { +macro_rules! gen_matrix_proptests { ($d:literal) => { paste! { proptest! { #![proptest_config(ProptestConfig::with_cases(64))] #[test] - fn []( - rows in proptest::array::[]( - proptest::array::[](small_f64()), + fn []( + rows in array::[]( + array::[](small_f64()), ), ) { let m = Matrix::<$d>::try_from_rows(rows).unwrap(); + prop_assert_eq!(m.as_rows(), &rows); for r in 0..$d { for c in 0..$d { assert_abs_diff_eq!(m.get(r, c).unwrap(), rows[r][c], epsilon = 0.0); - assert_abs_diff_eq!(m.get_checked(r, c).unwrap(), rows[r][c], epsilon = 0.0); + assert_abs_diff_eq!(m.try_get(r, c).unwrap(), rows[r][c], epsilon = 0.0); } } // Out-of-bounds is None. prop_assert_eq!(m.get($d, 0), None); prop_assert_eq!(m.get(0, $d), None); - prop_assert_eq!( - m.get_checked($d, 0), + let row_out_of_bounds = matches!( + m.try_get($d, 0), Err(LaError::IndexOutOfBounds { - row: $d, - col: 0, - dim: $d, + row, + col, + dim, + .. }) + if row == $d && col == 0 && dim == $d ); - prop_assert_eq!( - m.get_checked(0, $d), + prop_assert!(row_out_of_bounds); + let col_out_of_bounds = matches!( + m.try_get(0, $d), Err(LaError::IndexOutOfBounds { - row: 0, - col: $d, - dim: $d, + row, + col, + dim, + .. }) + if row == 0 && col == $d && dim == $d ); + prop_assert!(col_out_of_bounds); + + prop_assert_eq!(m.into_rows(), rows); } #[test] @@ -75,53 +76,50 @@ macro_rules! gen_public_api_matrix_proptests { let mut m = Matrix::<$d>::zero(); prop_assert_eq!(m.set(r, c, v), Ok(())); assert_abs_diff_eq!(m.get(r, c).unwrap(), v, epsilon = 0.0); - prop_assert_eq!(m.set_checked(r, c, -v), Ok(())); - assert_abs_diff_eq!(m.get_checked(r, c).unwrap(), -v, epsilon = 0.0); + prop_assert_eq!(m.set(r, c, -v), Ok(())); + assert_abs_diff_eq!(m.try_get(r, c).unwrap(), -v, epsilon = 0.0); } #[test] fn []( - rows in proptest::array::[]( - proptest::array::[](small_f64()), + rows in array::[]( + array::[](small_f64()), ), v in small_f64(), ) { let mut m = Matrix::<$d>::try_from_rows(rows).unwrap(); let original = m; - prop_assert_eq!( + let row_out_of_bounds = matches!( m.set($d, 0, v), Err(LaError::IndexOutOfBounds { - row: $d, - col: 0, - dim: $d, - }) - ); - prop_assert_eq!(m, original); - prop_assert_eq!( - m.set_checked($d, 0, v), - Err(LaError::IndexOutOfBounds { - row: $d, - col: 0, - dim: $d, + row, + col, + dim, + .. }) + if row == $d && col == 0 && dim == $d ); + prop_assert!(row_out_of_bounds); prop_assert_eq!(m, original); - prop_assert_eq!( - m.set_checked(0, $d, v), + let col_out_of_bounds = matches!( + m.set(0, $d, v), Err(LaError::IndexOutOfBounds { - row: 0, - col: $d, - dim: $d, + row, + col, + dim, + .. }) + if row == 0 && col == $d && dim == $d ); + prop_assert!(col_out_of_bounds); prop_assert_eq!(m, original); } #[test] fn []( - rows in proptest::array::[]( - proptest::array::[](small_f64()), + rows in array::[]( + array::[](small_f64()), ), ) { let m = Matrix::<$d>::try_from_rows(rows).unwrap(); @@ -131,14 +129,15 @@ macro_rules! gen_public_api_matrix_proptests { .map(|row| row.iter().map(|&x| x.abs()).sum::()) .fold(0.0f64, f64::max); - assert_abs_diff_eq!(m.inf_norm().unwrap(), expected, epsilon = 0.0); - prop_assert!(m.inf_norm().unwrap() >= 0.0); + let actual = m.inf_norm().unwrap(); + assert_abs_diff_eq!(actual, expected, epsilon = 0.0); + prop_assert!(actual >= 0.0); } #[test] fn []( - diag in proptest::array::[](small_nonzero_f64()), - b_arr in proptest::array::[](small_f64()), + diag in array::[](small_nonzero_f64()), + b_arr in array::[](small_f64()), ) { // Diagonal matrix: det is product of diagonal, and solve is element-wise division. let mut rows = [[0.0f64; $d]; $d]; @@ -171,71 +170,13 @@ macro_rules! gen_public_api_matrix_proptests { } } - #[test] - fn []( - l_raw in proptest::array::[]( - proptest::array::[](small_ldlt_l_entry()), - ), - d_diag in proptest::array::[](positive_ldlt_diag()), - x_true in proptest::array::[](small_f64()), - ) { - // Construct an SPD matrix A = L * diag(D) * L^T, where L is unit-lower-triangular - // and D has strictly positive entries. - let mut l = [[0.0f64; $d]; $d]; - for i in 0..$d { - for j in 0..$d { - l[i][j] = if i == j { - 1.0 - } else if i > j { - l_raw[i][j] - } else { - 0.0 - }; - } - } - - let mut a_rows = [[0.0f64; $d]; $d]; - for i in 0..$d { - for j in 0..=i { - let mut sum = 0.0; - for k in 0..=j { - sum = (l[i][k] * d_diag[k]).mul_add(l[j][k], sum); - } - a_rows[i][j] = sum; - a_rows[j][i] = sum; - } - } - - let mut b_arr = [0.0f64; $d]; - for i in 0..$d { - let mut sum = 0.0; - for j in 0..$d { - sum = a_rows[i][j].mul_add(x_true[j], sum); - } - b_arr[i] = sum; - } - - let a = Matrix::<$d>::try_from_rows(a_rows).unwrap(); - let ldlt = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap(); - - let det_ldlt = ldlt.det().unwrap(); - let det_lu = a.lu(DEFAULT_SINGULAR_TOL).unwrap().det().unwrap(); - assert_abs_diff_eq!(det_ldlt, det_lu, epsilon = 1e-8); - - let b = Vector::<$d>::try_new(b_arr).unwrap(); - let x = ldlt.solve(b).unwrap().into_array(); - - for i in 0..$d { - assert_abs_diff_eq!(x[i], x_true[i], epsilon = 1e-8); - } - } } } }; } // Mirror delaunay-style multi-dimension tests. -gen_public_api_matrix_proptests!(2); -gen_public_api_matrix_proptests!(3); -gen_public_api_matrix_proptests!(4); -gen_public_api_matrix_proptests!(5); +gen_matrix_proptests!(2); +gen_matrix_proptests!(3); +gen_matrix_proptests!(4); +gen_matrix_proptests!(5); diff --git a/tests/proptest_vector.rs b/tests/proptest_vector.rs index da171cf..04b33a6 100644 --- a/tests/proptest_vector.rs +++ b/tests/proptest_vector.rs @@ -1,8 +1,10 @@ +#![forbid(unsafe_code)] + //! Property-based tests for the `Vector` public API. use approx::assert_abs_diff_eq; use pastey::paste; -use proptest::prelude::*; +use proptest::{array, prelude::*}; use la_stack::prelude::*; @@ -10,7 +12,7 @@ fn small_f64() -> impl Strategy { (-1000i16..=1000i16).prop_map(|x| f64::from(x) / 10.0) } -macro_rules! gen_public_api_vector_proptests { +macro_rules! gen_vector_proptests { ($d:literal) => { paste! { proptest! { @@ -18,7 +20,7 @@ macro_rules! gen_public_api_vector_proptests { #[test] fn []( - arr in proptest::array::[](small_f64()), + arr in array::[](small_f64()), ) { let v = Vector::<$d>::try_new(arr).unwrap(); @@ -34,25 +36,26 @@ macro_rules! gen_public_api_vector_proptests { #[test] fn []( - a_arr in proptest::array::[](small_f64()), - b_arr in proptest::array::[](small_f64()), + a_arr in array::[](small_f64()), + b_arr in array::[](small_f64()), ) { let a = Vector::<$d>::try_new(a_arr).unwrap(); let b = Vector::<$d>::try_new(b_arr).unwrap(); - let dot_ab = a.dot(b).unwrap(); - let dot_reversed = b.dot(a).unwrap(); + let dot_ab = a.dot(&b).unwrap(); + let dot_reversed = b.dot(&a).unwrap(); assert_abs_diff_eq!(dot_ab, dot_reversed, epsilon = 1e-14); - let dot_aa = a.dot(a).unwrap(); - assert_abs_diff_eq!(a.norm2_sq().unwrap(), dot_aa, epsilon = 0.0); + let dot_aa = a.dot(&a).unwrap(); + let norm2_sq = a.norm2_sq().unwrap(); + assert_abs_diff_eq!(norm2_sq, dot_aa, epsilon = 0.0); // Squared norm is always non-negative for finite inputs. - prop_assert!(a.norm2_sq().unwrap() >= 0.0); + prop_assert!(norm2_sq >= 0.0); // Dot with zero vector is zero. let z = Vector::<$d>::zero(); - assert_abs_diff_eq!(a.dot(z).unwrap(), 0.0, epsilon = 1e-14); + assert_abs_diff_eq!(a.dot(&z).unwrap(), 0.0, epsilon = 1e-14); } } } @@ -60,7 +63,7 @@ macro_rules! gen_public_api_vector_proptests { } // Mirror delaunay-style multi-dimension tests. -gen_public_api_vector_proptests!(2); -gen_public_api_vector_proptests!(3); -gen_public_api_vector_proptests!(4); -gen_public_api_vector_proptests!(5); +gen_vector_proptests!(2); +gen_vector_proptests!(3); +gen_vector_proptests!(4); +gen_vector_proptests!(5); diff --git a/tests/regressions.rs b/tests/regressions.rs index 973b0b4..f4d4714 100644 --- a/tests/regressions.rs +++ b/tests/regressions.rs @@ -1,5 +1,8 @@ +#![forbid(unsafe_code)] + //! Regression tests for bugs caught in public API behavior. +use la_stack::ERR_COEFF_3; use la_stack::prelude::*; #[test] @@ -17,13 +20,14 @@ fn det_exact_f64_preserves_min_positive_subnormal() -> Result<(), LaError> { fn det_exact_f64_strict_vs_rounded_inexact_det() -> Result<(), LaError> { let m = Matrix::<2>::try_from_rows([[1.0 + f64::EPSILON, 0.0], [0.0, 1.0 - f64::EPSILON]])?; - assert_eq!( + assert!(matches!( m.det_exact_f64(), Err(LaError::Unrepresentable { index: None, reason: UnrepresentableReason::RequiresRounding, + .. }) - ); + )); assert_eq!( m.det_exact_rounded_f64().unwrap().to_bits(), 1.0f64.to_bits() @@ -37,13 +41,14 @@ fn solve_exact_f64_strict_vs_rounded_non_dyadic() -> Result<(), LaError> { let a = Matrix::<1>::try_from_rows([[3.0]])?; let b = Vector::<1>::try_new([1.0])?; - assert_eq!( + assert!(matches!( a.solve_exact_f64(b), Err(LaError::Unrepresentable { index: Some(0), reason: UnrepresentableReason::RequiresRounding, + .. }) - ); + )); assert_eq!( a.solve_exact_rounded_f64(b).unwrap().into_array()[0].to_bits(), (1.0f64 / 3.0).to_bits() @@ -67,6 +72,33 @@ fn requires_rounding_error_can_fall_back_to_rounded_solve() -> Result<(), LaErro Ok(()) } +#[test] +#[cfg(feature = "exact")] +fn exact_determinant_overflow_midpoint_is_not_recoverable_by_rounding() -> Result<(), LaError> { + let above_overflow_midpoint = 3.0 * 2.0_f64.powi(969); + let m = Matrix::<2>::try_from_rows([[f64::MAX, -above_overflow_midpoint], [1.0, 1.0]])?; + + let strict = m.det_exact_f64().unwrap_err(); + assert!(matches!( + strict, + LaError::Unrepresentable { + index: None, + reason: UnrepresentableReason::NotFinite, + .. + } + )); + assert!(!strict.requires_rounding()); + assert!(matches!( + m.det_exact_rounded_f64(), + Err(LaError::Unrepresentable { + reason: UnrepresentableReason::NotFinite, + .. + }) + )); + + Ok(()) +} + #[test] fn det_direct_skips_zero_coefficient_terms_that_would_overflow() -> Result<(), LaError> { let d3 = Matrix::<3>::try_from_rows([ diff --git a/tests/scaled_product_determinants.rs b/tests/scaled_product_determinants.rs new file mode 100644 index 0000000..0a0e262 --- /dev/null +++ b/tests/scaled_product_determinants.rs @@ -0,0 +1,57 @@ +#![forbid(unsafe_code)] + +//! Regression coverage for range-scaled public factor determinants. + +use la_stack::{LaError, Matrix, Tolerance}; + +const SUBNORMAL_ROUNDING_LEFT: f64 = f64::from_bits(0x3cb2_e219_27ac_435a); +const SUBNORMAL_ROUNDING_RIGHT: f64 = f64::from_bits(0x0014_55e5_f80b_50eb); +const TWO_NEG_800: f64 = f64::from_bits(223_u64 << 52); +const TWO_POS_800: f64 = f64::from_bits(1823_u64 << 52); +const LEAST_SUBNORMAL_BITS: u64 = 1; + +/// Build a finite diagonal matrix from the supplied entries. +fn diagonal_matrix(diagonal: [f64; D]) -> Result, LaError> { + let mut rows = [[0.0; D]; D]; + for (index, value) in diagonal.into_iter().enumerate() { + rows[index][index] = value; + } + Matrix::try_from_rows(rows) +} + +#[test] +fn public_lu_and_ldlt_determinants_round_final_subnormal_once() -> Result<(), LaError> { + let expected = SUBNORMAL_ROUNDING_LEFT * SUBNORMAL_ROUNDING_RIGHT; + assert_eq!(expected.to_bits(), LEAST_SUBNORMAL_BITS); + + let matrix = diagonal_matrix([SUBNORMAL_ROUNDING_LEFT, SUBNORMAL_ROUNDING_RIGHT])?; + let zero_tolerance = Tolerance::try_new(0.0)?; + let lu_det = matrix.lu(zero_tolerance)?.det()?; + let ldlt_det = matrix.ldlt(zero_tolerance)?.det()?; + + assert_eq!(lu_det.to_bits(), LEAST_SUBNORMAL_BITS); + assert_eq!(ldlt_det.to_bits(), LEAST_SUBNORMAL_BITS); + Ok(()) +} + +#[test] +fn public_factor_determinants_round_subnormal_after_earlier_range_loss() -> Result<(), LaError> { + let expected = SUBNORMAL_ROUNDING_LEFT * SUBNORMAL_ROUNDING_RIGHT; + assert_eq!(expected.to_bits(), LEAST_SUBNORMAL_BITS); + + let matrix = diagonal_matrix([ + TWO_NEG_800, + TWO_NEG_800, + TWO_POS_800, + TWO_POS_800, + SUBNORMAL_ROUNDING_LEFT, + SUBNORMAL_ROUNDING_RIGHT, + ])?; + let zero_tolerance = Tolerance::try_new(0.0)?; + let lu_det = matrix.lu(zero_tolerance)?.det()?; + let ldlt_det = matrix.ldlt(zero_tolerance)?.det()?; + + assert_eq!(lu_det.to_bits(), LEAST_SUBNORMAL_BITS); + assert_eq!(ldlt_det.to_bits(), LEAST_SUBNORMAL_BITS); + Ok(()) +} diff --git a/tests/semgrep/.github/workflows/action_policy.yml b/tests/semgrep/.github/workflows/action_policy.yml new file mode 100644 index 0000000..90d0e20 --- /dev/null +++ b/tests/semgrep/.github/workflows/action_policy.yml @@ -0,0 +1,76 @@ +name: Action policy fixtures +on: + workflow_dispatch: + # ruleid: la-stack.github-actions.no-pull-request-target + pull_request_target: + +jobs: + fixtures: + runs-on: ubuntu-latest + steps: + - name: Allowed pinned checkout + # ok: la-stack.github-actions.external-action-sha-pinned + # ok: la-stack.github-actions.external-action-approved-allowlist + # ok: la-stack.github-actions.external-action-version-comment + # ok: la-stack.github-actions.checkout-persist-credentials-false + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Checkout without explicit credential persistence + # ruleid: la-stack.github-actions.checkout-persist-credentials-false + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Moving tag action + # ruleid: la-stack.github-actions.external-action-sha-pinned + uses: actions/checkout@v7 + + - name: Unapproved external action + # ruleid: la-stack.github-actions.external-action-approved-allowlist + uses: unapproved/example-action@1111111111111111111111111111111111111111 # v1.0.0 + + - name: Missing readable version comment + # ruleid: la-stack.github-actions.external-action-version-comment + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae + + - name: Allowed setup-python action + # ok: la-stack.github-actions.external-action-approved-allowlist + # ok: la-stack.github-actions.external-action-sha-pinned + # ok: la-stack.github-actions.external-action-version-comment + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + + - name: Allowed Rust cache action + # ok: la-stack.github-actions.external-action-approved-allowlist + # ok: la-stack.github-actions.external-action-sha-pinned + # ok: la-stack.github-actions.external-action-version-comment + uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + + - name: Local action + # ok: la-stack.github-actions.external-action-sha-pinned + # ok: la-stack.github-actions.external-action-approved-allowlist + # ok: la-stack.github-actions.external-action-version-comment + uses: ./.github/actions/local-action + + - name: Interpolated github-script + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + # ruleid: la-stack.github-actions.github-script-no-expression-interpolation + script: | + core.info("${{ github.ref_name }}"); + + - name: Env-backed github-script + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + SAFE_REF_NAME: ${{ github.ref_name }} + with: + # ok: la-stack.github-actions.github-script-no-expression-interpolation + script: | + core.info(process.env.SAFE_REF_NAME); + + - name: Unlocked uv sync + # ruleid: la-stack.github-actions.uv-sync-locked-in-workflows + run: uv sync + + - name: Locked uv sync + # ok: la-stack.github-actions.uv-sync-locked-in-workflows + run: uv sync --locked diff --git a/tests/semgrep/docs/public_examples.md b/tests/semgrep/docs/public_examples.md new file mode 100644 index 0000000..d21b9bd --- /dev/null +++ b/tests/semgrep/docs/public_examples.md @@ -0,0 +1,11 @@ +# Public example policy fixture + +```rust +// ruleid: la-stack.rust.no-unwrap-expect-in-markdown-examples +let value = Some(1_u8).unwrap(); +``` + +```rust +// ok: la-stack.rust.no-unwrap-expect-in-markdown-examples +let value = maybe_value?; +``` diff --git a/tests/semgrep/scripts/tests/python_exceptions.py b/tests/semgrep/scripts/tests/python_exceptions.py new file mode 100644 index 0000000..4b598f1 --- /dev/null +++ b/tests/semgrep/scripts/tests/python_exceptions.py @@ -0,0 +1,78 @@ +import subprocess +from unittest.mock import MagicMock, Mock + + +def catches_broad_exception() -> None: + try: + pass + # ruleid: la-stack.python.no-broad-exception + except Exception: + pass + + +def catches_broad_exception_with_binding() -> None: + try: + pass + # ruleid: la-stack.python.no-broad-exception + except Exception as exc: + raise RuntimeError("wrapped") from exc + + +def catches_specific_exception() -> None: + try: + pass + # ok: la-stack.python.no-broad-exception + except OSError: + pass + + +def raises_raw_exception() -> None: + # ruleid: la-stack.python.no-raw-exception-in-tests + raise Exception("too broad") + + +def raises_specific_exception() -> None: + # ok: la-stack.python.no-raw-exception-in-tests + raise RuntimeError("specific failure") + + +def adhoc_mock_stdout() -> None: + # ruleid: la-stack.python.no-adhoc-completedprocess-mock + result = Mock() + result.stdout = "ok" + + +def adhoc_mock_returncode() -> None: + # ruleid: la-stack.python.no-adhoc-completedprocess-mock + result = MagicMock() + result.returncode = 0 + + +def adhoc_mock_constructor_stdout() -> None: + # ruleid: la-stack.python.no-adhoc-completedprocess-mock + Mock(stdout="ok") + + +def adhoc_mock_constructor_returncode() -> None: + # ruleid: la-stack.python.no-adhoc-completedprocess-mock + MagicMock(returncode=0) + + +def typed_completed_process() -> subprocess.CompletedProcess[str]: + # ok: la-stack.python.no-adhoc-completedprocess-mock + return subprocess.CompletedProcess(args=[], returncode=0, stdout="ok", stderr="") + + +def direct_subprocess_run() -> None: + # ruleid: la-stack.python.no-direct-subprocess-run-outside-wrapper + subprocess.run(["git", "status"], check=False) + + +# ruleid: la-stack.python.no-untyped-defs-in-scripts +def missing_return_annotation(): + return None + + +# ok: la-stack.python.no-untyped-defs-in-scripts +def explicit_return_annotation() -> None: + return None diff --git a/tests/semgrep/src/project_rules/finite_api_contract.rs b/tests/semgrep/src/project_rules/finite_api_contract.rs index 8ac5fea..670e01e 100644 --- a/tests/semgrep/src/project_rules/finite_api_contract.rs +++ b/tests/semgrep/src/project_rules/finite_api_contract.rs @@ -32,10 +32,35 @@ impl Matrix { Self { rows } } - // ok: la-stack.rust.no-public-unchecked-finite-constructors + // ruleid: la-stack.rust.no-public-unchecked-finite-constructors pub(crate) const fn from_rows_unchecked_internal(rows: [[f64; D]; D]) -> Self { Self { rows } } + + // ruleid: la-stack.rust.no-public-unchecked-finite-constructors + pub(crate) const fn rows_mut_unchecked(&mut self) -> &mut [[f64; D]; D] { + &mut self.rows + } + + // ruleid: la-stack.rust.no-public-unchecked-finite-constructors + pub(super) const fn from_rows_unchecked_super(rows: [[f64; D]; D]) -> Self { + Self { rows } + } + + // ruleid: la-stack.rust.no-public-unchecked-finite-constructors + pub(in crate::matrix) const fn rows_mut_unchecked_scoped(&mut self) -> &mut [[f64; D]; D] { + &mut self.rows + } + + // ok: la-stack.rust.no-public-unchecked-finite-constructors + const fn new_unchecked_private(rows: [[f64; D]; D]) -> Self { + Self { rows } + } + + // ruleid: la-stack.rust.det-sign-exact-must-be-infallible + pub fn det_sign_exact(&self) -> Result { + Ok(DeterminantSign::Zero) + } } impl Vector { @@ -44,12 +69,83 @@ impl Vector { Self { data } } - // ok: la-stack.rust.no-public-unchecked-finite-constructors + // ruleid: la-stack.rust.no-public-unchecked-finite-constructors pub(crate) const fn new_unchecked_internal(data: [f64; D]) -> Self { Self { data } } } +pub enum DeterminantSign { + Zero, +} + +pub enum LaError {} + +pub struct InfallibleMatrix; + +impl InfallibleMatrix { + // ok: la-stack.rust.det-sign-exact-must-be-infallible + pub fn det_sign_exact(&self) -> DeterminantSign { + DeterminantSign::Zero + } +} + +// ruleid: la-stack.rust.exact-benchmark-validation-must-return-proof +pub fn validate_exact_fixture(_input: &ExactInput) {} + +mod invalid_validation_return { + use super::ExactInput; + + // ruleid: la-stack.rust.exact-benchmark-validation-must-return-proof + pub fn validate_exact_fixture(_input: ExactInput) -> bool { + true + } +} + +mod valid_validation_return { + use super::{ExactInput, ValidatedExactInput}; + + // ok: la-stack.rust.exact-benchmark-validation-must-return-proof + pub fn validate_exact_fixture( + _input: ExactInput, + ) -> ValidatedExactInput { + todo!() + } +} + +pub struct ExactInput { + matrix: Matrix, + rhs: Vector, +} + +pub struct ValidatedExactInput { + // ruleid: la-stack.rust.validated-exact-input-fields-private + pub matrix: Matrix, + rhs: Vector, +} + +pub struct CleanValidatedExactInput { + // ok: la-stack.rust.validated-exact-input-fields-private + matrix: Matrix, + rhs: Vector, +} + +mod private_validated_fields { + use super::{Matrix, Vector}; + + pub struct ValidatedExactInput { + // ok: la-stack.rust.validated-exact-input-fields-private + pub(self) matrix: Matrix, + rhs: Vector, + } +} + +// ruleid: la-stack.rust.exact-benchmark-helpers-require-validated-input +fn bench_raw_exact_input(_input: &ExactInput) {} + +// ok: la-stack.rust.exact-benchmark-helpers-require-validated-input +fn bench_validated_exact_input(_input: &ValidatedExactInput) {} + pub struct Lu; impl Lu { diff --git a/tests/semgrep/src/project_rules/portable_policy.rs b/tests/semgrep/src/project_rules/portable_policy.rs new file mode 100644 index 0000000..fda78ef --- /dev/null +++ b/tests/semgrep/src/project_rules/portable_policy.rs @@ -0,0 +1,88 @@ +#![allow(dead_code, unused_imports)] + +use num_traits::NumCast; + +// ruleid: la-stack.rust.no-module-scope-cfg-test-use +#[cfg(test)] +use crate::FixtureOnlyImport; + +fn safe_f64(_value: u64) -> Option { + Some(1.0) +} + +pub fn silent_conversion_fallback(value: u64) -> f64 { + // ruleid: la-stack.rust.no-silent-conversion-fallbacks, la-stack.rust.no-silent-conversion-fallbacks-in-public-samples + NumCast::from(value).unwrap_or(0.0) +} + +pub fn partial_cmp_ordering_default(left: f64, right: f64) -> std::cmp::Ordering { + // ruleid: la-stack.rust.no-partial-cmp-ordering-defaults + left.partial_cmp(&right).unwrap_or(std::cmp::Ordering::Equal) +} + +pub fn function_local_use_fixture() { + // ruleid: la-stack.rust.no-function-local-use-in-src + use std::cmp::Ordering; + + let _ordering = Ordering::Equal; +} + +// ruleid: la-stack.rust.no-public-api-cfg-test-shim +#[cfg(any(test, feature = "diagnostics"))] +pub fn public_api_test_cfg_shim_fixture() {} + +// ruleid: la-stack.rust.borrowed-view-types-require-lifetime +pub struct OwnedView { + value: usize, +} + +// ok: la-stack.rust.borrowed-view-types-require-lifetime +pub struct BorrowedView<'a> { + value: &'a usize, +} + +pub struct UncheckedFixture; + +impl UncheckedFixture { + // ruleid: la-stack.rust.no-public-unchecked-apis + pub fn from_unchecked_value() -> Self { + Self + } +} + +pub fn ignored_question_mark_result(value: Result) -> Result<(), ()> { + // ruleid: la-stack.rust.no-ignored-fallible-results + let _ = value?; + Ok(()) +} + +// ruleid: la-stack.rust.no-box-dyn-error-in-src, la-stack.rust.no-box-dyn-error-in-examples-benches +type ErasedError = Box; + +// ruleid: la-stack.rust.no-clippy-allow-lints +#[allow(clippy::too_many_lines)] +fn clippy_allow_fixture() {} + +// ok: la-stack.rust.no-clippy-allow-lints +#[expect(clippy::too_many_lines, reason = "fixture documents the suppression")] +fn clippy_expect_fixture() {} + +// ruleid: la-stack.rust.no-ignored-tests +#[ignore = "use an explicit slow-test feature"] +fn ignored_test_fixture() {} + +// ruleid: la-stack.rust.expect-requires-reason +#[expect(clippy::too_many_lines)] +fn undocumented_expectation_fixture() {} + +// ok: la-stack.rust.expect-requires-reason +#[expect(clippy::too_many_lines, reason = "fixture documents the suppression")] +fn documented_expectation_fixture() {} + +// ruleid: la-stack.rust.no-box-dyn-error-in-doctests +/// # fn main() -> Result<(), Box> { Ok(()) } +fn doctest_erased_error_fixture() {} + +// ruleid: la-stack.rust.prefer-assert-matches-in-doctests +/// assert!(matches!(value, Some(_))); +fn doctest_assert_matches_fixture() {} diff --git a/tests/vs_linalg_inputs.rs b/tests/vs_linalg_inputs.rs index 405215e..dcb0159 100644 --- a/tests/vs_linalg_inputs.rs +++ b/tests/vs_linalg_inputs.rs @@ -1,19 +1,24 @@ +#![forbid(unsafe_code)] + //! Smoke tests for the deterministic inputs used by the `vs_linalg` benchmark. #![cfg(feature = "bench")] use faer::linalg::solvers::Solve; +use faer::mat::AsMatRef; +use faer::perm::PermRef; use faer::{Mat, Side}; -use nalgebra::{SMatrix, SVector}; +use nalgebra::{Const, DimMin, SMatrix, SVector}; -use la_stack::{DEFAULT_SINGULAR_TOL, Matrix, Vector}; +use la_stack::{DEFAULT_SINGULAR_TOL, Matrix, Tolerance, Vector}; #[path = "../benches/common/vs_linalg.rs"] -mod vs_linalg; +pub mod vs_linalg_common; -use vs_linalg::{ - faer_det_from_ldlt, faer_det_from_partial_piv_lu, make_matrix_rows, make_vector_array, - matrix_entry, nalgebra_inf_norm, vector_entry, +use vs_linalg_common::{ + faer_det_from_ldlt, faer_det_from_partial_piv_lu, faer_perm_sign, + make_balanced_dynamic_range_rows, make_ill_conditioned_matrix_rows, make_matrix_rows, + make_pivoting_matrix_rows, make_vector_array, matrix_entry, nalgebra_inf_norm, vector_entry, }; /// Assert scalar agreement with a tolerance that scales for larger magnitudes. @@ -49,137 +54,329 @@ fn nalgebra_vector_to_array(v: &SVector) -> [f64; D] { data } -/// Generate one cross-crate smoke test for a concrete benchmark dimension. -macro_rules! gen_smoke_test { - ($name:ident, $d:literal) => { +/// Check LU determinant and solve agreement for one benchmark dimension. +fn assert_lu_agreement() +where + Const: DimMin, Output = Const>, +{ + let a = Matrix::::try_from_rows(make_matrix_rows::()) + .unwrap_or_else(|err| panic!("la_stack matrix construction failed: {err}")); + let rhs = Vector::::try_new(make_vector_array::(0.0)) + .unwrap_or_else(|err| panic!("la_stack RHS vector construction failed: {err}")); + let na = SMatrix::::from_fn(matrix_entry::); + let nrhs = SVector::::from_fn(|i, _| vector_entry(i, 0.0)); + let fa = Mat::::from_fn(D, D, matrix_entry::); + let frhs = Mat::::from_fn(D, 1, |i, _| vector_entry(i, 0.0)); + + let la_lu = a + .lu(DEFAULT_SINGULAR_TOL) + .unwrap_or_else(|err| panic!("la_stack LU factorization failed: {err}")); + let na_lu = na.lu(); + let fa_lu = fa.partial_piv_lu(); + + let la_lu_det = la_lu + .det() + .unwrap_or_else(|err| panic!("la_stack LU determinant failed: {err}")); + assert_close("nalgebra_det_from_lu", na_lu.determinant(), la_lu_det); + assert_close( + "faer_det_from_lu", + faer_det_from_partial_piv_lu(&fa_lu), + la_lu_det, + ); + + let la_lu_x = la_lu + .solve(rhs) + .unwrap_or_else(|err| panic!("la_stack LU solve failed: {err}")); + let na_lu_x = na_lu + .solve(&nrhs) + .unwrap_or_else(|| panic!("nalgebra LU solve returned no result")); + let fa_lu_x = fa_lu.solve(&frhs); + let la_lu_x = la_lu_x.into_array(); + assert_vector_close( + "nalgebra_solve_from_lu", + nalgebra_vector_to_array(&na_lu_x), + la_lu_x, + ); + assert_vector_close( + "faer_solve_from_lu", + faer_column_to_array(&fa_lu_x), + la_lu_x, + ); +} + +/// Check LDLT/Cholesky determinant and solve agreement for one dimension. +fn assert_ldlt_agreement() { + let a = Matrix::::try_from_rows(make_matrix_rows::()) + .unwrap_or_else(|err| panic!("la_stack matrix construction failed: {err}")); + let rhs = Vector::::try_new(make_vector_array::(0.0)) + .unwrap_or_else(|err| panic!("la_stack RHS vector construction failed: {err}")); + let na = SMatrix::::from_fn(matrix_entry::); + let nrhs = SVector::::from_fn(|i, _| vector_entry(i, 0.0)); + let fa = Mat::::from_fn(D, D, matrix_entry::); + let frhs = Mat::::from_fn(D, 1, |i, _| vector_entry(i, 0.0)); + + let la_ldlt = a + .ldlt(DEFAULT_SINGULAR_TOL) + .unwrap_or_else(|err| panic!("la_stack LDLT factorization failed: {err}")); + let na_cholesky = na + .cholesky() + .unwrap_or_else(|| panic!("nalgebra Cholesky factorization returned no result")); + let fa_ldlt = fa + .ldlt(Side::Lower) + .unwrap_or_else(|err| panic!("faer LDLT factorization failed: {err}")); + + let la_ldlt_det = la_ldlt + .det() + .unwrap_or_else(|err| panic!("la_stack LDLT determinant failed: {err}")); + assert_close( + "nalgebra_det_from_cholesky", + na_cholesky.determinant(), + la_ldlt_det, + ); + assert_close( + "faer_det_from_ldlt", + faer_det_from_ldlt(&fa_ldlt), + la_ldlt_det, + ); + + let la_ldlt_x = la_ldlt + .solve(rhs) + .unwrap_or_else(|err| panic!("la_stack LDLT solve failed: {err}")); + let na_cholesky_x = na_cholesky.solve(&nrhs); + let fa_ldlt_x = fa_ldlt.solve(&frhs); + let la_ldlt_x = la_ldlt_x.into_array(); + assert_vector_close( + "nalgebra_solve_from_cholesky", + nalgebra_vector_to_array(&na_cholesky_x), + la_ldlt_x, + ); + assert_vector_close( + "faer_solve_from_ldlt", + faer_column_to_array(&fa_ldlt_x), + la_ldlt_x, + ); +} + +/// Check vector dot-product and squared-norm agreement for one benchmark dimension. +fn assert_vector_operation_agreement() { + let v1 = Vector::::try_new(make_vector_array::(0.0)) + .unwrap_or_else(|err| panic!("la_stack vector construction failed: {err}")); + let v2 = Vector::::try_new(make_vector_array::(1.0)) + .unwrap_or_else(|err| panic!("la_stack vector construction failed: {err}")); + let nv1 = SVector::::from_fn(|i, _| vector_entry(i, 0.0)); + let nv2 = SVector::::from_fn(|i, _| vector_entry(i, 1.0)); + let fv1 = Mat::::from_fn(D, 1, |i, _| vector_entry(i, 0.0)); + let fv2 = Mat::::from_fn(D, 1, |i, _| vector_entry(i, 1.0)); + + let la_dot = v1 + .dot(&v2) + .unwrap_or_else(|err| panic!("la_stack dot failed: {err}")); + assert_close("nalgebra_dot", nv1.dot(&nv2), la_dot); + let mut fa_dot = 0.0; + for i in 0..D { + fa_dot = fv1[(i, 0)].mul_add(fv2[(i, 0)], fa_dot); + } + assert_close("faer_dot", fa_dot, la_dot); + + let la_norm2_sq = v1 + .norm2_sq() + .unwrap_or_else(|err| panic!("la_stack norm2_sq failed: {err}")); + assert_close("nalgebra_norm_squared", nv1.norm_squared(), la_norm2_sq); + let fa_norm2_sq = fv1.as_mat_ref().squared_norm_l2(); + assert_close("faer_norm2_sq", fa_norm2_sq, la_norm2_sq); +} + +#[test] +fn faer_lu_determinant_includes_odd_row_permutation_sign() { + let matrix = Mat::::from_fn(2, 2, |row, col| [[0.0, 2.0], [3.0, 4.0]][row][col]); + let lu = matrix.partial_piv_lu(); + + assert_close( + "faer odd row-permutation sign", + faer_perm_sign(lu.P()), + -1.0, + ); + assert_close( + "faer determinant with one pivot swap", + faer_det_from_partial_piv_lu(&lu), + -6.0, + ); +} + +#[test] +fn faer_permutation_sign_handles_valid_cycle_parities() { + let empty = PermRef::new_checked(&[], &[], 0); + let identity = PermRef::new_checked(&[0, 1, 2], &[0, 1, 2], 3); + let transposition = PermRef::new_checked(&[1, 0], &[1, 0], 2); + let three_cycle = PermRef::new_checked(&[1, 2, 0], &[2, 0, 1], 3); + + assert_close("empty permutation sign", faer_perm_sign(empty), 1.0); + assert_close("identity permutation sign", faer_perm_sign(identity), 1.0); + assert_close( + "transposition permutation sign", + faer_perm_sign(transposition), + -1.0, + ); + assert_close( + "three-cycle permutation sign", + faer_perm_sign(three_cycle), + 1.0, + ); +} + +#[test] +fn faer_permutation_sign_handles_large_permutations_without_allocation() { + let mut forward: [usize; 129] = std::array::from_fn(|index| index); + forward.swap(127, 128); + let permutation = PermRef::new_checked(&forward, &forward, forward.len()); + + assert_close( + "large transposition permutation sign", + faer_perm_sign(permutation), + -1.0, + ); +} + +#[test] +fn stress_inputs_exercise_pivoting_conditioning_and_scaled_products() { + let zero_tolerance = Tolerance::try_new(0.0).unwrap(); + + let pivoting_rows = make_pivoting_matrix_rows::<8>(); + assert!(pivoting_rows[1][0].abs() > pivoting_rows[0][0].abs()); + let pivoting_lu = Matrix::<8>::try_from_rows(pivoting_rows) + .unwrap() + .lu(zero_tolerance) + .unwrap(); + assert!(pivoting_lu.det().unwrap().is_finite()); + + let ill_conditioned = Matrix::<8>::try_from_rows(make_ill_conditioned_matrix_rows()).unwrap(); + let expected_ill_conditioned_det = f64::from_bits((1023_u64 - 448) << 52); + assert_eq!( + ill_conditioned + .lu(zero_tolerance) + .unwrap() + .det() + .unwrap() + .to_bits(), + expected_ill_conditioned_det.to_bits() + ); + assert_eq!( + ill_conditioned + .ldlt(zero_tolerance) + .unwrap() + .det() + .unwrap() + .to_bits(), + expected_ill_conditioned_det.to_bits() + ); + + let balanced = Matrix::<8>::try_from_rows(make_balanced_dynamic_range_rows()).unwrap(); + assert_eq!(balanced.lu(zero_tolerance).unwrap().det(), Ok(1.0)); + assert_eq!(balanced.ldlt(zero_tolerance).unwrap().det(), Ok(1.0)); +} + +/// Check matrix infinity-norm agreement for one benchmark dimension. +fn assert_matrix_inf_norm_agreement() { + let a = Matrix::::try_from_rows(make_matrix_rows::()) + .unwrap_or_else(|err| panic!("la_stack matrix construction failed: {err}")); + let na = SMatrix::::from_fn(matrix_entry::); + let fa = Mat::::from_fn(D, D, matrix_entry::); + + let la_norm = a + .inf_norm() + .unwrap_or_else(|err| panic!("la_stack inf_norm failed: {err}")); + assert_close("nalgebra_inf_norm", nalgebra_inf_norm(&na), la_norm); + let mut fa_norm = 0.0; + for r in 0..D { + let mut row_sum = 0.0; + for c in 0..D { + row_sum += fa[(r, c)].abs(); + } + if row_sum > fa_norm { + fa_norm = row_sum; + } + } + assert_close("faer_inf_norm", fa_norm, la_norm); +} + +/// Generate focused cross-crate smoke tests for a benchmark dimension. +macro_rules! gen_smoke_tests { + ($d:literal, $lu:ident, $ldlt:ident, $vector:ident, $norm:ident) => { + #[test] + fn $lu() { + assert_lu_agreement::<$d>(); + } + + #[test] + fn $ldlt() { + assert_ldlt_agreement::<$d>(); + } + + #[test] + fn $vector() { + assert_vector_operation_agreement::<$d>(); + } + #[test] - #[allow(clippy::too_many_lines)] - fn $name() { - let a = Matrix::<$d>::try_from_rows(make_matrix_rows::<$d>()) - .unwrap_or_else(|err| panic!("la_stack matrix construction failed: {err}")); - let rhs = Vector::<$d>::try_new(make_vector_array::<$d>(0.0)) - .unwrap_or_else(|err| panic!("la_stack RHS vector construction failed: {err}")); - let v1 = Vector::<$d>::try_new(make_vector_array::<$d>(0.0)) - .unwrap_or_else(|err| panic!("la_stack vector construction failed: {err}")); - let v2 = Vector::<$d>::try_new(make_vector_array::<$d>(1.0)) - .unwrap_or_else(|err| panic!("la_stack vector construction failed: {err}")); - - let na = SMatrix::::from_fn(|r, c| matrix_entry::<$d>(r, c)); - let nrhs = SVector::::from_fn(|i, _| vector_entry(i, 0.0)); - let nv1 = SVector::::from_fn(|i, _| vector_entry(i, 0.0)); - let nv2 = SVector::::from_fn(|i, _| vector_entry(i, 1.0)); - - let fa = Mat::::from_fn($d, $d, |r, c| matrix_entry::<$d>(r, c)); - let frhs = Mat::::from_fn($d, 1, |i, _| vector_entry(i, 0.0)); - let fv1 = Mat::::from_fn($d, 1, |i, _| vector_entry(i, 0.0)); - let fv2 = Mat::::from_fn($d, 1, |i, _| vector_entry(i, 1.0)); - - let la_lu = a - .lu(DEFAULT_SINGULAR_TOL) - .unwrap_or_else(|err| panic!("la_stack LU factorization failed: {err}")); - let na_lu = na.lu(); - let fa_lu = fa.partial_piv_lu(); - - let la_lu_det = la_lu - .det() - .unwrap_or_else(|err| panic!("la_stack LU determinant failed: {err}")); - assert_close("nalgebra_det_from_lu", na_lu.determinant(), la_lu_det); - assert_close( - "faer_det_from_lu", - faer_det_from_partial_piv_lu(&fa_lu), - la_lu_det, - ); - - let la_lu_x = la_lu - .solve(rhs) - .unwrap_or_else(|err| panic!("la_stack LU solve failed: {err}")); - let na_lu_x = na_lu - .solve(&nrhs) - .unwrap_or_else(|| panic!("nalgebra LU solve returned no result")); - let fa_lu_x = fa_lu.solve(&frhs); - let la_lu_x = la_lu_x.into_array(); - assert_vector_close( - "nalgebra_solve_from_lu", - nalgebra_vector_to_array(&na_lu_x), - la_lu_x, - ); - assert_vector_close( - "faer_solve_from_lu", - faer_column_to_array(&fa_lu_x), - la_lu_x, - ); - - let la_ldlt = a - .ldlt(DEFAULT_SINGULAR_TOL) - .unwrap_or_else(|err| panic!("la_stack LDLT factorization failed: {err}")); - let na_cholesky = na - .cholesky() - .unwrap_or_else(|| panic!("nalgebra Cholesky factorization returned no result")); - let fa_ldlt = fa - .ldlt(Side::Lower) - .unwrap_or_else(|err| panic!("faer LDLT factorization failed: {err}")); - - let la_ldlt_det = la_ldlt - .det() - .unwrap_or_else(|err| panic!("la_stack LDLT determinant failed: {err}")); - assert_close( - "nalgebra_det_from_cholesky", - na_cholesky.determinant(), - la_ldlt_det, - ); - assert_close( - "faer_det_from_ldlt", - faer_det_from_ldlt(&fa_ldlt), - la_ldlt_det, - ); - - let la_ldlt_x = la_ldlt - .solve(rhs) - .unwrap_or_else(|err| panic!("la_stack LDLT solve failed: {err}")); - let na_cholesky_x = na_cholesky.solve(&nrhs); - let fa_ldlt_x = fa_ldlt.solve(&frhs); - let la_ldlt_x = la_ldlt_x.into_array(); - assert_vector_close( - "nalgebra_solve_from_cholesky", - nalgebra_vector_to_array(&na_cholesky_x), - la_ldlt_x, - ); - assert_vector_close( - "faer_solve_from_ldlt", - faer_column_to_array(&fa_ldlt_x), - la_ldlt_x, - ); - - let la_dot = v1 - .dot(v2) - .unwrap_or_else(|err| panic!("la_stack dot failed: {err}")); - assert_close("nalgebra_dot", nv1.dot(&nv2), la_dot); - let mut fa_dot = 0.0; - for i in 0..$d { - fa_dot = fv1[(i, 0)].mul_add(fv2[(i, 0)], fa_dot); - } - assert_close("faer_dot", fa_dot, la_dot); - - let la_norm = a - .inf_norm() - .unwrap_or_else(|err| panic!("la_stack inf_norm failed: {err}")); - assert_close("nalgebra_inf_norm", nalgebra_inf_norm(&na), la_norm); - let mut fa_norm = 0.0; - for r in 0..$d { - let mut row_sum = 0.0; - for c in 0..$d { - row_sum += fa[(r, c)].abs(); - } - if row_sum > fa_norm { - fa_norm = row_sum; - } - } - assert_close("faer_inf_norm", fa_norm, la_norm); + fn $norm() { + assert_matrix_inf_norm_agreement::<$d>(); } }; } -gen_smoke_test!(vs_linalg_shared_inputs_agree_2d, 2); -gen_smoke_test!(vs_linalg_shared_inputs_agree_3d, 3); -gen_smoke_test!(vs_linalg_shared_inputs_agree_4d, 4); -gen_smoke_test!(vs_linalg_shared_inputs_agree_5d, 5); +gen_smoke_tests!( + 2, + vs_linalg_lu_agrees_2d, + vs_linalg_ldlt_agrees_2d, + vs_linalg_vector_operations_agree_2d, + vs_linalg_matrix_inf_norm_agrees_2d +); +gen_smoke_tests!( + 3, + vs_linalg_lu_agrees_3d, + vs_linalg_ldlt_agrees_3d, + vs_linalg_vector_operations_agree_3d, + vs_linalg_matrix_inf_norm_agrees_3d +); +gen_smoke_tests!( + 4, + vs_linalg_lu_agrees_4d, + vs_linalg_ldlt_agrees_4d, + vs_linalg_vector_operations_agree_4d, + vs_linalg_matrix_inf_norm_agrees_4d +); +gen_smoke_tests!( + 5, + vs_linalg_lu_agrees_5d, + vs_linalg_ldlt_agrees_5d, + vs_linalg_vector_operations_agree_5d, + vs_linalg_matrix_inf_norm_agrees_5d +); +gen_smoke_tests!( + 8, + vs_linalg_lu_agrees_8d, + vs_linalg_ldlt_agrees_8d, + vs_linalg_vector_operations_agree_8d, + vs_linalg_matrix_inf_norm_agrees_8d +); +gen_smoke_tests!( + 16, + vs_linalg_lu_agrees_16d, + vs_linalg_ldlt_agrees_16d, + vs_linalg_vector_operations_agree_16d, + vs_linalg_matrix_inf_norm_agrees_16d +); +gen_smoke_tests!( + 32, + vs_linalg_lu_agrees_32d, + vs_linalg_ldlt_agrees_32d, + vs_linalg_vector_operations_agree_32d, + vs_linalg_matrix_inf_norm_agrees_32d +); +gen_smoke_tests!( + 64, + vs_linalg_lu_agrees_64d, + vs_linalg_ldlt_agrees_64d, + vs_linalg_vector_operations_agree_64d, + vs_linalg_matrix_inf_norm_agrees_64d +); diff --git a/ty.toml b/ty.toml index e86712f..293c364 100644 --- a/ty.toml +++ b/ty.toml @@ -6,10 +6,6 @@ # Restrict analysis strictly to Python tooling. include = [ "scripts" ] -[environment] -# Match the project's minimum supported Python version. -python-version = "3.13" - [terminal] # Keep output concise by default. output-format = "concise" diff --git a/uv.lock b/uv.lock index 6259155..50a7066 100644 --- a/uv.lock +++ b/uv.lock @@ -1,10 +1,6 @@ version = 1 revision = 3 -requires-python = ">=3.13" -resolution-markers = [ - "python_full_version >= '3.15'", - "python_full_version < '3.15'", -] +requires-python = ">=3.14" [[package]] name = "actionlint-py" @@ -78,18 +74,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, - { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, - { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, - { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, - { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, - { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, - { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, - { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, - { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, - { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, - { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, @@ -120,22 +104,6 @@ version = "3.4.7" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, - { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, - { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, - { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, - { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, - { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, - { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, - { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, - { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, - { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, - { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, - { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, - { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, - { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, - { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, - { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, @@ -418,7 +386,10 @@ dev = [ { name = "pytest" }, { name = "ruff" }, { name = "semgrep" }, + { name = "shellcheck-py" }, + { name = "shfmt-py" }, { name = "ty" }, + { name = "yamllint" }, ] [package.metadata] @@ -426,10 +397,13 @@ dev = [ [package.metadata.requires-dev] dev = [ { name = "actionlint-py", specifier = "==1.7.12.24" }, - { name = "pytest", specifier = "==9.0.3" }, - { name = "ruff", specifier = ">=0.15.14" }, - { name = "semgrep", specifier = "==1.164.0" }, - { name = "ty", specifier = ">=0.0.40" }, + { name = "pytest", specifier = "==9.1.1" }, + { name = "ruff", specifier = "==0.15.20" }, + { name = "semgrep", specifier = "==1.168.0" }, + { name = "shellcheck-py", specifier = "==0.11.0.1" }, + { name = "shfmt-py", specifier = "==4.0.0" }, + { name = "ty", specifier = "==0.0.56" }, + { name = "yamllint", specifier = "==1.38.0" }, ] [[package]] @@ -622,6 +596,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + [[package]] name = "peewee" version = "3.19.0" @@ -688,21 +671,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, - { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, - { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, - { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, - { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, - { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, - { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, - { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, - { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, - { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, - { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, - { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, - { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, - { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, @@ -760,11 +728,11 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.12.1" +version = "2.13.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, ] [package.optional-dependencies] @@ -774,7 +742,7 @@ crypto = [ [[package]] name = "pytest" -version = "9.0.3" +version = "9.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -783,9 +751,9 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] [[package]] @@ -811,14 +779,37 @@ name = "pywin32" version = "311" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, - { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, - { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, ] +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + [[package]] name = "referencing" version = "0.37.0" @@ -866,35 +857,6 @@ version = "2026.5.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/2e/43/25a8dcd3feedd735039a8f0b5b7e3b118232b5eae288c4fd9ab200d41094/rpds_py-2026.5.1.tar.gz", hash = "sha256:07b24fea40541e28570e5b795a4a38fbdcd12550c06bd0748005ecc8116ca256", size = 64459, upload-time = "2026-05-28T12:02:13.232Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6c/32/14c961ad295f490eb0849ada8b79683e93a59b9de3afdd983eaf55fa6867/rpds_py-2026.5.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:efef4ac29c6ff495531eb17ee705b62841ecaa291b7c7077e848ea03e237164d", size = 352787, upload-time = "2026-05-28T11:59:33.655Z" }, - { url = "https://files.pythonhosted.org/packages/ca/bb/d1b85117967c11191441a7274ae616c65d93901d082c588f89a50a8da5ae/rpds_py-2026.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c39f5b67a8a2e67179ada2a954227d670fe65fa9098457f698f56ddf248709b3", size = 345179, upload-time = "2026-05-28T11:59:35Z" }, - { url = "https://files.pythonhosted.org/packages/7c/46/d84105f062e626a1b233f863907288a4708c2d833b8b4c6fb2764bc080c0/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5c30f3f04eef4fbd362226a6f31d7c8895ca4fbb6e0b790f6890a98d8da8559", size = 376173, upload-time = "2026-05-28T11:59:36.43Z" }, - { url = "https://files.pythonhosted.org/packages/e2/ae/469d7959ce5b1201e1de135dc735b86db3b35dd0d1734f6a44246d5f061c/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:277f6c82f0580848796c7ecc8a7173aa3bfb928e4ff831261c2f60a81dc270db", size = 383162, upload-time = "2026-05-28T11:59:37.995Z" }, - { url = "https://files.pythonhosted.org/packages/dc/a2/57853d31a1116a561aa072794602ad3f6341e18d70a8523f1bd5b9fc1e5a/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:63c2c4c213f1a4e3f3de28ecab029dbdee976324e729c0d7a55211be72576b02", size = 495093, upload-time = "2026-05-28T11:59:39.453Z" }, - { url = "https://files.pythonhosted.org/packages/99/63/3a8eabcad9314b7daf5c65f451d2c33d989235cd8a5762186cf2c3f5a4f8/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3350ec808fb538fe71a1f94dfaa0e29c598dfad805ce49f0caec5ae3183c652b", size = 389829, upload-time = "2026-05-28T11:59:40.896Z" }, - { url = "https://files.pythonhosted.org/packages/4b/25/05678d97fc25e2622df14dc530fb82023174ecfff6733991ed0d78f167bd/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1b964e3ab599e718dc46c018d104b1ebc007cbc6567d827c94a687fca56d77e", size = 374786, upload-time = "2026-05-28T11:59:42.626Z" }, - { url = "https://files.pythonhosted.org/packages/88/d1/8c90b6431e80a3b91b284a5c7c8c0c4f9c006444d90477a740d6e0f9c694/rpds_py-2026.5.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:19cb09fab7b7fc96b2a6e28f2e34b72a3705ff27b37edb77455316e5d3f3dc9b", size = 386920, upload-time = "2026-05-28T11:59:44.124Z" }, - { url = "https://files.pythonhosted.org/packages/ff/99/4638f672ab356682d633ee0da9255f5b67ce6efd0b85eb94ad3e255e65a5/rpds_py-2026.5.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:abe76bcdba31e576cb83eeb8797aa0d882b738fef6dc65d0601fc753806a5b46", size = 405059, upload-time = "2026-05-28T11:59:47.177Z" }, - { url = "https://files.pythonhosted.org/packages/66/3f/3546524b6eb4cc2e1f363a3d638fa52f6c24faae3500c25fb488b02f1740/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8bff7073db3899158fff55ebf57b113a67030af26f80a18978f9f0aa60250ddf", size = 553030, upload-time = "2026-05-28T11:59:48.603Z" }, - { url = "https://files.pythonhosted.org/packages/c6/c3/7b3388c796fcf471bd17194242d4dc1a7608567c0fa422bcc1c5e79f9c1e/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8ba264fa49be666cd9cc56bf34ec7002fb3d27a4aee5bcb4d43d0d18feb1bb6f", size = 618975, upload-time = "2026-05-28T11:59:50.314Z" }, - { url = "https://files.pythonhosted.org/packages/61/1e/a3cb07f2795075d1d88efddae2f541359fde5f08c81ee114c29c2949c90a/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4860b603ddda0475a8885499b3729e90229d480105b42651962a5397d995fa89", size = 581178, upload-time = "2026-05-28T11:59:51.673Z" }, - { url = "https://files.pythonhosted.org/packages/a1/74/e758c03a5ef46f04c37f2651a2893db846d569ba8a7bca469d4b58939bcd/rpds_py-2026.5.1-cp313-cp313-win32.whl", hash = "sha256:7944270ae71383f6e2657dd7d5ce4eeb4ac2d0059a6738f0510583d462ab4842", size = 212481, upload-time = "2026-05-28T11:59:53.148Z" }, - { url = "https://files.pythonhosted.org/packages/70/ec/a2aca432db9c7359b40fa393eeeaa0d166c2f70175be956e75fa24197c44/rpds_py-2026.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:88647f43a73c4e01be19b04ceef0c8d3a1958153604d13c773becd8016f2a0cf", size = 228519, upload-time = "2026-05-28T11:59:54.505Z" }, - { url = "https://files.pythonhosted.org/packages/29/60/a73bfdd45b096574556acf303bbd9fa9eed36ca8a818b514e2a5d5fe2b9d/rpds_py-2026.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:453895624ecf7db7063b1004e44037522bbaef9ff6a945e59bc71662d7a03abd", size = 223446, upload-time = "2026-05-28T11:59:56.081Z" }, - { url = "https://files.pythonhosted.org/packages/18/e2/408105fd611823f00882aea810f3989a30d26b1bab8b6beb20f98c724e0e/rpds_py-2026.5.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:b4e4bc98639ec915f512fde3aa7a95e0041d95d9c3cc86eea841fa63cb1e8600", size = 355287, upload-time = "2026-05-28T11:59:57.448Z" }, - { url = "https://files.pythonhosted.org/packages/8d/58/5c4a43436843c90d0f6d19f82c200c80e3843ca9fa07b237623327f6d384/rpds_py-2026.5.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cacedb7a6e167680acba45ad5716e89067d225dc80da0d7040cae8c81d4572fa", size = 347033, upload-time = "2026-05-28T11:59:58.881Z" }, - { url = "https://files.pythonhosted.org/packages/fb/c2/1a71acdacaf4e259b10278fb87b039ded3cf80041bcd89dd8a3ea702ded6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68700371c5d7ae1412862ddfa719090925c93ecf351c566d66f09d04b136ea00", size = 376891, upload-time = "2026-05-28T12:00:00.516Z" }, - { url = "https://files.pythonhosted.org/packages/c2/c8/535f3d9b65addd8e28aa87b83c6e526799c3717a88273db8ea795beeef7a/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:296c799becfa849c779c8725494fe9ed94959ed886787df4364b058465bad7f0", size = 385646, upload-time = "2026-05-28T12:00:02.394Z" }, - { url = "https://files.pythonhosted.org/packages/1c/91/dc033f313345c354ade914dbe73cdb90b615a4409ea02430d5356794f3d8/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d3858b908218ee108d0bbfb2095ccc237648053c9bf98affad7cb079acaf1d97", size = 498830, upload-time = "2026-05-28T12:00:04.189Z" }, - { url = "https://files.pythonhosted.org/packages/27/fc/90fcbea459dbb8ddc18a2e0fd1de9412b48bc84ffff2db771cf714bacfd6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4fb8d2e7cb2f850b169806d61d1b991738acec96500a75c30f49caf064ce7cef", size = 392830, upload-time = "2026-05-28T12:00:05.797Z" }, - { url = "https://files.pythonhosted.org/packages/b2/1d/46cd11a228c9750684a798d98f878be6f614aa762438da7378f035e79e35/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27b74c10ed6a8f190f4287f53bcfea348b92a84a9c9f70d30183d1e6172d580d", size = 379613, upload-time = "2026-05-28T12:00:07.433Z" }, - { url = "https://files.pythonhosted.org/packages/24/4a/d9b0c6af3a1de03eb93741bbe8be2bdce84d8fda8224f3005451d86df389/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:b9a6528956191c48c52294a592dbd4a8386d7048bdb25c0efcb6b966466c6d83", size = 388183, upload-time = "2026-05-28T12:00:09.227Z" }, - { url = "https://files.pythonhosted.org/packages/c5/b4/db7aaabdda6d020afc87d981bcc2f57a434c7dec60ecfc2ab3dd50b20351/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:af03e34e860047bc7a352b842856fcf78798fbb81132cc98bd2f907ab4eb9cd2", size = 408578, upload-time = "2026-05-28T12:00:10.779Z" }, - { url = "https://files.pythonhosted.org/packages/08/d6/070f6a41cbb343e2ac4171859bf3f3623e0ab002f72619d6d505313ec2de/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fea6e836d10abbe191d557d33bd58bd5987725fe63aa1eefe557d230209855bd", size = 553573, upload-time = "2026-05-28T12:00:12.443Z" }, - { url = "https://files.pythonhosted.org/packages/75/ab/1a71ea3589c4345dac0a0518f0e6a031cb42689277851b683c46d27463a5/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:fc0c0f878ea770a0a8a462456c5ad36fc9fe6358e6b76fdadc7f17575e0b8bf1", size = 620861, upload-time = "2026-05-28T12:00:14.09Z" }, - { url = "https://files.pythonhosted.org/packages/8a/22/9bf80a56069c0c443fcfefac639a86a744550a2898817a6dfd3e26654924/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e0b360f316d966b048b085857630b3cc51f3db2f07b06f440eac8f695374d1e3", size = 585633, upload-time = "2026-05-28T12:00:15.66Z" }, - { url = "https://files.pythonhosted.org/packages/da/68/3b2c0a75c9e04125696f84ebdbbf304acf5a40b58ba4481cdb98a922c3ba/rpds_py-2026.5.1-cp313-cp313t-win32.whl", hash = "sha256:a2999883eedf72fdfb7520b92c7d4ec2572a71ff40239377aa604cc529eecafc", size = 210074, upload-time = "2026-05-28T12:00:17.291Z" }, - { url = "https://files.pythonhosted.org/packages/e7/8b/609157d5a25d37d4f29f92840ba531f416907c34ae5c5739dd21fc2bef98/rpds_py-2026.5.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e07be2a9d7122bd6e82dea89814ef8dc893feb1aae97fec1630f3263bbb30e55", size = 228635, upload-time = "2026-05-28T12:00:18.73Z" }, { url = "https://files.pythonhosted.org/packages/d4/6f/19c1918a4b590d8de87e712e4abe4b3875771eff60216fb6153cf6665c68/rpds_py-2026.5.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1f2c391c3059798093b65df23aca2cac150460ae9c630d99dec83d703d9485b9", size = 349756, upload-time = "2026-05-28T12:00:20.217Z" }, { url = "https://files.pythonhosted.org/packages/e5/60/a06fe7da34eca79dacbf958a2ba0c6eea85bc2b29de20080bf40f72f66fa/rpds_py-2026.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:413b424f7c4ee65ab5e5be91f5731be0f8b41a1ee2b12dfe810d716312e95a78", size = 343831, upload-time = "2026-05-28T12:00:21.711Z" }, { url = "https://files.pythonhosted.org/packages/bf/ec/b2333b97b90e2a6ef6ca8ad386ee284968e74bcfe113b3f1a8d9036429a9/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c595a1d9255dce0599e13130d1440ab2506654f2b50294226ee06402f8fef63", size = 375127, upload-time = "2026-05-28T12:00:23.326Z" }, @@ -970,16 +932,6 @@ version = "0.2.15" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/ea/97/60fda20e2fb54b83a61ae14648b0817c8f5d84a3821e40bfbdae1437026a/ruamel_yaml_clib-0.2.15.tar.gz", hash = "sha256:46e4cc8c43ef6a94885f72512094e482114a8a706d3c555a34ed4b0d20200600", size = 225794, upload-time = "2025-11-16T16:12:59.761Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/17/5e/2f970ce4c573dc30c2f95825f2691c96d55560268ddc67603dc6ea2dd08e/ruamel_yaml_clib-0.2.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dcec721fddbb62e60c2801ba08c87010bd6b700054a09998c4d09c08147b8fb", size = 147450, upload-time = "2025-11-16T16:13:33.542Z" }, - { url = "https://files.pythonhosted.org/packages/d6/03/a1baa5b94f71383913f21b96172fb3a2eb5576a4637729adbf7cd9f797f8/ruamel_yaml_clib-0.2.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:65f48245279f9bb301d1276f9679b82e4c080a1ae25e679f682ac62446fac471", size = 133139, upload-time = "2025-11-16T16:13:34.587Z" }, - { url = "https://files.pythonhosted.org/packages/dc/19/40d676802390f85784235a05788fd28940923382e3f8b943d25febbb98b7/ruamel_yaml_clib-0.2.15-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:46895c17ead5e22bea5e576f1db7e41cb273e8d062c04a6a49013d9f60996c25", size = 731474, upload-time = "2025-11-16T20:22:49.934Z" }, - { url = "https://files.pythonhosted.org/packages/ce/bb/6ef5abfa43b48dd55c30d53e997f8f978722f02add61efba31380d73e42e/ruamel_yaml_clib-0.2.15-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3eb199178b08956e5be6288ee0b05b2fb0b5c1f309725ad25d9c6ea7e27f962a", size = 748047, upload-time = "2025-11-16T16:13:35.633Z" }, - { url = "https://files.pythonhosted.org/packages/ff/5d/e4f84c9c448613e12bd62e90b23aa127ea4c46b697f3d760acc32cb94f25/ruamel_yaml_clib-0.2.15-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d1032919280ebc04a80e4fb1e93f7a738129857eaec9448310e638c8bccefcf", size = 782129, upload-time = "2025-11-16T16:13:36.781Z" }, - { url = "https://files.pythonhosted.org/packages/de/4b/e98086e88f76c00c88a6bcf15eae27a1454f661a9eb72b111e6bbb69024d/ruamel_yaml_clib-0.2.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab0df0648d86a7ecbd9c632e8f8d6b21bb21b5fc9d9e095c796cacf32a728d2d", size = 736848, upload-time = "2025-11-16T16:13:37.952Z" }, - { url = "https://files.pythonhosted.org/packages/0c/5c/5964fcd1fd9acc53b7a3a5d9a05ea4f95ead9495d980003a557deb9769c7/ruamel_yaml_clib-0.2.15-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:331fb180858dd8534f0e61aa243b944f25e73a4dae9962bd44c46d1761126bbf", size = 741630, upload-time = "2025-11-16T20:22:51.718Z" }, - { url = "https://files.pythonhosted.org/packages/07/1e/99660f5a30fceb58494598e7d15df883a07292346ef5696f0c0ae5dee8c6/ruamel_yaml_clib-0.2.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd4c928ddf6bce586285daa6d90680b9c291cfd045fc40aad34e445d57b1bf51", size = 766619, upload-time = "2025-11-16T16:13:39.178Z" }, - { url = "https://files.pythonhosted.org/packages/36/2f/fa0344a9327b58b54970e56a27b32416ffbcfe4dcc0700605516708579b2/ruamel_yaml_clib-0.2.15-cp313-cp313-win32.whl", hash = "sha256:bf0846d629e160223805db9fe8cc7aec16aaa11a07310c50c8c7164efa440aec", size = 100171, upload-time = "2025-11-16T16:13:40.456Z" }, - { url = "https://files.pythonhosted.org/packages/06/c4/c124fbcef0684fcf3c9b72374c2a8c35c94464d8694c50f37eef27f5a145/ruamel_yaml_clib-0.2.15-cp313-cp313-win_amd64.whl", hash = "sha256:45702dfbea1420ba3450bb3dd9a80b33f0badd57539c6aac09f42584303e0db6", size = 118845, upload-time = "2025-11-16T16:13:41.481Z" }, { url = "https://files.pythonhosted.org/packages/3e/bd/ab8459c8bb759c14a146990bf07f632c1cbec0910d4853feeee4be2ab8bb/ruamel_yaml_clib-0.2.15-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:753faf20b3a5906faf1fc50e4ddb8c074cb9b251e00b14c18b28492f933ac8ef", size = 147248, upload-time = "2025-11-16T16:13:42.872Z" }, { url = "https://files.pythonhosted.org/packages/69/f2/c4cec0a30f1955510fde498aac451d2e52b24afdbcb00204d3a951b772c3/ruamel_yaml_clib-0.2.15-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:480894aee0b29752560a9de46c0e5f84a82602f2bc5c6cde8db9a345319acfdf", size = 133764, upload-time = "2025-11-16T16:13:43.932Z" }, { url = "https://files.pythonhosted.org/packages/82/c7/2480d062281385a2ea4f7cc9476712446e0c548cd74090bff92b4b49e898/ruamel_yaml_clib-0.2.15-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d3b58ab2454b4747442ac76fab66739c72b1e2bb9bd173d7694b9f9dbc9c000", size = 730537, upload-time = "2025-11-16T20:22:52.918Z" }, @@ -994,27 +946,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.15" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/84/6f/a76f7d96e5c962f5b69cee865e49c15c1116897c01990faa8a57edb62e7f/ruff-0.15.15.tar.gz", hash = "sha256:b8dff018130b46d8e5bf0f926ef6b60cf871d6d5ae45fc9334e09632daa741d6", size = 4706985, upload-time = "2026-05-28T14:16:57.784Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/9d/3a45c05b8ab04b4705989de70a79008e27c8003296a0feaee9edc18dd7e9/ruff-0.15.15-py3-none-linux_armv6l.whl", hash = "sha256:cf93e5388f412e1b108b1f8b34a6e036b70fe8aff89393befad96fe48670311b", size = 10710652, upload-time = "2026-05-28T14:16:06.701Z" }, - { url = "https://files.pythonhosted.org/packages/05/66/da974431624bf3b49f6ee1f9543c02d929ff1cba78b0d5a79c38cf21f744/ruff-0.15.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ac5a646d1f6a7dadd5d50842dae2c1f9862ac887ef5d1b1375e02def791fde6e", size = 11096615, upload-time = "2026-05-28T14:16:23.313Z" }, - { url = "https://files.pythonhosted.org/packages/8c/09/7443452e5d290230a712103f2fdceeef7184f3ec99a2bd01c8be78aaceb5/ruff-0.15.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:77d955a431430c66f72dd94e379ad38a16daea3d25094872ac4edf9e797be530", size = 10436683, upload-time = "2026-05-28T14:16:40.974Z" }, - { url = "https://files.pythonhosted.org/packages/53/01/d330c26a57fa4f3943a14424904027428315b700fe4d14a84bb123a649e5/ruff-0.15.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7614ee79c69788cf6cedd568069ade9cecc22a1ad20494efe8d0c9ebb4b622d4", size = 10769064, upload-time = "2026-05-28T14:16:28.905Z" }, - { url = "https://files.pythonhosted.org/packages/1d/85/cc8770f8bdff541b1da8392d1634141fe4a0e3f4ee596605959b7906c27f/ruff-0.15.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3cdb1679e06a1f6b47bc384714ae96f6e2fb65ca441eb78c43d2ca554176ce1f", size = 10511987, upload-time = "2026-05-28T14:16:43.732Z" }, - { url = "https://files.pythonhosted.org/packages/7c/29/8c190c1472b63013583ba391f3342036e02010544c1270455ed8e519bdf3/ruff-0.15.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2728b93d7b23a603ea2c0ac6eb73d760bd38ec9de35f35fb41e18f7a3fee7622", size = 11275100, upload-time = "2026-05-28T14:16:55.244Z" }, - { url = "https://files.pythonhosted.org/packages/9f/6b/7e145ce2cc8e63d6834eca03d83a0e18d121def5c69f91b4cf4011ed4879/ruff-0.15.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be582fcc0db438902c7792b08d6ddf6c9b9e21addaa10092c2c741cfb09e5a45", size = 12176903, upload-time = "2026-05-28T14:16:14.368Z" }, - { url = "https://files.pythonhosted.org/packages/80/a3/d5974637f68e451f7fadf015cf3101d1cd7d8ba5027cffe0b9e3826ebe6b/ruff-0.15.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7aa77465b8ecaf1a27bea098d696f7fed5e1eccbd10b321b682d6de586ae5627", size = 11404550, upload-time = "2026-05-28T14:16:20.138Z" }, - { url = "https://files.pythonhosted.org/packages/fe/1c/e6e5e568f22be4fb05d6244234aba384c06b451252453b821e1a529263cf/ruff-0.15.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48decfa11d740de4889de623be1463308346312f2409a56e24aa280c86162dc4", size = 11382027, upload-time = "2026-05-28T14:16:46.615Z" }, - { url = "https://files.pythonhosted.org/packages/1d/01/170921b49fcd2e8858825593f91cf7146c3e40a5c3e6df763e4bb0484dde/ruff-0.15.15-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a5015088452ca0081387063649ec67f06d3d1d6b8b936a1f836b5e9657ecd48c", size = 11366041, upload-time = "2026-05-28T14:16:26.247Z" }, - { url = "https://files.pythonhosted.org/packages/87/54/a7bad711d7de93254e15e06a4c375b89a03d18de45d3e5dcc86a4472fb1a/ruff-0.15.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f5294aab6356c81600fcdea3a62bb1b924dfd5e91767c12318d3f68f86af57cd", size = 10741795, upload-time = "2026-05-28T14:16:17.11Z" }, - { url = "https://files.pythonhosted.org/packages/c9/31/38c075963668f8b41c6914ee0f6f318727fbe30ab9145cb29e6df464c5fa/ruff-0.15.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:db5bd4d802415cca656dc1616070b725952d6ae95eb5d4831e49fbd94a38f75f", size = 10511117, upload-time = "2026-05-28T14:16:31.767Z" }, - { url = "https://files.pythonhosted.org/packages/9d/96/6ff689e1f7e375d1d97075eca022f74c2bab59554a432fe4d2e6f091986a/ruff-0.15.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:587a6278ed42059191c1a466e490bd7930fb50bd2e255398bc29616c895a61cb", size = 10994867, upload-time = "2026-05-28T14:16:35.149Z" }, - { url = "https://files.pythonhosted.org/packages/c3/c2/5dce0ab9f92a8d534fa62b9bf9caca3eddb8c1a81b616f5e195ada4f0d6e/ruff-0.15.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:df0c1c084f5f4be9812f61518a45c440d3c30d69ce4bf6c5270e66d38338f02a", size = 11482101, upload-time = "2026-05-28T14:16:49.598Z" }, - { url = "https://files.pythonhosted.org/packages/b1/c0/1003b60edd697c649faf61f1a34094b1abb38fb3d1181e3f895781250a08/ruff-0.15.15-py3-none-win32.whl", hash = "sha256:29428ea79694afbe756d45fd59b36f22b6b020dc0443cf7de0173046236964b9", size = 10716774, upload-time = "2026-05-28T14:16:52.337Z" }, - { url = "https://files.pythonhosted.org/packages/02/a8/1269eddd6945a06c23f055ef7848886e37cf9d6a8bebb386a3115f01470c/ruff-0.15.15-py3-none-win_amd64.whl", hash = "sha256:8df0323902e15e24bc4bf246da830573d3cf3352bd0b9a164eab335d111ff4a4", size = 11868463, upload-time = "2026-05-28T14:16:11.333Z" }, - { url = "https://files.pythonhosted.org/packages/4e/b2/920464c907b191e37469d477a1aa8bc048b8f36c4c1610dfa4ab87b39e18/ruff-0.15.15-py3-none-win_arm64.whl", hash = "sha256:3c8ceca6792f38196b8f589bc92eccd03eef286602da92e5dc05cc42ef6441b7", size = 11138498, upload-time = "2026-05-28T14:16:38.425Z" }, +version = "0.15.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" }, + { url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" }, + { url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" }, + { url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" }, + { url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" }, + { url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" }, + { url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" }, + { url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" }, + { url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" }, ] [[package]] @@ -1028,7 +980,7 @@ wheels = [ [[package]] name = "semgrep" -version = "1.164.0" +version = "1.168.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, @@ -1059,15 +1011,40 @@ dependencies = [ { name = "urllib3" }, { name = "wcmatch" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/90/ce/6b778f43cc0896c4515f87bf48cb42da689de6539a75e1a0d4a4486b1a24/semgrep-1.164.0.tar.gz", hash = "sha256:a197bda58931d2e223e2ab7e45c3fd7b9ac37abd69516184ae09f1512f2d9b12", size = 55549587, upload-time = "2026-05-27T14:38:34.544Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ed/33/c40bd1f104d66817c082773cb9b6ba65ff50c53ccd118f8da747205dac0b/semgrep-1.168.0.tar.gz", hash = "sha256:a072b1734b5c54e39cbbe957b10cf0b83a114b9b9fc762b4ea51afe2f114cd11", size = 497911, upload-time = "2026-06-24T19:37:38.805Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/04/bfeed9429e302db73c54b1f06993361b7abd5d9e4abcdfee0031e35f90a3/semgrep-1.168.0-cp310.cp311.cp312.cp313.cp314.py310.py311.py312.py313.py314-none-macosx_10_14_x86_64.whl", hash = "sha256:1a5d6b5b47347b22bcb1d6f15a5e51a21e734244884624c3494e09bc1de955cd", size = 45011229, upload-time = "2026-06-24T19:38:58.978Z" }, + { url = "https://files.pythonhosted.org/packages/f6/61/c3aaf9b1d435707af74f7008063402107b9e2532337aa8b7cffea00e11bc/semgrep-1.168.0-cp310.cp311.cp312.cp313.cp314.py310.py311.py312.py313.py314-none-macosx_11_0_arm64.whl", hash = "sha256:e6b85d84e815ff86f56dcb6b4a6370b98f1bc0e402144daafa9b41aa00ea9a90", size = 49030891, upload-time = "2026-06-24T19:39:02.608Z" }, + { url = "https://files.pythonhosted.org/packages/76/31/e4545bb66c7334660541ababf771189a123259f6bf5d945d20ca2045d2ab/semgrep-1.168.0-cp310.cp311.cp312.cp313.cp314.py310.py311.py312.py313.py314-none-manylinux_2_34_aarch64.whl", hash = "sha256:fa22f0a41ee3857ecec1b3883b2920b1d41d07ef1bdd7e216da3c519210ae5fb", size = 70903799, upload-time = "2026-06-24T19:39:05.888Z" }, + { url = "https://files.pythonhosted.org/packages/01/6c/3398532fe8ced8d3f01fad16231f496b878c83c534d9266df1cbe4aaea35/semgrep-1.168.0-cp310.cp311.cp312.cp313.cp314.py310.py311.py312.py313.py314-none-manylinux_2_34_x86_64.whl", hash = "sha256:09dfacb0530ed4a17bd2deb7914e9a25fc3581d5d84d5365cdac77bbebed8081", size = 68761991, upload-time = "2026-06-24T19:39:09.255Z" }, + { url = "https://files.pythonhosted.org/packages/35/d4/bff2a3216900c4d564ca84fb2f4ca2811e2127b684edd90124e07b68732d/semgrep-1.168.0-cp310.cp311.cp312.cp313.cp314.py310.py311.py312.py313.py314-none-musllinux_1_2_aarch64.whl", hash = "sha256:d288699c4c056cc3d7cf82830e00f9c07fac33227c3a5290a2113bd1be63da46", size = 77644734, upload-time = "2026-06-24T19:39:12.826Z" }, + { url = "https://files.pythonhosted.org/packages/b1/7c/e10a59a82120eb2561281edbbb688419688d8c9b589ca9a32bd47968ae2e/semgrep-1.168.0-cp310.cp311.cp312.cp313.cp314.py310.py311.py312.py313.py314-none-musllinux_1_2_x86_64.whl", hash = "sha256:c4a664f4dda097fbdf657d64f79217e94c4d87502c16c991fac569f07aaecb8e", size = 75161809, upload-time = "2026-06-24T19:39:16.695Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b4/3fc0345031d40f49354b1052ac4faf603dcb92019dacdc4b8fd4e3639b9b/semgrep-1.168.0-cp310.cp311.cp312.cp313.cp314.py310.py311.py312.py313.py314-none-win_amd64.whl", hash = "sha256:86e99b095b80492b6cfe5087bf4a3b0175a1132c9c9dcd25a96c3745cc58d85a", size = 56931035, upload-time = "2026-06-24T19:39:20.217Z" }, +] + +[[package]] +name = "shellcheck-py" +version = "0.11.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/55/455b097417b3df3d330eff029c72c32f08b25739e3010acb30ad06d268ef/shellcheck_py-0.11.0.1.tar.gz", hash = "sha256:5c620c88901e8f1d3be5934b31ea99e3310065e1245253741eafd0a275c8c9cc", size = 3139, upload-time = "2025-08-09T17:53:42.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/27/d75b03e5458cefdb6d3b674566cd20476c3e4d3fe6cc9d68b7e3b854b296/shellcheck_py-0.11.0.1-py2.py3-none-macosx_10_9_x86_64.whl", hash = "sha256:b6a3fee28efda2e16e38d6e6d59faf7224300256456639727370d404730849e8", size = 6774472, upload-time = "2025-08-09T17:53:34.573Z" }, + { url = "https://files.pythonhosted.org/packages/61/ac/2a84c37171c0cf5a10ea4b0a27d43eb0a1d29bd98b49c2c5ffe17ad24bbe/shellcheck_py-0.11.0.1-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:6b88d0a244c82ed07e06a53e444da841f69330ca59ae15d4a66c391655dae7a0", size = 11381835, upload-time = "2025-08-09T17:53:36.852Z" }, + { url = "https://files.pythonhosted.org/packages/96/55/250e0e3367613a5c22bd82e33b16b889287d81ab0f7dda67e6514a4cccf4/shellcheck_py-0.11.0.1-py2.py3-none-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1b274df81de5b000ff78db433e7328b87e52e3c38481c60f8e488c3095beef05", size = 3800600, upload-time = "2025-08-09T17:53:38.643Z" }, + { url = "https://files.pythonhosted.org/packages/15/5b/bb14c0a7474463b1aa3c09e866cb172dffc66ed2993b7ea8f1db581e86ee/shellcheck_py-0.11.0.1-py2.py3-none-win_amd64.whl", hash = "sha256:784156289ecb17e91c692cd783ab5152333309588cabb10032a047331c63e759", size = 8027541, upload-time = "2025-08-09T17:53:40.889Z" }, +] + +[[package]] +name = "shfmt-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/d5/c2ad5c6593a34da7344cf39bde65763e8cda752589074ba1619e55b317ad/shfmt_py-4.0.0.tar.gz", hash = "sha256:1e5fdacf40aabaa77a97639d52a6220df0893b46658d82b7f136f4e66e2b2fb0", size = 11947, upload-time = "2026-05-13T09:25:50.153Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dd/cd/61521a6a593d6eea8c3f12374f2cb1d5c007915ebe33f47de06d406593c9/semgrep-1.164.0-cp310.cp311.cp312.cp313.cp314.py310.py311.py312.py313.py314-none-macosx_10_14_x86_64.whl", hash = "sha256:3b4912696a2ee467a8568d5430fe09da6e1101039052a03e606dcf68ebe37c0a", size = 44965926, upload-time = "2026-05-27T14:38:06.867Z" }, - { url = "https://files.pythonhosted.org/packages/8f/98/f87f5a32e8798410293e5b01e75d4fb69f27e57eba8e4a5367774d7f7865/semgrep-1.164.0-cp310.cp311.cp312.cp313.cp314.py310.py311.py312.py313.py314-none-macosx_11_0_arm64.whl", hash = "sha256:02c2b10395a6cb7344409d244baca5137dddd16f3a7bd178f786a2224c32adc0", size = 48896546, upload-time = "2026-05-27T14:38:10.844Z" }, - { url = "https://files.pythonhosted.org/packages/d8/82/1cc9e1bac12b8ce407f9f3b6204f09a95a6d05a657fa869e139e98dcdb18/semgrep-1.164.0-cp310.cp311.cp312.cp313.cp314.py310.py311.py312.py313.py314-none-manylinux_2_34_aarch64.whl", hash = "sha256:376e04f713b244eee95e8a51b3e81240cda7c07136d7819f727b5ca07ec7eca9", size = 70762505, upload-time = "2026-05-27T14:38:14.591Z" }, - { url = "https://files.pythonhosted.org/packages/aa/3f/df691763c0ccc0b02360647dbbcc7ccb6622aa69bd9f62a7816cdd909c7c/semgrep-1.164.0-cp310.cp311.cp312.cp313.cp314.py310.py311.py312.py313.py314-none-manylinux_2_34_x86_64.whl", hash = "sha256:745ae5ce1bef7c9b03c92b431d174ca6ee7801a5e2a047c64ac2104c7e6952fb", size = 68651973, upload-time = "2026-05-27T14:38:18.174Z" }, - { url = "https://files.pythonhosted.org/packages/41/e5/fe4c0b307283a643d4483fd6ac918b2ad7fc60f0081d8ea683fb490bb6f9/semgrep-1.164.0-cp310.cp311.cp312.cp313.cp314.py310.py311.py312.py313.py314-none-musllinux_1_2_aarch64.whl", hash = "sha256:341d3b32dbb652dcaa5ebe6f47f2bf79af1555bf357f2e14b2c54553b59e30d6", size = 78677869, upload-time = "2026-05-27T14:38:22.219Z" }, - { url = "https://files.pythonhosted.org/packages/f5/3b/740d597bb217ddaacc6f9c5b732fcf925246a3ceb964c11852ddc9c7ff11/semgrep-1.164.0-cp310.cp311.cp312.cp313.cp314.py310.py311.py312.py313.py314-none-musllinux_1_2_x86_64.whl", hash = "sha256:7ad8550639c9f7881d9551919b09aab86cfd0464417f113cfd6290d9f9cfcc4d", size = 76186419, upload-time = "2026-05-27T14:38:25.944Z" }, - { url = "https://files.pythonhosted.org/packages/f5/09/9fcb561fae64b7ba7dcb98139739e9208d7c6ad14b731669bd796fbc3ee3/semgrep-1.164.0-cp310.cp311.cp312.cp313.cp314.py310.py311.py312.py313.py314-none-win_amd64.whl", hash = "sha256:f0fc972bd0e33893ef73e8144c2c2293f7acafa86c00cca4e22673bc1b6c35ca", size = 56482229, upload-time = "2026-05-27T14:38:30.043Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1d/8f72824e2a0e06dc0bc2687baacaba0573be7d2e93c01d1e895fddd8c13e/shfmt_py-4.0.0-py2.py3-none-macosx_10_9_x86_64.whl", hash = "sha256:75a4919a03fb3bcff9795e3cc7b971e37e74905654d2f11605001cab42e5f92f", size = 1343695, upload-time = "2026-05-13T09:25:42.969Z" }, + { url = "https://files.pythonhosted.org/packages/a8/82/9564a2c2a76fbec94db1b3a3c37a9a1d00e7eafca2cdd2e0d19082618d7e/shfmt_py-4.0.0-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:bb3d236163ff39c7790953e069938caf247e7646399f7a059f00f65d4e6916d6", size = 1237947, upload-time = "2026-05-13T09:25:44.767Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a9/6fce944efa530db941edd11388d70dc7384aaf12169a3b0847b6a6c987b0/shfmt_py-4.0.0-py2.py3-none-manylinux2014_aarch64.whl", hash = "sha256:4701336c3cb5f3959a5e85481b14f02054ea094b3c666f3d04649bbe10de3c25", size = 1218771, upload-time = "2026-05-13T09:25:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/64/43/e3965a25bb39555f2791c6860214f62b6f976f9ac7e9786073364bcdd9a6/shfmt_py-4.0.0-py2.py3-none-manylinux2014_x86_64.whl", hash = "sha256:e57877abe0177a9da7bbb5390fe7e96aa19b00958189a025634039aef8834d44", size = 1350939, upload-time = "2026-05-13T09:25:47.584Z" }, + { url = "https://files.pythonhosted.org/packages/95/20/db2430d9262d2cffadcad2b330441e13031f1ab849ec069659edb7f23257/shfmt_py-4.0.0-py2.py3-none-win_amd64.whl", hash = "sha256:bd4f3d36264d4ba8b014ff73e5e702aaa2345845c021f563480128de3705135b", size = 1427721, upload-time = "2026-05-13T09:25:48.865Z" }, ] [[package]] @@ -1101,15 +1078,6 @@ version = "2.4.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, - { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, - { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, - { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, - { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, - { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, - { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, - { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, - { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, @@ -1133,27 +1101,27 @@ wheels = [ [[package]] name = "ty" -version = "0.0.42" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/91/5b5ec4ed8721c18be8d9611778d7c07723cd755676f03b41bf0ea0caa5d3/ty-0.0.42.tar.gz", hash = "sha256:70f5553ac678fc63558d4d77b08a18a68a228f44be2a2fe1afc3f5988db662e7", size = 5769116, upload-time = "2026-06-01T19:40:32.869Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/7c/2df5136ad7c0db69a3973b0b19da8f52bfacdc453c7dffc832d1bf7d23ff/ty-0.0.42-py3-none-linux_armv6l.whl", hash = "sha256:c08a0066610c13627b7d7ad758adb96ca99685791e641eb26837e20803851c53", size = 11544141, upload-time = "2026-06-01T19:40:41.065Z" }, - { url = "https://files.pythonhosted.org/packages/0a/82/96cf406d39d8976e825361a27e332224445812793060ac9506d8a5d32b39/ty-0.0.42-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3e944ee4e3d5cdaf70e4ea87f9dd474cc3db612837b50a3ce57afa8da400ecc2", size = 11283538, upload-time = "2026-06-01T19:40:26.758Z" }, - { url = "https://files.pythonhosted.org/packages/dc/fe/813b60b9332df835c16c05859ff5aac1896593d01b638ba0e461ede415ac/ty-0.0.42-py3-none-macosx_11_0_arm64.whl", hash = "sha256:603085306e4aac2ce592b39119a4b49ebf8b780cd394e2cfc7dbf3fd8228f954", size = 10711874, upload-time = "2026-06-01T19:40:28.77Z" }, - { url = "https://files.pythonhosted.org/packages/07/64/1f609265be0302ce0f51aa03a27636d018947a76100ff1405258d8445e6c/ty-0.0.42-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a58f17834d7f078c49326a01111a5aac16c979a774b98cdfd8e2350068316676", size = 11213021, upload-time = "2026-06-01T19:40:45.01Z" }, - { url = "https://files.pythonhosted.org/packages/88/c0/f147b2fde7cd01b5f77682937e85a5cdfe35f48b3f1d0f41021024cbd927/ty-0.0.42-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e6ed1027f313202c5c74e376007d1eb5d214494299beb0ea047078b8ad307d40", size = 11321604, upload-time = "2026-06-01T19:40:55.164Z" }, - { url = "https://files.pythonhosted.org/packages/a9/72/74a5e68a9bd194681f15c4aac7a0dfab378e76e7a107e8ecd93971a22377/ty-0.0.42-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:063838d2360c1d2c065b45ca76a56ecd6df07fff6570813e74183236559e16d9", size = 11802178, upload-time = "2026-06-01T19:40:19.558Z" }, - { url = "https://files.pythonhosted.org/packages/3c/9f/06a31dc9cc91faa2cb8a4bf2f0ba5f7a9a96a4828fb8434338682954ca86/ty-0.0.42-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c3f9ed508dfae4cbc943d7766324dd9c57ac8302c8543505fc29cae8ed425fe9", size = 12358436, upload-time = "2026-06-01T19:40:48.968Z" }, - { url = "https://files.pythonhosted.org/packages/06/33/a5bb1afcb671e0b9197f007264682b22f1063bf0e83c151eeaf9958f9047/ty-0.0.42-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fe1eb7d98472ac56ac19fec51c6ed8fe56d86ea0d232a11a127e8c62c882a66", size = 11997849, upload-time = "2026-06-01T19:40:42.951Z" }, - { url = "https://files.pythonhosted.org/packages/ee/8d/560e4ec4c2f69e68fa094bb93cd19b5eb92c9732d2e0f5e7cc3accde84c4/ty-0.0.42-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de75f9e78bfa81209f2f297528758977cfd4518ba35ef45a0acb516c892a27a5", size = 11869087, upload-time = "2026-06-01T19:40:24.38Z" }, - { url = "https://files.pythonhosted.org/packages/b2/dd/3794db15199c03eda60961690046a56c4fbf5d8ef073c82fe2402c851b8c/ty-0.0.42-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:c63281f2f1d4df339117fcd4a6dfe17cb999f84eafe707b30e9ebbe26f0bb54a", size = 12059000, upload-time = "2026-06-01T19:40:34.982Z" }, - { url = "https://files.pythonhosted.org/packages/98/9a/02b61cc65ecbd90f18bd02178845c5b756c78fb92643571e77df52c6eed8/ty-0.0.42-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:19e3856477f25255f772851fa7f16f5356c4e1927324d074d49c7bc9a9b211e1", size = 11195698, upload-time = "2026-06-01T19:40:37.109Z" }, - { url = "https://files.pythonhosted.org/packages/7b/d6/3927335c956b06a806269dcd2e5b46bc4284b0a95ecc6b0246094a1de28c/ty-0.0.42-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2f0f4acac9028264cee5ea0b88229df0b9b2586fc917dbadb6ee35a0e99e8b06", size = 11353487, upload-time = "2026-06-01T19:40:47.035Z" }, - { url = "https://files.pythonhosted.org/packages/d6/87/79e7ae4f5f9fb3bea5f3cfac2b4f8c60e905f13962e3c0d97f8b51a5bff6/ty-0.0.42-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ae244c84e30fdf2bb1a3cbf2b973da8aa535e57c701f12db44b2939604586c04", size = 11463474, upload-time = "2026-06-01T19:40:30.812Z" }, - { url = "https://files.pythonhosted.org/packages/49/f9/73305ead1b3ccc3d79c04258877db2cd7908c6a6c2060d4e598deca384d2/ty-0.0.42-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:2430434f4a52bec0da552ff6a061dcc1c5d11973259248679a1146d776c12f37", size = 11961710, upload-time = "2026-06-01T19:40:39.056Z" }, - { url = "https://files.pythonhosted.org/packages/e1/04/52b7325dad8d1a86653f90240208f3e3657bed5e3c8144eed367a0f736b0/ty-0.0.42-py3-none-win32.whl", hash = "sha256:984a55c2fe63b40dac03f5a144b99033c7ed720eb7611787e3f0bd49af8dcf12", size = 10783897, upload-time = "2026-06-01T19:40:22.189Z" }, - { url = "https://files.pythonhosted.org/packages/94/d2/3d2d61255c76c0843766f00b39290115c23cc1cd4fcb0471d84a48f482f1/ty-0.0.42-py3-none-win_amd64.whl", hash = "sha256:f7afd81b10b377d9d4ce6aad355a4f47fd37d47f443118c01ca6e79d46fe6608", size = 11878640, upload-time = "2026-06-01T19:40:50.903Z" }, - { url = "https://files.pythonhosted.org/packages/b4/91/1eb0c1e3d558707ead7424f8bfd89b58f42e576714cbed7ad46dcceef34a/ty-0.0.42-py3-none-win_arm64.whl", hash = "sha256:4068c24b0b264fc9f1901e06b97988a041fcaa36c90f18d7747f05124701c7b3", size = 11202335, upload-time = "2026-06-01T19:40:53.155Z" }, +version = "0.0.56" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/55/07/fb29aea5235b0aa8ecfc4d1cc6ddf9fba8b863d67d96c6d345694d644c43/ty-0.0.56.tar.gz", hash = "sha256:84d114dc3796361c0fc72945016eabd74d46b9ee64f198cb0e485719704681e5", size = 6050123, upload-time = "2026-07-01T16:44:56.036Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/48/bce79e7ca5c1cc529d3e0d37ddd1121aea4b68a4f749974ad1cc77161871/ty-0.0.56-py3-none-linux_armv6l.whl", hash = "sha256:186d4a53e15747c947e1ec3d7eec8e345d8e40a1ca10e634c585db52497e87dd", size = 11643066, upload-time = "2026-07-01T16:44:18.374Z" }, + { url = "https://files.pythonhosted.org/packages/80/d1/22555d8a1d719661f10050f3865d877bbf497da908961c75fe22217dd18a/ty-0.0.56-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:aae1a980fd9535da0469b7ba2b2e1b54a907743a5e0f442dd57eee9f5bfd034c", size = 11407487, upload-time = "2026-07-01T16:44:20.956Z" }, + { url = "https://files.pythonhosted.org/packages/cf/2d/b3b7a74ce8bc59ef48843ad80179bb0d9598bbd6cfc0d11d519bdf6b1352/ty-0.0.56-py3-none-macosx_11_0_arm64.whl", hash = "sha256:afd3058c0a6c5f241e814734f133008c93ee805f61c9cf4ce7412b8822b5d9ad", size = 10962270, upload-time = "2026-07-01T16:44:22.959Z" }, + { url = "https://files.pythonhosted.org/packages/64/ac/6c2fd7de0304a8a7218a756af74f7e62a5e8540fdb175e0a869e51042345/ty-0.0.56-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:058b52f7a823ac13aae3cae30809dd6b5145794b64d8478f9ef38c75d79b4483", size = 11471406, upload-time = "2026-07-01T16:44:25.327Z" }, + { url = "https://files.pythonhosted.org/packages/50/b6/11d861156861c03c7726b74558f9a0e0092661aff83a4fda1279df28c425/ty-0.0.56-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c66e00c1522add1f2bbdd2e45828c953b35c306b7bef03ec9169c75a63699a0", size = 11445612, upload-time = "2026-07-01T16:44:27.531Z" }, + { url = "https://files.pythonhosted.org/packages/fb/ba/09df108582090f3c0770ec4bc8675affed60248f6793a78d909be16211d9/ty-0.0.56-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40903d71c669a30691b5a5d5728056c7877a1bd6be4f233a38883a8b28cf34d7", size = 12093889, upload-time = "2026-07-01T16:44:29.548Z" }, + { url = "https://files.pythonhosted.org/packages/d7/f7/dbb4b4ccb69cd64c209ae55b1ab788ace8222c2bc1f6845be9e7cbedbf25/ty-0.0.56-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:63fe3947fe0c46c69a7d950e6832ee70a9ec17321fefbff3d2e3c20baf9e5bd0", size = 12666337, upload-time = "2026-07-01T16:44:31.586Z" }, + { url = "https://files.pythonhosted.org/packages/86/e9/73f903fe4a3d9ea02f26f57c1eb07e3b1029ec92b0e8c2364718893440e3/ty-0.0.56-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71a0c1a72f9854532e710e119b6871ffe4542c8a65146f1f65dcd78fecd885b4", size = 12280247, upload-time = "2026-07-01T16:44:33.637Z" }, + { url = "https://files.pythonhosted.org/packages/d6/90/cebd222495832f1a00dcd321ba25f3cab804221a4991b992c2178bec68ee/ty-0.0.56-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70d1665596494e24d8ebd198438872b5a56ec3cae5f2bcf6c673be797acc4e3c", size = 11991107, upload-time = "2026-07-01T16:44:36.122Z" }, + { url = "https://files.pythonhosted.org/packages/b7/07/8f7337a07250f42d975cdb6decf47fc5b421e6c7da5e3e7be1e85f63a7e5/ty-0.0.56-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:778f99e51558afc1dbbe48ee38ab6aae7b31390ed8c1a1ef1499b295e9f1e82f", size = 12298970, upload-time = "2026-07-01T16:44:38.243Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b9/a52cd59034a48f5f18c6b155cc2cc36861d874b6d0af204b12c898024c3d/ty-0.0.56-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:867bc5708e0066bb4ff6c7db524bd5deea2676c62bfe71d3303138b3be850af0", size = 11425683, upload-time = "2026-07-01T16:44:40.473Z" }, + { url = "https://files.pythonhosted.org/packages/1d/2e/48e42d33357d52eefb695c0c3fcfc96879b73668a7447d1d1e0ad774fedc/ty-0.0.56-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a6012f4189c928edb330a37deb9930f982380bd4aa7c4b8e0428eec9651c7551", size = 11469258, upload-time = "2026-07-01T16:44:42.513Z" }, + { url = "https://files.pythonhosted.org/packages/d5/01/ad1b4138be1e3fa97863af3925aa2134f17a593240c35dc38c3429fb5ad1/ty-0.0.56-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8ee83de1a7ff4cc32837ec06134ce391d441bc5b35ecd8d3cfe053f120f3e4c1", size = 11758736, upload-time = "2026-07-01T16:44:44.567Z" }, + { url = "https://files.pythonhosted.org/packages/09/34/9d81967ff240eaa57e9249728ef7b7790747cf6d3c9a98ec86b2cfdcc8ee/ty-0.0.56-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:62619b3b0e2c6248ef30d3f0e2f2217ae9893040585be07f32324242f197cd6f", size = 12100242, upload-time = "2026-07-01T16:44:46.584Z" }, + { url = "https://files.pythonhosted.org/packages/c3/36/f51d4666d2de6cf33c1f3a1fcc4bb6b70b197dd6ceaa491eef71d78fe8e8/ty-0.0.56-py3-none-win32.whl", hash = "sha256:b30687bb5cd9729d34c889a289edf32770388d9bb05243e534e723fb45e0381b", size = 11093759, upload-time = "2026-07-01T16:44:49.171Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b4/8fb5d4acfa4afb152245b20fa263069a7547bd1f8e4bfca4eda280c897d7/ty-0.0.56-py3-none-win_amd64.whl", hash = "sha256:ad4c8c47b6f4e3f9ed3fc0b1a5d650088d229e17dd8f63c1826d6bbe94cc3235", size = 12100327, upload-time = "2026-07-01T16:44:51.26Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fc/6a183e71edde90d0c35c2303f23f7a45b6891d1a2c45daf7b8f869831e19/ty-0.0.56-py3-none-win_arm64.whl", hash = "sha256:57538f273d444a5f1293fa7860e967178afe3917611fc5eff16b64e1204fe0d6", size = 11538780, upload-time = "2026-07-01T16:44:53.8Z" }, ] [[package]] @@ -1217,16 +1185,6 @@ version = "1.17.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/f6/759ece88472157acb55fc195e5b116e06730f1b651b5b314c66291729193/wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0", size = 54003, upload-time = "2025-08-12T05:51:48.627Z" }, - { url = "https://files.pythonhosted.org/packages/4f/a9/49940b9dc6d47027dc850c116d79b4155f15c08547d04db0f07121499347/wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77", size = 39025, upload-time = "2025-08-12T05:51:37.156Z" }, - { url = "https://files.pythonhosted.org/packages/45/35/6a08de0f2c96dcdd7fe464d7420ddb9a7655a6561150e5fc4da9356aeaab/wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7", size = 39108, upload-time = "2025-08-12T05:51:58.425Z" }, - { url = "https://files.pythonhosted.org/packages/0c/37/6faf15cfa41bf1f3dba80cd3f5ccc6622dfccb660ab26ed79f0178c7497f/wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277", size = 88072, upload-time = "2025-08-12T05:52:37.53Z" }, - { url = "https://files.pythonhosted.org/packages/78/f2/efe19ada4a38e4e15b6dff39c3e3f3f73f5decf901f66e6f72fe79623a06/wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d", size = 88214, upload-time = "2025-08-12T05:52:15.886Z" }, - { url = "https://files.pythonhosted.org/packages/40/90/ca86701e9de1622b16e09689fc24b76f69b06bb0150990f6f4e8b0eeb576/wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa", size = 87105, upload-time = "2025-08-12T05:52:17.914Z" }, - { url = "https://files.pythonhosted.org/packages/fd/e0/d10bd257c9a3e15cbf5523025252cc14d77468e8ed644aafb2d6f54cb95d/wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050", size = 87766, upload-time = "2025-08-12T05:52:39.243Z" }, - { url = "https://files.pythonhosted.org/packages/e8/cf/7d848740203c7b4b27eb55dbfede11aca974a51c3d894f6cc4b865f42f58/wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8", size = 36711, upload-time = "2025-08-12T05:53:10.074Z" }, - { url = "https://files.pythonhosted.org/packages/57/54/35a84d0a4d23ea675994104e667ceff49227ce473ba6a59ba2c84f250b74/wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb", size = 38885, upload-time = "2025-08-12T05:53:08.695Z" }, - { url = "https://files.pythonhosted.org/packages/01/77/66e54407c59d7b02a3c4e0af3783168fff8e5d61def52cda8728439d86bc/wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16", size = 36896, upload-time = "2025-08-12T05:52:55.34Z" }, { url = "https://files.pythonhosted.org/packages/02/a2/cd864b2a14f20d14f4c496fab97802001560f9f41554eef6df201cd7f76c/wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39", size = 54132, upload-time = "2025-08-12T05:51:49.864Z" }, { url = "https://files.pythonhosted.org/packages/d5/46/d011725b0c89e853dc44cceb738a307cde5d240d023d6d40a82d1b4e1182/wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235", size = 39091, upload-time = "2025-08-12T05:51:38.935Z" }, { url = "https://files.pythonhosted.org/packages/2e/9e/3ad852d77c35aae7ddebdbc3b6d35ec8013af7d7dddad0ad911f3d891dae/wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c", size = 39172, upload-time = "2025-08-12T05:51:59.365Z" }, @@ -1250,6 +1208,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, ] +[[package]] +name = "yamllint" +version = "1.38.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pathspec" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/a0/8fc2d68e132cf918f18273fdc8a1b8432b60d75ac12fdae4b0ef5c9d2e8d/yamllint-1.38.0.tar.gz", hash = "sha256:09e5f29531daab93366bb061e76019d5e91691ef0a40328f04c927387d1d364d", size = 142446, upload-time = "2026-01-13T07:47:53.276Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/92/aed08e68de6e6a3d7c2328ce7388072cd6affc26e2917197430b646aed02/yamllint-1.38.0-py3-none-any.whl", hash = "sha256:fc394a5b3be980a4062607b8fdddc0843f4fa394152b6da21722f5d59013c220", size = 68940, upload-time = "2026-01-13T07:47:51.343Z" }, +] + [[package]] name = "zipp" version = "4.1.0" From 4ac5af9ce104c5126bfd6217414977cca269b62e Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Sat, 11 Jul 2026 06:58:29 -0700 Subject: [PATCH 2/3] fix(bench): make v0.4.3 comparisons correctness-aware - Adapt the shared benchmark harness across v0.4.3 API differences without changing measured operations - Exclude invalid balanced-range baselines while requiring current samples and reporting unavailable comparisons - Preserve benchmark provenance, suite-specific fallback commands, and publication rollback guarantees - Harden Windows Git input, changelog links, and version-reference parsing across platforms --- Cargo.toml | 2 + benches/common/exact.rs | 42 ++++- benches/common/vs_linalg.rs | 58 ++++++- benches/vs_linalg.rs | 37 +++-- docs/BENCHMARKING.md | 14 ++ scripts/archive_changelog.py | 10 +- scripts/archive_performance.py | 74 ++++++++- scripts/bench_compare.py | 135 ++++++++++++--- scripts/check_docs_version_sync.py | 2 +- scripts/criterion_dim_plot.py | 8 + scripts/subprocess_utils.py | 20 ++- scripts/tests/test_archive_changelog.py | 18 +- scripts/tests/test_archive_performance.py | 58 +++++++ scripts/tests/test_bench_compare.py | 157 +++++++++++++++++- scripts/tests/test_check_docs_version_sync.py | 16 ++ scripts/tests/test_criterion_dim_plot.py | 22 ++- scripts/tests/test_subprocess_utils.py | 23 ++- src/error.rs | 20 +++ src/ldlt.rs | 16 ++ src/matrix.rs | 23 +++ src/scaled_product.rs | 47 +++++- tests/vs_linalg_inputs.rs | 43 +++-- 22 files changed, 736 insertions(+), 109 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 0ff387f..3567a6f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ include = [ "/docs/assets/**/*.csv", "/docs/assets/**/*.jpg", "/docs/assets/**/*.png", + "/docs/assets/**/*.provenance.json", "/docs/assets/**/*.svg", "/examples/**/*.rs", "/src/**/*.rs", @@ -88,6 +89,7 @@ unsafe_code = "forbid" missing_docs = { level = "deny", priority = 0 } dead_code = { level = "deny", priority = 0 } unreachable_pub = { level = "deny", priority = 0 } +unexpected_cfgs = { level = "deny", priority = 0, check-cfg = [ 'cfg(la_stack_v0_4_3_api)' ] } [lints.rustdoc] bare_urls = "deny" diff --git a/benches/common/exact.rs b/benches/common/exact.rs index f04a135..badda2e 100644 --- a/benches/common/exact.rs +++ b/benches/common/exact.rs @@ -7,7 +7,7 @@ use core::cmp::Ordering; use std::fmt::{self, Display}; use std::num::NonZeroU64; -use la_stack::{DeterminantSign, LaError, Matrix, UnrepresentableReason, Vector}; +use la_stack::{LaError, Matrix, UnrepresentableReason, Vector}; use num_bigint::BigInt; use num_rational::BigRational; use num_traits::{FromPrimitive, Signed, ToPrimitive, Zero}; @@ -19,8 +19,10 @@ pub const RANDOM_SEED: [u8; 32] = [0; 32]; /// Configuration errors for exact-arithmetic benchmark input generation. #[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] pub enum ExactBenchConfigError { /// The inclusive lower bound was greater than the inclusive upper bound. + #[non_exhaustive] UnorderedRange { /// Inclusive lower bound. min: i16, @@ -153,6 +155,29 @@ fn require_ok(result: Result, operation: &str) -> T { } } +/// Return one matrix entry through the bounds-checked API shared by current and +/// v0.4.3 releases. +fn stored_matrix_entry(matrix: &Matrix, row: usize, col: usize) -> f64 { + matrix + .get(row, col) + .unwrap_or_else(|| panic!("matrix entry ({row}, {col}) is outside dimension {D}")) +} + +/// Normalize the exact determinant-sign API across the v0.4.3 compatibility +/// boundary used only by historical benchmark worktrees. +#[cfg(not(la_stack_v0_4_3_api))] +fn checked_det_sign(matrix: &Matrix) -> i8 { + matrix.det_sign_exact().as_i8() +} + +#[cfg(la_stack_v0_4_3_api)] +fn checked_det_sign(matrix: &Matrix) -> i8 { + require_ok( + matrix.det_sign_exact(), + "exact determinant sign oracle check", + ) +} + /// Return a deterministic, strictly diagonally-dominant matrix entry. #[inline] #[expect( @@ -349,14 +374,13 @@ fn next_permutation(values: &mut [usize]) -> bool { /// Compute a determinant with the independent factorial-time Leibniz formula. fn determinant_leibniz(matrix: &Matrix) -> BigRational { - let rows = matrix.as_rows(); let mut determinant = BigRational::zero(); let mut permutation: [usize; D] = from_fn(|index| index); loop { let mut term = BigRational::from_integer(BigInt::from(1)); for (row, &col) in permutation.iter().enumerate() { - term *= rational_from_f64(rows[row][col]); + term *= rational_from_f64(stored_matrix_entry(matrix, row, col)); } if permutation_is_even(&permutation) { determinant += term; @@ -372,11 +396,11 @@ fn determinant_leibniz(matrix: &Matrix) -> BigRational { } /// Return the exact determinant sign implied by an independent rational value. -fn determinant_sign(exact: &BigRational) -> DeterminantSign { +fn determinant_sign(exact: &BigRational) -> i8 { match exact.cmp(&BigRational::zero()) { - Ordering::Less => DeterminantSign::Negative, - Ordering::Equal => DeterminantSign::Zero, - Ordering::Greater => DeterminantSign::Positive, + Ordering::Less => -1, + Ordering::Equal => 0, + Ordering::Greater => 1, } } @@ -474,7 +498,7 @@ fn assert_exact_residual(input: &ExactInput, solution: &[BigR for row in 0..D { let mut observed = BigRational::zero(); for (col, value) in solution.iter().enumerate() { - observed += rational_from_f64(input.matrix.as_rows()[row][col]) * value; + observed += rational_from_f64(stored_matrix_entry(&input.matrix, row, col)) * value; } assert_eq!(observed, rational_from_f64(input.rhs.as_array()[row])); } @@ -499,7 +523,7 @@ pub fn validate_exact_fixture(input: ExactInput) -> Validated determinant ); assert_eq!( - input.matrix.det_sign_exact(), + checked_det_sign(&input.matrix), determinant_sign(&determinant) ); assert_strict_scalar(input.matrix.det_exact_f64(), &determinant, None); diff --git a/benches/common/vs_linalg.rs b/benches/common/vs_linalg.rs index 20f8b67..a69036e 100644 --- a/benches/common/vs_linalg.rs +++ b/benches/common/vs_linalg.rs @@ -4,8 +4,58 @@ use faer::linalg::solvers::{Ldlt as FaerLdlt, PartialPivLu}; use faer::perm::PermRef; +use la_stack::{LaError, Tolerance, Vector}; use nalgebra::SMatrix; +/// Evaluate la-stack's dot product through the ownership contract used by the +/// selected library revision. +/// +/// # Errors +/// +/// Returns the selected revision's typed error if finite inputs overflow during +/// dot-product accumulation. +#[cfg(not(la_stack_v0_4_3_api))] +#[inline] +pub fn la_stack_dot(left: &Vector, right: &Vector) -> Result { + left.dot(right) +} + +/// Evaluate the v0.4.3 by-value dot-product API without changing benchmark +/// inputs or the mathematical operation. +/// +/// # Errors +/// +/// Returns v0.4.3's typed error if finite inputs overflow during dot-product +/// accumulation. +#[cfg(la_stack_v0_4_3_api)] +#[inline] +pub fn la_stack_dot(left: &Vector, right: &Vector) -> Result { + (*left).dot(*right) +} + +/// Parse a tolerance through the constructor exposed by the selected library +/// revision. +/// +/// # Errors +/// +/// Returns a typed error when `value` is negative or non-finite. +#[cfg(not(la_stack_v0_4_3_api))] +#[inline] +pub const fn la_stack_tolerance(value: f64) -> Result { + Tolerance::try_new(value) +} + +/// Parse a tolerance through v0.4.3's pre-`try_` constructor name. +/// +/// # Errors +/// +/// Returns a typed error when `value` is negative or non-finite. +#[cfg(la_stack_v0_4_3_api)] +#[inline] +pub const fn la_stack_tolerance(value: f64) -> Result { + Tolerance::new(value) +} + /// Return `det(P)` for faer's permutation representation. /// /// Sign(det(P)) is +1 for even permutations and -1 for odd. Parity is computed @@ -127,11 +177,13 @@ pub fn make_pivoting_matrix_rows() -> [[f64; D]; D] { /// Build a positive-definite diagonal matrix spanning 112 binary exponents at D=8. /// /// Each successive pivot is `2^-16` times the previous one. Benchmarks use a -/// zero tolerance so the complete, finite factorization remains in scope. +/// zero tolerance so the complete, finite factorization remains in scope. The +/// fixed return dimension prevents extending the progression until a diagonal +/// entry underflows to zero and destroys positive-definiteness. #[inline] #[must_use] -pub fn make_ill_conditioned_matrix_rows() -> [[f64; D]; D] { - let mut rows = [[0.0; D]; D]; +pub fn make_ill_conditioned_matrix_rows() -> [[f64; 8]; 8] { + let mut rows = [[0.0; 8]; 8]; let mut diagonal = 1.0; for (index, row) in rows.iter_mut().enumerate() { row[index] = diagonal; diff --git a/benches/vs_linalg.rs b/benches/vs_linalg.rs index 3b0f2f7..bb7e3a9 100644 --- a/benches/vs_linalg.rs +++ b/benches/vs_linalg.rs @@ -19,15 +19,15 @@ use faer::mat::AsMatRef; use faer::{Mat, Side}; use nalgebra::{Const, DimMin, SMatrix, SVector}; -use la_stack::{DEFAULT_SINGULAR_TOL, Matrix, Tolerance, Vector}; +use la_stack::{DEFAULT_SINGULAR_TOL, Matrix, Vector}; #[path = "common/vs_linalg.rs"] pub mod vs_linalg_common; use vs_linalg_common::{ - faer_det_from_ldlt, faer_det_from_partial_piv_lu, make_balanced_dynamic_range_rows, - make_ill_conditioned_matrix_rows, make_matrix_rows, make_pivoting_matrix_rows, - make_vector_array, matrix_entry, nalgebra_inf_norm, vector_entry, + faer_det_from_ldlt, faer_det_from_partial_piv_lu, la_stack_dot, la_stack_tolerance, + make_balanced_dynamic_range_rows, make_ill_conditioned_matrix_rows, make_matrix_rows, + make_pivoting_matrix_rows, make_vector_array, matrix_entry, nalgebra_inf_norm, vector_entry, }; /// Return a successful benchmark operation result or panic with the named operation. @@ -489,7 +489,7 @@ fn register_vector_benchmarks(group: &mut BenchmarkGroup<'_, Wal group.bench_function("la_stack_dot", |bencher| { bencher.iter(|| { - let result = require_ok(black_box(&v1).dot(black_box(&v2)), "la_stack dot"); + let result = require_ok(la_stack_dot(black_box(&v1), black_box(&v2)), "la_stack dot"); black_box(result); }); }); @@ -577,22 +577,19 @@ fn register_matrix_norm_benchmarks(group: &mut BenchmarkGroup<'_ } /// Register D=8 stress cases that exercise pivoting, conditioning, and scaled products. -fn register_stress_benchmarks(group: &mut BenchmarkGroup<'_, WallTime>) { - if D != 8 { - return; - } - - let zero_tolerance = require_ok(Tolerance::try_new(0.0), "zero benchmark tolerance"); +fn register_stress_benchmarks(group: &mut BenchmarkGroup<'_, WallTime>) { + let zero_tolerance = require_ok(la_stack_tolerance(0.0), "zero benchmark tolerance"); let pivoting = require_ok( - Matrix::::try_from_rows(make_pivoting_matrix_rows()), + Matrix::<8>::try_from_rows(make_pivoting_matrix_rows()), "pivoting benchmark matrix construction", ); let ill_conditioned = require_ok( - Matrix::::try_from_rows(make_ill_conditioned_matrix_rows()), + Matrix::<8>::try_from_rows(make_ill_conditioned_matrix_rows()), "ill-conditioned benchmark matrix construction", ); + #[cfg(not(la_stack_v0_4_3_api))] let balanced = require_ok( - Matrix::::try_from_rows(make_balanced_dynamic_range_rows()), + Matrix::<8>::try_from_rows(make_balanced_dynamic_range_rows()), "balanced-range benchmark matrix construction", ); @@ -638,15 +635,18 @@ fn register_stress_benchmarks(group: &mut BenchmarkGroup<'_, Wal ); }); + #[cfg(not(la_stack_v0_4_3_api))] let balanced_lu = require_ok( balanced.lu(zero_tolerance), "balanced-range LU factorization", ); + #[cfg(not(la_stack_v0_4_3_api))] let balanced_ldlt = require_ok( balanced.ldlt(zero_tolerance), "balanced-range LDLT factorization", ); + #[cfg(not(la_stack_v0_4_3_api))] group.bench_function("la_stack_det_from_lu_balanced_range", |bencher| { bencher.iter(|| { let det = require_ok( @@ -657,6 +657,7 @@ fn register_stress_benchmarks(group: &mut BenchmarkGroup<'_, Wal }); }); + #[cfg(not(la_stack_v0_4_3_api))] group.bench_function("la_stack_det_from_ldlt_balanced_range", |bencher| { bencher.iter(|| { let det = require_ok( @@ -669,7 +670,7 @@ fn register_stress_benchmarks(group: &mut BenchmarkGroup<'_, Wal } macro_rules! define_vs_linalg_benches_for_dim { - ($fn_name:ident, $d:literal) => { + ($fn_name:ident, $d:literal $(, $register_stress:ident)?) => { fn $fn_name(c: &mut Criterion) { let mut group = c.benchmark_group(concat!("d", stringify!($d))); register_determinant_benchmarks::<$d>(&mut group); @@ -682,7 +683,9 @@ macro_rules! define_vs_linalg_benches_for_dim { register_precomputed_ldlt_determinant_benchmarks::<$d>(&mut group); register_vector_benchmarks::<$d>(&mut group); register_matrix_norm_benchmarks::<$d>(&mut group); - register_stress_benchmarks::<$d>(&mut group); + $( + $register_stress(&mut group); + )? group.finish(); } }; @@ -692,7 +695,7 @@ define_vs_linalg_benches_for_dim!(bench_d2, 2); define_vs_linalg_benches_for_dim!(bench_d3, 3); define_vs_linalg_benches_for_dim!(bench_d4, 4); define_vs_linalg_benches_for_dim!(bench_d5, 5); -define_vs_linalg_benches_for_dim!(bench_d8, 8); +define_vs_linalg_benches_for_dim!(bench_d8, 8, register_stress_benchmarks); define_vs_linalg_benches_for_dim!(bench_d16, 16); define_vs_linalg_benches_for_dim!(bench_d32, 32); define_vs_linalg_benches_for_dim!(bench_d64, 64); diff --git a/docs/BENCHMARKING.md b/docs/BENCHMARKING.md index 513c764..fa8a92d 100644 --- a/docs/BENCHMARKING.md +++ b/docs/BENCHMARKING.md @@ -81,6 +81,20 @@ Criterion selection/commands, and both correctness-gate results. The report reader rejects malformed or mismatched provenance and incomplete selected-suite coverage. +The shared harness carries an explicit v0.4.3-only API adapter for renamed or +ownership-adjusted calls (`det_sign_exact`, `Tolerance`, and vector dot +products). The adapter changes only how the same operation is invoked; it does +not patch either library implementation. Comparison builds cap lint diagnostics +at warning for both revisions because the current manifest's lint policy may +reject historical source that predates a lint, even though that source remains +valid benchmark input. + +The v0.4.3 LU/LDLT balanced-range determinant paths return an incorrect zero, +so their two D=8 stress rows are deliberately not timed as baselines. Reports +leave those baselines explicitly unavailable rather than presenting invalid +performance evidence. The other v0.4.3 D=8 pivoting and ill-conditioned rows +remain in the comparison. + This command does not depend on existing local `target/criterion/` baselines. It is slower than reusing a saved baseline, but less sensitive to stale local benchmark state. diff --git a/scripts/archive_changelog.py b/scripts/archive_changelog.py index 81e4904..364e0b6 100755 --- a/scripts/archive_changelog.py +++ b/scripts/archive_changelog.py @@ -320,14 +320,8 @@ def _archive_dir_link_prefix(archive_dir: Path, changelog_parent: Path) -> str: try: archive_dir_rel = Path(os.path.relpath(archive_dir, changelog_parent)).as_posix() except ValueError as err: - archive_dir_rel = archive_dir.as_posix() - LOGGER.warning( - "Could not compute relative archive directory: %s; archive_dir=%s changelog_parent=%s; generated Markdown links use %s", - err, - archive_dir, - changelog_parent, - archive_dir_rel, - ) + msg = "cannot compute relative archive links because the archive and changelog directories are on different filesystem roots" + raise ValueError(msg) from err if archive_dir_rel == ".." or archive_dir_rel.startswith("../") or Path(archive_dir_rel).is_absolute(): LOGGER.warning( "Archive directory %s is outside changelog directory %s; generated Markdown links use %s", diff --git a/scripts/archive_performance.py b/scripts/archive_performance.py index 9c9bedf..1208c98 100644 --- a/scripts/archive_performance.py +++ b/scripts/archive_performance.py @@ -55,6 +55,7 @@ _HOW_TO_UPDATE_RE = re.compile(r"(?ms)^## How to Update\n.*\Z") _BENCHMARK_HARNESS_DIRS = ("benches",) _BENCHMARK_HARNESS_FILES = ( + ".config/nextest.toml", "Cargo.toml", "Cargo.lock", "rust-toolchain.toml", @@ -64,6 +65,9 @@ ) _BENCHMARK_HARNESS_METADATA = ".la-stack-benchmark-harness.json" _BENCHMARK_INPUT_GATE = ("just", "test-bench-inputs") +_COMPARISON_LINT_CAP = "--cap-lints=warn" +_V0_4_3_API_CFG = "la_stack_v0_4_3_api" +_V0_4_3_TAG = "v0.4.3" type BaselineSource = Literal["local", "github-assets"] @@ -152,6 +156,7 @@ class BaselineRun: harness_sha256: str git_clean: bool source_state_sha256: str + api_compatibility: str | None def normalize_tag(tag: str) -> str: @@ -580,6 +585,7 @@ def _write_local_run_provenance( """Tie locally generated samples to their shared harness and environment.""" publication = _environment_metadata(worktree, harness_sha256=baseline_run.harness_sha256) measurement = { + "baseline_api_compatibility": baseline_run.api_compatibility or "none", "baseline_commit": baseline_run.commit, "cargo_lock_sha256": publication["cargo_lock_sha256"], "cpu": publication["cpu"], @@ -606,6 +612,7 @@ def _write_local_run_provenance( "publication": publication, "schema": 2, "validation": { + "baseline_api_compatibility": baseline_run.api_compatibility or "none", "baseline_commit": baseline_run.commit, "baseline_git_clean": baseline_run.git_clean, "baseline_revision": "passed", @@ -648,6 +655,7 @@ def _write_historical_asset_provenance( "publication": publication, "schema": 2, "validation": { + "baseline_api_compatibility": baseline_run.api_compatibility or "none", "baseline_commit": baseline_run.commit, "baseline_git_clean": baseline_run.git_clean, "baseline_revision": "passed", @@ -689,6 +697,40 @@ def _benchmark_env(checkout: Path) -> dict[str, str] | None: return env +def _append_rustflag(env: dict[str, str], flag: str) -> None: + """Append one rustc flag without discarding caller-selected codegen flags.""" + encoded = env.get("CARGO_ENCODED_RUSTFLAGS") + if encoded is not None: + env["CARGO_ENCODED_RUSTFLAGS"] = "\x1f".join(part for part in (encoded, flag) if part) + return + + rustflags = env.get("RUSTFLAGS", "").strip() + env["RUSTFLAGS"] = f"{rustflags} {flag}".strip() + + +def _baseline_api_compatibility(baseline_tag: str) -> str | None: + """Return the shared-harness API adapter required by one baseline tag.""" + return _V0_4_3_API_CFG if normalize_tag(baseline_tag) == _V0_4_3_TAG else None + + +def _comparison_benchmark_env(checkout: Path, *, baseline_tag: str | None = None) -> dict[str, str]: + """Build a comparable benchmark environment for current or historical code.""" + env = _benchmark_env(checkout) + if env is None: + env = os.environ.copy() + + # The shared current manifest can enable lints unknown to historical source. + # Cap diagnostics for both revisions so lint-policy drift cannot prevent a + # performance comparison; the cap changes diagnostics, not code generation. + _append_rustflag(env, _COMPARISON_LINT_CAP) + + if baseline_tag is not None: + compatibility = _baseline_api_compatibility(baseline_tag) + if compatibility is not None: + _append_rustflag(env, f"--cfg={compatibility}") + return env + + def _safe_extract_tar(archive: Path, target_dir: Path) -> None: target_dir.mkdir(parents=True, exist_ok=True) target_root = target_dir.resolve() @@ -852,6 +894,18 @@ def _latest_recipe_args(*, suite: str) -> list[str]: raise ValueError(msg) +def _fallback_current_command(*, suite: str) -> tuple[str, ...]: + """Return the Cargo command used when current benchmark recipes are unavailable.""" + match suite: + case "all" | "exact": + return ("cargo", "bench", "--locked", "--features", "bench,exact", "--bench", "exact") + case "vs_linalg": + return ("cargo", "bench", "--locked", "--features", "bench", "--bench", "vs_linalg") + case _: + msg = f"unsupported benchmark suite: {suite}" + raise ValueError(msg) + + def _generate_release_baseline(*, baseline_tag: str, suite: str, repo_root: Path, target_worktree: Path, tmp_dir: Path) -> BaselineRun: baseline_worktree = tmp_dir / "baseline-worktree" _run_git(["worktree", "add", "--detach", str(baseline_worktree), baseline_tag], cwd=repo_root) @@ -865,7 +919,8 @@ def _generate_release_baseline(*, baseline_tag: str, suite: str, repo_root: Path suite=suite, baseline_worktree=baseline_worktree, ) - benchmark_env = _benchmark_env(repo_root) + api_compatibility = _baseline_api_compatibility(baseline_tag) + benchmark_env = _comparison_benchmark_env(repo_root, baseline_tag=baseline_tag) _run_benchmark_input_gate(baseline_worktree, env=benchmark_env) _run_tool( baseline_command, @@ -887,6 +942,7 @@ def _generate_release_baseline(*, baseline_tag: str, suite: str, repo_root: Path harness_sha256=harness_sha256, git_clean=_git_clean(baseline_worktree), source_state_sha256=_source_state_digest(baseline_worktree), + api_compatibility=api_compatibility, ) finally: try: @@ -920,13 +976,18 @@ def _validate_release_revision( source=harness_source, destination=validation_worktree, ) - _run_benchmark_input_gate(validation_worktree, env=_benchmark_env(repo_root)) + api_compatibility = _baseline_api_compatibility(revision) + _run_benchmark_input_gate( + validation_worktree, + env=_comparison_benchmark_env(repo_root, baseline_tag=revision), + ) return BaselineRun( commit=_checkout_commit(validation_worktree), command=("historical-release-asset", revision), harness_sha256=harness_sha256, git_clean=_git_clean(validation_worktree), source_state_sha256=_source_state_digest(validation_worktree), + api_compatibility=api_compatibility, ) finally: try: @@ -1029,7 +1090,7 @@ def _run_benchmarks_and_render_report( config: GenerationConfig, baseline_run: BaselineRun, ) -> None: - benchmark_env = _benchmark_env(config.repo_root) + benchmark_env = _comparison_benchmark_env(config.repo_root) _run_benchmark_input_gate(worktree, env=benchmark_env) _purge_criterion_new_samples( criterion_dir=worktree / "target" / "criterion", @@ -1038,7 +1099,7 @@ def _run_benchmarks_and_render_report( if _has_current_release_signal_tooling(worktree): current_command = ("just", *_latest_recipe_args(suite=config.suite)) else: - current_command = ("cargo", "bench", "--locked", "--features", "bench,exact", "--bench", "exact") + current_command = _fallback_current_command(suite=config.suite) _run_tool( current_command[0], list(current_command[1:]), @@ -1082,7 +1143,10 @@ def _generate_report_in_temp_worktree( harness_source=worktree, tmp_dir=tmp_dir, ) - _run_benchmark_input_gate(worktree, env=_benchmark_env(config.repo_root)) + _run_benchmark_input_gate( + worktree, + env=_comparison_benchmark_env(config.repo_root), + ) _write_historical_asset_provenance( worktree=worktree, config=config, diff --git a/scripts/bench_compare.py b/scripts/bench_compare.py index 34a59d4..66d6987 100644 --- a/scripts/bench_compare.py +++ b/scripts/bench_compare.py @@ -137,6 +137,16 @@ "la_stack_det_from_lu_balanced_range", "la_stack_det_from_ldlt_balanced_range", ] +_V0_4_3_API_COMPATIBILITY = "la_stack_v0_4_3_api" +_V0_4_3_UNAVAILABLE_BASELINE_ROWS: frozenset[tuple[str, str]] = frozenset( + { + ("d8", "la_stack_det_from_lu_balanced_range"), + ("d8", "la_stack_det_from_ldlt_balanced_range"), + } +) +_UNAVAILABLE_BASELINE_ROWS_BY_COMPATIBILITY: dict[str, frozenset[tuple[str, str]]] = { + _V0_4_3_API_COMPATIBILITY: _V0_4_3_UNAVAILABLE_BASELINE_ROWS, +} VS_LINALG_RELEASE_SIGNAL_BENCHES_BY_DIM: dict[int, list[str]] = { 8: VS_LINALG_D8_RELEASE_SIGNAL_BENCHES, } @@ -268,6 +278,17 @@ class ComparisonCollection: gaps: list[CoverageGap] +@dataclass(frozen=True, slots=True) +class ComparisonPolicy: + """Coverage policy for one baseline comparison.""" + + scope: str = "release-signal" + baseline_api_compatibility: str | None = None + + +_DEFAULT_COMPARISON_POLICY = ComparisonPolicy() + + @dataclass(frozen=True, slots=True) class HarnessProvenance: """Validated benchmark measurement and correctness provenance.""" @@ -414,6 +435,7 @@ def _read_harness_provenance(criterion_dir: Path, *, expected_baseline: str) -> _validate_environment_metadata(publication, path=provenance_path, context="publication") _validate_criterion_metadata(criterion, path=provenance_path) _validate_validation_metadata(validation, path=provenance_path) + _validate_baseline_api_compatibility(validation, baseline=baseline, path=provenance_path) sha256: str | None = None if measurement.get("status") == "recorded": @@ -528,6 +550,18 @@ def _validate_validation_metadata(data: dict[str, object], *, path: Path) -> Non if not isinstance(data.get(field), bool): msg = f"invalid or missing validation.{field} in {path}" raise TypeError(msg) + compatibility = data.get("baseline_api_compatibility") + if compatibility is not None and (not isinstance(compatibility, str) or not compatibility): + msg = f"invalid validation.baseline_api_compatibility in {path}" + raise TypeError(msg) + + +def _validate_baseline_api_compatibility(data: dict[str, object], *, baseline: str, path: Path) -> None: + """Reject compatibility adapters attached to an unrelated baseline.""" + compatibility = data.get("baseline_api_compatibility") + if compatibility == _V0_4_3_API_COMPATIBILITY and baseline != "v0.4.3": + msg = f"validation.baseline_api_compatibility {_V0_4_3_API_COMPATIBILITY!r} in {path} is valid only for baseline 'v0.4.3', got {baseline!r}" + raise ValueError(msg) def _assess_change(baseline: CriterionEstimate, current: CriterionEstimate) -> ChangeAssessment: @@ -710,6 +744,21 @@ def _ordered_vs_linalg_comparison_benches(group_dir: Path, baseline_name: str, s return [*ordered, *extras] +def _vs_linalg_dimension_groups(criterion_dir: Path, scope: str) -> list[tuple[int, Path]]: + """Return canonical or discovered dimension groups for a comparison.""" + if scope == "release-signal": + return [(dim, criterion_dir / f"d{dim}") for dim in VS_LINALG_CANONICAL_DIMS] + + groups: list[tuple[int, Path]] = [] + for group_dir in criterion_dir.iterdir(): + if not group_dir.is_dir(): + continue + dim = _dim_from_vs_linalg_group(group_dir.name) + if dim is not None: + groups.append((dim, group_dir)) + return groups + + def _read_optional_estimate(estimates_json: Path, stat: str) -> CriterionEstimate | None: """Read an optional Criterion estimate.""" if not estimates_json.exists(): @@ -745,31 +794,41 @@ def _collect_vs_linalg_comparisons( criterion_dir: Path, baseline_name: str, stat: str, - scope: str, + policy: ComparisonPolicy, ) -> ComparisonCollection: """Compare vs_linalg results while retaining one-sided rows.""" comparisons: list[Comparison] = [] gaps: list[CoverageGap] = [] - dim_groups: list[tuple[int, Path]] = [] - - if scope == "release-signal": - dim_groups.extend((dim, criterion_dir / f"d{dim}") for dim in VS_LINALG_CANONICAL_DIMS) - else: - for group_dir in criterion_dir.iterdir(): - if not group_dir.is_dir(): - continue - dim = _dim_from_vs_linalg_group(group_dir.name) - if dim is None: - continue - dim_groups.append((dim, group_dir)) + unavailable_baseline_rows = _UNAVAILABLE_BASELINE_ROWS_BY_COMPATIBILITY.get( + policy.baseline_api_compatibility or "", + frozenset(), + ) + dim_groups = _vs_linalg_dimension_groups(criterion_dir, policy.scope) for _dim, group_dir in sorted(dim_groups, key=lambda item: item[0]): - expected_benches = _ordered_vs_linalg_comparison_benches(group_dir, baseline_name, scope) + expected_benches = _ordered_vs_linalg_comparison_benches(group_dir, baseline_name, policy.scope) for bench in expected_benches: new_path = group_dir / bench / "new" / "estimates.json" base_path = group_dir / bench / baseline_name / "estimates.json" missing_current = not new_path.exists() missing_baseline = not base_path.exists() + baseline_unavailable = (group_dir.name, bench) in unavailable_baseline_rows + + if baseline_unavailable: + if missing_current: + gaps.append( + CoverageGap( + suite="vs_linalg", + group=group_dir.name, + bench=bench, + baseline_bench=bench, + missing_current=True, + missing_baseline=False, + ) + ) + else: + _read_estimate(new_path, stat) + continue if missing_current or missing_baseline: gaps.append( @@ -883,17 +942,17 @@ def _collect_comparisons( baseline_name: str, stat: str, suite: str = "all", - scope: str = "release-signal", + policy: ComparisonPolicy = _DEFAULT_COMPARISON_POLICY, ) -> ComparisonCollection: """Compare current results and report every expected coverage gap.""" comparisons: list[Comparison] = [] gaps: list[CoverageGap] = [] if suite in ("all", "exact"): - exact = _collect_exact_comparisons(criterion_dir, baseline_name, stat, scope) + exact = _collect_exact_comparisons(criterion_dir, baseline_name, stat, policy.scope) comparisons.extend(exact.comparisons) gaps.extend(exact.gaps) if suite in ("all", "vs_linalg"): - vs_linalg = _collect_vs_linalg_comparisons(criterion_dir, baseline_name, stat, scope) + vs_linalg = _collect_vs_linalg_comparisons(criterion_dir, baseline_name, stat, policy) comparisons.extend(vs_linalg.comparisons) gaps.extend(vs_linalg.gaps) return ComparisonCollection(comparisons=comparisons, gaps=gaps) @@ -1232,6 +1291,15 @@ def _provenance_markdown(provenance: HarnessProvenance) -> list[str]: validation = provenance.validation baseline_command = cast("list[str]", criterion["baseline_command"]) current_command = cast("list[str]", criterion["current_command"]) + if provenance.mode == "shared-current-harness": + correctness_gate = ( + "- Correctness gate: `just test-bench-inputs` passed against both the current and baseline revisions using the shared current fixture harness." + ) + else: + correctness_gate = ( + "- Correctness gate: `just test-bench-inputs` passed for both referenced source revisions " + "during publication; this gate validates benchmark inputs separately from the historical timing measurements." + ) lines = ["### Reproducibility Provenance", ""] if measurement["status"] == "recorded": lines.extend( @@ -1276,7 +1344,7 @@ def _provenance_markdown(provenance: HarnessProvenance) -> list[str]: f"- Criterion dependency version: `{criterion['criterion_version']}`", f"- Baseline command: `{' '.join(baseline_command)}`", f"- Current command: `{' '.join(current_command)}`", - "- Correctness gate: `just test-bench-inputs` passed against both the current and baseline revisions using the shared current fixture harness.", + correctness_gate, ( f"- Validated current revision: `{validation['current_commit']}` " f"(Git clean: `{str(validation['current_git_clean']).lower()}`, " @@ -1289,6 +1357,18 @@ def _provenance_markdown(provenance: HarnessProvenance) -> list[str]: ), ] ) + compatibility = validation.get("baseline_api_compatibility") + if isinstance(compatibility, str) and compatibility != "none": + lines.append( + f"- Baseline API compatibility: `{compatibility}` selects only source-compatible benchmark calls; " + "rows outside the baseline's correctness domain remain explicitly unavailable." + ) + if compatibility == _V0_4_3_API_COMPATIBILITY and criterion["suite"] in {"all", "vs_linalg"}: + lines.append( + "- Baseline-unavailable rows: `d8/la_stack_det_from_lu_balanced_range` and " + "`d8/la_stack_det_from_ldlt_balanced_range` were not timed because v0.4.3 returns zero for a fixture " + "whose exact determinant is one; current samples remain required, but no speedup is claimed." + ) return lines @@ -1343,6 +1423,17 @@ def _parse_args(argv: list[str]) -> argparse.Namespace: return parser.parse_args(argv) +def _comparison_policy(scope: str, provenance: HarnessProvenance | None) -> ComparisonPolicy: + """Build comparison coverage policy from validated provenance.""" + if provenance is None or provenance.validation is None: + return ComparisonPolicy(scope=scope) + compatibility = provenance.validation.get("baseline_api_compatibility") + return ComparisonPolicy( + scope=scope, + baseline_api_compatibility=compatibility if isinstance(compatibility, str) else None, + ) + + def _run_bench_hint(suite: str) -> str: if suite == "exact": return "just bench-exact" @@ -1384,7 +1475,13 @@ def main(argv: list[str] | None = None) -> int: # noqa: PLR0911 print(f"Invalid benchmark harness provenance: {err}", file=sys.stderr) return 2 - collection = _collect_comparisons(criterion_dir, baseline_name, args.stat, args.suite, args.scope) + collection = _collect_comparisons( + criterion_dir, + baseline_name, + args.stat, + args.suite, + _comparison_policy(args.scope, harness_provenance), + ) if collection.gaps: print( f"Incomplete benchmark coverage: {len(collection.gaps)} required comparison row(s) are missing; report publication aborted.", diff --git a/scripts/check_docs_version_sync.py b/scripts/check_docs_version_sync.py index ab1fce1..f46a4b3 100644 --- a/scripts/check_docs_version_sync.py +++ b/scripts/check_docs_version_sync.py @@ -259,7 +259,7 @@ def _dependency_references(path: Path, package_name: str) -> list[VersionReferen _README_TAG_LINK_RE = re.compile( r"https://(?:github\.com/acgetchell/la-stack/(?:blob|raw|tree)/|raw\.githubusercontent\.com/acgetchell/la-stack/)" - r"v(?P[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?)(?=/|\b)" + r"v(?P[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)(?=/|\b)" ) diff --git a/scripts/criterion_dim_plot.py b/scripts/criterion_dim_plot.py index 647efc6..022b1be 100644 --- a/scripts/criterion_dim_plot.py +++ b/scripts/criterion_dim_plot.py @@ -700,6 +700,14 @@ def _run_publication_command(root: Path, command: tuple[str, ...]) -> None: detail = f"\nstderr:\n{stderr}" if stderr else "" msg = f"publication command failed ({exc.returncode}): {' '.join(command)}{detail}" raise RuntimeError(msg) from exc + except ExecutableNotFoundError as exc: + msg = f"publication command could not start: {' '.join(command)}: {exc}" + raise RuntimeError(msg) from exc + except subprocess.TimeoutExpired as exc: + stderr = exc.stderr.strip() if isinstance(exc.stderr, str) else "" + detail = f"\nstderr:\n{stderr}" if stderr else "" + msg = f"publication command timed out after {exc.timeout} seconds: {' '.join(command)}{detail}" + raise RuntimeError(msg) from exc def _vs_linalg_new_samples(criterion_dir: Path) -> list[Path]: diff --git a/scripts/subprocess_utils.py b/scripts/subprocess_utils.py index 074f18d..6bbe763 100644 --- a/scripts/subprocess_utils.py +++ b/scripts/subprocess_utils.py @@ -14,6 +14,7 @@ import shutil import subprocess +import tempfile from pathlib import Path from typing import Any @@ -207,7 +208,7 @@ def run_git_command_with_input( Args: args: Git command arguments (without 'git' prefix) - input_data: Data to send to stdin + input_data: Text to encode and send to stdin without newline translation cwd: Working directory for the command **kwargs: Additional arguments passed to subprocess.run @@ -221,12 +222,17 @@ def run_git_command_with_input( """ git_path = get_safe_executable("git") run_kwargs = _build_run_kwargs("run_git_command_with_input", **kwargs) - return subprocess.run( # noqa: S603,PLW1510 - [git_path, *args], - cwd=cwd, - input=input_data, - **run_kwargs, - ) + encoding: str = run_kwargs.get("encoding") or "utf-8" + errors: str = run_kwargs.get("errors") or "strict" + with tempfile.TemporaryFile() as stdin: + stdin.write(input_data.encode(encoding, errors)) + stdin.seek(0) + return subprocess.run( # noqa: S603,PLW1510 + [git_path, *args], + cwd=cwd, + stdin=stdin, + **run_kwargs, + ) class ProjectRootNotFoundError(Exception): diff --git a/scripts/tests/test_archive_changelog.py b/scripts/tests/test_archive_changelog.py index e11c84c..88db3b1 100644 --- a/scripts/tests/test_archive_changelog.py +++ b/scripts/tests/test_archive_changelog.py @@ -438,17 +438,17 @@ def test_archive_dir_outside_changelog_tree_uses_relative_link( assert str(archive_dir) in caplog.text assert str(changelog_dir) in caplog.text - def test_archive_dir_relpath_value_error_uses_absolute_fallback( + def test_archive_dir_relpath_value_error_preserves_changelog( self, tmp_path: Path, - caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Archive splitting survives Windows-style relpath failures across drives.""" + """Cross-volume paths fail without publishing absolute archive links.""" changelog_dir = tmp_path / "repo" changelog_dir.mkdir() changelog = changelog_dir / "CHANGELOG.md" - changelog.write_text(_full_changelog(), encoding="utf-8") + original = _full_changelog() + changelog.write_text(original, encoding="utf-8") archive_dir = tmp_path / "outside" / "archive" def raise_cross_drive_value_error(_path: Path, _start: Path) -> str: @@ -457,14 +457,14 @@ def raise_cross_drive_value_error(_path: Path, _start: Path) -> str: monkeypatch.setattr("archive_changelog.os.path.relpath", raise_cross_drive_value_error) - with caplog.at_level(logging.WARNING, logger="archive_changelog"): + with pytest.raises(ValueError, match="different filesystem roots") as exc_info: archive_changelog(changelog, archive_dir) root = changelog.read_text(encoding="utf-8") - assert f"- [0.6.x]({archive_dir.as_posix()}/0.6.md)" in root - assert "path is on mount 'D:', start on mount 'C:'" in caplog.text - assert str(archive_dir) in caplog.text - assert str(changelog_dir) in caplog.text + assert root == original + assert archive_dir.as_posix() not in root + assert archive_dir.as_posix() not in str(exc_info.value) + assert isinstance(exc_info.value.__cause__, ValueError) # --------------------------------------------------------------------------- diff --git a/scripts/tests/test_archive_performance.py b/scripts/tests/test_archive_performance.py index 4666f81..8f308a8 100644 --- a/scripts/tests/test_archive_performance.py +++ b/scripts/tests/test_archive_performance.py @@ -111,6 +111,7 @@ def _write_unsafe_baseline_archive(path: Path) -> None: def _write_current_benchmark_tooling(worktree: Path) -> None: + (worktree / ".config").mkdir(parents=True, exist_ok=True) (worktree / "scripts").mkdir(parents=True, exist_ok=True) (worktree / "benches" / "common").mkdir(parents=True, exist_ok=True) (worktree / "src").mkdir(parents=True, exist_ok=True) @@ -120,6 +121,7 @@ def _write_current_benchmark_tooling(worktree: Path) -> None: (worktree / "src" / "lib.rs").write_text("pub fn fixture() {}\n", encoding="utf-8") (worktree / "tests" / "exact_bench_config.rs").write_text("// exact fixture\n", encoding="utf-8") (worktree / "tests" / "vs_linalg_inputs.rs").write_text("// linalg fixture\n", encoding="utf-8") + (worktree / ".config" / "nextest.toml").write_text("[profile.ci]\nretries = 1\n", encoding="utf-8") (worktree / "Cargo.toml").write_text( '[package]\nname = "fixture"\nversion = "0.1.0"\n[dev-dependencies]\ncriterion = "0.7.0"\n', encoding="utf-8", @@ -149,6 +151,7 @@ def test_shared_benchmark_harness_replaces_baseline_content_and_has_stable_diges (baseline / "benches" / "vs_linalg.rs").write_text("fn obsolete() {}\n", encoding="utf-8") (baseline / "Cargo.lock").write_text("version = 3\n", encoding="utf-8") (baseline / "justfile").write_text("obsolete-benchmark-recipe:\n", encoding="utf-8") + (baseline / ".config" / "nextest.toml").write_text("[profile.default]\nretries = 0\n", encoding="utf-8") digest = archive_performance._install_shared_benchmark_harness( source=current, @@ -159,6 +162,7 @@ def test_shared_benchmark_harness_replaces_baseline_content_and_has_stable_diges assert digest == archive_performance._benchmark_harness_digest(baseline) assert (baseline / "benches" / "vs_linalg.rs").read_text(encoding="utf-8") == "fn main() {}\n" assert (baseline / "justfile").read_text(encoding="utf-8") == (current / "justfile").read_text(encoding="utf-8") + assert (baseline / ".config" / "nextest.toml").read_text(encoding="utf-8") == (current / ".config" / "nextest.toml").read_text(encoding="utf-8") def test_github_release_assets_discard_embedded_shared_harness_metadata(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: @@ -444,6 +448,45 @@ def test_benchmark_env_respects_existing_toolchain_override(tmp_path: Path, monk assert archive_performance._benchmark_env(tmp_path) is None +def test_comparison_benchmark_env_preserves_flags_and_selects_v043_adapter( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("CARGO_ENCODED_RUSTFLAGS", raising=False) + monkeypatch.delenv("RUSTUP_TOOLCHAIN", raising=False) + monkeypatch.setenv("RUSTFLAGS", "-C target-cpu=native") + (tmp_path / "rust-toolchain.toml").write_text( + '[toolchain]\nchannel = "1.96.0"\n', + encoding="utf-8", + ) + + current = archive_performance._comparison_benchmark_env(tmp_path) + baseline = archive_performance._comparison_benchmark_env( + tmp_path, + baseline_tag="v0.4.3", + ) + + assert current["RUSTFLAGS"] == "-C target-cpu=native --cap-lints=warn" + assert baseline["RUSTFLAGS"] == ("-C target-cpu=native --cap-lints=warn --cfg=la_stack_v0_4_3_api") + assert current["RUSTUP_TOOLCHAIN"] == "1.96.0" + + +def test_comparison_benchmark_env_extends_encoded_rustflags( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("RUSTUP_TOOLCHAIN", "nightly") + monkeypatch.setenv("CARGO_ENCODED_RUSTFLAGS", "-C\x1ftarget-cpu=native") + + env = archive_performance._comparison_benchmark_env( + tmp_path, + baseline_tag="0.4.3", + ) + + assert env["CARGO_ENCODED_RUSTFLAGS"] == ("-C\x1ftarget-cpu=native\x1f--cap-lints=warn\x1f--cfg=la_stack_v0_4_3_api") + assert env["RUSTUP_TOOLCHAIN"] == "nightly" + + @pytest.mark.parametrize("suite", ["exact", "vs_linalg"]) def test_fallback_baseline_cargo_commands_enforce_lockfile( tmp_path: Path, @@ -463,6 +506,21 @@ def test_fallback_baseline_cargo_commands_enforce_lockfile( assert args[:2] == ["bench", "--locked"] +@pytest.mark.parametrize( + ("suite", "expected"), + [ + ("all", ("cargo", "bench", "--locked", "--features", "bench,exact", "--bench", "exact")), + ("exact", ("cargo", "bench", "--locked", "--features", "bench,exact", "--bench", "exact")), + ("vs_linalg", ("cargo", "bench", "--locked", "--features", "bench", "--bench", "vs_linalg")), + ], +) +def test_fallback_current_cargo_command_matches_suite( + suite: str, + expected: tuple[str, ...], +) -> None: + assert archive_performance._fallback_current_command(suite=suite) == expected + + def test_promote_report_archives_previous_and_updates_sorted_index(tmp_path: Path) -> None: source = tmp_path / "target" / "bench-reports" / "performance.md" current = tmp_path / "docs" / "PERFORMANCE.md" diff --git a/scripts/tests/test_bench_compare.py b/scripts/tests/test_bench_compare.py index b3775e5..be62e30 100644 --- a/scripts/tests/test_bench_compare.py +++ b/scripts/tests/test_bench_compare.py @@ -101,6 +101,7 @@ def _schema2_provenance_data() -> dict[str, object]: "publication": environment, "schema": 2, "validation": { + "baseline_api_compatibility": "la_stack_v0_4_3_api", "baseline_commit": "baseline-commit", "baseline_git_clean": False, "baseline_revision": "passed", @@ -379,7 +380,7 @@ def test_collect_comparisons(tmp_path: Path) -> None: tmp_path, "v0.3.0", "median", - scope="all-benches", + policy=bench_compare.ComparisonPolicy(scope="all-benches"), ) all_comparisons = all_collection.comparisons assert len(all_comparisons) == 15 @@ -438,7 +439,7 @@ def test_collect_comparisons_reports_wholly_absent_selected_suite(tmp_path: Path "last", "median", suite="all", - scope="all-benches", + policy=bench_compare.ComparisonPolicy(scope="all-benches"), ) assert any(gap.suite == "vs_linalg" and gap.group == "(entire suite)" and gap.bench == "all selected rows" for gap in collection.gaps) @@ -493,17 +494,90 @@ def test_collect_comparisons_classifies_from_criterion_intervals(tmp_path: Path) } -def test_d8_release_signal_rows_are_explicit_and_report_missing_baselines(tmp_path: Path) -> None: +@pytest.mark.parametrize("compatibility", [None, "unknown-adapter"]) +def test_d8_release_signal_rows_are_explicit_and_report_missing_baselines( + tmp_path: Path, + compatibility: str | None, +) -> None: group = tmp_path / "d8" for bench in bench_compare.VS_LINALG_D8_RELEASE_SIGNAL_BENCHES: _write_estimates(group / bench / "new" / "estimates.json", "median", 10.0) - collection = bench_compare._collect_comparisons(tmp_path, "last", "median", suite="vs_linalg") + collection = bench_compare._collect_comparisons( + tmp_path, + "last", + "median", + suite="vs_linalg", + policy=bench_compare.ComparisonPolicy(baseline_api_compatibility=compatibility), + ) special_gaps = [gap for gap in collection.gaps if gap.bench in bench_compare.VS_LINALG_D8_RELEASE_SIGNAL_BENCHES] assert [gap.bench for gap in special_gaps] == bench_compare.VS_LINALG_D8_RELEASE_SIGNAL_BENCHES assert all(not gap.missing_current and gap.missing_baseline for gap in special_gaps) +def test_v043_adapter_allows_only_known_unavailable_d8_baselines(tmp_path: Path) -> None: + group = tmp_path / "d8" + for bench in bench_compare.VS_LINALG_D8_RELEASE_SIGNAL_BENCHES: + _write_estimates(group / bench / "new" / "estimates.json", "median", 10.0) + _write_estimates( + group / "la_stack_det_from_lu_balanced_range" / "v0.4.3" / "estimates.json", + "median", + 1.0, + ) + + collection = bench_compare._collect_comparisons( + tmp_path, + "v0.4.3", + "median", + suite="vs_linalg", + policy=bench_compare.ComparisonPolicy(baseline_api_compatibility="la_stack_v0_4_3_api"), + ) + + special_gaps = [gap for gap in collection.gaps if gap.bench in bench_compare.VS_LINALG_D8_RELEASE_SIGNAL_BENCHES] + assert [gap.bench for gap in special_gaps] == [ + "la_stack_lu_pivoting", + "la_stack_lu_ill_conditioned", + "la_stack_ldlt_ill_conditioned", + ] + assert all(not gap.missing_current and gap.missing_baseline for gap in special_gaps) + assert not any("balanced_range" in comparison.bench for comparison in collection.comparisons) + + +def test_v043_adapter_still_requires_current_balanced_range_sample(tmp_path: Path) -> None: + group = tmp_path / "d8" + _write_estimates( + group / "la_stack_det_from_lu_balanced_range" / "new" / "estimates.json", + "median", + 10.0, + ) + + collection = bench_compare._collect_comparisons( + tmp_path, + "v0.4.3", + "median", + suite="vs_linalg", + policy=bench_compare.ComparisonPolicy(baseline_api_compatibility="la_stack_v0_4_3_api"), + ) + + balanced_gaps = [gap for gap in collection.gaps if "balanced_range" in gap.bench] + assert [(gap.bench, gap.missing_current, gap.missing_baseline) for gap in balanced_gaps] == [("la_stack_det_from_ldlt_balanced_range", True, False)] + + +def test_v043_adapter_validates_current_balanced_range_sample(tmp_path: Path) -> None: + estimate = tmp_path / "d8" / "la_stack_det_from_lu_balanced_range" / "new" / "estimates.json" + estimate.parent.mkdir(parents=True) + estimate.write_text("{not json", encoding="utf-8") + + with pytest.raises(ValueError, match="malformed Criterion estimates JSON"): + bench_compare._collect_comparisons( + tmp_path, + "v0.4.3", + "median", + suite="vs_linalg", + policy=bench_compare.ComparisonPolicy(baseline_api_compatibility="la_stack_v0_4_3_api"), + ) + + def test_collect_vs_linalg_release_signal_uses_baseline_peer_context(tmp_path: Path) -> None: _build_vs_linalg_tree(tmp_path) collection = bench_compare._collect_comparisons(tmp_path, "last", "median", suite="vs_linalg") @@ -523,7 +597,13 @@ def test_collect_vs_linalg_release_signal_uses_baseline_peer_context(tmp_path: P def test_collect_vs_linalg_all_benches_includes_latest_peer_rows(tmp_path: Path) -> None: _build_vs_linalg_tree(tmp_path) - collection = bench_compare._collect_comparisons(tmp_path, "last", "median", suite="vs_linalg", scope="all-benches") + collection = bench_compare._collect_comparisons( + tmp_path, + "last", + "median", + suite="vs_linalg", + policy=bench_compare.ComparisonPolicy(scope="all-benches"), + ) comparisons = collection.comparisons assert [c.bench for c in comparisons] == [ @@ -654,6 +734,34 @@ def test_read_schema2_provenance_records_versions_dirty_source_and_both_gates(tm assert "Criterion dependency version: `0.7.0`" in rendered assert "Current Git clean: `false`" in rendered assert "Validated baseline revision: `baseline-commit`" in rendered + assert "Baseline API compatibility: `la_stack_v0_4_3_api`" in rendered + assert "d8/la_stack_det_from_lu_balanced_range" in rendered + assert "d8/la_stack_det_from_ldlt_balanced_range" in rendered + assert "exact determinant is one" in rendered + assert bench_compare._comparison_policy("release-signal", provenance) == bench_compare.ComparisonPolicy( + scope="release-signal", + baseline_api_compatibility="la_stack_v0_4_3_api", + ) + + +def test_historical_asset_provenance_uses_mode_appropriate_gate_wording(tmp_path: Path) -> None: + data = _schema2_provenance_data() + data["mode"] = "historical-assets" + data["measurement"] = { + "status": "unavailable", + "reason": "historical release assets do not record the timing environment", + } + (tmp_path / ".la-stack-benchmark-harness.json").write_text(json.dumps(data), encoding="utf-8") + + provenance = bench_compare._read_harness_provenance(tmp_path, expected_baseline="v0.4.3") + + assert provenance is not None + rendered = "\n".join(bench_compare._provenance_markdown(provenance)) + assert "historical release assets do not record the timing environment" in rendered + assert "passed for both referenced source revisions during publication" in rendered + assert "separately from the historical timing measurements" in rendered + assert "shared current fixture harness" not in rendered + assert "both samples under one shared current harness" not in rendered def test_read_schema2_provenance_requires_criterion_version(tmp_path: Path) -> None: @@ -667,6 +775,15 @@ def test_read_schema2_provenance_requires_criterion_version(tmp_path: Path) -> N bench_compare._read_harness_provenance(tmp_path, expected_baseline="v0.4.3") +def test_read_schema2_provenance_rejects_v043_adapter_for_other_baseline(tmp_path: Path) -> None: + data = _schema2_provenance_data() + data["baseline"] = "v0.4.4" + (tmp_path / ".la-stack-benchmark-harness.json").write_text(json.dumps(data), encoding="utf-8") + + with pytest.raises(ValueError, match=r"valid only for baseline 'v0\.4\.3'"): + bench_compare._read_harness_provenance(tmp_path, expected_baseline="v0.4.4") + + def test_read_harness_provenance_rejects_different_requested_baseline(tmp_path: Path) -> None: _write_harness_provenance(tmp_path, baseline="v0.4.3") @@ -773,6 +890,36 @@ def test_main_comparison_refuses_incomplete_coverage_before_writing(tmp_path: Pa assert "## Incomplete Comparison Coverage" in error +def test_main_v043_comparison_allows_only_unavailable_balanced_baselines(tmp_path: Path) -> None: + criterion_dir = tmp_path / "criterion" + unavailable = bench_compare._V0_4_3_UNAVAILABLE_BASELINE_ROWS + for dimension in bench_compare.VS_LINALG_CANONICAL_DIMS: + group = f"d{dimension}" + benches = { + *bench_compare.VS_LINALG_LA_STACK_BENCHES, + *bench_compare.VS_LINALG_RELEASE_SIGNAL_BENCHES_BY_DIM.get(dimension, []), + } + for bench in benches: + _write_estimates(criterion_dir / group / bench / "new" / "estimates.json", "median", 10.0) + if (group, bench) not in unavailable: + _write_estimates(criterion_dir / group / bench / "v0.4.3" / "estimates.json", "median", 20.0) + + provenance = _schema2_provenance_data() + criterion = provenance["criterion"] + assert isinstance(criterion, dict) + cast("dict[str, object]", criterion)["suite"] = "vs_linalg" + (criterion_dir / ".la-stack-benchmark-harness.json").write_text(json.dumps(provenance), encoding="utf-8") + output = tmp_path / "report.md" + + rc = bench_compare.main(["v0.4.3", "--suite", "vs_linalg", "--criterion-dir", str(criterion_dir), "--output", str(output)]) + + assert rc == 0 + rendered = output.read_text(encoding="utf-8") + assert "d8/la_stack_det_from_lu_balanced_range" in rendered + assert "d8/la_stack_det_from_ldlt_balanced_range" in rendered + assert "no speedup is claimed" in rendered + + def test_generate_markdown_labels_absent_provenance_unavailable(tmp_path: Path) -> None: report = bench_compare._generate_markdown( tmp_path, diff --git a/scripts/tests/test_check_docs_version_sync.py b/scripts/tests/test_check_docs_version_sync.py index bb0b1f0..8cba949 100644 --- a/scripts/tests/test_check_docs_version_sync.py +++ b/scripts/tests/test_check_docs_version_sync.py @@ -120,6 +120,22 @@ def test_find_version_mismatches_reports_readme_tag_links(tmp_path: Path) -> Non assert [mismatch.reference.version for mismatch in mismatches] == ["1.2.2", "1.2.1"] +@pytest.mark.parametrize( + "version", + ["1.2.3", "1.2.3-rc.1", "1.2.3+build.7", "1.2.3-rc.1+build.7"], +) +def test_readme_tag_references_accept_semver_suffixes(tmp_path: Path, version: str) -> None: + readme = tmp_path / "README.md" + readme.write_text( + f"[tagged](https://github.com/acgetchell/la-stack/blob/v{version}/README.md)\n", + encoding="utf-8", + ) + + references = check_docs_version_sync._readme_tag_references(readme) + + assert [(reference.line, reference.version) for reference in references] == [(1, version)] + + def test_find_version_mismatches_ignores_historical_docs_and_test_fixtures(tmp_path: Path) -> None: _write_project(tmp_path) archive = tmp_path / "docs" / "archive" diff --git a/scripts/tests/test_criterion_dim_plot.py b/scripts/tests/test_criterion_dim_plot.py index 1560f8f..1e4a1b5 100644 --- a/scripts/tests/test_criterion_dim_plot.py +++ b/scripts/tests/test_criterion_dim_plot.py @@ -855,9 +855,20 @@ def fail_gate(command: str, args: list[str], **_kwargs: object) -> SimpleNamespa assert calls == [("just", ("test-bench-inputs",))] +@pytest.mark.parametrize( + ("failure_kind", "expected_details", "cause_type"), + [ + ("process", ("timing failed",), subprocess.CalledProcessError), + ("missing", ("Required executable 'cargo' not found in PATH",), criterion_dim_plot.ExecutableNotFoundError), + ("timeout", ("timed out after 17 seconds", "timing stalled"), subprocess.TimeoutExpired), + ], +) def test_failed_timing_restores_staged_new_samples( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, + failure_kind: str, + expected_details: tuple[str, ...], + cause_type: type[Exception], ) -> None: old_estimate = tmp_path / "target" / "criterion" / "d2" / "la_stack_lu" / "new" / "estimates.json" old_estimate.parent.mkdir(parents=True) @@ -867,15 +878,22 @@ def fail_timing(command: str, args: list[str], **_kwargs: object) -> SimpleNames if command == "cargo": old_estimate.parent.mkdir(parents=True, exist_ok=True) old_estimate.write_text("partial\n", encoding="utf-8") - raise subprocess.CalledProcessError(1, [command, *args], stderr="timing failed") + if failure_kind == "process": + raise subprocess.CalledProcessError(1, [command, *args], stderr="timing failed") + if failure_kind == "missing": + msg = "Required executable 'cargo' not found in PATH" + raise criterion_dim_plot.ExecutableNotFoundError(msg) + raise subprocess.TimeoutExpired([command, *args], 17, stderr="timing stalled") return SimpleNamespace(stdout="") monkeypatch.setattr(criterion_dim_plot, "run_safe_command", fail_timing) - with pytest.raises(RuntimeError, match="cargo bench"): + with pytest.raises(RuntimeError, match="cargo bench") as exc_info: criterion_dim_plot._run_publication_benchmarks(tmp_path) assert old_estimate.read_text(encoding="utf-8") == "old\n" + assert all(detail in str(exc_info.value) for detail in expected_details) + assert isinstance(exc_info.value.__cause__, cause_type) def test_readme_publication_cannot_reuse_stale_new_samples( diff --git a/scripts/tests/test_subprocess_utils.py b/scripts/tests/test_subprocess_utils.py index a2a6723..981e2b9 100644 --- a/scripts/tests/test_subprocess_utils.py +++ b/scripts/tests/test_subprocess_utils.py @@ -2,6 +2,7 @@ from __future__ import annotations +from typing import BinaryIO, cast from unittest.mock import MagicMock, patch import pytest @@ -102,19 +103,29 @@ def test_passes_stdin_data(self) -> None: ) # git hash-object of "hello\n" is a well-known SHA assert result.returncode == 0 - assert result.stdout.strip() # should be a 40-char hex hash + assert result.stdout.strip() == "ce013625030ba8dba906f756967f9e9ca394464a" + + def test_input_data_forwarded_as_raw_utf8(self) -> None: + """Verify stdin bytes preserve LF even when subprocess output is text.""" + observed_input = b"" + + def capture_run(*_args: object, **kwargs: object) -> subprocess_utils.subprocess.CompletedProcess[str]: + nonlocal observed_input + stdin = cast("BinaryIO", kwargs["stdin"]) + observed_input = stdin.read() + return subprocess_utils.subprocess.CompletedProcess(args=["git"], returncode=0, stdout="", stderr="") - def test_input_data_forwarded(self) -> None: - """Verify input_data is passed as the 'input' kwarg to subprocess.run.""" with ( patch("subprocess_utils.get_safe_executable", return_value="/usr/bin/git") as mock_executable, - patch("subprocess_utils.subprocess.run") as mock_run, + patch("subprocess_utils.subprocess.run", side_effect=capture_run) as mock_run, ): - run_git_command_with_input(["tag", "-a", "v1.0.0", "-F", "-"], input_data="tag body") + run_git_command_with_input(["tag", "-a", "v1.0.0", "-F", "-"], input_data="tag body\n") mock_executable.assert_called_once_with("git") mock_run.assert_called_once() _args, kwargs = mock_run.call_args - assert kwargs["input"] == "tag body" + assert observed_input == b"tag body\n" + assert "input" not in kwargs + assert kwargs["text"] is True class TestAdditionalHelpers: diff --git a/src/error.rs b/src/error.rs index e47d5f5..90e6144 100644 --- a/src/error.rs +++ b/src/error.rs @@ -845,12 +845,28 @@ mod tests { #[test] fn unrepresentable_helpers_preserve_recovery_reason() { let rounding = LaError::unrepresentable(Some(2), UnrepresentableReason::RequiresRounding); + let scalar_rounding = + LaError::unrepresentable(None, UnrepresentableReason::RequiresRounding); + let indexed_not_finite = + LaError::unrepresentable(Some(2), UnrepresentableReason::NotFinite); let not_finite = LaError::unrepresentable(None, UnrepresentableReason::NotFinite); assert_eq!( rounding.unrepresentable_reason(), Some(UnrepresentableReason::RequiresRounding) ); assert!(rounding.requires_rounding()); + assert_eq!( + rounding.to_string(), + "exact result requires rounding to fit finite f64 at index 2" + ); + assert_eq!( + scalar_rounding.to_string(), + "exact result requires rounding to fit finite f64" + ); + assert_eq!( + indexed_not_finite.to_string(), + "exact result has no finite f64 representation after rounding at index 2" + ); assert_eq!( not_finite.to_string(), "exact result has no finite f64 representation after rounding" @@ -879,6 +895,10 @@ mod tests { LaError::invalid_tolerance(-1.0).to_string(), "invalid tolerance -1; expected value >= 0" ); + assert_eq!( + LaError::invalid_tolerance(f64::NEG_INFINITY).to_string(), + "invalid tolerance -inf; expected a finite value" + ); } #[test] diff --git a/src/ldlt.rs b/src/ldlt.rs index e562b9a..414140f 100644 --- a/src/ldlt.rs +++ b/src/ldlt.rs @@ -646,6 +646,22 @@ mod tests { ); } + #[test] + fn small_positive_pivot_does_not_mask_earlier_non_finite_update() { + let a = Matrix::<3>::try_from_rows(black_box([ + [1.0, 1.0, f64::MAX], + [1.0, 1.0 + f64::EPSILON, -f64::MAX], + [f64::MAX, -f64::MAX, 1.0], + ])) + .unwrap(); + + let err = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap_err(); + assert_eq!( + err, + LaError::non_finite_computation_matrix(ArithmeticOperation::LdltFactorization, 2, 1) + ); + } + #[test] fn negative_initial_diagonal_reports_not_positive_semidefinite() { let a = Matrix::<2>::try_from_rows(black_box([[-1.0, 0.0], [0.0, 1.0]])).unwrap(); diff --git a/src/matrix.rs b/src/matrix.rs index 6ee7735..2b19049 100644 --- a/src/matrix.rs +++ b/src/matrix.rs @@ -2262,6 +2262,29 @@ mod tests { assert_eq!(matrix.is_symmetric(tolerance), Ok(true)); } + #[test] + fn symmetry_epsilon_scales_terms_when_row_sum_overflows() { + let matrix = + Matrix::<2>::try_from_rows([[f64::MAX, f64::MAX], [f64::MAX / 2.0, f64::MAX]]).unwrap(); + + assert_eq!( + matrix.inf_norm(), + Err(LaError::non_finite_computation_matrix( + ArithmeticOperation::MatrixInfinityNorm, + 0, + 1 + )) + ); + assert_eq!( + matrix.first_asymmetry(Tolerance::try_new(0.25).unwrap()), + Ok(None) + ); + assert_eq!( + matrix.first_asymmetry(Tolerance::try_new(0.125).unwrap()), + Ok(Some((0, 1))) + ); + } + #[test] fn first_asymmetry_returns_lexicographically_first_pair() { // Two asymmetric pairs: (0, 2) and (1, 2). We must get (0, 2) first. diff --git a/src/scaled_product.rs b/src/scaled_product.rs index aa12b5f..0ff67a4 100644 --- a/src/scaled_product.rs +++ b/src/scaled_product.rs @@ -143,16 +143,13 @@ impl ScaledProduct { self.mantissa *= factor.mantissa; self.exponent += factor.exponent; - // Both operands were in [1, 2), so the exact product is below 4.0. - // Rounding can produce 4.0 at the upper boundary; normalize twice in - // that exceptional case to preserve `mantissa < 2`. + // Both operands were in [1, 2), and even the two largest binary64 + // mantissas multiply to a value that rounds below 4.0. Therefore one + // factor-of-two normalization is sufficient to preserve + // `mantissa < 2`. if self.mantissa >= 2.0 { self.mantissa *= 0.5; self.exponent += 1; - if self.mantissa >= 2.0 { - self.mantissa *= 0.5; - self.exponent += 1; - } } } @@ -238,6 +235,42 @@ mod tests { product.finish().map(f64::to_bits) } + #[test] + fn mantissa_product_is_renormalized_once() { + assert_eq!(scaled_product_bits(1.5, 1.5), Some(2.25_f64.to_bits())); + } + + #[test] + fn signed_zero_tracks_later_factor_signs() { + let mut product = ScaledProduct::new(false); + product.multiply(-0.0); + product.multiply(-2.0); + + assert_eq!(product.finish().map(f64::to_bits), Some(0.0_f64.to_bits())); + } + + #[test] + fn empty_product_preserves_initial_sign() { + assert_eq!( + ScaledProduct::new(false).finish().map(f64::to_bits), + Some(1.0_f64.to_bits()) + ); + assert_eq!( + ScaledProduct::new(true).finish().map(f64::to_bits), + Some((-1.0_f64).to_bits()) + ); + } + + #[test] + fn non_finite_factors_make_the_product_unrepresentable() { + for factor in [f64::INFINITY, f64::NEG_INFINITY, f64::NAN] { + let mut product = ScaledProduct::new(false); + product.multiply(factor); + + assert_eq!(product.finish(), None); + } + } + #[test] fn balanced_extreme_factors_do_not_depend_on_storage_order() { let mut forward = ScaledProduct::new(false); diff --git a/tests/vs_linalg_inputs.rs b/tests/vs_linalg_inputs.rs index dcb0159..d9f500e 100644 --- a/tests/vs_linalg_inputs.rs +++ b/tests/vs_linalg_inputs.rs @@ -10,15 +10,16 @@ use faer::perm::PermRef; use faer::{Mat, Side}; use nalgebra::{Const, DimMin, SMatrix, SVector}; -use la_stack::{DEFAULT_SINGULAR_TOL, Matrix, Tolerance, Vector}; +use la_stack::{DEFAULT_SINGULAR_TOL, Matrix, Vector}; #[path = "../benches/common/vs_linalg.rs"] pub mod vs_linalg_common; use vs_linalg_common::{ - faer_det_from_ldlt, faer_det_from_partial_piv_lu, faer_perm_sign, - make_balanced_dynamic_range_rows, make_ill_conditioned_matrix_rows, make_matrix_rows, - make_pivoting_matrix_rows, make_vector_array, matrix_entry, nalgebra_inf_norm, vector_entry, + faer_det_from_ldlt, faer_det_from_partial_piv_lu, faer_perm_sign, la_stack_dot, + la_stack_tolerance, make_balanced_dynamic_range_rows, make_ill_conditioned_matrix_rows, + make_matrix_rows, make_pivoting_matrix_rows, make_vector_array, matrix_entry, + nalgebra_inf_norm, vector_entry, }; /// Assert scalar agreement with a tolerance that scales for larger magnitudes. @@ -168,9 +169,7 @@ fn assert_vector_operation_agreement() { let fv1 = Mat::::from_fn(D, 1, |i, _| vector_entry(i, 0.0)); let fv2 = Mat::::from_fn(D, 1, |i, _| vector_entry(i, 1.0)); - let la_dot = v1 - .dot(&v2) - .unwrap_or_else(|err| panic!("la_stack dot failed: {err}")); + let la_dot = la_stack_dot(&v1, &v2).unwrap_or_else(|err| panic!("la_stack dot failed: {err}")); assert_close("nalgebra_dot", nv1.dot(&nv2), la_dot); let mut fa_dot = 0.0; for i in 0..D { @@ -237,9 +236,28 @@ fn faer_permutation_sign_handles_large_permutations_without_allocation() { ); } +#[test] +fn ill_conditioned_fixture_is_fixed_positive_definite_d8() { + let rows = make_ill_conditioned_matrix_rows(); + + for (row_index, row) in rows.iter().enumerate() { + for (col_index, &value) in row.iter().enumerate() { + if row_index == col_index { + assert!(value.is_normal() && value.is_sign_positive()); + } else { + assert_eq!(value.to_bits(), 0.0f64.to_bits()); + } + } + } + assert_eq!( + rows[7][7].to_bits(), + f64::from_bits(911_u64 << 52).to_bits() + ); +} + #[test] fn stress_inputs_exercise_pivoting_conditioning_and_scaled_products() { - let zero_tolerance = Tolerance::try_new(0.0).unwrap(); + let zero_tolerance = la_stack_tolerance(0.0).unwrap(); let pivoting_rows = make_pivoting_matrix_rows::<8>(); assert!(pivoting_rows[1][0].abs() > pivoting_rows[0][0].abs()); @@ -270,9 +288,12 @@ fn stress_inputs_exercise_pivoting_conditioning_and_scaled_products() { expected_ill_conditioned_det.to_bits() ); - let balanced = Matrix::<8>::try_from_rows(make_balanced_dynamic_range_rows()).unwrap(); - assert_eq!(balanced.lu(zero_tolerance).unwrap().det(), Ok(1.0)); - assert_eq!(balanced.ldlt(zero_tolerance).unwrap().det(), Ok(1.0)); + #[cfg(not(la_stack_v0_4_3_api))] + { + let balanced = Matrix::<8>::try_from_rows(make_balanced_dynamic_range_rows()).unwrap(); + assert_eq!(balanced.lu(zero_tolerance).unwrap().det(), Ok(1.0)); + assert_eq!(balanced.ldlt(zero_tolerance).unwrap().det(), Ok(1.0)); + } } /// Check matrix infinity-norm agreement for one benchmark dimension. From db2fad50087c773f7ae37f181f720da2a9ff4a23 Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Sat, 11 Jul 2026 09:25:11 -0700 Subject: [PATCH 3/3] fix: harden exact arithmetic and benchmark publication - add `DeterminantWithErrorBound` for paired determinant estimates and certified bounds - scale exact systems independently and round exact values directly to IEEE-754 - fail benchmark publication closed on invalid samples or mismatched provenance - make release and changelog tooling transactional, path-safe, and Windows-portable - align benchmark CI with pinned local tools and least-privilege publishing --- .github/dependabot.yml | 16 + .github/workflows/benchmarks.yml | 28 +- .github/workflows/codeql.yml | 8 - .github/workflows/release-benchmarks.yml | 34 +- .github/workflows/rust-clippy.yml | 5 + AGENTS.md | 4 +- CONTRIBUTING.md | 6 +- README.md | 39 +- benches/common/exact.rs | 80 ++++ benches/common/vs_linalg.rs | 37 +- benches/exact.rs | 3 +- benches/vs_linalg.rs | 7 +- docs/BENCHMARKING.md | 14 +- examples/exact_solve_3x3.rs | 13 +- justfile | 87 ++--- pyproject.toml | 6 +- scripts/README.md | 8 +- scripts/archive_changelog.py | 16 +- scripts/archive_performance.py | 232 +++++++++--- scripts/bench_compare.py | 238 +++++++++--- scripts/criterion_dim_plot.py | 224 +++++++---- scripts/postprocess_changelog.py | 179 +++++++-- scripts/subprocess_utils.py | 17 +- scripts/tag_release.py | 71 +++- scripts/tests/test_archive_changelog.py | 36 +- scripts/tests/test_archive_performance.py | 201 +++++++++- scripts/tests/test_bench_compare.py | 150 ++++++-- scripts/tests/test_criterion_dim_plot.py | 347 ++++++++++++++++-- scripts/tests/test_postprocess_changelog.py | 85 ++++- scripts/tests/test_subprocess_utils.py | 18 + scripts/tests/test_tag_release.py | 77 +++- semgrep.yaml | 3 + src/exact.rs | 321 +++++++++++++--- src/lib.rs | 48 ++- src/matrix.rs | 220 ++++++++--- src/vector.rs | 11 + tests/exact_bench_config.rs | 17 +- tests/prelude_exports.rs | 5 + tests/proptest_factorizations.rs | 2 - tests/proptest_matrix.rs | 38 ++ tests/proptest_vector.rs | 11 + tests/semgrep/docs/public_examples.md | 7 + .../src/project_rules/portable_policy.rs | 15 + tests/vs_linalg_inputs.rs | 34 +- uv.lock | 108 +++--- 45 files changed, 2529 insertions(+), 597 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 12f9ea7..56c4372 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -28,3 +28,19 @@ updates: dependencies: patterns: - "*" + + # Enable version updates for Python development dependencies locked by uv + - package-ecosystem: "uv" + directory: "/" + schedule: + interval: "weekly" + cooldown: + default-days: 7 + open-pull-requests-limit: 10 + labels: + - "dependencies" + - "python" + groups: + dependencies: + patterns: + - "*" diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index 553db7e..e000f36 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -18,6 +18,7 @@ on: - "benches/**" - "tests/exact_bench_config.rs" - "tests/vs_linalg_inputs.rs" + - ".config/nextest.toml" - "Cargo.toml" - "Cargo.lock" - "justfile" @@ -31,6 +32,7 @@ on: - "benches/**" - "tests/exact_bench_config.rs" - "tests/vs_linalg_inputs.rs" + - ".config/nextest.toml" - "Cargo.toml" - "Cargo.lock" - "justfile" @@ -67,10 +69,30 @@ jobs: cache: true cache-bin: false + - name: Read just version + id: just_version + run: | + version="$(grep '^just_version :=' justfile | cut -d '"' -f 2)" + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Install just + uses: taiki-e/cache-cargo-install-action@417450f3c33ee20393705369577571770643d4c7 # v3.0.7 + with: + tool: just@${{ steps.just_version.outputs.version }} + + - name: Read cargo-nextest version + id: cargo_nextest_version + run: | + version="$(just --evaluate cargo_nextest_version)" + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Install cargo-nextest + uses: taiki-e/cache-cargo-install-action@417450f3c33ee20393705369577571770643d4c7 # v3.0.7 + with: + tool: cargo-nextest@${{ steps.cargo_nextest_version.outputs.version }} + - name: Validate benchmark inputs - run: > - cargo test --locked --features bench,exact - --test vs_linalg_inputs --test exact_bench_config + run: just test-bench-inputs # ── PR: find and download the latest main baseline ────────────── - name: Find latest main baseline diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index de677eb..3b26ddb 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -33,14 +33,6 @@ jobs: with: persist-credentials: false - - name: Install Rust toolchain - if: matrix.language == 'rust' - uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0 - with: - cache: true - cache-bin: false - # toolchain, components, etc. are specified in rust-toolchain.toml - - name: Initialize CodeQL if: matrix.language != 'rust' uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 diff --git a/.github/workflows/release-benchmarks.yml b/.github/workflows/release-benchmarks.yml index 71f235a..57d25fc 100644 --- a/.github/workflows/release-benchmarks.yml +++ b/.github/workflows/release-benchmarks.yml @@ -5,7 +5,7 @@ name: Release Benchmarks # or save dependency caches. permissions: - contents: write + contents: read on: release: @@ -24,6 +24,8 @@ jobs: release-baseline: runs-on: ubuntu-latest timeout-minutes: 60 + outputs: + release-asset: ${{ steps.package-baseline.outputs.asset }} steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -36,10 +38,17 @@ jobs: with: cache: false + - name: Install benchmark validation tools + run: | + set -euo pipefail + + just_version="$(grep '^just_version :=' justfile | cut -d '"' -f 2)" + cargo install --locked just --version "$just_version" + nextest_version="$(just --evaluate cargo_nextest_version)" + cargo install --locked cargo-nextest --version "$nextest_version" + - name: Validate benchmark inputs - run: > - cargo test --locked --features bench,exact - --test vs_linalg_inputs --test exact_bench_config + run: just test-bench-inputs - name: Save release Criterion baseline env: @@ -69,11 +78,24 @@ jobs: retention-days: 30 if-no-files-found: error + publish-baseline: + needs: release-baseline + permissions: + contents: write + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Download release baseline + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: bench-baseline-${{ github.event.release.tag_name }} + - name: Attach baseline to GitHub Release env: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ github.event.release.tag_name }} - RELEASE_ASSET: ${{ steps.package-baseline.outputs.asset }} + RELEASE_ASSET: ${{ needs.release-baseline.outputs.release-asset }} run: | set -euo pipefail @@ -81,7 +103,7 @@ jobs: - name: Release baseline summary env: - RELEASE_ASSET: ${{ steps.package-baseline.outputs.asset }} + RELEASE_ASSET: ${{ needs.release-baseline.outputs.release-asset }} run: | set -euo pipefail diff --git a/.github/workflows/rust-clippy.yml b/.github/workflows/rust-clippy.yml index 1b3d3ac..2152fb2 100644 --- a/.github/workflows/rust-clippy.yml +++ b/.github/workflows/rust-clippy.yml @@ -3,6 +3,11 @@ name: "Clippy Security Analysis" +concurrency: + group: >- + clippy-${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.ref }} + cancel-in-progress: true + on: pull_request: branches: ["main"] diff --git a/AGENTS.md b/AGENTS.md index 85ec808..d82b32c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -309,8 +309,8 @@ just examples # Run all examples - Format: `cargo fmt` (or `just fmt`) - Integration tests: `just test-integration` - Benchmark-input smoke tests: `just test-bench-inputs` -- Lint (Clippy): `cargo clippy --all-targets --all-features -- -D warnings` (or `just clippy`) -- Lint (Clippy, exact feature): `cargo clippy --features exact --all-targets -- -D warnings` (or `just clippy-exact`) +- Lint (Clippy, canonical default and all-feature passes): `just clippy` +- Lint (Clippy, focused exact-feature pass): `just clippy-exact` - Lint/validate: `just check` - Cargo manifest/lockfile synchronization: `just cargo-lock-check` - Unused dependency check: `just unused-deps` (uses `cargo-machete`) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4a77b73..210381b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,11 +7,11 @@ clarity, and the fixed-dimension stack-allocation model. ## Getting Started Install Rust through [rustup](https://rustup.rs/), Git, Python 3.14, -[uv](https://docs.astral.sh/uv/), and `just`. Install `just` from its locked -dependency graph: +[`uv` 0.11.28](https://docs.astral.sh/uv/), and `jq`. Install the repository's +pinned `just` version from its locked dependency graph: ```bash -cargo install --locked just +cargo install --locked just --version 1.56.0 ``` Set up the remaining development tools and validate the checkout: diff --git a/README.md b/README.md index 2e4e17c..b471fe5 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.18158926.svg)](https://doi.org/10.5281/zenodo.18158926) [![Crates.io](https://img.shields.io/crates/v/la-stack.svg)](https://crates.io/crates/la-stack) [![Downloads](https://img.shields.io/crates/d/la-stack.svg)](https://crates.io/crates/la-stack) -[![License](https://img.shields.io/crates/l/la-stack.svg)](./LICENSE) +[![License](https://img.shields.io/crates/l/la-stack.svg)](https://github.com/acgetchell/la-stack/blob/v0.4.3/LICENSE) [![Docs.rs](https://docs.rs/la-stack/badge.svg)](https://docs.rs/la-stack) [![CI](https://github.com/acgetchell/la-stack/actions/workflows/ci.yml/badge.svg)](https://github.com/acgetchell/la-stack/actions/workflows/ci.yml) [![rust-clippy analyze][clippy-badge]][clippy-workflow] @@ -328,14 +328,15 @@ filter and uses fraction-free Bareiss elimination in `BigInt`. Because `Matrix` stores only finite entries, arithmetic range failures in the filter are inconclusive rather than errors and the exact fallback is total. -### Adaptive precision with `det_errbound()` +### Adaptive precision with `det_direct_with_errbound()` -`det_errbound()` returns the conservative absolute error bound used by the fast -filter when the relative-error analysis is valid. It returns `None` when a -D ≤ 4 computation may be affected by gradual underflow, as well as for -unsupported D ≥ 5 dimensions. This method does NOT require the `exact` feature -— it uses pure f64 arithmetic and is available by default. This enables -building custom adaptive-precision logic for geometric predicates: +`det_direct_with_errbound()` returns a closed-form determinant together with +the conservative absolute error bound used by the fast filter, computed from +one shared traversal. It returns `None` when a D ≤ 4 computation may be +affected by gradual underflow, as well as for unsupported D ≥ 5 dimensions. +This method does NOT require the `exact` feature — it uses pure f64 arithmetic +and is available by default. Use `det_errbound()` when only the bound is needed. +The paired API enables custom adaptive-precision logic for geometric predicates: ```rust,ignore use la_stack::prelude::*; @@ -343,11 +344,9 @@ use la_stack::prelude::*; fn adaptive_det_sign( matrix: &Matrix, ) -> DeterminantSign { - if let (Ok(Some(bound)), Ok(Some(det))) = - (matrix.det_errbound(), matrix.det_direct()) - { - if det.abs() > bound { - return if det > 0.0 { + if let Ok(Some(estimate)) = matrix.det_direct_with_errbound() { + if estimate.determinant().abs() > estimate.absolute_error_bound() { + return if estimate.determinant() > 0.0 { DeterminantSign::Positive } else { DeterminantSign::Negative @@ -422,6 +421,7 @@ out of the common prelude. |---|---|---|---| | `Vector` | `[f64; D]` | Finite fixed-length vector for input and computation | `try_new`, `as_array`, `into_array`, `dot`, `norm2_sq` | | `Matrix` | `[[f64; D]; D]` | Finite square matrix for input and computation | See below | +| `DeterminantWithErrorBound` | two private `f64` fields | Paired direct determinant and certified absolute bound | `determinant`, `absolute_error_bound` | | `Lu` | `Matrix` + pivot array | Factorization for solves/det | `solve`, `det` | | `Ldlt` | `Matrix` | Factorization for symmetric SPD/PSD solves/det | `solve`, `det` | | `Tolerance` | finite non-negative `f64` | Validated numerical threshold | `try_new`, `get` | @@ -431,7 +431,7 @@ out of the common prelude. Storage shown above reflects the intentional `f64` scalar model. `Matrix` key methods: `as_rows`, `into_rows`, `lu`, `ldlt`, `det`, -`det_direct`, `det_errbound`, +`det_direct`, `det_direct_with_errbound`, `det_errbound`, `det_exact`¹, `det_exact_f64`¹, `det_exact_rounded_f64`¹, `det_sign_exact`¹, `solve_exact`¹, `solve_exact_f64`¹, `solve_exact_rounded_f64`¹. Matrix and vector constructors validate non-finite inputs at public API @@ -469,7 +469,7 @@ breaking callers. Raw data: [docs/assets/bench/vs_linalg_lu_solve_median.csv](https://github.com/acgetchell/la-stack/blob/v0.4.3/docs/assets/bench/vs_linalg_lu_solve_median.csv) Historical provenance status: -[docs/assets/bench/vs_linalg_lu_solve_median.provenance.json](docs/assets/bench/vs_linalg_lu_solve_median.provenance.json) +[docs/assets/bench/vs_linalg_lu_solve_median.provenance.json][benchmark-provenance] Representative benchmark: `lu_solve` factors the matrix and solves one right-hand side. Median time is lower-is-better, and the “la-stack vs @@ -540,8 +540,12 @@ cargo run --features exact --example exact_solve_3x3 A short contributor workflow: +Install Rust through [rustup](https://rustup.rs/), Git, Python 3.14, +[`uv` 0.11.28](https://docs.astral.sh/uv/), and `jq`. Then install the pinned +`just` release from its locked dependency graph: + ```bash -cargo install --locked just +cargo install --locked just --version 1.56.0 just setup # install/verify dev tools + sync Python deps just check # lint/validate (non-mutating) just fix # apply auto-fixes (mutating) @@ -585,10 +589,11 @@ for the repository's AI-assisted development note. ## 📄 License -BSD 3-Clause License. See [LICENSE](./LICENSE). +BSD 3-Clause License. See [LICENSE](https://github.com/acgetchell/la-stack/blob/v0.4.3/LICENSE). [audit-badge]: https://github.com/acgetchell/la-stack/actions/workflows/audit.yml/badge.svg [audit-workflow]: https://github.com/acgetchell/la-stack/actions/workflows/audit.yml +[benchmark-provenance]: https://github.com/acgetchell/la-stack/blob/v0.4.3/docs/assets/bench/vs_linalg_lu_solve_median.provenance.json [clippy-badge]: https://github.com/acgetchell/la-stack/actions/workflows/rust-clippy.yml/badge.svg [clippy-workflow]: https://github.com/acgetchell/la-stack/actions/workflows/rust-clippy.yml [lu-solve-benchmark]: https://raw.githubusercontent.com/acgetchell/la-stack/v0.4.3/docs/assets/bench/vs_linalg_lu_solve_median.svg diff --git a/benches/common/exact.rs b/benches/common/exact.rs index badda2e..0cfca41 100644 --- a/benches/common/exact.rs +++ b/benches/common/exact.rs @@ -493,6 +493,86 @@ fn assert_rounded_scalar(actual: Result, exact: &BigRational) { } } +/// Check a floating-point determinant against an independent exact oracle. +fn assert_approximate_determinant(actual: f64, exact: &BigRational, operation: &str) { + assert!( + actual.is_finite(), + "{operation} produced a non-finite result" + ); + let Some(expected) = exact.to_f64() else { + panic!("{operation} oracle does not round to binary64"); + }; + assert!( + expected.is_finite(), + "{operation} oracle rounds outside finite binary64" + ); + + let scale = expected.abs().max(1.0); + let tolerance = 1024.0 * f64::EPSILON * scale; + assert!( + (actual - expected).abs() <= tolerance, + "{operation} result {actual:?} differs from exact-oracle rounding {expected:?} by more than {tolerance:?}", + ); +} + +/// Validate the floating-point determinant operations used by Criterion. +/// +/// This runs during setup, outside timed closures. The deterministic `det` and +/// `det_direct` results are compared with the independent Leibniz oracle; on +/// current revisions the combined direct result is additionally checked +/// against its certified absolute bound. +/// +/// # Panics +/// +/// Panics if either floating-point operation fails, falls outside its documented +/// dimension, or disagrees with the independent exact oracle. +pub fn validate_f64_determinant_benchmarks(input: &ValidatedExactInput) { + let exact = determinant_leibniz(input.matrix()); + let determinant = require_ok(input.matrix().det(), "f64 determinant oracle check"); + assert_approximate_determinant(determinant, &exact, "f64 determinant"); + + let direct = require_ok( + input.matrix().det_direct(), + "direct f64 determinant oracle check", + ); + if D <= 4 { + let Some(direct) = direct else { + panic!("det_direct must support benchmark dimension {D}"); + }; + assert_approximate_determinant(direct, &exact, "direct f64 determinant"); + + #[cfg(not(la_stack_v0_4_3_api))] + { + let estimate = require_ok( + input.matrix().det_direct_with_errbound(), + "combined direct determinant oracle check", + ); + let Some(estimate) = estimate else { + panic!("the baseline fixture must have a certified D={D} determinant bound"); + }; + assert_eq!(estimate.determinant().to_bits(), direct.to_bits()); + let observed_error = (rational_from_f64(direct) - &exact).abs(); + let certified_bound = rational_from_f64(estimate.absolute_error_bound()); + assert!( + observed_error <= certified_bound, + "direct determinant error {observed_error} exceeds certified bound {certified_bound}", + ); + } + } else { + assert!(direct.is_none(), "det_direct unexpectedly supports D={D}"); + + #[cfg(not(la_stack_v0_4_3_api))] + assert!( + require_ok( + input.matrix().det_direct_with_errbound(), + "combined direct determinant scope check", + ) + .is_none(), + "combined direct determinant unexpectedly supports D={D}", + ); + } +} + /// Verify `A · x = b` exactly using independently reconstructed binary64 inputs. fn assert_exact_residual(input: &ExactInput, solution: &[BigRational; D]) { for row in 0..D { diff --git a/benches/common/vs_linalg.rs b/benches/common/vs_linalg.rs index a69036e..376490e 100644 --- a/benches/common/vs_linalg.rs +++ b/benches/common/vs_linalg.rs @@ -108,16 +108,37 @@ fn permutation_is_odd_by_inversions(forward: &[usize]) -> bool { is_odd } -/// Compute a determinant from a faer partial-pivot LU factorization. +/// Borrow a faer partial-pivot LU factorization together with its precomputed +/// permutation sign. +/// +/// The private sign field is derived from the borrowed factorization, so callers +/// cannot accidentally combine one LU decomposition with another permutation. #[must_use] -pub fn faer_det_from_partial_piv_lu(lu: &PartialPivLu) -> f64 { - // For PA = LU with unit-lower L, det(A) = det(P) * det(U). - let u = lu.U(); - let mut det = 1.0; - for i in 0..u.nrows() { - det *= u[(i, i)]; +pub struct PreparedFaerLuDet<'a> { + lu: &'a PartialPivLu, + permutation_sign: f64, +} + +impl<'a> PreparedFaerLuDet<'a> { + /// Prepare repeated determinant queries for one borrowed LU factorization. + pub fn new(lu: &'a PartialPivLu) -> Self { + Self { + lu, + permutation_sign: faer_perm_sign(lu.P()), + } + } + + /// Compute the determinant from the prepared factorization. + #[must_use] + pub fn det(&self) -> f64 { + // For PA = LU with unit-lower L, det(A) = det(P) * det(U). + let u = self.lu.U(); + let mut det = 1.0; + for i in 0..u.nrows() { + det *= u[(i, i)]; + } + det * self.permutation_sign } - det * faer_perm_sign(lu.P()) } /// Compute a determinant from a faer LDLT factorization. diff --git a/benches/exact.rs b/benches/exact.rs index 5e4534c..47956d3 100644 --- a/benches/exact.rs +++ b/benches/exact.rs @@ -39,7 +39,7 @@ pub mod exact_bench; use exact_bench::{ ExactInput, RANDOM_INPUT_ARRAY_LEN, ValidatedExactInput, hilbert_input, large_entries_3x3_input, make_matrix_rows, make_random_input_corpus, make_vector_array, - near_singular_3x3_input, validate_exact_fixture, + near_singular_3x3_input, validate_exact_fixture, validate_f64_determinant_benchmarks, }; /// Return a successful benchmark operation result or panic with the named operation. @@ -223,6 +223,7 @@ macro_rules! gen_exact_benches_for_dim { "benchmark RHS vector construction", ), }); + validate_f64_determinant_benchmarks(&input); let mut group = ($c).benchmark_group(concat!("exact_d", stringify!($d))); diff --git a/benches/vs_linalg.rs b/benches/vs_linalg.rs index bb7e3a9..8885a06 100644 --- a/benches/vs_linalg.rs +++ b/benches/vs_linalg.rs @@ -25,7 +25,7 @@ use la_stack::{DEFAULT_SINGULAR_TOL, Matrix, Vector}; pub mod vs_linalg_common; use vs_linalg_common::{ - faer_det_from_ldlt, faer_det_from_partial_piv_lu, la_stack_dot, la_stack_tolerance, + PreparedFaerLuDet, faer_det_from_ldlt, la_stack_dot, la_stack_tolerance, make_balanced_dynamic_range_rows, make_ill_conditioned_matrix_rows, make_matrix_rows, make_pivoting_matrix_rows, make_vector_array, matrix_entry, nalgebra_inf_norm, vector_entry, }; @@ -117,7 +117,7 @@ where || &fa, |fa| { let lu = black_box(fa).partial_piv_lu(); - let det = faer_det_from_partial_piv_lu(&lu); + let det = PreparedFaerLuDet::new(&lu).det(); black_box(det); }, BatchSize::SmallInput, @@ -416,6 +416,7 @@ fn register_precomputed_lu_determinant_benchmarks( let a_lu = require_ok(a.lu(DEFAULT_SINGULAR_TOL), "precomputed la_stack LU"); let na_lu = na.lu(); let fa_lu = fa.partial_piv_lu(); + let fa_lu_det = PreparedFaerLuDet::new(&fa_lu); group.bench_function("la_stack_det_from_lu", |bencher| { bencher.iter(|| { @@ -436,7 +437,7 @@ fn register_precomputed_lu_determinant_benchmarks( group.bench_function("faer_det_from_lu", |bencher| { bencher.iter(|| { - let det = faer_det_from_partial_piv_lu(black_box(&fa_lu)); + let det = black_box(&fa_lu_det).det(); black_box(det); }); }); diff --git a/docs/BENCHMARKING.md b/docs/BENCHMARKING.md index fa8a92d..d61ee40 100644 --- a/docs/BENCHMARKING.md +++ b/docs/BENCHMARKING.md @@ -75,7 +75,9 @@ same machine with the current checkout's benchmark sources, manifests, lockfile, benchmark-input tests, recipes, and Rust toolchain. Only the baseline library implementation comes from the release tag. Before either timing run, the command runs `just test-bench-inputs` against that revision under the shared current -fixture harness. It writes `target/bench-reports/performance.md` and records both +fixture harness. This is a prerequisite correctness gate over the deterministic +fixtures and operations, not validation of each timed Criterion sample. It +writes `target/bench-reports/performance.md` and records both commits, CPU, operating system, Rust toolchain, lockfile and harness digests, Criterion selection/commands, and both correctness-gate results. The report reader rejects malformed or mismatched provenance and incomplete selected-suite @@ -202,7 +204,10 @@ when a selected suite or required dimension is absent. After releases are published, the GitHub Release benchmark workflow attaches a compressed Criterion baseline artifact. To compare those stored artifacts -without running cargo locally: +without running cargo locally, install the GitHub CLI (`gh`) and authenticate it +with access to the repository (`gh auth login` or an equivalent token). The +requirement applies even when both release tags are supplied explicitly because +the recipe still downloads their GitHub Release assets: ```bash just performance-github-assets v0.4.3 v0.4.2 @@ -342,6 +347,11 @@ and first failing component. These checks run outside timed Criterion closures. Any disagreement or unexpected error fails setup instead of becoming an artificially fast measurement. +The proof-bearing fixture is therefore a prerequisite correctness gate, not a +claim that every timed sample is revalidated. Criterion closures remain free of +oracle work so their measurements cover only the named operation; the operation +is deterministic for the already-validated input. + For exact-arithmetic comparisons against v0.4.2 or older baselines, rows such as `det_exact_rounded_f64 (vs det_exact_f64)` mean the current rounded API is being compared to the historical lossy `*_exact_f64` benchmark. Rows such as diff --git a/examples/exact_solve_3x3.rs b/examples/exact_solve_3x3.rs index f517fdb..58b806b 100644 --- a/examples/exact_solve_3x3.rs +++ b/examples/exact_solve_3x3.rs @@ -2,11 +2,12 @@ //! Exact linear system solve for a near-singular 3×3 system. //! -//! This example demonstrates `solve_exact()` and `solve_exact_f64()`. The exact +//! This example demonstrates `solve_exact()` and [`ExactF64Conversion`]. The exact //! solve uses arbitrary-precision rational arithmetic to compute a provably //! correct solution — even when the matrix is so close to singular that the f64 -//! LU solve produces a wildly inaccurate result. The `solve_exact_f64()` helper -//! only succeeds when every exact component is exactly representable as `f64`. +//! LU solve produces a wildly inaccurate result. `try_to_f64()` only succeeds +//! when every exact component is exactly representable as `f64`, while +//! `to_rounded_f64()` explicitly opts into nearest-even rounding. //! //! Run with: `cargo run --features exact --example exact_solve_3x3` @@ -63,15 +64,15 @@ fn main() -> Result<(), LaError> { Ok(x) => { let x = x.into_array(); println!( - "solve_exact_f64(): x = [{:+.6e}, {:+.6e}, {:+.6e}]", + "exact try_to_f64(): x = [{:+.6e}, {:+.6e}, {:+.6e}]", x[0], x[1], x[2] ); } Err(err) if err.requires_rounding() => { - println!("solve_exact_f64(): {err}"); + println!("exact try_to_f64(): {err}"); let x = exact_x.to_rounded_f64()?.into_array(); println!( - "rounded fallback: x = [{:+.6e}, {:+.6e}, {:+.6e}]", + "exact to_rounded_f64(): x = [{:+.6e}, {:+.6e}, {:+.6e}]", x[0], x[1], x[2] ); } diff --git a/justfile b/justfile index 69afaeb..3737f77 100644 --- a/justfile +++ b/justfile @@ -98,7 +98,7 @@ _ensure-git-cliff: _ensure-jq: #!/usr/bin/env bash set -euo pipefail - command -v jq >/dev/null || { echo "❌ 'jq' not found. See 'just setup' or install: brew install jq"; exit 1; } + command -v jq >/dev/null || { echo "❌ 'jq' not found. Install jq and re-run this command."; exit 1; } _ensure-rumdl: #!/usr/bin/env bash @@ -201,8 +201,8 @@ bench: bench-compare baseline="last" suite="all" scope="release-signal": python-sync #!/usr/bin/env bash set -euo pipefail - baseline="{{ baseline }}" - uv run --locked bench-compare "$baseline" --suite "{{ suite }}" --scope "{{ scope }}" + baseline={{ quote(baseline) }} + uv run --locked bench-compare "$baseline" --suite {{ quote(suite) }} --scope {{ quote(scope) }} # Compile benchmarks without running them, treating warnings as errors. # This catches bench/release-profile-only warnings that won't show up in normal debug-profile runs. @@ -219,27 +219,27 @@ bench-latest: bench-vs-linalg-la-stack bench-exact # Run latest measurements and render the latest-vs-last performance report. bench-latest-vs-last baseline="last": bench-latest python-sync - uv run --locked bench-compare {{ baseline }} + uv run --locked bench-compare {{ quote(baseline) }} # Run only la-stack vs_linalg measurements and render a non-exact performance report. bench-vs-linalg-latest-vs baseline="last": bench-vs-linalg-la-stack python-sync - uv run --locked bench-compare {{ baseline }} --suite vs_linalg --scope release-signal + uv run --locked bench-compare {{ quote(baseline) }} --suite vs_linalg --scope release-signal # Save a Criterion baseline. Defaults to all release-signal benchmark suites. bench-save-baseline tag suite="all": #!/usr/bin/env bash set -euo pipefail - suite="{{ suite }}" + suite={{ quote(suite) }} case "$suite" in all) - cargo bench --locked --features bench --bench vs_linalg -- --save-baseline {{ tag }} - cargo bench --locked --features bench,exact --bench exact -- --save-baseline {{ tag }} + cargo bench --locked --features bench --bench vs_linalg -- --save-baseline {{ quote(tag) }} + cargo bench --locked --features bench,exact --bench exact -- --save-baseline {{ quote(tag) }} ;; exact) - cargo bench --locked --features bench,exact --bench exact -- --save-baseline {{ tag }} + cargo bench --locked --features bench,exact --bench exact -- --save-baseline {{ quote(tag) }} ;; vs_linalg) - cargo bench --locked --features bench --bench vs_linalg -- --save-baseline {{ tag }} + cargo bench --locked --features bench --bench vs_linalg -- --save-baseline {{ quote(tag) }} ;; *) echo "unknown benchmark suite: $suite" >&2 @@ -255,7 +255,7 @@ bench-save-last: bench-vs-linalg filter="": #!/usr/bin/env bash set -euo pipefail - filter="{{ filter }}" + filter={{ quote(filter) }} if [ -n "$filter" ]; then cargo bench --locked --features bench --bench vs_linalg -- "$filter" else @@ -270,7 +270,7 @@ bench-vs-linalg-la-stack: bench-vs-linalg-quick filter="": #!/usr/bin/env bash set -euo pipefail - filter="{{ filter }}" + filter={{ quote(filter) }} if [ -n "$filter" ]; then cargo bench --locked --features bench --bench vs_linalg -- "$filter" --quick --noplot else @@ -307,7 +307,7 @@ changelog: _ensure-git-cliff _ensure-rumdl python-sync changelog-unreleased version: _ensure-git-cliff _ensure-rumdl python-sync #!/usr/bin/env bash set -euo pipefail - GIT_CLIFF_OFFLINE=true git-cliff --tag {{ version }} -o CHANGELOG.md + GIT_CLIFF_OFFLINE=true git-cliff --tag {{ quote(version) }} -o CHANGELOG.md uv run --locked postprocess-changelog uv run --locked archive-changelog archive_files=() @@ -404,12 +404,14 @@ examples: exe_suffix=".exe" fi + target_dir="${CARGO_TARGET_DIR:-target}" + shopt -s nullglob for example_path in examples/*.rs; do [[ -f "${example_path}" ]] || continue example="${example_path##*/}" example="${example%.rs}" - "target/debug/examples/${example}${exe_suffix}" + "${target_dir}/debug/examples/${example}${exe_suffix}" done # Fix (mutating): apply formatters/auto-fixes @@ -557,14 +559,14 @@ markdown-lint: markdown-check # Backward-compatible alias for the GitHub Actions release-asset comparison. performance-archive-published current_tag="" baseline_tag="": - just performance-github-assets "{{ current_tag }}" "{{ baseline_tag }}" + just performance-github-assets {{ quote(current_tag) }} {{ quote(baseline_tag) }} # Compare stored GitHub Actions release benchmark assets without local cargo runs. performance-github-assets current_tag="" baseline_tag="": python-sync #!/usr/bin/env bash set -euo pipefail - current_tag="{{ current_tag }}" - baseline_tag="{{ baseline_tag }}" + current_tag={{ quote(current_tag) }} + baseline_tag={{ quote(baseline_tag) }} if [[ -n "$current_tag" || -n "$baseline_tag" ]]; then if [[ -z "$current_tag" || -z "$baseline_tag" ]]; then echo "current_tag and baseline_tag must be provided together" >&2 @@ -583,8 +585,8 @@ performance-local: python-sync performance-local-vs-linalg current_tag="" baseline_tag="": python-sync #!/usr/bin/env bash set -euo pipefail - current_tag="{{ current_tag }}" - baseline_tag="{{ baseline_tag }}" + current_tag={{ quote(current_tag) }} + baseline_tag={{ quote(baseline_tag) }} if [[ -n "$current_tag" || -n "$baseline_tag" ]]; then if [[ -z "$current_tag" || -z "$baseline_tag" ]]; then echo "current_tag and baseline_tag must be provided together" >&2 @@ -599,8 +601,8 @@ performance-local-vs-linalg current_tag="" baseline_tag="": python-sync performance-release current_tag="" baseline_tag="": python-sync #!/usr/bin/env bash set -euo pipefail - current_tag="{{ current_tag }}" - baseline_tag="{{ baseline_tag }}" + current_tag={{ quote(current_tag) }} + baseline_tag={{ quote(baseline_tag) }} if [[ -n "$current_tag" || -n "$baseline_tag" ]]; then if [[ -z "$current_tag" || -z "$baseline_tag" ]]; then echo "current_tag and baseline_tag must be provided together" >&2 @@ -615,11 +617,11 @@ performance-release current_tag="" baseline_tag="": python-sync plot-vs-linalg metric="lu_solve" stat="median" sample="new" log_y="false" allow_partial="false": python-sync #!/usr/bin/env bash set -euo pipefail - args=(--metric "{{ metric }}" --stat "{{ stat }}" --sample "{{ sample }}") - if [ "{{ log_y }}" = "true" ]; then + args=(--metric {{ quote(metric) }} --stat {{ quote(stat) }} --sample {{ quote(sample) }}) + if [ {{ quote(log_y) }} = "true" ]; then args+=(--log-y) fi - if [ "{{ allow_partial }}" = "true" ]; then + if [ {{ quote(allow_partial) }} = "true" ]; then args+=(--allow-partial) fi uv run --locked criterion-dim-plot "${args[@]}" @@ -628,8 +630,8 @@ plot-vs-linalg metric="lu_solve" stat="median" sample="new" log_y="false" allow_ plot-vs-linalg-readme metric="lu_solve" stat="median" sample="new" log_y="true": python-sync #!/usr/bin/env bash set -euo pipefail - args=(--metric "{{ metric }}" --stat "{{ stat }}" --sample "{{ sample }}" --update-readme) - if [ "{{ log_y }}" = "true" ]; then + args=(--metric {{ quote(metric) }} --stat {{ quote(stat) }} --sample {{ quote(sample) }} --update-readme) + if [ {{ quote(log_y) }} = "true" ]; then args+=(--log-y) fi uv run --locked criterion-dim-plot "${args[@]}" @@ -724,6 +726,17 @@ setup-tools: echo "🔧 Ensuring tooling required by just recipes is installed..." echo "" + uv_version="{{ uv_version }}" + if ! have uv; then + echo "❌ 'uv' not found. Install uv $uv_version and re-run: just setup-tools" >&2 + exit 1 + fi + verify_tool_version uv "$uv_version" + if ! have jq; then + echo "❌ 'jq' not found. Install jq and re-run: just setup-tools" >&2 + exit 1 + fi + echo "Ensuring Rust components..." if ! have rustup; then echo "❌ 'rustup' not found. Install Rust via https://rustup.rs and re-run: just setup-tools" @@ -777,21 +790,9 @@ setup-tools: fi echo "" - uv_version="{{ uv_version }}" - if have uv; then - echo "Ensuring uv-managed Python tools..." - uv sync --locked --group dev - echo "" - else - echo "❌ uv missing; cannot install project-managed Python tools." - echo "Install uv and re-run: just setup-tools" - exit 1 - fi - - if ! have jq; then - echo "❌ 'jq' not found. See 'just setup' or install: brew install jq" - echo "" - fi + echo "Ensuring uv-managed Python tools..." + uv sync --locked --group dev + echo "" echo "" echo "Verifying required commands and versions..." @@ -876,11 +877,11 @@ spell-check: _ensure-typos # Create an annotated git tag from the CHANGELOG.md section for the given version tag version: python-sync - uv run --locked tag-release {{ version }} + uv run --locked tag-release {{ quote(version) }} # Recreate an existing tag (delete + recreate) tag-force version: python-sync - uv run --locked tag-release {{ version }} --force + uv run --locked tag-release {{ quote(version) }} --force # Testing: runnable Rust tests use nextest; rustdoc doctests remain on cargo test. test: test-lib test-doc diff --git a/pyproject.toml b/pyproject.toml index b55cdc7..b49ed62 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -145,10 +145,10 @@ package = true dev = [ "actionlint-py==1.7.12.24", "pytest==9.1.1", - "ruff==0.15.20", - "semgrep==1.168.0", + "ruff==0.15.21", + "semgrep==1.169.0", "shellcheck-py==0.11.0.1", "shfmt-py==4.0.0", - "ty==0.0.56", + "ty==0.0.58", "yamllint==1.38.0", ] diff --git a/scripts/README.md b/scripts/README.md index 15cc2e0..f10efbe 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -42,6 +42,10 @@ baseline. Use the top-level `just` workflows for routine release and local comparisons: +`performance-github-assets` always requires the GitHub CLI (`gh`) authenticated +for the repository because it downloads release assets. Local-generation +recipes require `gh` only when discovering published release tags. + ```bash # Local development: compare the current tree with the latest release just performance-local @@ -213,11 +217,13 @@ tag-annotation size limit. | Script | Purpose | |---|---| +| `archive_changelog.py` | Split completed changelog minor series into archives | | `archive_performance.py` | Promote release performance docs and archive older comparisons | | `bench_compare.py` | Compare Criterion benchmark baselines and render Markdown reports | +| `check_docs_version_sync.py` | Verify versioned documentation links and snippets stay synchronized | | `criterion_dim_plot.py` | Plot Criterion benchmark results (CSV + SVG + README table) | | `tag_release.py` | Create annotated git tags from CHANGELOG.md sections | -| `postprocess_changelog.py` | Strip trailing blank lines from git-cliff output | +| `postprocess_changelog.py` | Normalize and reflow generated git-cliff Markdown safely | | `subprocess_utils.py` | Safe subprocess wrappers for git commands | See `docs/RELEASING.md` for the full release workflow. diff --git a/scripts/archive_changelog.py b/scripts/archive_changelog.py index 364e0b6..b6fe47d 100755 --- a/scripts/archive_changelog.py +++ b/scripts/archive_changelog.py @@ -168,13 +168,13 @@ def parse_changelog(text: str) -> tuple[str, str, list[tuple[str, str]]]: block = "\n".join(lines[start:end]) heading_line = lines[start] - if "Unreleased" in heading_line: + if heading_line.startswith("## [Unreleased]"): unreleased = block else: m = _VERSION_RE.match(heading_line) if not m: - # Skip headings that don't contain a recognisable semver. - continue + msg = f"Unrecognized changelog version heading at line {start + 1}: {heading_line!r}; expected '## [Unreleased]' or a semantic version" + raise ValueError(msg) version_blocks.append((m.group(1), block)) return preamble, unreleased, version_blocks @@ -370,17 +370,19 @@ def archive_changelog( active_minor = minor_keys[0] # Archive every minor except the active one. - archived_minors: list[str] = [] - for minor in minor_keys[1:]: - write_archive(archive_dir, minor, groups[minor], link_defs) - archived_minors.append(minor) + archived_minors = minor_keys[1:] if not archived_minors: _postprocess_existing_archives(archive_dir) return # only one minor series — nothing to archive yet + # Validate link portability before creating or modifying archive files. + # In particular, os.path.relpath() cannot cross Windows volumes. archive_dir_rel = _archive_dir_link_prefix(archive_dir, changelog_path.parent) + for minor in archived_minors: + write_archive(archive_dir, minor, groups[minor], link_defs) + root_text = build_root( preamble, unreleased, diff --git a/scripts/archive_performance.py b/scripts/archive_performance.py index 1208c98..48fa948 100644 --- a/scripts/archive_performance.py +++ b/scripts/archive_performance.py @@ -1,4 +1,4 @@ -#!/usr/bin/env -S uv run +#!/usr/bin/env -S uv run --locked """Promote a benchmark report into docs/PERFORMANCE.md and archive the old one. Release performance docs have two different lifetimes: @@ -27,12 +27,14 @@ import tarfile import tempfile import tomllib -from collections.abc import Mapping +from collections.abc import Iterator, Mapping +from contextlib import contextmanager from dataclasses import dataclass +from datetime import UTC, datetime from pathlib import Path from typing import Any, Literal, cast -from subprocess_utils import run_git_command, run_git_command_with_input, run_safe_command +from subprocess_utils import ExecutableNotFoundError, run_git_command, run_git_command_with_input, run_safe_command _VERSION_RE = re.compile(r"^\*\*la-stack\*\* v(?P[^\s`]+)", re.MULTILINE) _BASELINE_RE = re.compile(r"^Comparison against baseline \*\*(?P[^*]+)\*\*:", re.MULTILINE) @@ -50,6 +52,7 @@ _DEFAULT_SUITE = "all" _DEFAULT_SCOPE = "release-signal" _SUPPORTED_SUITES = ("all", "exact", "vs_linalg") +_SUPPORTED_SCOPES = ("release-signal", "all-benches") _BENCH_TIMEOUT_SECONDS = 7200 _COMMAND_TIMEOUT_SECONDS = 600 _HOW_TO_UPDATE_RE = re.compile(r"(?ms)^## How to Update\n.*\Z") @@ -69,6 +72,8 @@ _V0_4_3_API_CFG = "la_stack_v0_4_3_api" _V0_4_3_TAG = "v0.4.3" type BaselineSource = Literal["local", "github-assets"] +type BenchmarkSuite = Literal["all", "exact", "vs_linalg"] +type ComparisonScope = Literal["release-signal", "all-benches"] @dataclass(frozen=True) @@ -92,11 +97,20 @@ class GenerationConfig: current_tag: str baseline_tag: str worktree_ref: str - suite: str = _DEFAULT_SUITE - scope: str = _DEFAULT_SCOPE + suite: BenchmarkSuite = "all" + scope: ComparisonScope = "release-signal" apply_current_diff: bool = True baseline_source: BaselineSource = "local" + def __post_init__(self) -> None: + """Reject unsupported benchmark selections at construction time.""" + if self.suite not in _SUPPORTED_SUITES: + msg = f"unsupported benchmark suite: {self.suite}" + raise ValueError(msg) + if self.scope not in _SUPPORTED_SCOPES: + msg = f"unsupported comparison scope: {self.scope}" + raise ValueError(msg) + @dataclass(frozen=True) class ResolvedArchiveRequest: @@ -144,7 +158,7 @@ class PublishedRelease: """Stable GitHub release metadata used to infer release pairs.""" tag: str - published_at: str + published_at: datetime @dataclass(frozen=True) @@ -199,22 +213,45 @@ def _semver_sort_key(tag: str) -> tuple[int, int, int]: return (int(match.group("major")), int(match.group("minor")), int(match.group("patch"))) +def _parse_published_at(value: object, *, release_index: int) -> datetime: + """Parse one GitHub publication timestamp as an aware UTC datetime.""" + if not isinstance(value, str) or not value: + msg = f"GitHub release entry {release_index} has invalid publishedAt: {value!r}" + raise TypeError(msg) + try: + parsed = datetime.fromisoformat(value) + except ValueError as exc: + msg = f"GitHub release entry {release_index} has invalid publishedAt: {value!r}" + raise ValueError(msg) from exc + if parsed.tzinfo is None or parsed.utcoffset() is None: + msg = f"GitHub release entry {release_index} publishedAt must include a UTC offset: {value!r}" + raise ValueError(msg) + return parsed.astimezone(UTC) + + def _stable_published_releases(releases: object) -> list[PublishedRelease]: if not isinstance(releases, list): msg = "expected GitHub release list to be a JSON array" raise TypeError(msg) stable_releases: dict[str, PublishedRelease] = {} - for release in releases: + for index, release in enumerate(releases): if not isinstance(release, Mapping): - continue + msg = f"GitHub release entry {index} is not a JSON object" + raise TypeError(msg) release = cast("Mapping[str, Any]", release) - if release.get("isDraft") or release.get("isPrerelease"): + is_draft = release.get("isDraft") + is_prerelease = release.get("isPrerelease") + if not isinstance(is_draft, bool) or not isinstance(is_prerelease, bool): + msg = f"GitHub release entry {index} must contain boolean isDraft and isPrerelease fields" + raise TypeError(msg) + if is_draft or is_prerelease: continue tag_name = release.get("tagName") - published_at = release.get("publishedAt") - if not isinstance(tag_name, str) or not isinstance(published_at, str) or not published_at: - continue + if not isinstance(tag_name, str) or not tag_name: + msg = f"GitHub release entry {index} has invalid tagName: {tag_name!r}" + raise TypeError(msg) + published_at = _parse_published_at(release.get("publishedAt"), release_index=index) try: normalized = normalize_tag(tag_name) _semver_sort_key(normalized) @@ -234,15 +271,12 @@ def _github_release_list(repo_root: Path) -> object: "--limit", "100", ] - try: - result = run_safe_command( - "gh", - command, - cwd=repo_root, - timeout=_COMMAND_TIMEOUT_SECONDS, - ) - except subprocess.CalledProcessError as exc: - raise RuntimeError(_format_command_failure(["gh", *command], exc)) from exc + result = _run_tool_output( + "gh", + command, + cwd=repo_root, + timeout=_COMMAND_TIMEOUT_SECONDS, + ) try: return json.loads(result.stdout) except json.JSONDecodeError as exc: @@ -409,10 +443,50 @@ def _format_command_failure(command: list[str], exc: subprocess.CalledProcessErr return "\n".join(parts) +def _format_command_timeout(command: list[str], exc: subprocess.TimeoutExpired) -> str: + parts = [f"command timed out after {exc.timeout} seconds: {' '.join(command)}"] + if exc.stdout: + parts.append(f"stdout:\n{str(exc.stdout).strip()}") + if exc.stderr: + parts.append(f"stderr:\n{str(exc.stderr).strip()}") + return "\n".join(parts) + + +def _format_command_start_failure(command: list[str], exc: BaseException) -> str: + return f"command could not start: {' '.join(command)}: {exc}" + + def _run_git(args: list[str], *, cwd: Path, timeout: int = _COMMAND_TIMEOUT_SECONDS) -> None: _run_git_output(args, cwd=cwd, timeout=timeout) +@contextmanager +def _temporary_detached_worktree( + *, + repo_root: Path, + worktree: Path, + revision: str, + label: str, +) -> Iterator[Path]: + """Create and remove a detached worktree without masking primary failures.""" + _run_git(["worktree", "add", "--detach", str(worktree), revision], cwd=repo_root) + primary_error: BaseException | None = None + try: + yield worktree + except BaseException as exc: + primary_error = exc + raise + finally: + try: + _run_git(["worktree", "remove", "--force", str(worktree)], cwd=repo_root) + except RuntimeError as cleanup_error: + if primary_error is None: + msg = f"failed to remove {label}: {cleanup_error}" + raise RuntimeError(msg) from cleanup_error + msg = f"operation failed ({primary_error}); additionally failed to remove {label}: {cleanup_error}" + raise RuntimeError(msg) from primary_error + + def _run_git_output( args: list[str], *, @@ -424,6 +498,10 @@ def _run_git_output( return run_git_command(args, cwd=cwd, timeout=timeout, env=env).stdout except subprocess.CalledProcessError as exc: raise RuntimeError(_format_command_failure(["git", *args], exc)) from exc + except subprocess.TimeoutExpired as exc: + raise RuntimeError(_format_command_timeout(["git", *args], exc)) from exc + except (ExecutableNotFoundError, OSError) as exc: + raise RuntimeError(_format_command_start_failure(["git", *args], exc)) from exc def _fetch_release_tags(*, repo_root: Path, tags: list[str]) -> None: @@ -431,11 +509,27 @@ def _fetch_release_tags(*, repo_root: Path, tags: list[str]) -> None: _run_git(["fetch", "origin", *refspecs], cwd=repo_root) -def _run_tool(command: str, args: list[str], *, cwd: Path, timeout: int = _COMMAND_TIMEOUT_SECONDS, env: dict[str, str] | None = None) -> None: +def _run_tool_output( + command: str, + args: list[str], + *, + cwd: Path, + timeout: int = _COMMAND_TIMEOUT_SECONDS, + env: dict[str, str] | None = None, +) -> subprocess.CompletedProcess[str]: + """Run a support command and normalize all expected launch failures.""" try: - run_safe_command(command, args, cwd=cwd, timeout=timeout, env=env) + return run_safe_command(command, args, cwd=cwd, timeout=timeout, env=env) except subprocess.CalledProcessError as exc: raise RuntimeError(_format_command_failure([command, *args], exc)) from exc + except subprocess.TimeoutExpired as exc: + raise RuntimeError(_format_command_timeout([command, *args], exc)) from exc + except (ExecutableNotFoundError, OSError) as exc: + raise RuntimeError(_format_command_start_failure([command, *args], exc)) from exc + + +def _run_tool(command: str, args: list[str], *, cwd: Path, timeout: int = _COMMAND_TIMEOUT_SECONDS, env: dict[str, str] | None = None) -> None: + _run_tool_output(command, args, cwd=cwd, timeout=timeout, env=env) def _run_benchmark_input_gate(checkout: Path, *, env: dict[str, str] | None = None) -> None: @@ -498,16 +592,13 @@ def _source_state_digest(checkout: Path) -> str: def _rustc_version(checkout: Path) -> str: """Return one-line rustc version provenance for the active benchmark toolchain.""" - try: - result = run_safe_command( - "rustc", - ["--version"], - cwd=checkout, - timeout=_COMMAND_TIMEOUT_SECONDS, - env=_benchmark_env(checkout), - ) - except subprocess.CalledProcessError as exc: - raise RuntimeError(_format_command_failure(["rustc", "--version"], exc)) from exc + result = _run_tool_output( + "rustc", + ["--version"], + cwd=checkout, + timeout=_COMMAND_TIMEOUT_SECONDS, + env=_benchmark_env(checkout), + ) version = result.stdout.strip() return version or "unavailable" @@ -897,7 +988,9 @@ def _latest_recipe_args(*, suite: str) -> list[str]: def _fallback_current_command(*, suite: str) -> tuple[str, ...]: """Return the Cargo command used when current benchmark recipes are unavailable.""" match suite: - case "all" | "exact": + case "all": + return ("cargo", "bench", "--locked", "--features", "bench,exact") + case "exact": return ("cargo", "bench", "--locked", "--features", "bench,exact", "--bench", "exact") case "vs_linalg": return ("cargo", "bench", "--locked", "--features", "bench", "--bench", "vs_linalg") @@ -908,8 +1001,12 @@ def _fallback_current_command(*, suite: str) -> tuple[str, ...]: def _generate_release_baseline(*, baseline_tag: str, suite: str, repo_root: Path, target_worktree: Path, tmp_dir: Path) -> BaselineRun: baseline_worktree = tmp_dir / "baseline-worktree" - _run_git(["worktree", "add", "--detach", str(baseline_worktree), baseline_tag], cwd=repo_root) - try: + with _temporary_detached_worktree( + repo_root=repo_root, + worktree=baseline_worktree, + revision=baseline_tag, + label="baseline worktree", + ): harness_sha256 = _install_shared_benchmark_harness( source=target_worktree, destination=baseline_worktree, @@ -944,11 +1041,6 @@ def _generate_release_baseline(*, baseline_tag: str, suite: str, repo_root: Path source_state_sha256=_source_state_digest(baseline_worktree), api_compatibility=api_compatibility, ) - finally: - try: - _run_git(["worktree", "remove", "--force", str(baseline_worktree)], cwd=repo_root) - except RuntimeError as exc: - print(f"archive-performance: failed to remove baseline worktree: {exc}", file=sys.stderr) def _prepare_local_release_baseline(*, baseline_tag: str, suite: str, repo_root: Path, target_worktree: Path, tmp_dir: Path) -> BaselineRun: @@ -970,8 +1062,12 @@ def _validate_release_revision( ) -> BaselineRun: """Validate one historical revision with the shared current fixture harness.""" validation_worktree = tmp_dir / "baseline-validation-worktree" - _run_git(["worktree", "add", "--detach", str(validation_worktree), revision], cwd=repo_root) - try: + with _temporary_detached_worktree( + repo_root=repo_root, + worktree=validation_worktree, + revision=revision, + label="baseline validation worktree", + ): harness_sha256 = _install_shared_benchmark_harness( source=harness_source, destination=validation_worktree, @@ -989,11 +1085,6 @@ def _validate_release_revision( source_state_sha256=_source_state_digest(validation_worktree), api_compatibility=api_compatibility, ) - finally: - try: - _run_git(["worktree", "remove", "--force", str(validation_worktree)], cwd=repo_root) - except RuntimeError as exc: - print(f"archive-performance: failed to remove baseline validation worktree: {exc}", file=sys.stderr) def _prepare_github_release_assets(*, current_tag: str, baseline_tag: str, repo_root: Path, target_worktree: Path, tmp_dir: Path) -> None: @@ -1027,16 +1118,27 @@ def _apply_current_diff_to_worktree(*, repo_root: Path, worktree: Path) -> None: # binary blobs and symlink metadata directly and applies its normal safe-path # checks when the patch is replayed in the detached worktree. with tempfile.TemporaryDirectory(prefix="la-stack-current-tree-index-") as tmp: + temporary_dir = Path(tmp) env = os.environ.copy() - env["GIT_INDEX_FILE"] = str(Path(tmp) / "index") + env["GIT_INDEX_FILE"] = str(temporary_dir / "index") _run_git_output(["read-tree", "HEAD"], cwd=repo_root, env=env) _run_git_output(["add", "--all", "--", "."], cwd=repo_root, env=env) - diff = _run_git_output(["diff", "--cached", "--binary", "HEAD"], cwd=repo_root, env=env) + patch_path = temporary_dir / "current-tree.patch" + _run_git_output( + ["diff", "--cached", "--binary", f"--output={patch_path}", "HEAD"], + cwd=repo_root, + env=env, + ) + diff = patch_path.read_bytes() if diff.strip(): try: run_git_command_with_input(["apply", "--binary"], diff, cwd=worktree) except subprocess.CalledProcessError as exc: raise RuntimeError(_format_command_failure(["git", "apply", "--binary"], exc)) from exc + except subprocess.TimeoutExpired as exc: + raise RuntimeError(_format_command_timeout(["git", "apply", "--binary"], exc)) from exc + except (ExecutableNotFoundError, OSError) as exc: + raise RuntimeError(_format_command_start_failure(["git", "apply", "--binary"], exc)) from exc def _has_current_release_signal_tooling(worktree: Path) -> bool: @@ -1056,6 +1158,7 @@ def _render_report(*, worktree: Path, report: Path, config: GenerationConfig) -> "uv", [ "run", + "--locked", "bench-compare", config.baseline_tag, "--suite", @@ -1073,6 +1176,7 @@ def _render_report(*, worktree: Path, report: Path, config: GenerationConfig) -> "uv", [ "run", + "--locked", "bench-compare", config.baseline_tag, "--output", @@ -1125,8 +1229,12 @@ def _generate_report_in_temp_worktree( worktree = tmp_dir / "worktree" report = tmp_dir / f"{config.current_tag}-vs-{config.baseline_tag}.md" - _run_git(["worktree", "add", "--detach", str(worktree), config.worktree_ref], cwd=config.repo_root) - try: + with _temporary_detached_worktree( + repo_root=config.repo_root, + worktree=worktree, + revision=config.worktree_ref, + label="temporary worktree", + ): if config.apply_current_diff: _apply_current_diff_to_worktree(repo_root=config.repo_root, worktree=worktree) if config.baseline_source == "github-assets": @@ -1168,11 +1276,6 @@ def _generate_report_in_temp_worktree( baseline_run=baseline_run, ) return _read_text(report) - finally: - try: - _run_git(["worktree", "remove", "--force", str(worktree)], cwd=config.repo_root) - except RuntimeError as exc: - print(f"archive-performance: failed to remove temporary worktree: {exc}", file=sys.stderr) def promote_report( @@ -1423,6 +1526,7 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument( "--scope", default=_DEFAULT_SCOPE, + choices=_SUPPORTED_SCOPES, help=f"Comparison scope for --generate-in-temp-worktree (default: {_DEFAULT_SCOPE})", ) return parser @@ -1458,8 +1562,8 @@ def _generation_config(*, args: argparse.Namespace, request: ResolvedArchiveRequ current_tag=request.current_tag, baseline_tag=request.baseline_tag, worktree_ref=request.worktree_ref, - suite=args.suite, - scope=args.scope, + suite=cast("BenchmarkSuite", args.suite), + scope=cast("ComparisonScope", args.scope), apply_current_diff=not args.no_apply_current_diff and not args.github_assets, baseline_source="github-assets" if args.github_assets else "local", ) @@ -1524,7 +1628,15 @@ def main(argv: list[str] | None = None) -> int: ) ) result = _run_archive_request(args=args, paths=paths, request=request, repo_root=root) - except (ValueError, RuntimeError, FileNotFoundError, subprocess.CalledProcessError) as exc: + except ( + ExecutableNotFoundError, + OSError, + TypeError, + ValueError, + RuntimeError, + subprocess.CalledProcessError, + subprocess.TimeoutExpired, + ) as exc: print(f"archive-performance: {exc}", file=sys.stderr) return 1 diff --git a/scripts/bench_compare.py b/scripts/bench_compare.py index 66d6987..8c8b5b9 100644 --- a/scripts/bench_compare.py +++ b/scripts/bench_compare.py @@ -170,6 +170,9 @@ SCOPE_CHOICES: tuple[str, ...] = ("release-signal", "all-benches") type ChangeAssessment = Literal["improvement", "regression", "inconclusive", "unknown"] +type BenchmarkSuite = Literal["all", "exact", "vs_linalg"] +type ComparisonScope = Literal["release-signal", "all-benches"] +type Statistic = Literal["mean", "median"] @dataclass(frozen=True, slots=True) @@ -180,6 +183,23 @@ class CriterionEstimate: ci_lo_ns: float | None ci_hi_ns: float | None + def __post_init__(self) -> None: + """Keep every stored timing finite, positive, and interval-complete.""" + for field, value in ( + ("point_ns", self.point_ns), + ("ci_lo_ns", self.ci_lo_ns), + ("ci_hi_ns", self.ci_hi_ns), + ): + if value is not None and (not math.isfinite(value) or value <= 0): + msg = f"{field} must be finite and positive: {value!r}" + raise ValueError(msg) + if (self.ci_lo_ns is None) != (self.ci_hi_ns is None): + msg = "Criterion confidence interval must contain both bounds or neither" + raise ValueError(msg) + if self.ci_lo_ns is not None and self.ci_hi_ns is not None and self.ci_lo_ns > self.ci_hi_ns: + msg = f"Criterion confidence interval lower bound exceeds upper bound: {self.ci_lo_ns} > {self.ci_hi_ns}" + raise ValueError(msg) + @property def has_confidence_interval(self) -> bool: """Return whether both confidence bounds were present.""" @@ -238,13 +258,11 @@ def current_ns(self) -> float: @property def speedup(self) -> float: """Return baseline/current, where values above one are faster.""" - return self.baseline_ns / self.current_ns if self.current_ns > 0 else float("inf") + return self.baseline_ns / self.current_ns @property def pct_change(self) -> float: """Return signed point-estimate change, where negative is faster.""" - if self.baseline_ns <= 0: - return 0.0 return ((self.current_ns - self.baseline_ns) / self.baseline_ns) * 100.0 @property @@ -289,6 +307,16 @@ class ComparisonPolicy: _DEFAULT_COMPARISON_POLICY = ComparisonPolicy() +@dataclass(frozen=True, slots=True) +class CriterionSelection: + """Requested Criterion settings that provenance must describe exactly.""" + + suite: BenchmarkSuite + scope: ComparisonScope + statistic: Statistic + sample: str + + @dataclass(frozen=True, slots=True) class HarnessProvenance: """Validated benchmark measurement and correctness provenance.""" @@ -299,18 +327,31 @@ class HarnessProvenance: baseline: str measurement: dict[str, object] | None = None publication: dict[str, object] | None = None - criterion: dict[str, object] | None = None + criterion: CriterionProvenance | None = None validation: dict[str, object] | None = None +@dataclass(frozen=True, slots=True) +class CriterionProvenance: + """Validated Criterion settings and commands recorded for one report.""" + + suite: BenchmarkSuite + scope: ComparisonScope + statistic: Statistic + sample: str + criterion_version: str + baseline_command: tuple[str, ...] + current_command: tuple[str, ...] + + @dataclass(frozen=True, slots=True) class ReportSettings: """Settings rendered into the benchmark report header.""" baseline_name: str | None - stat: str - suite: str - scope: str + stat: Statistic + suite: BenchmarkSuite + scope: ComparisonScope harness_provenance: HarnessProvenance | None = None @@ -382,16 +423,21 @@ def _read_numeric_field( raise TypeError(msg) try: result = float(value) - except ValueError as err: + except (OverflowError, ValueError) as err: msg = f"field '{field}' for stat '{stat}' in {estimates_json} is not numeric: {value!r}" raise ValueError(msg) from err - if not math.isfinite(result) or result < 0: - msg = f"field '{field}' for stat '{stat}' in {estimates_json} must be finite and non-negative: {value!r}" + if not math.isfinite(result) or result <= 0: + msg = f"field '{field}' for stat '{stat}' in {estimates_json} must be finite and positive: {value!r}" raise ValueError(msg) return result -def _read_harness_provenance(criterion_dir: Path, *, expected_baseline: str) -> HarnessProvenance | None: +def _read_harness_provenance( + criterion_dir: Path, + *, + expected_baseline: str, + expected: CriterionSelection, +) -> HarnessProvenance | None: """Read provenance tied to the exact Criterion samples being compared.""" provenance_path = criterion_dir / ".la-stack-benchmark-harness.json" if not provenance_path.exists(): @@ -429,11 +475,15 @@ def _read_harness_provenance(criterion_dir: Path, *, expected_baseline: str) -> measurement = _required_metadata_object(data, "measurement", provenance_path) publication = _required_metadata_object(data, "publication", provenance_path) - criterion = _required_metadata_object(data, "criterion", provenance_path) + criterion_data = _required_metadata_object(data, "criterion", provenance_path) validation = _required_metadata_object(data, "validation", provenance_path) _validate_measurement_metadata(measurement, mode=mode, path=provenance_path) _validate_environment_metadata(publication, path=provenance_path, context="publication") - _validate_criterion_metadata(criterion, path=provenance_path) + criterion = _parse_criterion_metadata( + criterion_data, + path=provenance_path, + expected=expected, + ) _validate_validation_metadata(validation, path=provenance_path) _validate_baseline_api_compatibility(validation, baseline=baseline, path=provenance_path) @@ -520,15 +570,64 @@ def _validate_measurement_metadata(data: dict[str, object], *, mode: object, pat raise ValueError(msg) -def _validate_criterion_metadata(data: dict[str, object], *, path: Path) -> None: - """Validate the Criterion selection and exact commands used for the report.""" - for field in ("suite", "scope", "statistic", "sample", "criterion_version"): - _required_metadata_string(data, field, path) +def _parse_criterion_metadata( + data: dict[str, object], + *, + path: Path, + expected: CriterionSelection, +) -> CriterionProvenance: + """Parse Criterion metadata and bind it to the requested report settings.""" + suite = _required_metadata_string(data, "suite", path) + scope = _required_metadata_string(data, "scope", path) + statistic = _required_metadata_string(data, "statistic", path) + sample = _required_metadata_string(data, "sample", path) + criterion_version = _required_metadata_string(data, "criterion_version", path) + + expected_fields = { + "suite": expected.suite, + "scope": expected.scope, + "statistic": expected.statistic, + "sample": expected.sample, + } + observed_fields = { + "suite": suite, + "scope": scope, + "statistic": statistic, + "sample": sample, + } + for field, expected_value in expected_fields.items(): + observed = observed_fields[field] + if observed != expected_value: + msg = f"criterion.{field} {observed!r} in {path} does not match requested value {expected_value!r}" + raise ValueError(msg) + + if suite not in SUITE_CHOICES: + msg = f"unsupported criterion.suite in {path}: {suite!r}" + raise ValueError(msg) + if scope not in SCOPE_CHOICES: + msg = f"unsupported criterion.scope in {path}: {scope!r}" + raise ValueError(msg) + if statistic not in {"mean", "median"}: + msg = f"unsupported criterion.statistic in {path}: {statistic!r}" + raise ValueError(msg) + + commands: dict[str, tuple[str, ...]] = {} for field in ("baseline_command", "current_command"): value = data.get(field) if not isinstance(value, list) or not value or not all(isinstance(part, str) and part for part in value): msg = f"invalid or missing criterion.{field} in {path}" raise ValueError(msg) + commands[field] = tuple(cast("list[str]", value)) + + return CriterionProvenance( + suite=cast("BenchmarkSuite", suite), + scope=cast("ComparisonScope", scope), + statistic=cast("Statistic", statistic), + sample=sample, + criterion_version=criterion_version, + baseline_command=commands["baseline_command"], + current_command=commands["current_command"], + ) def _validate_validation_metadata(data: dict[str, object], *, path: Path) -> None: @@ -1182,16 +1281,49 @@ def _get_git_info(root: Path) -> tuple[str, str]: try: result = run_git_command(["--no-pager", "rev-parse", "--short", "HEAD"], cwd=root) short_hash = result.stdout.strip() - except ExecutableNotFoundError, subprocess.CalledProcessError: + except ( + ExecutableNotFoundError, + OSError, + subprocess.CalledProcessError, + subprocess.TimeoutExpired, + ): pass try: result = run_git_command(["--no-pager", "rev-parse", "--abbrev-ref", "HEAD"], cwd=root) branch = result.stdout.strip() - except ExecutableNotFoundError, subprocess.CalledProcessError: + except ( + ExecutableNotFoundError, + OSError, + subprocess.CalledProcessError, + subprocess.TimeoutExpired, + ): pass return short_hash, branch +def _get_git_source_date(root: Path) -> str: + """Return the reproducible source revision timestamp normalized to UTC.""" + try: + result = run_git_command(["--no-pager", "show", "-s", "--format=%cI", "HEAD"], cwd=root) + except ( + ExecutableNotFoundError, + OSError, + subprocess.CalledProcessError, + subprocess.TimeoutExpired, + ): + return "unknown" + value = result.stdout.strip() + if not value: + return "unknown" + try: + parsed = datetime.fromisoformat(value) + except ValueError: + return "unknown" + if parsed.tzinfo is None or parsed.utcoffset() is None: + return "unknown" + return parsed.astimezone(UTC).strftime("%Y-%m-%d %H:%M:%S UTC") + + def _generate_markdown( root: Path, table: str, @@ -1200,12 +1332,14 @@ def _generate_markdown( """Generate the complete benchmark report content.""" version = _read_cargo_version(root) short_hash, branch = _get_git_info(root) - now = datetime.now(tz=UTC).strftime("%Y-%m-%d %H:%M:%S UTC") + source_date = _get_git_source_date(root) lines = [ "# Benchmark Performance", "", - f"**la-stack** v{version} · `{short_hash}` ({branch}) · {now}", + f"**la-stack** v{version} · `{short_hash}` ({branch})", + f"**Source revision timestamp**: {source_date} (deterministic report metadata; not the benchmark measurement time)", + "**Benchmark measurement timestamp**: not recorded by Criterion; use the provenance below to identify the measured revisions and environment.", f"**Statistic**: {settings.stat}", f"**Suite**: {settings.suite}", f"**Scope**: {settings.scope}", @@ -1289,8 +1423,6 @@ def _provenance_markdown(provenance: HarnessProvenance) -> list[str]: publication = provenance.publication criterion = provenance.criterion validation = provenance.validation - baseline_command = cast("list[str]", criterion["baseline_command"]) - current_command = cast("list[str]", criterion["current_command"]) if provenance.mode == "shared-current-harness": correctness_gate = ( "- Correctness gate: `just test-bench-inputs` passed against both the current and baseline revisions using the shared current fixture harness." @@ -1339,11 +1471,11 @@ def _provenance_markdown(provenance: HarnessProvenance) -> list[str]: f"- Publication source-state SHA-256: `{publication['source_state_sha256']}`", f"- Publication Cargo.lock SHA-256: `{publication['cargo_lock_sha256']}`", f"- Publication harness SHA-256: `{publication['harness_sha256']}`", - f"- Criterion suite/scope: `{criterion['suite']}` / `{criterion['scope']}`", - f"- Criterion statistic/sample: `{criterion['statistic']}` / `{criterion['sample']}`", - f"- Criterion dependency version: `{criterion['criterion_version']}`", - f"- Baseline command: `{' '.join(baseline_command)}`", - f"- Current command: `{' '.join(current_command)}`", + f"- Criterion suite/scope: `{criterion.suite}` / `{criterion.scope}`", + f"- Criterion statistic/sample: `{criterion.statistic}` / `{criterion.sample}`", + f"- Criterion dependency version: `{criterion.criterion_version}`", + f"- Baseline command: `{' '.join(criterion.baseline_command)}`", + f"- Current command: `{' '.join(criterion.current_command)}`", correctness_gate, ( f"- Validated current revision: `{validation['current_commit']}` " @@ -1363,7 +1495,7 @@ def _provenance_markdown(provenance: HarnessProvenance) -> list[str]: f"- Baseline API compatibility: `{compatibility}` selects only source-compatible benchmark calls; " "rows outside the baseline's correctness domain remain explicitly unavailable." ) - if compatibility == _V0_4_3_API_COMPATIBILITY and criterion["suite"] in {"all", "vs_linalg"}: + if compatibility == _V0_4_3_API_COMPATIBILITY and criterion.suite in {"all", "vs_linalg"}: lines.append( "- Baseline-unavailable rows: `d8/la_stack_det_from_lu_balanced_range` and " "`d8/la_stack_det_from_ldlt_balanced_range` were not timed because v0.4.3 returns zero for a fixture " @@ -1450,9 +1582,13 @@ def _save_baseline_hint(suite: str, baseline: str) -> str: return f"just bench-save-baseline {baseline}" -def main(argv: list[str] | None = None) -> int: # noqa: PLR0911 +def main(argv: list[str] | None = None) -> int: # noqa: C901, PLR0911 """Generate a benchmark snapshot or comparison report from CLI arguments.""" args = _parse_args(sys.argv[1:] if argv is None else argv) + stat = cast("Statistic", args.stat) + suite = cast("BenchmarkSuite", args.suite) + scope = cast("ComparisonScope", args.scope) + selection = CriterionSelection(suite=suite, scope=scope, statistic=stat, sample="new") root = _repo_root() criterion_dir = root / args.criterion_dir @@ -1470,18 +1606,26 @@ def main(argv: list[str] | None = None) -> int: # noqa: PLR0911 if baseline_name: try: - harness_provenance = _read_harness_provenance(criterion_dir, expected_baseline=baseline_name) + harness_provenance = _read_harness_provenance( + criterion_dir, + expected_baseline=baseline_name, + expected=selection, + ) except (OSError, TypeError, ValueError) as err: print(f"Invalid benchmark harness provenance: {err}", file=sys.stderr) return 2 - collection = _collect_comparisons( - criterion_dir, - baseline_name, - args.stat, - args.suite, - _comparison_policy(args.scope, harness_provenance), - ) + try: + collection = _collect_comparisons( + criterion_dir, + baseline_name, + stat, + suite, + _comparison_policy(scope, harness_provenance), + ) + except (OSError, KeyError, TypeError, ValueError) as err: + print(f"Invalid Criterion estimate data: {err}", file=sys.stderr) + return 2 if collection.gaps: print( f"Incomplete benchmark coverage: {len(collection.gaps)} required comparison row(s) are missing; report publication aborted.", @@ -1509,28 +1653,32 @@ def main(argv: list[str] | None = None) -> int: # noqa: PLR0911 coverage_errors = _snapshot_coverage_errors( criterion_dir, sample="new", - suite=args.suite, - scope=args.scope, + suite=suite, + scope=scope, ) if coverage_errors: print("Incomplete benchmark coverage; snapshot publication aborted:", file=sys.stderr) for error in coverage_errors: print(f" - {error}", file=sys.stderr) return 2 - results = _collect_results(criterion_dir, "new", args.stat, args.suite) + try: + results = _collect_results(criterion_dir, "new", stat, suite) + except (OSError, KeyError, TypeError, ValueError) as err: + print(f"Invalid Criterion estimate data: {err}", file=sys.stderr) + return 2 if not results: print( f"No benchmark results found.\nRun benchmarks first:\n {_run_bench_hint(args.suite)}\n", file=sys.stderr, ) return 2 - table = _snapshot_tables(results, args.stat) + table = _snapshot_tables(results, stat) settings = ReportSettings( baseline_name=baseline_name, - stat=args.stat, - suite=args.suite, - scope=args.scope, + stat=stat, + suite=suite, + scope=scope, harness_provenance=harness_provenance, ) md = _generate_markdown(root, table, settings) diff --git a/scripts/criterion_dim_plot.py b/scripts/criterion_dim_plot.py index 022b1be..a3eb692 100644 --- a/scripts/criterion_dim_plot.py +++ b/scripts/criterion_dim_plot.py @@ -25,7 +25,7 @@ import sys import tempfile import tomllib -from dataclasses import dataclass +from dataclasses import dataclass, field as dataclass_field from pathlib import Path from typing import Final, Protocol, TypeGuard, cast @@ -105,10 +105,55 @@ def __post_init__(self) -> None: ("fa_lo", self.fa_lo), ("fa_hi", self.fa_hi), ): - _require_nonnegative_finite_time(value, field) - _require_confidence_interval(self.la_lo, self.la_time, self.la_hi, "la_stack row") - _require_confidence_interval(self.na_lo, self.na_time, self.na_hi, "nalgebra row") - _require_confidence_interval(self.fa_lo, self.fa_time, self.fa_hi, "faer row") + _require_positive_finite_time(value, field) + _require_confidence_interval(self.la_lo, self.la_hi, "la_stack row") + _require_confidence_interval(self.na_lo, self.na_hi, "nalgebra row") + _require_confidence_interval(self.fa_lo, self.fa_hi, "faer row") + + +@dataclass(slots=True) +class _CriterionSampleTransaction: + """Move stale samples aside and restore them atomically on timing failure.""" + + criterion_dir: Path + backup_root: Path + moved: list[Path] = dataclass_field(default_factory=list) + + def stage(self) -> None: + """Move all existing vs_linalg `new` samples into the backup tree.""" + for sample in _vs_linalg_new_samples(self.criterion_dir): + relative = sample.relative_to(self.criterion_dir) + backup = self.backup_root / relative + backup.parent.mkdir(parents=True, exist_ok=True) + sample.replace(backup) + self.moved.append(relative) + + def rollback(self, *, remove_fresh: bool) -> None: + """Restore moved samples, retaining backups when any step fails.""" + errors: list[str] = [] + if remove_fresh: + for sample in _vs_linalg_new_samples(self.criterion_dir): + try: + shutil.rmtree(sample) + except OSError as exc: + errors.append(f"could not remove fresh sample {sample}: {exc}") + + for relative in reversed(self.moved): + source = self.backup_root / relative + destination = self.criterion_dir / relative + if not source.exists(): + continue + if destination.exists(): + errors.append(f"could not restore {destination}: destination already exists") + continue + try: + destination.parent.mkdir(parents=True, exist_ok=True) + source.replace(destination) + except OSError as exc: + errors.append(f"could not restore {destination}: {exc}") + + if errors: + raise RuntimeError("; ".join(errors)) class ReadmeMarkerError(ValueError): @@ -123,6 +168,10 @@ class MarkerOrderError(ReadmeMarkerError): """Raised when README markers are out of order.""" +class PublicationRollbackError(RuntimeError): + """Raised when artifact publication fails and rollback is incomplete.""" + + class _ReadmeArgs(Protocol): @property def update_readme(self) -> bool: ... @@ -329,13 +378,17 @@ def _read_estimate(estimates_json: Path, stat: str) -> tuple[float, float, float raise KeyError(f"stat '{stat}' not found in {estimates_json}") point = _read_numeric_field(stat_obj, "point_estimate", estimates_json, stat) - ci = stat_obj.get("confidence_interval") + if "confidence_interval" not in stat_obj: + msg = f"field 'confidence_interval' for stat '{stat}' not found in {estimates_json}" + raise KeyError(msg) + ci = stat_obj["confidence_interval"] if not _is_parsed_object(ci): - return (point, point, point) + msg = f"field 'confidence_interval' for stat '{stat}' in {estimates_json} is not an object" + raise TypeError(msg) - lo = _read_numeric_field(ci, "lower_bound", estimates_json, stat, default=point) - hi = _read_numeric_field(ci, "upper_bound", estimates_json, stat, default=point) - _require_confidence_interval(lo, point, hi, f"{stat}.confidence_interval in {estimates_json}") + lo = _read_numeric_field(ci, "lower_bound", estimates_json, stat) + hi = _read_numeric_field(ci, "upper_bound", estimates_json, stat) + _require_confidence_interval(lo, hi, f"{stat}.confidence_interval in {estimates_json}") return (point, lo, hi) @@ -353,12 +406,8 @@ def _read_numeric_field( field: str, estimates_json: Path, stat: str, - *, - default: float | None = None, ) -> float: if field not in obj: - if default is not None: - return default msg = f"field '{field}' for stat '{stat}' not found in {estimates_json}" raise KeyError(msg) @@ -369,26 +418,23 @@ def _read_numeric_field( try: parsed = float(value) - except ValueError as err: + except (OverflowError, ValueError) as err: msg = f"field '{field}' for stat '{stat}' in {estimates_json} is not numeric: {value!r}" raise ValueError(msg) from err - return _require_nonnegative_finite_time(parsed, f"{stat}.{field} in {estimates_json}") + return _require_positive_finite_time(parsed, f"{stat}.{field} in {estimates_json}") -def _require_nonnegative_finite_time(value: float, context: str) -> float: - if not math.isfinite(value) or value < 0.0: - msg = f"{context} must be finite and nonnegative: {value!r}" +def _require_positive_finite_time(value: float, context: str) -> float: + if not math.isfinite(value) or value <= 0.0: + msg = f"{context} must be finite and positive: {value!r}" raise ValueError(msg) return value -def _require_confidence_interval(lo: float, point: float, hi: float, context: str) -> None: +def _require_confidence_interval(lo: float, hi: float, context: str) -> None: if lo > hi: msg = f"{context} lower bound must be <= upper bound: {lo!r} > {hi!r}" raise ValueError(msg) - if not lo <= point <= hi: - msg = f"{context} point estimate must be inside confidence interval: {lo!r} <= {point!r} <= {hi!r}" - raise ValueError(msg) def _write_csv(out_csv: Path, rows: list[Row]) -> None: @@ -671,19 +717,39 @@ def _run_publication_benchmarks(root: Path) -> None: """Validate fixtures, then produce fresh README publication measurements.""" _run_publication_command(root, _PUBLICATION_GATE) criterion_dir = root / "target" / "criterion" - with tempfile.TemporaryDirectory(prefix="la-stack-stale-criterion-") as tmp: - backup_root = Path(tmp) - moved = _stage_existing_new_samples(criterion_dir, backup_root) + criterion_dir.parent.mkdir(parents=True, exist_ok=True) + backup_root = Path(tempfile.mkdtemp(prefix="la-stack-stale-criterion-", dir=criterion_dir.parent)) + transaction = _CriterionSampleTransaction(criterion_dir=criterion_dir, backup_root=backup_root) + preserve_backup = False + try: + try: + transaction.stage() + except OSError as primary: + try: + transaction.rollback(remove_fresh=False) + except RuntimeError as rollback: + preserve_backup = True + msg = f"could not stage Criterion samples and rollback failed: {rollback}; backups preserved at {backup_root}" + raise RuntimeError(msg) from primary + msg = f"could not stage existing Criterion samples: {primary}" + raise RuntimeError(msg) from primary + try: _run_publication_command(root, _PUBLICATION_BENCHMARK) - except RuntimeError: - _remove_vs_linalg_new_samples(criterion_dir) - for relative in moved: - source = backup_root / relative - destination = criterion_dir / relative - destination.parent.mkdir(parents=True, exist_ok=True) - source.replace(destination) + except RuntimeError as primary: + try: + transaction.rollback(remove_fresh=True) + except RuntimeError as rollback: + preserve_backup = True + msg = f"{primary}\nCriterion sample rollback failed: {rollback}; backups preserved at {backup_root}" + raise RuntimeError(msg) from primary raise + finally: + if not preserve_backup: + try: + shutil.rmtree(backup_root) + except OSError as exc: + print(f"Warning: could not remove Criterion sample backup {backup_root}: {exc}", file=sys.stderr) def _run_publication_command(root: Path, command: tuple[str, ...]) -> None: @@ -708,6 +774,9 @@ def _run_publication_command(root: Path, command: tuple[str, ...]) -> None: detail = f"\nstderr:\n{stderr}" if stderr else "" msg = f"publication command timed out after {exc.timeout} seconds: {' '.join(command)}{detail}" raise RuntimeError(msg) from exc + except OSError as exc: + msg = f"publication command could not start: {' '.join(command)}: {exc}" + raise RuntimeError(msg) from exc def _vs_linalg_new_samples(criterion_dir: Path) -> list[Path]: @@ -726,29 +795,16 @@ def _vs_linalg_new_samples(criterion_dir: Path) -> list[Path]: ) -def _stage_existing_new_samples(criterion_dir: Path, backup_root: Path) -> list[Path]: - """Move stale `new` samples aside so only the fresh timing run can satisfy coverage.""" - moved: list[Path] = [] - for sample in _vs_linalg_new_samples(criterion_dir): - relative = sample.relative_to(criterion_dir) - backup = backup_root / relative - backup.parent.mkdir(parents=True, exist_ok=True) - sample.replace(backup) - moved.append(relative) - return moved - - -def _remove_vs_linalg_new_samples(criterion_dir: Path) -> None: - """Remove partial current samples before restoring a failed publication run.""" - for sample in _vs_linalg_new_samples(criterion_dir): - shutil.rmtree(sample) - - def _git_value(root: Path, args: list[str]) -> str: """Return deterministic Git provenance or an explicit unavailable label.""" try: value = run_git_command(args, cwd=root).stdout.strip() - except ExecutableNotFoundError, subprocess.CalledProcessError: + except ( + ExecutableNotFoundError, + OSError, + subprocess.CalledProcessError, + subprocess.TimeoutExpired, + ): return "unavailable" return value or "unavailable" @@ -760,7 +816,12 @@ def _git_status_metadata(root: Path) -> tuple[bool | None, str]: ["--no-pager", "status", "--porcelain=v1", "--untracked-files=all"], cwd=root, ).stdout - except ExecutableNotFoundError, subprocess.CalledProcessError: + except ( + ExecutableNotFoundError, + OSError, + subprocess.CalledProcessError, + subprocess.TimeoutExpired, + ): return (None, hashlib.sha256(b"unavailable").hexdigest()) return (not status.strip(), hashlib.sha256(status.encode()).hexdigest()) @@ -835,7 +896,12 @@ def _rustc_version(root: Path) -> str: cwd=root, timeout=60, ).stdout.strip() - except ExecutableNotFoundError, subprocess.CalledProcessError: + except ( + ExecutableNotFoundError, + OSError, + subprocess.CalledProcessError, + subprocess.TimeoutExpired, + ): return "unavailable" return value or "unavailable" @@ -974,16 +1040,45 @@ def _replace_staged_files(pairs: list[tuple[Path, Path]], backup_dir: Path) -> N for staged, destination in pairs: staged.replace(destination) replaced.append(destination) - except OSError: + except OSError as primary: + rollback_errors: list[str] = [] for destination in reversed(replaced): backup = backups[destination] - if backup is None: - destination.unlink(missing_ok=True) - else: - backup.replace(destination) + try: + if backup is None: + destination.unlink(missing_ok=True) + else: + backup.replace(destination) + except OSError as rollback: + rollback_errors.append(f"could not restore {destination}: {rollback}") + if rollback_errors: + msg = f"artifact replacement failed ({primary}); rollback failed: {'; '.join(rollback_errors)}; backups preserved at {backup_dir}" + raise PublicationRollbackError(msg) from primary raise +def _publish_staged_files(pairs: list[tuple[Path, Path]], root: Path) -> bool: + """Publish staged files together, preserving backups after rollback failure.""" + backup_dir = Path(tempfile.mkdtemp(prefix=".criterion-dim-plot-backup-", dir=root)) + preserve_backup = False + try: + _replace_staged_files(pairs, backup_dir) + except PublicationRollbackError as exc: + preserve_backup = True + print(f"could not publish benchmark artifacts atomically: {exc}", file=sys.stderr) + return False + except (OSError, ValueError) as exc: + print(f"could not publish benchmark artifacts atomically: {exc}", file=sys.stderr) + return False + finally: + if not preserve_backup: + try: + shutil.rmtree(backup_dir) + except OSError as exc: + print(f"Warning: could not remove artifact backup {backup_dir}: {exc}", file=sys.stderr) + return True + + def _stage_and_publish_outputs( # noqa: PLR0913 *, root: Path, @@ -1040,10 +1135,7 @@ def _stage_and_publish_outputs( # noqa: PLR0913 return 2 pairs.append((staged_readme, readme_path)) - try: - _replace_staged_files(pairs, stage_dir) - except (OSError, ValueError) as exc: - print(f"could not publish benchmark artifacts atomically: {exc}", file=sys.stderr) + if not _publish_staged_files(pairs, root): return 2 if skipped: @@ -1142,7 +1234,11 @@ def main(argv: list[str] | None = None) -> int: # noqa: C901, PLR0911, PLR0912, out_svg, out_csv = _resolve_output_paths(root, args.metric, args.stat, args.out, args.csv) - rows, skipped = _collect_rows(criterion_dir, dims, metric, args.stat, args.sample) + try: + rows, skipped = _collect_rows(criterion_dir, dims, metric, args.stat, args.sample) + except (OSError, KeyError, TypeError, ValueError) as exc: + print(f"Invalid Criterion estimate data: {exc}", file=sys.stderr) + return 2 if not rows: print( "No benchmark results found to plot for the selected metric/stat.\n" diff --git a/scripts/postprocess_changelog.py b/scripts/postprocess_changelog.py index 06f1327..cd47cef 100644 --- a/scripts/postprocess_changelog.py +++ b/scripts/postprocess_changelog.py @@ -23,21 +23,94 @@ import argparse import re import sys +from dataclasses import dataclass from pathlib import Path # rumdl MD013 line-length limit used by this project. MAX_LINE_WIDTH = 160 -# Tokenise a line into atomic markdown units that must not be split. -# Order matters: longer patterns first. -_TOKEN_RE = re.compile( - r""" - \[[^\]]*\]\([^)]*\) # markdown link: [text](url) - | `[^`]+` # code span: `code` - | \S+ # regular word - """, - re.VERBOSE, -) + +@dataclass(frozen=True, slots=True) +class _CodeFence: + """Delimiter evidence for an open Markdown fenced code block.""" + + delimiter: str + length: int + + +_FENCE_RE = re.compile(r"^(?P[ \t]*)(?P`{3,}|~{3,})(?P.*)$") + + +def _backtick_span_end(text: str, start: int) -> int | None: + """Return the end of a code span whose closing run matches its opener.""" + delimiter_length = 1 + while start + delimiter_length < len(text) and text[start + delimiter_length] == "`": + delimiter_length += 1 + + position = start + delimiter_length + while position < len(text): + run_start = text.find("`", position) + if run_start < 0: + return None + run_end = run_start + 1 + while run_end < len(text) and text[run_end] == "`": + run_end += 1 + if run_end - run_start == delimiter_length: + return run_end + position = run_end + return None + + +def _balanced_delimiter_end(text: str, start: int, opening: str, closing: str) -> int | None: + """Return the end of a balanced delimiter pair, honoring escapes.""" + depth = 1 + position = start + 1 + while position < len(text): + character = text[position] + if character == "\\" and position + 1 < len(text): + position += 2 + continue + if character == opening: + depth += 1 + elif character == closing: + depth -= 1 + if depth == 0: + return position + 1 + position += 1 + return None + + +def _markdown_link_end(text: str, start: int) -> int | None: + """Return the end of an inline link, balancing brackets and parentheses.""" + label_end = _balanced_delimiter_end(text, start, "[", "]") + if label_end is None or label_end >= len(text) or text[label_end] != "(": + return None + return _balanced_delimiter_end(text, label_end, "(", ")") + + +def _markdown_tokens(text: str) -> list[str]: + """Tokenize reflowable prose without splitting links or code spans.""" + tokens: list[str] = [] + position = 0 + while position < len(text): + while position < len(text) and text[position].isspace(): + position += 1 + if position >= len(text): + break + + token_end: int | None = None + if text[position] == "[": + token_end = _markdown_link_end(text, position) + elif text[position] == "`": + token_end = _backtick_span_end(text, position) + + if token_end is None: + token_end = position + 1 + while token_end < len(text) and not text[token_end].isspace(): + token_end += 1 + tokens.append(text[position:token_end]) + position = token_end + return tokens # Version section heading: ## [X.Y.Z], ## [vX.Y.Z], or ## [Unreleased] @@ -361,7 +434,7 @@ def _reflow_line(line: str, max_width: int = MAX_LINE_WIDTH) -> str: content = stripped cont_indent = indent - tokens = _TOKEN_RE.findall(content) + tokens = _markdown_tokens(content) if not tokens: return line @@ -574,39 +647,83 @@ def _normalize_entry_heading(line: str) -> str: def normalize_entry_headings_text(text: str) -> str: """Normalize accidental entry headings in an existing changelog document.""" result: list[str] = [] - in_code_block = False + active_fence: _CodeFence | None = None for line in text.split("\n"): - if line.lstrip().startswith("```"): + if active_fence is not None: + result.append(line) + if _closes_code_fence(line, active_fence): + active_fence = None + continue + + active_fence = _opening_code_fence(line) + if active_fence is not None: result.append(line) - in_code_block = not in_code_block continue - result.append(line if in_code_block else _normalize_entry_heading(line)) + result.append(_normalize_entry_heading(line)) return "\n".join(result).rstrip("\n") + "\n" -def _process_code_fence(line: str, result: list[str], in_code_block: bool, next_line: str | None) -> tuple[bool, bool]: - """Handle fenced-code transitions and append the line when consumed.""" - stripped = line.lstrip() - if not stripped.startswith("```"): - return False, in_code_block +def _fence_parts(line: str) -> tuple[str, str, str] | None: + """Return indentation, delimiter run, and info string for a fence line.""" + match = _FENCE_RE.fullmatch(line) + if match is None: + return None + return match.group("indent"), match.group("fence"), match.group("info") + + +def _opening_code_fence(line: str) -> _CodeFence | None: + """Parse an opening backtick or tilde fence.""" + parts = _fence_parts(line) + if parts is None: + return None + _, delimiter_run, info = parts + delimiter = delimiter_run[0] + if delimiter == "`" and "`" in info: + return None + return _CodeFence(delimiter=delimiter, length=len(delimiter_run)) + - if not in_code_block: - in_code_block = True +def _closes_code_fence(line: str, active_fence: _CodeFence) -> bool: + """Return whether *line* validly closes *active_fence*.""" + parts = _fence_parts(line) + if parts is None: + return False + _, delimiter_run, info = parts + return delimiter_run[0] == active_fence.delimiter and len(delimiter_run) >= active_fence.length and not info.strip() + + +def _process_code_fence( + line: str, + result: list[str], + active_fence: _CodeFence | None, + next_line: str | None, +) -> tuple[bool, _CodeFence | None]: + """Handle fenced-code transitions and append the line when consumed.""" + if active_fence is None: + active_fence = _opening_code_fence(line) + if active_fence is None: + return False, None # MD031: blank line before fenced code block. if result and result[-1].strip(): result.append("") # MD040: add language tag if missing. - if stripped == "```": - line = line.replace("```", "```text", 1) - else: - in_code_block = False + parts = _fence_parts(line) + if parts is not None: + indent, delimiter_run, info = parts + if not info.strip(): + line = f"{indent}{delimiter_run}text" + result.append(line) + return True, active_fence + + if not _closes_code_fence(line, active_fence): + return False, active_fence result.append(line) - if not in_code_block and next_line is not None and next_line.strip(): + if next_line is not None and next_line.strip(): result.append("") - return True, in_code_block + return True, None def _update_entry_summary(line: str, current_entry_summary: str | None) -> str | None: @@ -654,7 +771,7 @@ def postprocess_text(text: str) -> str: lines = text.split("\n") result: list[str] = [] - in_code_block = False + active_fence: _CodeFence | None = None current_entry_summary: str | None = None drop_next_blank = False @@ -663,12 +780,12 @@ def postprocess_text(text: str) -> str: # --- fenced code-block tracking --- next_line = lines[idx + 1] if idx + 1 < len(lines) else None - handled, in_code_block = _process_code_fence(line, result, in_code_block, next_line) + handled, active_fence = _process_code_fence(line, result, active_fence, next_line) if handled: continue # Never reflow inside code blocks. - if in_code_block: + if active_fence is not None: result.append(line) continue diff --git a/scripts/subprocess_utils.py b/scripts/subprocess_utils.py index 6bbe763..0179deb 100644 --- a/scripts/subprocess_utils.py +++ b/scripts/subprocess_utils.py @@ -182,7 +182,10 @@ def check_git_repo() -> bool: """Return true when the current directory is inside a git repository.""" try: run_git_command(["rev-parse", "--git-dir"]) - except ExecutableNotFoundError, subprocess.CalledProcessError: + except ( + ExecutableNotFoundError, + subprocess.CalledProcessError, + ): return False else: return True @@ -192,7 +195,10 @@ def check_git_history() -> bool: """Return true when the current git repository has at least one commit.""" try: run_git_command(["log", "--oneline", "-n", "1"]) - except ExecutableNotFoundError, subprocess.CalledProcessError: + except ( + ExecutableNotFoundError, + subprocess.CalledProcessError, + ): return False else: return True @@ -200,7 +206,7 @@ def check_git_history() -> bool: def run_git_command_with_input( args: list[str], - input_data: str, + input_data: str | bytes, cwd: Path | None = None, **kwargs: Any, ) -> subprocess.CompletedProcess[str]: @@ -208,7 +214,7 @@ def run_git_command_with_input( Args: args: Git command arguments (without 'git' prefix) - input_data: Text to encode and send to stdin without newline translation + input_data: Text to encode or bytes to send to stdin without translation cwd: Working directory for the command **kwargs: Additional arguments passed to subprocess.run @@ -224,8 +230,9 @@ def run_git_command_with_input( run_kwargs = _build_run_kwargs("run_git_command_with_input", **kwargs) encoding: str = run_kwargs.get("encoding") or "utf-8" errors: str = run_kwargs.get("errors") or "strict" + payload = input_data if isinstance(input_data, bytes) else input_data.encode(encoding, errors) with tempfile.TemporaryFile() as stdin: - stdin.write(input_data.encode(encoding, errors)) + stdin.write(payload) stdin.seek(0) return subprocess.run( # noqa: S603,PLW1510 [git_path, *args], diff --git a/scripts/tag_release.py b/scripts/tag_release.py index 14f2778..326b05d 100755 --- a/scripts/tag_release.py +++ b/scripts/tag_release.py @@ -20,6 +20,7 @@ import subprocess import sys from pathlib import Path +from urllib.parse import urlsplit from subprocess_utils import ( ExecutableNotFoundError, @@ -38,6 +39,9 @@ log = logging.getLogger(__name__) +_GITHUB_SCP_REMOTE_RE = re.compile(r"^git@github\.com:(?P.+)$", re.IGNORECASE) +_GITHUB_REPO_COMPONENT_RE = re.compile(r"^[A-Za-z0-9_.-]+$") + # --------------------------------------------------------------------------- # SemVer validation @@ -181,23 +185,64 @@ def _delete_tag(tag_version: str) -> None: run_git_command(["tag", "-d", tag_version]) +def _github_repo_url(path: str) -> str: + """Return a canonical GitHub URL for a validated two-component path.""" + normalized = path.strip("/").removesuffix(".git") + components = normalized.split("/") + if ( + len(components) != 2 + or any(component in {"", ".", ".."} for component in components) + or any(_GITHUB_REPO_COMPONENT_RE.fullmatch(component) is None for component in components) + ): + msg = "Origin remote must identify exactly one GitHub owner and repository." + raise ValueError(msg) + return f"https://github.com/{components[0]}/{components[1]}" + + def _get_repo_url() -> str: - """Detect the GitHub HTTPS URL from the ``origin`` remote.""" + """Detect and validate the public GitHub HTTPS URL for ``origin``.""" result = run_git_command(["remote", "get-url", "origin"]) raw = result.stdout.strip() - patterns = [ - r"^git@github\.com:(?P[^/]+/[^/]+?)(?:\.git)?/?$", - r"^https://github\.com/(?P[^/]+/[^/]+?)(?:\.git)?/?$", - r"^ssh://git@github\.com[:/](?P[^/]+/[^/]+?)(?:\.git)?/?$", - ] - for pat in patterns: - m = re.match(pat, raw) - if m: - return f"https://github.com/{m.group('slug')}" - if re.search(r"://[^/@]+:[^/@]+@", raw) or re.search(r"://[^/@]+@", raw) or re.match(r"[^@]+@", raw): - msg = f"Remote URL appears to contain credentials; cannot use as a public URL: {raw[:20]}..." + if not raw or any(character.isspace() for character in raw): + msg = "Origin remote must be a GitHub HTTPS or SSH repository URL." + raise ValueError(msg) + + scp_match = _GITHUB_SCP_REMOTE_RE.fullmatch(raw) + if scp_match is not None: + path = scp_match.group("path") + if "?" in path or "#" in path: + msg = "Origin remote must not include query parameters or fragments." + raise ValueError(msg) + return _github_repo_url(path) + + try: + parsed = urlsplit(raw) + hostname = parsed.hostname + port = parsed.port + except ValueError as err: + msg = "Origin remote must be a GitHub HTTPS or SSH repository URL." + raise ValueError(msg) from err + + if parsed.query or parsed.fragment: + msg = "Origin remote must not include query parameters or fragments." + raise ValueError(msg) + if parsed.password is not None or (parsed.scheme == "https" and parsed.username is not None): + msg = "Origin remote must not contain credentials." raise ValueError(msg) - return raw # best-effort fallback + if parsed.scheme == "ssh" and parsed.username not in {None, "git"}: + msg = "Origin remote must not contain credentials other than the standard SSH user." + raise ValueError(msg) + if ( + parsed.scheme not in {"https", "ssh"} + or hostname is None + or hostname.casefold() != "github.com" + or port is not None + or (parsed.scheme == "ssh" and parsed.username != "git") + ): + msg = "Origin remote must be a GitHub HTTPS or SSH repository URL." + raise ValueError(msg) + + return _github_repo_url(parsed.path) def _version_header_re(version: str) -> re.Pattern[str]: diff --git a/scripts/tests/test_archive_changelog.py b/scripts/tests/test_archive_changelog.py index 88db3b1..d0d92b0 100644 --- a/scripts/tests/test_archive_changelog.py +++ b/scripts/tests/test_archive_changelog.py @@ -160,13 +160,11 @@ def test_no_unreleased(self) -> None: assert unreleased == "" assert len(blocks) == 2 - def test_skips_non_semver_headings(self) -> None: + def test_rejects_non_semver_headings(self) -> None: text = _PREAMBLE + _V072 + "## [CustomLabel]\n\n- Something\n\n" + _V071 - _, _, blocks = parse_changelog(text) - # The non-semver heading should be silently skipped. - assert len(blocks) == 2 - assert blocks[0][0] == "0.7.2" - assert blocks[1][0] == "0.7.1" + + with pytest.raises(ValueError, match="Unrecognized changelog version heading"): + parse_changelog(text) class TestGroupByMinor: @@ -296,6 +294,24 @@ def test_no_link_defs_by_default(self) -> None: class TestArchiveChangelog: + def test_unknown_heading_preserves_root_and_archives(self, tmp_path: Path) -> None: + """An unknown version-like heading fails before any output is rewritten.""" + changelog = tmp_path / "CHANGELOG.md" + original = _PREAMBLE + _V072 + "## [CustomLabel]\n\n- Preserve me\n\n" + _V062 + changelog.write_text(original, encoding="utf-8") + archive_dir = tmp_path / "docs" / "archive" / "changelog" + archive_dir.mkdir(parents=True) + existing_archive = archive_dir / "0.5.md" + existing = "# Changelog - 0.5.x\n\nHistorical content\n" + existing_archive.write_text(existing, encoding="utf-8") + + with pytest.raises(ValueError, match="CustomLabel"): + archive_changelog(changelog, archive_dir) + + assert changelog.read_text(encoding="utf-8") == original + assert existing_archive.read_text(encoding="utf-8") == existing + assert sorted(path.name for path in archive_dir.iterdir()) == ["0.5.md"] + def test_splits_and_archives(self, tmp_path: Path) -> None: changelog = tmp_path / "CHANGELOG.md" changelog.write_text(_full_changelog(), encoding="utf-8") @@ -450,6 +466,10 @@ def test_archive_dir_relpath_value_error_preserves_changelog( original = _full_changelog() changelog.write_text(original, encoding="utf-8") archive_dir = tmp_path / "outside" / "archive" + archive_dir.mkdir(parents=True) + existing_archive = archive_dir / "0.5.md" + existing = "# Changelog - 0.5.x\n\nHistorical content\n" + existing_archive.write_text(existing, encoding="utf-8") def raise_cross_drive_value_error(_path: Path, _start: Path) -> str: msg = "path is on mount 'D:', start on mount 'C:'" @@ -462,9 +482,13 @@ def raise_cross_drive_value_error(_path: Path, _start: Path) -> str: root = changelog.read_text(encoding="utf-8") assert root == original + assert str(archive_dir) not in root assert archive_dir.as_posix() not in root + assert str(archive_dir) not in str(exc_info.value) assert archive_dir.as_posix() not in str(exc_info.value) assert isinstance(exc_info.value.__cause__, ValueError) + assert existing_archive.read_text(encoding="utf-8") == existing + assert sorted(path.name for path in archive_dir.iterdir()) == ["0.5.md"] # --------------------------------------------------------------------------- diff --git a/scripts/tests/test_archive_performance.py b/scripts/tests/test_archive_performance.py index 8f308a8..6f12900 100644 --- a/scripts/tests/test_archive_performance.py +++ b/scripts/tests/test_archive_performance.py @@ -255,6 +255,34 @@ def test_apply_current_diff_includes_complete_current_tree_without_mutating_inde assert _git(repo_root, "status", "--porcelain=v1", "--untracked-files=all") == status_before +def test_apply_current_diff_preserves_crlf_patch_bytes(tmp_path: Path) -> None: + repo_root = tmp_path / "repo" + worktree = tmp_path / "worktree" + repo_root.mkdir() + _git(repo_root, "init", "--quiet") + _git(repo_root, "config", "user.name", "Test User") + _git(repo_root, "config", "user.email", "test@example.com") + _git(repo_root, "config", "commit.gpgsign", "false") + _git(repo_root, "config", "core.autocrlf", "false") + + (repo_root / ".gitattributes").write_text("tracked.txt -text\n", encoding="utf-8") + tracked = repo_root / "tracked.txt" + tracked.write_bytes(b"committed\r\n") + _git(repo_root, "add", "--", ".gitattributes", tracked.name) + _git(repo_root, "commit", "--quiet", "-m", "initial") + _git(repo_root, "worktree", "add", "--quiet", "--detach", str(worktree), "HEAD") + + tracked.write_bytes(b"staged\r\n") + _git(repo_root, "add", "--", tracked.name) + tracked.write_bytes(b"working tree\r\n") + index_before = _git(repo_root, "rev-parse", f":{tracked.name}") + + archive_performance._apply_current_diff_to_worktree(repo_root=repo_root, worktree=worktree) + + assert (worktree / tracked.name).read_bytes() == b"working tree\r\n" + assert _git(repo_root, "rev-parse", f":{tracked.name}") == index_before + + def test_apply_current_diff_fails_loudly_and_cleans_temporary_index( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -293,6 +321,86 @@ def fake_run_git(args: Sequence[str], cwd: Path | None = None, **kwargs: Any) -> assert not temporary_index.parent.exists() +@pytest.mark.parametrize( + ("failure", "message"), + [ + (archive_performance.ExecutableNotFoundError("missing tool"), "command could not start: tool --flag: missing tool"), + (subprocess.TimeoutExpired(["tool", "--flag"], 17, stderr="stalled"), "command timed out after 17 seconds: tool --flag"), + (OSError("working directory unavailable"), "command could not start: tool --flag: working directory unavailable"), + ], +) +def test_run_tool_normalizes_launch_timeout_and_os_errors( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + failure: Exception, + message: str, +) -> None: + def fail_run(*_args: object, **_kwargs: object) -> SimpleNamespace: + raise failure + + monkeypatch.setattr(archive_performance, "run_safe_command", fail_run) + + with pytest.raises(RuntimeError) as exc_info: + archive_performance._run_tool("tool", ["--flag"], cwd=tmp_path) + + assert str(exc_info.value).startswith(message) + assert exc_info.value.__cause__ is failure + + +def test_temporary_worktree_cleanup_failure_fails_successful_operation( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + def fake_run_git(args: list[str], *, cwd: Path, timeout: int = 600) -> None: + del cwd, timeout + if args[:3] == ["worktree", "remove", "--force"]: + msg = "cleanup failed" + raise RuntimeError(msg) + + monkeypatch.setattr(archive_performance, "_run_git", fake_run_git) + + def complete_operation() -> None: + with archive_performance._temporary_detached_worktree( + repo_root=tmp_path, + worktree=tmp_path / "worktree", + revision="HEAD", + label="test worktree", + ): + pass + + with pytest.raises(RuntimeError, match="failed to remove test worktree"): + complete_operation() + + +def test_temporary_worktree_cleanup_does_not_mask_primary_error( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + def fake_run_git(args: list[str], *, cwd: Path, timeout: int = 600) -> None: + del cwd, timeout + if args[:3] == ["worktree", "remove", "--force"]: + msg = "cleanup failed" + raise RuntimeError(msg) + + monkeypatch.setattr(archive_performance, "_run_git", fake_run_git) + + def fail_operation() -> None: + with archive_performance._temporary_detached_worktree( + repo_root=tmp_path, + worktree=tmp_path / "worktree", + revision="HEAD", + label="test worktree", + ): + msg = "primary failed" + raise ValueError(msg) + + with pytest.raises(RuntimeError, match="operation failed") as exc_info: + fail_operation() + + assert "additionally failed to remove test worktree: cleanup failed" in str(exc_info.value) + assert isinstance(exc_info.value.__cause__, ValueError) + + def test_normalize_tag_adds_leading_v() -> None: assert normalize_tag("0.4.2") == "v0.4.2" assert normalize_tag("v0.4.2") == "v0.4.2" @@ -363,6 +471,55 @@ def fake_run_safe(command: str, args: Sequence[str], cwd: Path | None = None, ** assert report_id.baseline_tag == "v0.4.8" +@pytest.mark.parametrize( + ("field", "value"), + [ + ("isDraft", "false"), + ("isDraft", 0), + ("isPrerelease", None), + ], +) +def test_stable_published_releases_requires_boolean_flags(field: str, value: object) -> None: + release: dict[str, object] = { + "tagName": "v0.4.3", + "isDraft": False, + "isPrerelease": False, + "publishedAt": "2026-02-01T00:00:00Z", + } + release[field] = value + + with pytest.raises(TypeError, match="boolean isDraft and isPrerelease"): + archive_performance._stable_published_releases([release]) + + +@pytest.mark.parametrize("published_at", ["2026-02-01T00:00:00", "not-a-timestamp", ""]) +def test_stable_published_releases_requires_aware_timestamp(published_at: str) -> None: + release = { + "tagName": "v0.4.3", + "isDraft": False, + "isPrerelease": False, + "publishedAt": published_at, + } + + with pytest.raises((TypeError, ValueError), match="publishedAt"): + archive_performance._stable_published_releases([release]) + + +def test_stable_published_releases_normalizes_timestamp_to_utc() -> None: + releases = archive_performance._stable_published_releases( + [ + { + "tagName": "v0.4.3", + "isDraft": False, + "isPrerelease": False, + "publishedAt": "2026-02-01T01:00:00+01:00", + } + ] + ) + + assert releases[0].published_at.isoformat() == "2026-02-01T00:00:00+00:00" + + def test_resolve_archive_request_infer_release_uses_package_version_and_previous_release(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: (tmp_path / "Cargo.toml").write_text('[package]\nversion = "0.4.3"\n', encoding="utf-8") @@ -448,6 +605,36 @@ def test_benchmark_env_respects_existing_toolchain_override(tmp_path: Path, monk assert archive_performance._benchmark_env(tmp_path) is None +def test_parser_rejects_unsupported_scope() -> None: + with pytest.raises(SystemExit): + archive_performance.build_parser().parse_args(["v0.4.3", "v0.4.2", "--scope", "quick"]) + + +@pytest.mark.parametrize( + ("field", "value", "message"), + [ + ("suite", "other", "unsupported benchmark suite"), + ("scope", "quick", "unsupported comparison scope"), + ], +) +def test_generation_config_rejects_unsupported_benchmark_selection( + tmp_path: Path, + field: str, + value: str, + message: str, +) -> None: + kwargs: dict[str, Any] = { + "repo_root": tmp_path, + "current_tag": "v0.4.3", + "baseline_tag": "v0.4.2", + "worktree_ref": "HEAD", + } + kwargs[field] = value + + with pytest.raises(ValueError, match=message): + GenerationConfig(**kwargs) + + def test_comparison_benchmark_env_preserves_flags_and_selects_v043_adapter( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -509,7 +696,7 @@ def test_fallback_baseline_cargo_commands_enforce_lockfile( @pytest.mark.parametrize( ("suite", "expected"), [ - ("all", ("cargo", "bench", "--locked", "--features", "bench,exact", "--bench", "exact")), + ("all", ("cargo", "bench", "--locked", "--features", "bench,exact")), ("exact", ("cargo", "bench", "--locked", "--features", "bench,exact", "--bench", "exact")), ("vs_linalg", ("cargo", "bench", "--locked", "--features", "bench", "--bench", "vs_linalg")), ], @@ -787,6 +974,7 @@ def fake_run_safe(command: str, args: Sequence[str], cwd: Path | None = None, ** assert any(kind == "git" and args[:3] == ("worktree", "add", "--detach") and args[4] == "v0.4.3" for kind, args, _ in calls) assert any(kind == "just" and args == ("bench-exact",) for kind, args, _ in calls) assert any(kind == "uv" and "--suite" in args and args[args.index("--suite") + 1] == "exact" for kind, args, _ in calls) + assert any(kind == "uv" and args[:2] == ("run", "--locked") for kind, args, _ in calls) assert not any(kind == "git" and args[:1] == ("read-tree",) for kind, args, _ in calls) assert not any(kind == "git-stdin" for kind, _, _ in calls) @@ -1372,13 +1560,14 @@ def fake_run_git(args: Sequence[str], cwd: Path | None = None, **kwargs: Any) -> worktree = Path(args[3]) worktree.mkdir(parents=True) _write_current_benchmark_tooling(worktree) - if args == ["diff", "--cached", "--binary", "HEAD"]: - return _result("diff --git a/README.md b/README.md\n") + if args[:3] == ["diff", "--cached", "--binary"]: + output_arg = next(arg for arg in args if arg.startswith("--output=")) + Path(output_arg.removeprefix("--output=")).write_bytes(b"diff --git a/README.md b/README.md\n") return _result() - def fake_run_git_with_input(args: Sequence[str], input_data: str, cwd: Path | None = None, **kwargs: Any) -> SimpleNamespace: + def fake_run_git_with_input(args: Sequence[str], input_data: str | bytes, cwd: Path | None = None, **kwargs: Any) -> SimpleNamespace: calls.append(("git-stdin", tuple(args), cwd)) - assert "diff --git" in input_data + assert b"diff --git" in input_data if isinstance(input_data, bytes) else "diff --git" in input_data return _result() def fake_run_safe(command: str, args: Sequence[str], cwd: Path | None = None, **kwargs: Any) -> SimpleNamespace: @@ -1469,7 +1658,7 @@ def fake_run_safe(command: str, args: Sequence[str], cwd: Path | None = None, ** assert report_id.archive_name == "v0.4.2-vs-v0.4.1.md" assert current.read_text(encoding="utf-8") == _normalized_report("0.4.2", "v0.4.1") assert any(kind == "git" and args[:3] == ("worktree", "add", "--detach") and args[4] == "v0.4.2" for kind, args, _ in calls) - assert any(kind == "cargo" and args[:2] == ("bench", "--locked") and "exact" in args for kind, args, _ in calls) + assert any(kind == "cargo" and args == ("bench", "--locked", "--features", "bench,exact") for kind, args, _ in calls) assert not any(kind == "just" and args == ("bench-exact",) for kind, args, _ in calls) assert not any(kind == "just" and args == ("bench-latest",) for kind, args, _ in calls) assert not any(kind == "uv" and "--suite" in args for kind, args, _ in calls) diff --git a/scripts/tests/test_bench_compare.py b/scripts/tests/test_bench_compare.py index be62e30..55264f8 100644 --- a/scripts/tests/test_bench_compare.py +++ b/scripts/tests/test_bench_compare.py @@ -4,6 +4,7 @@ import json import re +import subprocess from typing import TYPE_CHECKING, cast import pytest @@ -13,6 +14,8 @@ if TYPE_CHECKING: from pathlib import Path +_OVERFLOWING_TIMING = 10**400 + def _write_estimates( path: Path, @@ -116,6 +119,27 @@ def _schema2_provenance_data() -> dict[str, object]: } +def _read_harness_provenance( # noqa: PLR0913 + criterion_dir: Path, + *, + baseline: str = "v0.4.3", + suite: bench_compare.BenchmarkSuite = "all", + scope: bench_compare.ComparisonScope = "release-signal", + stat: bench_compare.Statistic = "median", + sample: str = "new", +) -> bench_compare.HarnessProvenance | None: + return bench_compare._read_harness_provenance( + criterion_dir, + expected_baseline=baseline, + expected=bench_compare.CriterionSelection( + suite=suite, + scope=scope, + statistic=stat, + sample=sample, + ), + ) + + def _build_criterion_tree(criterion_dir: Path, stat: str = "median") -> None: """Create a fake Criterion directory with exact benchmark results.""" for d, det, det_exact in [(2, 1.0, 4000.0), (3, 5.0, 21000.0)]: @@ -290,6 +314,16 @@ def test_read_estimate_non_numeric_ci_bound_names_field(tmp_path: Path) -> None: bench_compare._read_estimate(est, "median") +def test_read_estimate_rejects_numeric_overflow(tmp_path: Path) -> None: + est = tmp_path / "estimates.json" + est.write_text(json.dumps({"median": {"point_estimate": _OVERFLOWING_TIMING}}), encoding="utf-8") + + with pytest.raises(ValueError, match=r"field 'point_estimate'.*not numeric") as exc_info: + bench_compare._read_estimate(est, "median") + + assert isinstance(exc_info.value.__cause__, OverflowError) + + def test_read_estimate_rejects_partial_confidence_interval(tmp_path: Path) -> None: est = tmp_path / "estimates.json" est.write_text( @@ -316,15 +350,20 @@ def test_read_estimate_rejects_reversed_confidence_interval(tmp_path: Path) -> N bench_compare._read_estimate(est, "median") -@pytest.mark.parametrize("point", [float("nan"), float("inf"), -1.0]) +@pytest.mark.parametrize("point", [float("nan"), float("inf"), -1.0, 0.0]) def test_read_estimate_rejects_invalid_timing(tmp_path: Path, point: float) -> None: est = tmp_path / "estimates.json" est.write_text(json.dumps({"median": {"point_estimate": point}}), encoding="utf-8") - with pytest.raises(ValueError, match="must be finite and non-negative"): + with pytest.raises(ValueError, match="must be finite and positive"): bench_compare._read_estimate(est, "median") +def test_criterion_estimate_rejects_partial_interval_even_when_constructed_directly() -> None: + with pytest.raises(ValueError, match="both bounds or neither"): + bench_compare.CriterionEstimate(point_ns=1.0, ci_lo_ns=0.9, ci_hi_ns=None) + + # --------------------------------------------------------------------------- # collect_results / collect_comparisons # --------------------------------------------------------------------------- @@ -392,21 +431,6 @@ def test_collect_comparisons(tmp_path: Path) -> None: } -def test_collect_comparisons_zero_current(tmp_path: Path) -> None: - """When the current estimate is zero, speedup should be infinity.""" - group = tmp_path / "exact_d2" - # Current (new) has a zero point estimate. - _write_estimates(group / "det" / "new" / "estimates.json", "median", 0.0) - # Baseline has a normal value. - _write_estimates(group / "det" / "v0.3.0" / "estimates.json", "median", 5.0) - - comparisons = bench_compare._collect_comparisons(tmp_path, "v0.3.0", "median").comparisons - assert len(comparisons) == 1 - c = comparisons[0] - assert c.speedup == float("inf") - assert c.pct_change == pytest.approx(-100.0) - - def test_collect_comparisons_missing_baseline(tmp_path: Path) -> None: _build_criterion_tree(tmp_path) collection = bench_compare._collect_comparisons(tmp_path, "v0.3.0", "median", suite="exact") @@ -702,7 +726,7 @@ def test_coverage_table_makes_missing_samples_explicit(tmp_path: Path) -> None: def test_read_harness_provenance_validates_shared_harness_metadata(tmp_path: Path) -> None: _write_harness_provenance(tmp_path) - provenance = bench_compare._read_harness_provenance(tmp_path, expected_baseline="v0.4.3") + provenance = _read_harness_provenance(tmp_path) assert provenance == bench_compare.HarnessProvenance( schema=1, @@ -713,7 +737,7 @@ def test_read_harness_provenance_validates_shared_harness_metadata(tmp_path: Pat def test_read_harness_provenance_is_optional(tmp_path: Path) -> None: - assert bench_compare._read_harness_provenance(tmp_path, expected_baseline="v0.4.3") is None + assert _read_harness_provenance(tmp_path) is None def test_read_schema2_provenance_records_versions_dirty_source_and_both_gates(tmp_path: Path) -> None: @@ -723,12 +747,12 @@ def test_read_schema2_provenance_records_versions_dirty_source_and_both_gates(tm encoding="utf-8", ) - provenance = bench_compare._read_harness_provenance(tmp_path, expected_baseline="v0.4.3") + provenance = _read_harness_provenance(tmp_path) assert provenance is not None assert provenance.schema == 2 assert provenance.criterion is not None - assert provenance.criterion["criterion_version"] == "0.7.0" + assert provenance.criterion.criterion_version == "0.7.0" markdown = bench_compare._provenance_markdown(provenance) rendered = "\n".join(markdown) assert "Criterion dependency version: `0.7.0`" in rendered @@ -753,7 +777,7 @@ def test_historical_asset_provenance_uses_mode_appropriate_gate_wording(tmp_path } (tmp_path / ".la-stack-benchmark-harness.json").write_text(json.dumps(data), encoding="utf-8") - provenance = bench_compare._read_harness_provenance(tmp_path, expected_baseline="v0.4.3") + provenance = _read_harness_provenance(tmp_path) assert provenance is not None rendered = "\n".join(bench_compare._provenance_markdown(provenance)) @@ -772,7 +796,31 @@ def test_read_schema2_provenance_requires_criterion_version(tmp_path: Path) -> N (tmp_path / ".la-stack-benchmark-harness.json").write_text(json.dumps(data), encoding="utf-8") with pytest.raises(ValueError, match="criterion_version"): - bench_compare._read_harness_provenance(tmp_path, expected_baseline="v0.4.3") + _read_harness_provenance(tmp_path) + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("suite", "exact"), + ("scope", "all-benches"), + ("statistic", "mean"), + ("sample", "v0.4.3"), + ], +) +def test_read_schema2_provenance_binds_criterion_settings_to_request( + tmp_path: Path, + field: str, + value: str, +) -> None: + data = _schema2_provenance_data() + criterion = data["criterion"] + assert isinstance(criterion, dict) + cast("dict[str, object]", criterion)[field] = value + (tmp_path / ".la-stack-benchmark-harness.json").write_text(json.dumps(data), encoding="utf-8") + + with pytest.raises(ValueError, match=rf"criterion\.{field}.*does not match requested value"): + _read_harness_provenance(tmp_path) def test_read_schema2_provenance_rejects_v043_adapter_for_other_baseline(tmp_path: Path) -> None: @@ -781,14 +829,14 @@ def test_read_schema2_provenance_rejects_v043_adapter_for_other_baseline(tmp_pat (tmp_path / ".la-stack-benchmark-harness.json").write_text(json.dumps(data), encoding="utf-8") with pytest.raises(ValueError, match=r"valid only for baseline 'v0\.4\.3'"): - bench_compare._read_harness_provenance(tmp_path, expected_baseline="v0.4.4") + _read_harness_provenance(tmp_path, baseline="v0.4.4") def test_read_harness_provenance_rejects_different_requested_baseline(tmp_path: Path) -> None: _write_harness_provenance(tmp_path, baseline="v0.4.3") with pytest.raises(ValueError, match="does not match requested Criterion baseline 'last'"): - bench_compare._read_harness_provenance(tmp_path, expected_baseline="last") + _read_harness_provenance(tmp_path, baseline="last") @pytest.mark.parametrize( @@ -817,7 +865,7 @@ def test_read_harness_provenance_rejects_malformed_fields( (tmp_path / ".la-stack-benchmark-harness.json").write_text(json.dumps(data), encoding="utf-8") with pytest.raises(ValueError, match=message): - bench_compare._read_harness_provenance(tmp_path, expected_baseline="v0.4.3") + _read_harness_provenance(tmp_path) # --------------------------------------------------------------------------- @@ -890,6 +938,41 @@ def test_main_comparison_refuses_incomplete_coverage_before_writing(tmp_path: Pa assert "## Incomplete Comparison Coverage" in error +def test_main_rejects_invalid_timing_without_writing_or_traceback( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + criterion_dir = tmp_path / "criterion" + group = criterion_dir / "exact_d2" + _write_estimates(group / "det" / "new" / "estimates.json", "median", 0.0) + _write_estimates(group / "det" / "last" / "estimates.json", "median", 10.0) + output = tmp_path / "report.md" + + rc = bench_compare.main(["last", "--suite", "exact", "--criterion-dir", str(criterion_dir), "--output", str(output)]) + + assert rc == 2 + assert "Invalid Criterion estimate data" in capsys.readouterr().err + assert not output.exists() + + +def test_main_rejects_overflowing_timing_without_writing_or_traceback( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + criterion_dir = tmp_path / "criterion" + current = criterion_dir / "exact_d2" / "det" / "new" / "estimates.json" + current.parent.mkdir(parents=True) + current.write_text(json.dumps({"median": {"point_estimate": _OVERFLOWING_TIMING}}), encoding="utf-8") + _write_estimates(criterion_dir / "exact_d2" / "det" / "last" / "estimates.json", "median", 10.0) + output = tmp_path / "report.md" + + rc = bench_compare.main(["last", "--suite", "exact", "--criterion-dir", str(criterion_dir), "--output", str(output)]) + + assert rc == 2 + assert "Invalid Criterion estimate data" in capsys.readouterr().err + assert not output.exists() + + def test_main_v043_comparison_allows_only_unavailable_balanced_baselines(tmp_path: Path) -> None: criterion_dir = tmp_path / "criterion" unavailable = bench_compare._V0_4_3_UNAVAILABLE_BASELINE_ROWS @@ -920,7 +1003,8 @@ def test_main_v043_comparison_allows_only_unavailable_balanced_baselines(tmp_pat assert "no speedup is claimed" in rendered -def test_generate_markdown_labels_absent_provenance_unavailable(tmp_path: Path) -> None: +def test_generate_markdown_labels_absent_provenance_unavailable(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(bench_compare, "_get_git_source_date", lambda _root: "2026-06-01 12:34:56 UTC") report = bench_compare._generate_markdown( tmp_path, "tables", @@ -935,6 +1019,18 @@ def test_generate_markdown_labels_absent_provenance_unavailable(tmp_path: Path) assert "**Reproducibility provenance**: unavailable" in report assert "CPU, OS, rustc, commit, dependency lock" in report assert "performance-improvement claim" in report + assert "**Source revision timestamp**: 2026-06-01 12:34:56 UTC (deterministic report metadata; not the benchmark measurement time)" in report + assert "**Benchmark measurement timestamp**: not recorded by Criterion" in report + + +def test_git_source_date_is_normalized_to_unambiguous_utc(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + bench_compare, + "run_git_command", + lambda *_args, **_kwargs: subprocess.CompletedProcess([], 0, stdout="2026-06-01T05:34:56-07:00\n"), + ) + + assert bench_compare._get_git_source_date(tmp_path) == "2026-06-01 12:34:56 UTC" def test_main_rejects_malformed_harness_provenance(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: diff --git a/scripts/tests/test_criterion_dim_plot.py b/scripts/tests/test_criterion_dim_plot.py index 1e4a1b5..e3ef01b 100644 --- a/scripts/tests/test_criterion_dim_plot.py +++ b/scripts/tests/test_criterion_dim_plot.py @@ -18,6 +18,8 @@ if TYPE_CHECKING: from pathlib import Path +_OVERFLOWING_TIMING = 10**400 + def _toml_dependency_version(data: dict[str, object], name: str) -> str | None: for section in ("dependencies", "dev-dependencies", "build-dependencies"): @@ -107,9 +109,8 @@ def test_markdown_table_formats_values_and_pct() -> None: assert "| 64 | 1,000.000 | 900.000 | 800.000 | -11.1% | -25.0% |" in table -def test_markdown_table_handles_zero_nalgebra_time() -> None: - rows = [ - # nalgebra time of 0 indicates missing/corrupt data; ensure we don't crash. +def test_row_rejects_zero_peer_time_before_markdown_rendering() -> None: + with pytest.raises(ValueError, match="na_time must be finite and positive"): criterion_dim_plot.Row( dim=2, la_time=10.0, @@ -121,11 +122,7 @@ def test_markdown_table_handles_zero_nalgebra_time() -> None: fa_time=100.0, fa_lo=90.0, fa_hi=110.0, - ), - ] - - table = criterion_dim_plot._markdown_table(rows, stat="median") - assert "| 2 | 10.000 | 0.000 | 100.000 | n/a | +90.0% |" in table + ) def test_gp_quote_escapes_backslashes_and_quotes() -> None: @@ -518,6 +515,37 @@ def test_read_estimate_non_numeric_ci_bound_names_field(tmp_path: Path) -> None: criterion_dim_plot._read_estimate(estimates, "median") +def test_read_estimate_rejects_numeric_overflow(tmp_path: Path) -> None: + estimates = tmp_path / "estimates.json" + estimates.write_text(json.dumps({"median": {"point_estimate": _OVERFLOWING_TIMING}}), encoding="utf-8") + + with pytest.raises(ValueError, match=r"field 'point_estimate'.*not numeric") as exc_info: + criterion_dim_plot._read_estimate(estimates, "median") + + assert isinstance(exc_info.value.__cause__, OverflowError) + + +def test_read_estimate_rejects_missing_or_partial_confidence_interval(tmp_path: Path) -> None: + estimates = tmp_path / "estimates.json" + estimates.write_text(json.dumps({"median": {"point_estimate": 1.0}}), encoding="utf-8") + with pytest.raises(KeyError, match="field 'confidence_interval'"): + criterion_dim_plot._read_estimate(estimates, "median") + + estimates.write_text( + json.dumps( + { + "median": { + "point_estimate": 1.0, + "confidence_interval": {"lower_bound": 0.9}, + } + } + ), + encoding="utf-8", + ) + with pytest.raises(KeyError, match="field 'upper_bound'"): + criterion_dim_plot._read_estimate(estimates, "median") + + @pytest.mark.parametrize( ("payload", "field"), [ @@ -545,7 +573,7 @@ def test_read_estimate_rejects_nonfinite_time(tmp_path: Path) -> None: estimates = tmp_path / "estimates.json" estimates.write_text(json.dumps({"median": {"point_estimate": "NaN"}}), encoding="utf-8") - with pytest.raises(ValueError, match=r"median\.point_estimate.*finite and nonnegative"): + with pytest.raises(ValueError, match=r"median\.point_estimate.*finite and positive"): criterion_dim_plot._read_estimate(estimates, "median") @@ -553,7 +581,15 @@ def test_read_estimate_rejects_negative_time(tmp_path: Path) -> None: estimates = tmp_path / "estimates.json" estimates.write_text(json.dumps({"median": {"point_estimate": -1.0}}), encoding="utf-8") - with pytest.raises(ValueError, match=r"median\.point_estimate.*finite and nonnegative"): + with pytest.raises(ValueError, match=r"median\.point_estimate.*finite and positive"): + criterion_dim_plot._read_estimate(estimates, "median") + + +def test_read_estimate_rejects_zero_time(tmp_path: Path) -> None: + estimates = tmp_path / "estimates.json" + estimates.write_text(json.dumps({"median": {"point_estimate": 0.0}}), encoding="utf-8") + + with pytest.raises(ValueError, match=r"median\.point_estimate.*finite and positive"): criterion_dim_plot._read_estimate(estimates, "median") @@ -575,7 +611,7 @@ def test_read_estimate_rejects_inverted_confidence_interval(tmp_path: Path) -> N criterion_dim_plot._read_estimate(estimates, "median") -def test_read_estimate_rejects_point_outside_confidence_interval(tmp_path: Path) -> None: +def test_read_estimate_allows_point_outside_percentile_confidence_interval(tmp_path: Path) -> None: estimates = tmp_path / "estimates.json" estimates.write_text( json.dumps( @@ -589,8 +625,7 @@ def test_read_estimate_rejects_point_outside_confidence_interval(tmp_path: Path) encoding="utf-8", ) - with pytest.raises(ValueError, match="point estimate must be inside confidence interval"): - criterion_dim_plot._read_estimate(estimates, "median") + assert criterion_dim_plot._read_estimate(estimates, "median") == (5.0, 1.0, 4.0) def test_row_rejects_invalid_dimension_and_times() -> None: @@ -608,7 +643,7 @@ def test_row_rejects_invalid_dimension_and_times() -> None: fa_hi=1.0, ) - with pytest.raises(ValueError, match="la_time must be finite and nonnegative"): + with pytest.raises(ValueError, match="la_time must be finite and positive"): criterion_dim_plot.Row( dim=2, la_time=float("inf"), @@ -622,19 +657,19 @@ def test_row_rejects_invalid_dimension_and_times() -> None: fa_hi=1.0, ) - with pytest.raises(ValueError, match="point estimate must be inside confidence interval"): - criterion_dim_plot.Row( - dim=2, - la_time=5.0, - la_lo=1.0, - la_hi=4.0, - na_time=1.0, - na_lo=1.0, - na_hi=1.0, - fa_time=1.0, - fa_lo=1.0, - fa_hi=1.0, - ) + row = criterion_dim_plot.Row( + dim=2, + la_time=5.0, + la_lo=1.0, + la_hi=4.0, + na_time=1.0, + na_lo=1.0, + na_hi=1.0, + fa_time=1.0, + fa_lo=1.0, + fa_hi=1.0, + ) + assert row.la_time == 5.0 def test_write_csv_and_collect_rows(tmp_path: Path) -> None: @@ -840,6 +875,145 @@ def test_main_partial_mode_is_explicit_and_labels_measurement_unavailable( assert provenance["publication"]["correctness_gate"] == "not-run-exploratory" +def test_main_rejects_missing_confidence_interval_without_writing( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + criterion_dir = tmp_path / "criterion" + metric = criterion_dim_plot.METRICS["lu_solve"] + for bench in (metric.la_bench, metric.na_bench, metric.fa_bench): + estimates = criterion_dir / "d2" / bench / "new" / "estimates.json" + estimates.parent.mkdir(parents=True, exist_ok=True) + estimates.write_text(json.dumps({"median": {"point_estimate": 1.0}}), encoding="utf-8") + output = tmp_path / "out.csv" + monkeypatch.setattr(criterion_dim_plot, "_repo_root", lambda: tmp_path) + + rc = criterion_dim_plot.main( + [ + "--criterion-dir", + str(criterion_dir), + "--csv", + str(output), + "--no-plot", + "--allow-partial", + ] + ) + + assert rc == 2 + assert "Invalid Criterion estimate data" in capsys.readouterr().err + assert not output.exists() + + +def test_main_rejects_overflowing_timing_without_writing_or_traceback( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + criterion_dir = tmp_path / "criterion" + metric = criterion_dim_plot.METRICS["lu_solve"] + for bench in (metric.la_bench, metric.na_bench, metric.fa_bench): + estimates = criterion_dir / "d2" / bench / "new" / "estimates.json" + estimates.parent.mkdir(parents=True, exist_ok=True) + estimates.write_text(json.dumps({"median": {"point_estimate": _OVERFLOWING_TIMING}}), encoding="utf-8") + output = tmp_path / "out.csv" + monkeypatch.setattr(criterion_dim_plot, "_repo_root", lambda: tmp_path) + + rc = criterion_dim_plot.main( + [ + "--criterion-dir", + str(criterion_dir), + "--csv", + str(output), + "--no-plot", + "--allow-partial", + ] + ) + + assert rc == 2 + assert "Invalid Criterion estimate data" in capsys.readouterr().err + assert not output.exists() + + +@pytest.mark.parametrize( + "failure", + [ + subprocess.TimeoutExpired(["tool"], 17), + OSError("working directory unavailable"), + ], +) +def test_provenance_helpers_treat_timeout_and_os_error_as_unavailable( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + failure: Exception, +) -> None: + def fail_command(*_args: object, **_kwargs: object) -> SimpleNamespace: + raise failure + + monkeypatch.setattr(criterion_dim_plot, "run_git_command", fail_command) + assert criterion_dim_plot._git_value(tmp_path, ["rev-parse", "HEAD"]) == "unavailable" + git_clean, _status_digest = criterion_dim_plot._git_status_metadata(tmp_path) + assert git_clean is None + + monkeypatch.setattr(criterion_dim_plot, "run_safe_command", fail_command) + assert criterion_dim_plot._rustc_version(tmp_path) == "unavailable" + + +def test_main_publication_fails_closed_when_provenance_tool_is_unavailable( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + criterion_dir = tmp_path / "target" / "criterion" + criterion_dir.mkdir(parents=True) + begin, end = criterion_dim_plot._readme_table_markers("lu_solve", "median", "new") + readme = tmp_path / "README.fixture.md" + readme.write_text(f"{begin}\nold table\n{end}\n", encoding="utf-8") + output = tmp_path / "out.csv" + row = criterion_dim_plot.Row(2, 1.0, 0.9, 1.1, 2.0, 1.9, 2.1, 3.0, 2.9, 3.1) + + monkeypatch.setattr(criterion_dim_plot, "_repo_root", lambda: tmp_path) + monkeypatch.setattr(criterion_dim_plot, "_run_publication_benchmarks", lambda _root: None) + monkeypatch.setattr(criterion_dim_plot, "_detect_versions", lambda _root: {}) + monkeypatch.setattr(criterion_dim_plot, "_discover_dims", lambda _criterion_dir: [2]) + monkeypatch.setattr(criterion_dim_plot, "_collect_rows", lambda *_args: ([row], [])) + monkeypatch.setattr( + criterion_dim_plot, + "_capture_provenance", + lambda *_args, **_kwargs: { + "publication": { + "cargo_lock_sha256": "a" * 64, + "commit": "commit", + "cpu": "cpu", + "git_clean": True, + "missing_harness_files": [], + "os": "os", + "rustc": "unavailable", + "source_missing": False, + } + }, + ) + + rc = criterion_dim_plot.main( + [ + "--criterion-dir", + str(criterion_dir), + "--csv", + str(output), + "--out", + str(tmp_path / "out.svg"), + "--update-readme", + "--readme", + str(readme), + ] + ) + + assert rc == 2 + assert "required fields are unavailable: rustc" in capsys.readouterr().err + assert not output.exists() + assert "old table" in readme.read_text(encoding="utf-8") + + def test_publication_gate_failure_stops_before_timing(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: calls: list[tuple[str, tuple[str, ...]]] = [] @@ -861,6 +1035,7 @@ def fail_gate(command: str, args: list[str], **_kwargs: object) -> SimpleNamespa ("process", ("timing failed",), subprocess.CalledProcessError), ("missing", ("Required executable 'cargo' not found in PATH",), criterion_dim_plot.ExecutableNotFoundError), ("timeout", ("timed out after 17 seconds", "timing stalled"), subprocess.TimeoutExpired), + ("os-error", ("could not start", "working directory unavailable"), OSError), ], ) def test_failed_timing_restores_staged_new_samples( @@ -883,6 +1058,9 @@ def fail_timing(command: str, args: list[str], **_kwargs: object) -> SimpleNames if failure_kind == "missing": msg = "Required executable 'cargo' not found in PATH" raise criterion_dim_plot.ExecutableNotFoundError(msg) + if failure_kind == "os-error": + msg = "working directory unavailable" + raise OSError(msg) raise subprocess.TimeoutExpired([command, *args], 17, stderr="timing stalled") return SimpleNamespace(stdout="") @@ -896,6 +1074,82 @@ def fail_timing(command: str, args: list[str], **_kwargs: object) -> SimpleNames assert isinstance(exc_info.value.__cause__, cause_type) +def test_partial_staging_failure_restores_every_moved_sample( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + criterion_dir = tmp_path / "target" / "criterion" + first = criterion_dir / "d2" / "a_bench" / "new" / "estimates.json" + second = criterion_dir / "d2" / "b_bench" / "new" / "estimates.json" + for path, text in ((first, "first\n"), (second, "second\n")): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + backup_root = tmp_path / "criterion-backup" + backup_root.mkdir() + original_replace = criterion_dim_plot.Path.replace + + def fail_second_move(source: Path, destination: Path) -> Path: + if source == second.parent and backup_root in destination.parents: + msg = "simulated second staging failure" + raise OSError(msg) + return original_replace(source, destination) + + def fake_mkdtemp(*, prefix: str, **kwargs: object) -> str: + assert prefix == "la-stack-stale-criterion-" + assert kwargs == {"dir": criterion_dir.parent} + return str(backup_root) + + monkeypatch.setattr(criterion_dim_plot.tempfile, "mkdtemp", fake_mkdtemp) + monkeypatch.setattr(criterion_dim_plot.Path, "replace", fail_second_move) + monkeypatch.setattr(criterion_dim_plot, "run_safe_command", lambda *_args, **_kwargs: SimpleNamespace(stdout="")) + + with pytest.raises(RuntimeError, match="could not stage existing Criterion samples"): + criterion_dim_plot._run_publication_benchmarks(tmp_path) + + assert first.read_text(encoding="utf-8") == "first\n" + assert second.read_text(encoding="utf-8") == "second\n" + assert not backup_root.exists() + + +def test_failed_timing_preserves_backup_when_rollback_fails( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + estimate = tmp_path / "target" / "criterion" / "d2" / "la_stack_lu" / "new" / "estimates.json" + estimate.parent.mkdir(parents=True) + estimate.write_text("old\n", encoding="utf-8") + backup_root = tmp_path / "criterion-backup" + backup_root.mkdir() + + def fail_timing(command: str, args: list[str], **_kwargs: object) -> SimpleNamespace: + if command == "cargo": + estimate.parent.mkdir(parents=True, exist_ok=True) + estimate.write_text("partial\n", encoding="utf-8") + raise subprocess.CalledProcessError(1, [command, *args], stderr="timing failed") + return SimpleNamespace(stdout="") + + original_rmtree = criterion_dim_plot.shutil.rmtree + + def fail_fresh_removal(path: Path) -> None: + if path == estimate.parent: + msg = "simulated rollback removal failure" + raise OSError(msg) + original_rmtree(path) + + monkeypatch.setattr(criterion_dim_plot.tempfile, "mkdtemp", lambda **_kwargs: str(backup_root)) + monkeypatch.setattr(criterion_dim_plot.shutil, "rmtree", fail_fresh_removal) + monkeypatch.setattr(criterion_dim_plot, "run_safe_command", fail_timing) + + with pytest.raises(RuntimeError, match="backups preserved") as exc_info: + criterion_dim_plot._run_publication_benchmarks(tmp_path) + + assert str(backup_root) in str(exc_info.value) + preserved = backup_root / "d2" / "la_stack_lu" / "new" / "estimates.json" + assert preserved.read_text(encoding="utf-8") == "old\n" + assert estimate.read_text(encoding="utf-8") == "partial\n" + + def test_readme_publication_cannot_reuse_stale_new_samples( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -1013,3 +1267,42 @@ def fail_render(_request: criterion_dim_plot.PlotRequest) -> None: assert svg_path.read_text(encoding="utf-8") == "old svg\n" assert provenance_path.read_text(encoding="utf-8") == "old provenance\n" assert "old table" in readme.read_text(encoding="utf-8") + + +def test_artifact_rollback_failure_preserves_backups( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + destination_one = tmp_path / "one.txt" + destination_two = tmp_path / "two.txt" + staged_one = tmp_path / "staged-one.txt" + staged_two = tmp_path / "staged-two.txt" + backup_dir = tmp_path / "backups" + backup_dir.mkdir() + destination_one.write_text("old one\n", encoding="utf-8") + destination_two.write_text("old two\n", encoding="utf-8") + staged_one.write_text("new one\n", encoding="utf-8") + staged_two.write_text("new two\n", encoding="utf-8") + original_replace = criterion_dim_plot.Path.replace + + def fail_replacement_and_rollback(source: Path, destination: Path) -> Path: + if source == staged_two and destination == destination_two: + msg = "simulated publish failure" + raise OSError(msg) + if source == backup_dir / "backup-0" and destination == destination_one: + msg = "simulated rollback failure" + raise OSError(msg) + return original_replace(source, destination) + + monkeypatch.setattr(criterion_dim_plot.Path, "replace", fail_replacement_and_rollback) + + with pytest.raises(criterion_dim_plot.PublicationRollbackError, match="backups preserved") as exc_info: + criterion_dim_plot._replace_staged_files( + [(staged_one, destination_one), (staged_two, destination_two)], + backup_dir, + ) + + assert str(backup_dir) in str(exc_info.value) + assert (backup_dir / "backup-0").read_text(encoding="utf-8") == "old one\n" + assert destination_one.read_text(encoding="utf-8") == "new one\n" + assert destination_two.read_text(encoding="utf-8") == "old two\n" diff --git a/scripts/tests/test_postprocess_changelog.py b/scripts/tests/test_postprocess_changelog.py index 18a5edb..8970923 100644 --- a/scripts/tests/test_postprocess_changelog.py +++ b/scripts/tests/test_postprocess_changelog.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING from postprocess_changelog import ( + _CodeFence, _compact_entry, _inject_summary_sections, _is_duplicate_squash_heading, @@ -267,6 +268,22 @@ def test_commit_link_with_backticks(self) -> None: # All links must be intact. assert link in result + def test_preserves_link_with_balanced_destination_parentheses(self) -> None: + link = "[API](https://example.com/search(function(arg(nested))))" + line = f"- Read the detailed publication API notes before continuing with the release process {link}" + + result = _reflow_line(line, max_width=60) + + assert link in result + + def test_preserves_multi_backtick_code_span(self) -> None: + span = "``call(`inner`, value)``" + line = f"- Use {span} when documenting the generated command and all of its arguments" + + result = _reflow_line(line, max_width=45) + + assert span in result + # --------------------------------------------------------------------------- # Summary-section helpers @@ -614,6 +631,14 @@ def test_existing_archive_normalization_preserves_fenced_headings(self) -> None: assert "```markdown\n## Example Heading\n### Fixed: Example\n```" in result assert "#### Duplicate Vertex Handling" in result + def test_existing_archive_normalization_preserves_tilde_fenced_headings(self) -> None: + text = "# Changelog - 0.5.x\n\n~~~markdown\n## Example Heading\n~~~\n\n## Duplicate Vertex Handling\n" + + result = normalize_entry_headings_text(text) + + assert "~~~markdown\n## Example Heading\n~~~" in result + assert "#### Duplicate Vertex Handling" in result + class TestSquashHeadingNormalization: """GitHub squash-body pseudo-commit headings are rendered as prose.""" @@ -737,39 +762,71 @@ class TestCodeBlockLanguage: def test_process_code_fence_opens_and_tags_bare_fence(self) -> None: result: list[str] = [] - handled, in_code_block = _process_code_fence("```", result, in_code_block=False, next_line="let x = 1;") + handled, active_fence = _process_code_fence("```", result, active_fence=None, next_line="let x = 1;") assert handled - assert in_code_block + assert active_fence == _CodeFence(delimiter="`", length=3) assert result == ["```text"] def test_process_code_fence_closes_existing_block(self) -> None: result: list[str] = [] - handled, in_code_block = _process_code_fence("```", result, in_code_block=True, next_line=None) + handled, active_fence = _process_code_fence( + "```", + result, + active_fence=_CodeFence(delimiter="`", length=3), + next_line=None, + ) assert handled - assert not in_code_block + assert active_fence is None assert result == ["```"] def test_process_code_fence_adds_blank_after_closing_fence(self) -> None: result: list[str] = [] - handled, in_code_block = _process_code_fence("```", result, in_code_block=True, next_line="following prose") + handled, active_fence = _process_code_fence( + "```", + result, + active_fence=_CodeFence(delimiter="`", length=3), + next_line="following prose", + ) assert handled - assert not in_code_block + assert active_fence is None assert result == ["```", ""] def test_process_code_fence_ignores_regular_line(self) -> None: result: list[str] = [] - handled, in_code_block = _process_code_fence("regular text", result, in_code_block=False, next_line=None) + handled, active_fence = _process_code_fence("regular text", result, active_fence=None, next_line=None) assert not handled - assert not in_code_block + assert active_fence is None assert result == [] + def test_tilde_fence_is_supported_and_tagged(self) -> None: + result: list[str] = [] + + handled, active_fence = _process_code_fence("~~~~", result, active_fence=None, next_line="code") + + assert handled + assert active_fence == _CodeFence(delimiter="~", length=4) + assert result == ["~~~~text"] + + def test_shorter_or_different_delimiter_does_not_close_fence(self) -> None: + active = _CodeFence(delimiter="`", length=4) + + handled_short, still_active = _process_code_fence("```", [], active_fence=active, next_line=None) + handled_tilde, still_active = _process_code_fence("~~~~", [], active_fence=still_active, next_line=None) + handled_close, closed = _process_code_fence("`````", [], active_fence=still_active, next_line=None) + + assert not handled_short + assert not handled_tilde + assert still_active == active + assert handled_close + assert closed is None + def test_adds_language_to_bare_fence(self, tmp_path: Path) -> None: f = tmp_path / "CHANGELOG.md" f.write_text(" ```\n let x = 1;\n ```\n", encoding="utf-8") @@ -799,6 +856,18 @@ def test_no_reflow_inside_code_block(self, tmp_path: Path) -> None: result = f.read_text(encoding="utf-8") assert long_code in result + def test_no_reflow_or_heading_rewrite_inside_tilde_block(self, tmp_path: Path) -> None: + long_code = "## " + "code " * 50 + f = tmp_path / "CHANGELOG.md" + f.write_text(f"~~~markdown\n{long_code.rstrip()}\n~~~\n## Outside\n", encoding="utf-8") + + postprocess(f) + + result = f.read_text(encoding="utf-8") + assert long_code.rstrip() in result + assert "~~~markdown" in result + assert "#### Outside" in result + def test_adds_blank_after_code_block_before_prose(self, tmp_path: Path) -> None: f = tmp_path / "CHANGELOG.md" f.write_text("```text\ncode\n```\nfollowing prose\n", encoding="utf-8") diff --git a/scripts/tests/test_subprocess_utils.py b/scripts/tests/test_subprocess_utils.py index 981e2b9..5c7d501 100644 --- a/scripts/tests/test_subprocess_utils.py +++ b/scripts/tests/test_subprocess_utils.py @@ -127,6 +127,24 @@ def capture_run(*_args: object, **kwargs: object) -> subprocess_utils.subprocess assert "input" not in kwargs assert kwargs["text"] is True + def test_binary_input_forwarded_without_newline_or_encoding_changes(self) -> None: + observed_input = b"" + + def capture_run(*_args: object, **kwargs: object) -> subprocess_utils.subprocess.CompletedProcess[str]: + nonlocal observed_input + stdin = cast("BinaryIO", kwargs["stdin"]) + observed_input = stdin.read() + return subprocess_utils.subprocess.CompletedProcess(args=["git"], returncode=0, stdout="", stderr="") + + payload = b"line\r\n\x00\xff" + with ( + patch("subprocess_utils.get_safe_executable", return_value="/usr/bin/git"), + patch("subprocess_utils.subprocess.run", side_effect=capture_run), + ): + run_git_command_with_input(["apply", "--binary"], input_data=payload) + + assert observed_input == payload + class TestAdditionalHelpers: def test_run_cargo_command_uses_safe_executable(self) -> None: diff --git a/scripts/tests/test_tag_release.py b/scripts/tests/test_tag_release.py index f7b6087..bf65c6b 100644 --- a/scripts/tests/test_tag_release.py +++ b/scripts/tests/test_tag_release.py @@ -337,6 +337,35 @@ def test_force_does_not_delete_tag_if_changelog_fails( mock_extract.assert_called_once() mock_git_input.assert_not_called() + def test_invalid_remote_does_not_replace_existing_tag( + self, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + ) -> None: + """Oversized-tag fallback validates its remote before mutating a tag.""" + changelog = tmp_path / "CHANGELOG.md" + changelog.write_text("# Changelog\n", encoding="utf-8") + section = "x" * (_GITHUB_TAG_ANNOTATION_LIMIT + 1) + marker = "release-token" + + with ( + patch("tag_release._tag_exists", return_value=True), + patch("tag_release.find_changelog", return_value=changelog), + patch("tag_release.extract_changelog_section", return_value=(section, changelog)), + patch("tag_release.run_git_command") as mock_git, + patch("tag_release.run_git_command_with_input") as mock_git_input, + ): + mock_git.return_value.stdout = f"https://github.com/acgetchell/la-stack.git?token={marker}" + + with pytest.raises(ValueError, match="query parameters") as exc_info: + tag_release.create_tag("v1.0.0", force=True) + + mock_git_input.assert_not_called() + output = capsys.readouterr() + assert marker not in str(exc_info.value) + assert marker not in output.out + assert marker not in output.err + class TestRepoUrl: @pytest.mark.parametrize( @@ -355,7 +384,51 @@ def test_normalizes_github_remotes(self, mock_git: MagicMock, raw: str, expected @patch("tag_release.run_git_command") def test_rejects_remote_urls_with_credentials(self, mock_git: MagicMock) -> None: - mock_git.return_value.stdout = "https://user:token@example.com/acgetchell/la-stack.git" + raw = "https://user:secret-token@github.com/acgetchell/la-stack.git" + mock_git.return_value.stdout = raw + + with pytest.raises(ValueError, match="contain credentials") as exc_info: + _get_repo_url() + + assert "secret-token" not in str(exc_info.value) + assert raw not in str(exc_info.value) + + @pytest.mark.parametrize( + "raw", + [ + "https://github.com/acgetchell/la-stack.git?token=secret-token", + "https://github.com/acgetchell/la-stack.git#secret-token", + ], + ) + @patch("tag_release.run_git_command") + def test_rejects_query_and_fragment_without_echoing_them(self, mock_git: MagicMock, raw: str) -> None: + mock_git.return_value.stdout = raw + + with pytest.raises(ValueError, match="query parameters or fragments") as exc_info: + _get_repo_url() + + assert "secret-token" not in str(exc_info.value) + assert raw not in str(exc_info.value) - with pytest.raises(ValueError, match="contain credentials"): + @pytest.mark.parametrize( + "raw", + [ + "http://github.com/acgetchell/la-stack.git", + "https://example.com/acgetchell/la-stack.git", + "git@example.com:acgetchell/la-stack.git", + "ssh://developer@github.com/acgetchell/la-stack.git", + "https://github.com/acgetchell/la-stack/extra.git", + "file:///tmp/la-stack.git", + "/home/example/la-stack.git", + "../la-stack.git", + "https://[malformed", + ], + ) + @patch("tag_release.run_git_command") + def test_rejects_unsupported_remotes_without_echoing_them(self, mock_git: MagicMock, raw: str) -> None: + mock_git.return_value.stdout = raw + + with pytest.raises(ValueError, match="Origin remote") as exc_info: _get_repo_url() + + assert raw not in str(exc_info.value) diff --git a/semgrep.yaml b/semgrep.yaml index 5af07eb..84ad747 100644 --- a/semgrep.yaml +++ b/semgrep.yaml @@ -17,6 +17,7 @@ rules: paths: include: - "/src/**/*.rs" + - "/tests/semgrep/src/project_rules/portable_policy.rs" patterns: - pattern-either: - pattern: println!(...) @@ -67,6 +68,7 @@ rules: paths: include: - "/src/**/*.rs" + - "/tests/semgrep/src/project_rules/portable_policy.rs" pattern-either: - pattern: $VALUE.unwrap_or(f64::NAN) - pattern: $VALUE.unwrap_or(f64::INFINITY) @@ -272,6 +274,7 @@ rules: paths: include: - "/src/**/*.rs" + - "/tests/semgrep/src/project_rules/portable_policy.rs" pattern-regex: '(?m)(?{}]*)?\s*\{' - id: la-stack.rust.no-unwrap-expect-in-doctests diff --git a/src/exact.rs b/src/exact.rs index 7835e4f..9515a6a 100644 --- a/src/exact.rs +++ b/src/exact.rs @@ -39,9 +39,12 @@ //! `A x = b` with a hybrid algorithm that shares the determinant path's exact //! integer scaling and then applies Bareiss elimination to the augmented //! system. Matrix and RHS entries are decomposed via -//! `decompose_proven_finite_f64` into `mantissa × 2^exponent`, scaled to a shared -//! base `2^e_min`, and assembled into a `BigInt` augmented system -//! `(A | b)`. Forward elimination runs entirely in `BigInt` with +//! `decompose_proven_finite_f64` into `mantissa × 2^exponent`. Each side is +//! independently scaled to its own minimum exponent before assembly into a +//! `BigInt` augmented system `(A | b)`; the resulting solution is adjusted by +//! the exact power-of-two ratio between those scales. This avoids inflating one +//! side's integers merely because the other side has much smaller entries. +//! Forward elimination runs entirely in `BigInt` with //! fraction-free Bareiss updates — no `BigRational`, no GCD //! normalisation in the `O(D³)` phase. Once the system is upper //! triangular, back-substitution is performed in `BigRational`, where @@ -495,6 +498,161 @@ fn shifted_magnitude_to_u64(value: &BigInt, shift: u64) -> Option { } } +/// Return whether a bit is set in the magnitude of `value`. +fn magnitude_bit_is_set(value: &BigInt, bit: u64) -> bool { + let word_bits = u64::from(u64::BITS); + let Ok(word_index) = usize::try_from(bit / word_bits) else { + return false; + }; + let bit_index = u32::try_from(bit % word_bits).unwrap_or(0); + value + .iter_u64_digits() + .nth(word_index) + .is_some_and(|word| word & (1_u64 << bit_index) != 0) +} + +/// Return whether any magnitude bit below `exclusive_end` is set. +fn magnitude_has_lower_bits(value: &BigInt, exclusive_end: u64) -> bool { + let word_bits = u64::from(u64::BITS); + let Ok(full_words) = usize::try_from(exclusive_end / word_bits) else { + return value.sign() != Sign::NoSign; + }; + let partial_bits = u32::try_from(exclusive_end % word_bits).unwrap_or(0); + let mut digits = value.iter_u64_digits(); + + for _ in 0..full_words { + if digits.next().unwrap_or(0) != 0 { + return true; + } + } + + if partial_bits == 0 { + false + } else { + let mask = (1_u64 << partial_bits) - 1; + digits.next().is_some_and(|word| word & mask != 0) + } +} + +/// Right-shift a magnitude and round the retained integer to nearest-even. +fn rounded_shifted_magnitude_to_u64(value: &BigInt, shift: u64) -> Option { + if shift > value.bits() { + return Some(0); + } + let retained = shifted_magnitude_to_u64(value, shift).unwrap_or(0); + if shift == 0 { + return Some(retained); + } + + let guard_bit = shift - 1; + let increment = magnitude_bit_is_set(value, guard_bit) + && (magnitude_has_lower_bits(value, guard_bit) || retained & 1 != 0); + retained.checked_add(u64::from(increment)) +} + +/// Round a `BigInt × 2^exp` pair directly to finite binary64. +/// +/// The implementation reads only the magnitude bits needed for the binary64 +/// significand and rounding decision. It therefore avoids constructing a +/// potentially enormous [`BigRational`] denominator for very negative +/// exponents. +fn big_int_exp_ref_to_rounded_f64( + value: &BigInt, + exp: i32, + index: Option, +) -> Result { + if value.sign() == Sign::NoSign { + return Ok(0.0); + } + + let sign = if value.sign() == Sign::Minus { + 1_u64 << 63 + } else { + 0 + }; + let Ok(bit_len) = i64::try_from(value.bits()) else { + cold_path(); + return Err(LaError::unrepresentable( + index, + UnrepresentableReason::NotFinite, + )); + }; + let Some(mut top_bit_exp) = i64::from(exp).checked_add(bit_len - 1) else { + cold_path(); + return Err(LaError::unrepresentable( + index, + UnrepresentableReason::NotFinite, + )); + }; + if top_bit_exp > F64_MAX_BINARY_EXPONENT { + cold_path(); + return Err(LaError::unrepresentable( + index, + UnrepresentableReason::NotFinite, + )); + } + + if top_bit_exp >= F64_MIN_NORMAL_EXPONENT { + let mut significand = if bit_len <= F64_SIGNIFICAND_BITS { + let Some(magnitude) = shifted_magnitude_to_u64(value, 0) else { + cold_path(); + unreachable!("nonzero integer must expose magnitude digits"); + }; + let shift = u32::try_from(F64_SIGNIFICAND_BITS - bit_len) + .unwrap_or_else(|_| unreachable!("normal significand shift must fit u32")); + magnitude + .checked_shl(shift) + .unwrap_or_else(|| unreachable!("normal significand must fit u64")) + } else { + let shift = u64::try_from(bit_len - F64_SIGNIFICAND_BITS) + .unwrap_or_else(|_| unreachable!("positive significand shift must fit u64")); + rounded_shifted_magnitude_to_u64(value, shift) + .unwrap_or_else(|| unreachable!("rounded binary64 significand must fit u64")) + }; + + if significand == 1_u64 << F64_SIGNIFICAND_BITS { + significand >>= 1; + top_bit_exp += 1; + } + if top_bit_exp > F64_MAX_BINARY_EXPONENT { + cold_path(); + return Err(LaError::unrepresentable( + index, + UnrepresentableReason::NotFinite, + )); + } + + let biased_exp = u64::try_from(top_bit_exp + F64_EXPONENT_BIAS) + .unwrap_or_else(|_| unreachable!("normal exponent must be positive")); + return Ok(f64::from_bits( + sign | (biased_exp << F64_FRACTION_BITS) | (significand & F64_FRACTION_MASK), + )); + } + + let subnormal_shift = i64::from(exp) - F64_MIN_BINARY_EXPONENT; + let significand = if subnormal_shift >= 0 { + let Some(magnitude) = shifted_magnitude_to_u64(value, 0) else { + cold_path(); + unreachable!("nonzero integer must expose magnitude digits"); + }; + let shift = u32::try_from(subnormal_shift) + .unwrap_or_else(|_| unreachable!("subnormal left shift must fit u32")); + magnitude + .checked_shl(shift) + .unwrap_or_else(|| unreachable!("subnormal significand must fit u64")) + } else { + let shift = u64::try_from(-subnormal_shift) + .unwrap_or_else(|_| unreachable!("subnormal right shift must fit u64")); + rounded_shifted_magnitude_to_u64(value, shift) + .unwrap_or_else(|| unreachable!("rounded subnormal significand must fit u64")) + }; + + if significand == 1_u64 << F64_FRACTION_BITS { + return Ok(f64::from_bits(sign | (1_u64 << F64_FRACTION_BITS))); + } + Ok(f64::from_bits(sign | significand)) +} + /// Borrowed core for exact integer-and-exponent conversion. /// /// The normalized significand is read directly from the [`BigInt`] digits, so @@ -609,15 +767,16 @@ fn big_int_exp_to_finite_f64( index: Option, ) -> Result { big_int_exp_ref_to_finite_f64(value, exp, index, || { - let exact = big_int_exp_to_big_rational(value.clone(), exp); - rounded_rational_unrepresentable_reason(&exact) + match big_int_exp_ref_to_rounded_f64(value, exp, index) { + Ok(_) => UnrepresentableReason::RequiresRounding, + Err(_) => UnrepresentableReason::NotFinite, + } }) } /// Convert a `BigInt × 2^exp` determinant pair to a rounded finite `f64`. -fn big_int_exp_to_rounded_f64(value: BigInt, exp: i32) -> Result { - let exact = big_int_exp_to_big_rational(value, exp); - exact_rational_to_rounded_f64(&exact, None) +fn big_int_exp_to_rounded_f64(value: &BigInt, exp: i32) -> Result { + big_int_exp_ref_to_rounded_f64(value, exp, None) } // ----------------------------------------------------------------------- @@ -628,8 +787,9 @@ fn big_int_exp_to_rounded_f64(value: BigInt, exp: i32) -> Result { // systems) parse every f64 entry into a proof-bearing component, track the // minimum exponent across non-zero entries, and scale each entry by // `2^(exp − e_min)`. Determinants then use direct expansions for D≤4 and -// fraction-free Bareiss elimination for D≥5; solves use Bareiss elimination on -// the augmented system before rational back-substitution. +// fraction-free Bareiss elimination for D≥5; solves scale the matrix and RHS +// independently, use Bareiss elimination on the augmented system, and restore +// their exact power-of-two scale ratio after rational back-substitution. /// Decomposed finite f64 in the form `(-1)^is_negative · mantissa · 2^exponent`. /// @@ -712,7 +872,7 @@ mod decomposition { } } - /// A shared scaling exponent proven no greater than either input minimum. + /// A scaling exponent derived from a component collection's minimum. /// /// The private field prevents raw construction outside this proof-owning /// module. @@ -734,28 +894,12 @@ mod decomposition { Self { value } } - /// Select a common scale for two decomposed collections. - pub(super) const fn shared(left: &Decomposed, right: &Decomposed) -> Self { - let exponent = match (left.min_exponent(), right.min_exponent()) { - (Some(left), Some(right)) => { - if left < right { - left - } else { - right - } - } - (Some(exponent), None) | (None, Some(exponent)) => exponent, - (None, None) => 0, - }; - Self { value: exponent } - } - - /// Return the proven common exponent. + /// Return the proven collection exponent. pub(super) const fn get(self) -> i32 { self.value } - /// Compute a non-negative shift from this proven common exponent. + /// Compute a non-negative shift from this proven collection exponent. /// /// # Panics /// Panics only if a private decomposition invariant is broken and an entry @@ -765,7 +909,7 @@ mod decomposition { unreachable!("finite f64 exponent difference cannot overflow"); }; let Ok(shift) = u32::try_from(shift) else { - unreachable!("common exponent cannot exceed a component exponent"); + unreachable!("scale exponent cannot exceed a component exponent"); }; shift } @@ -1113,9 +1257,10 @@ fn bareiss_solve_components( matrix: &Decomposed<[[Component; D]; D]>, rhs: &Decomposed<[Component; D]>, ) -> Result<[BigRational; D], LaError> { - let scale = ScaleExponent::shared(matrix, rhs); - let mut a = build_big_int_matrix(matrix.components(), scale); - let mut rhs = build_big_int_vec(rhs.components(), scale); + let matrix_scale = ScaleExponent::for_decomposed(matrix); + let rhs_scale = ScaleExponent::for_decomposed(rhs); + let mut a = build_big_int_matrix(matrix.components(), matrix_scale); + let mut rhs = build_big_int_vec(rhs.components(), rhs_scale); match bareiss_forward_eliminate(&mut a, Some(&mut rhs)) { BareissResult::Upper { .. } => {} @@ -1136,6 +1281,17 @@ fn bareiss_solve_components( x[i] = sum / &a_ii; } + let solution_scale_exp = rhs_scale + .get() + .checked_sub(matrix_scale.get()) + .unwrap_or_else(|| unreachable!("finite f64 scale difference cannot overflow i32")); + if solution_scale_exp != 0 { + let solution_scale = big_int_exp_to_big_rational(BigInt::from(1_u8), solution_scale_exp); + for component in &mut x { + *component *= &solution_scale; + } + } + Ok(x) } @@ -1173,7 +1329,7 @@ fn det_exact_f64_finite(m: &Matrix) -> Result { #[inline] fn det_exact_rounded_f64_finite(m: &Matrix) -> Result { let (det_int, total_exp) = exact_det_int_finite(m)?; - big_int_exp_to_rounded_f64(det_int, total_exp) + big_int_exp_to_rounded_f64(&det_int, total_exp) } /// Exact determinant sign for an already finite matrix. @@ -1184,7 +1340,9 @@ fn det_exact_rounded_f64_finite(m: &Matrix) -> Result(m: &Matrix) -> DeterminantSign { - if let Some((det_f64, error_bound)) = m.det_filter() { + if let Ok(Some(estimate)) = m.det_direct_with_errbound() { + let det_f64 = estimate.determinant(); + let error_bound = estimate.absolute_error_bound(); if det_f64 > error_bound { return DeterminantSign::Positive; } @@ -1340,8 +1498,11 @@ impl Matrix { /// # Algorithm /// /// Matrix and RHS entries are decomposed via IEEE 754 bit extraction and - /// scaled to a shared power-of-two base so the augmented system `(A | b)` - /// becomes integer-valued. Forward elimination runs entirely in `BigInt` + /// independently scaled to their own power-of-two bases so both sides of + /// the augmented system `(A | b)` become integer-valued without needless + /// cross-side shifts. After solving that integer system, the exact + /// power-of-two ratio between the RHS and matrix scales is restored. + /// Forward elimination runs entirely in `BigInt` /// with fraction-free Bareiss updates — no `BigRational`, no GCD, no /// denominator tracking in the `O(D³)` phase. Only the upper-triangular /// result is lifted into `BigRational` for back-substitution (the `O(D²)` @@ -1515,8 +1676,8 @@ mod tests { /// Thin wrapper over [`decompose_f64`] that packs the mantissa/exponent /// pair into a fully-formed `BigRational` of the form `±m · 2^e`. The /// production code paths (`exact_det_int_finite`, `bareiss_solve_finite`) instead - /// decompose every entry into a shared-scale `BigInt` matrix, which - /// avoids per-entry GCD work in the elimination loops — so this helper + /// decompose entries into scaled `BigInt` collections, which avoids + /// per-entry GCD work in the elimination loops — so this helper /// is not used by them and lives here to keep test assertions concise /// (e.g. `assert_eq!(x[0], f64_to_big_rational(3.0))`). /// @@ -1967,6 +2128,41 @@ mod tests { assert_unrepresentable(&result, None, UnrepresentableReason::NotFinite); } + #[test] + fn direct_big_int_rounding_handles_extreme_negative_exponent_without_large_denominator() { + let positive = big_int_exp_ref_to_rounded_f64(&BigInt::from(1_u8), i32::MIN, None).unwrap(); + let negative = + big_int_exp_ref_to_rounded_f64(&BigInt::from(-1_i8), i32::MIN, None).unwrap(); + + assert_eq!(positive.to_bits(), 0.0_f64.to_bits()); + assert_eq!(negative.to_bits(), (-0.0_f64).to_bits()); + } + + proptest! { + #[test] + fn direct_big_int_rounding_matches_rational_oracle( + value in any::(), + exp in -1200_i32..=1200_i32, + ) { + let value = BigInt::from(value); + let direct = big_int_exp_ref_to_rounded_f64(&value, exp, None); + let exact = big_int_exp_to_big_rational(value, exp); + let oracle = exact_rational_to_rounded_f64(&exact, None); + + match (direct, oracle) { + (Ok(actual), Ok(expected)) => { + prop_assert_eq!(actual.to_bits(), expected.to_bits()); + } + (Err(actual), Err(expected)) => { + prop_assert_eq!(actual, expected); + } + (actual, expected) => { + prop_assert_eq!(actual, expected); + } + } + } + } + #[test] fn component_to_big_int_distinguishes_zero_from_nonzero_mantissa() { let baseline = Component::NonZero { @@ -2015,7 +2211,7 @@ mod tests { } #[test] - fn shared_scale_is_no_greater_than_each_component_exponent() { + fn matrix_and_rhs_scales_are_derived_independently() { let tiny = f64::from_bits(1); let matrix = Matrix::<2>::try_from_rows([[f64::MAX, 0.0], [0.0, 1.0]]).unwrap(); let rhs = Vector::<2>::try_new([tiny, 0.0]).unwrap(); @@ -2025,10 +2221,12 @@ mod tests { assert_eq!(matrix.min_exponent(), Some(0)); assert_eq!(rhs.min_exponent(), Some(-1074)); - let scale = ScaleExponent::shared(&matrix, &rhs); - assert_eq!(scale.get(), -1074); - assert_eq!(scale.shift_for(-1074), 0); - assert_eq!(scale.shift_for(0), 1074); + let matrix_scale = ScaleExponent::for_decomposed(&matrix); + let rhs_scale = ScaleExponent::for_decomposed(&rhs); + assert_eq!(matrix_scale.get(), 0); + assert_eq!(matrix_scale.shift_for(0), 0); + assert_eq!(rhs_scale.get(), -1074); + assert_eq!(rhs_scale.shift_for(-1074), 0); } proptest! { @@ -2709,10 +2907,11 @@ mod tests { gen_solve_exact_large_finite_entries_tests!(5); /// Matrix and RHS entries span many orders of magnitude (from - /// `f64::MIN_POSITIVE` up through `1e100`). This exercises the - /// shared `e_min` scaling: even the largest shift keeps every entry a - /// representable `BigInt`. The D×D case alternates `huge`/`tiny` - /// along the diagonal with a matching RHS, giving `x = [1, …, 1]`. + /// `f64::MIN_POSITIVE` up through `1e100`). This exercises each + /// collection's independently derived minimum exponent: even the largest + /// within-collection shift remains a representable `BigInt`. The D×D case + /// alternates `huge`/`tiny` along the diagonal with a matching RHS, giving + /// `x = [1, …, 1]`. macro_rules! gen_solve_exact_mixed_magnitude_entries_tests { ($d:literal) => { paste! { @@ -2744,6 +2943,32 @@ mod tests { gen_solve_exact_mixed_magnitude_entries_tests!(4); gen_solve_exact_mixed_magnitude_entries_tests!(5); + #[test] + fn solve_exact_restores_independent_matrix_and_rhs_scales() { + let large = 2.0_f64.powi(500); + let tiny = 2.0_f64.powi(-1000); + + let large_matrix = Matrix::<2>::try_from_rows([[large, 0.0], [0.0, large]]).unwrap(); + let tiny_rhs = Vector::<2>::new([tiny, -2.0 * tiny]); + let small_solution = large_matrix.solve_exact(tiny_rhs).unwrap(); + assert_eq!( + small_solution[0], + BigRational::new(BigInt::from(1_u8), BigInt::from(1_u8) << 1500_u32) + ); + assert_eq!( + small_solution[1], + BigRational::new(BigInt::from(-1_i8), BigInt::from(1_u8) << 1499_u32) + ); + + let tiny_matrix = Matrix::<1>::try_from_rows([[tiny]]).unwrap(); + let large_rhs = Vector::<1>::new([large]); + let large_solution = tiny_matrix.solve_exact(large_rhs).unwrap(); + assert_eq!( + large_solution[0], + BigRational::from_integer(BigInt::from(1_u8) << 1500_u32) + ); + } + /// Subnormal RHS entries must survive the decomposition and /// back-substitution paths unchanged. The D×D case uses the identity /// matrix and RHS `[1·tiny, 2·tiny, …, D·tiny]`; each entry remains a diff --git a/src/lib.rs b/src/lib.rs index ff2db88..3a6e1ae 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -151,11 +151,9 @@ mod readme_doctests { /// fn adaptive_det_sign( /// matrix: &Matrix, /// ) -> DeterminantSign { - /// if let (Ok(Some(bound)), Ok(Some(det))) = - /// (matrix.det_errbound(), matrix.det_direct()) - /// { - /// if det.abs() > bound { - /// return if det > 0.0 { + /// if let Ok(Some(estimate)) = matrix.det_direct_with_errbound() { + /// if estimate.determinant().abs() > estimate.absolute_error_bound() { + /// return if estimate.determinant() > 0.0 { /// DeterminantSign::Positive /// } else { /// DeterminantSign::Negative @@ -216,7 +214,8 @@ pub use num_rational::BigRational; pub use num_traits::{FromPrimitive, Signed, ToPrimitive}; // --------------------------------------------------------------------------- -// Error-bound constants for `Matrix::det_errbound()`. +// Error-bound constants for `Matrix::det_direct_with_errbound()` and +// `Matrix::det_errbound()`. // // For `D ∈ {2, 3, 4}`, `Matrix::det_direct()` evaluates the Leibniz expansion // of the determinant as a tree of f64 multiplies and fused multiply-adds @@ -243,8 +242,10 @@ pub use num_traits::{FromPrimitive, Signed, ToPrimitive}; // // These constants are NOT feature-gated — they rely only on f64 arithmetic // and are useful for adaptive-precision logic even without the `exact` -// feature. Most callers should prefer `Matrix::det_errbound()`, which -// applies these constants to the actual matrix; the raw constants are +// feature. Most callers should prefer `Matrix::det_direct_with_errbound()` +// when they need the approximation and bound together, or +// `Matrix::det_errbound()` when they need only the bound. Those methods apply +// these constants to the actual matrix; the raw constants are // exposed for advanced use cases (composing the bound with a pre-reduced // permanent, rolling a custom adaptive filter, etc.). See // `Matrix::det_sign_exact()` (behind the `exact` feature) for the @@ -272,8 +273,10 @@ const EPS: f64 = f64::EPSILON; // 2^-52 /// interaction. Derivation follows Shewchuk's framework; see /// `REFERENCES.md` \[8\]. /// -/// Prefer [`Matrix::det_errbound`](crate::Matrix::det_errbound) unless -/// you already have the absolute-Leibniz sum available; see +/// Prefer +/// [`Matrix::det_direct_with_errbound`](crate::Matrix::det_direct_with_errbound) +/// unless you need only the bound or already have the absolute-Leibniz sum; +/// see /// `Matrix::det_sign_exact` (under the `exact` feature) for the reference /// adaptive-precision filter. /// @@ -318,8 +321,10 @@ pub const ERR_COEFF_2: f64 = 3.0 * EPS + 16.0 * EPS * EPS; /// FMA, yielding the `8·EPS + 64·EPS²` bound. See `REFERENCES.md` /// \[8\] for the Shewchuk framework these bounds follow. /// -/// Prefer [`Matrix::det_errbound`](crate::Matrix::det_errbound) over this -/// constant for typical use; see [`ERR_COEFF_2`] for a worked example. +/// Prefer +/// [`Matrix::det_direct_with_errbound`](crate::Matrix::det_direct_with_errbound) +/// over this constant for typical use; see [`ERR_COEFF_2`] for a worked +/// example. pub const ERR_COEFF_3: f64 = 8.0 * EPS + 64.0 * EPS * EPS; /// Absolute error coefficient for [`Matrix::<4>::det_direct`](crate::Matrix::det_direct). @@ -341,8 +346,10 @@ pub const ERR_COEFF_3: f64 = 8.0 * EPS + 64.0 * EPS * EPS; /// `12·EPS + 128·EPS²` bound. See `REFERENCES.md` \[8\] for the /// Shewchuk framework these bounds follow. /// -/// Prefer [`Matrix::det_errbound`](crate::Matrix::det_errbound) over this -/// constant for typical use; see [`ERR_COEFF_2`] for a worked example. +/// Prefer +/// [`Matrix::det_direct_with_errbound`](crate::Matrix::det_direct_with_errbound) +/// over this constant for typical use; see [`ERR_COEFF_2`] for a worked +/// example. pub const ERR_COEFF_4: f64 = 12.0 * EPS + 128.0 * EPS * EPS; /// Largest dimension supported by [`try_with_stack_matrix!`]. @@ -359,7 +366,7 @@ pub use error::{ }; pub use ldlt::Ldlt; pub use lu::Lu; -pub use matrix::Matrix; +pub use matrix::{DeterminantWithErrorBound, Matrix}; pub use tolerance::{DEFAULT_SINGULAR_TOL, Tolerance}; pub use vector::Vector; @@ -465,7 +472,8 @@ macro_rules! try_with_stack_matrix { /// Common imports for ergonomic usage. /// /// This prelude re-exports the primary types and common constants: [`Matrix`], -/// [`Vector`], [`Lu`], [`Ldlt`], [`Tolerance`], and [`LaError`]. Its typed +/// [`DeterminantWithErrorBound`], [`Vector`], [`Lu`], [`Ldlt`], [`Tolerance`], +/// and [`LaError`]. Its typed /// error categories include [`ArithmeticOperation`], [`FactorizationKind`], /// [`InvalidToleranceReason`], [`NonFiniteLocation`], [`NonFiniteOrigin`], /// [`PositiveSemidefiniteViolation`], [`SingularityReason`], and @@ -487,10 +495,10 @@ macro_rules! try_with_stack_matrix { /// `.is_positive()` / `.is_negative()` / `.abs()`. pub mod prelude { pub use crate::{ - ArithmeticOperation, DEFAULT_SINGULAR_TOL, FactorizationKind, InvalidToleranceReason, - LaError, Ldlt, Lu, MAX_STACK_MATRIX_DISPATCH_DIM, Matrix, NonFiniteLocation, - NonFiniteOrigin, PositiveSemidefiniteViolation, SingularityReason, Tolerance, - UnrepresentableReason, Vector, try_with_stack_matrix, + ArithmeticOperation, DEFAULT_SINGULAR_TOL, DeterminantWithErrorBound, FactorizationKind, + InvalidToleranceReason, LaError, Ldlt, Lu, MAX_STACK_MATRIX_DISPATCH_DIM, Matrix, + NonFiniteLocation, NonFiniteOrigin, PositiveSemidefiniteViolation, SingularityReason, + Tolerance, UnrepresentableReason, Vector, try_with_stack_matrix, }; #[cfg(feature = "exact")] diff --git a/src/matrix.rs b/src/matrix.rs index 2b19049..48bb4cf 100644 --- a/src/matrix.rs +++ b/src/matrix.rs @@ -10,6 +10,41 @@ use crate::{ ArithmeticOperation, ERR_COEFF_2, ERR_COEFF_3, ERR_COEFF_4, LaError, SymmetricMatrix, Tolerance, }; +/// A closed-form determinant and its certified absolute error bound. +/// +/// Values of this type are produced by +/// [`Matrix::det_direct_with_errbound`]. The paired result guarantees that the +/// determinant and bound came from one traversal of the same rounded +/// arithmetic tree. The guarantee is unavailable when gradual underflow could +/// invalidate the relative-error analysis or when the matrix dimension exceeds +/// the closed-form D ≤ 4 scope. +#[must_use] +#[non_exhaustive] +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct DeterminantWithErrorBound { + determinant: f64, + absolute_error_bound: f64, +} + +impl DeterminantWithErrorBound { + /// Return the closed-form determinant approximation. + #[inline] + #[must_use] + pub const fn determinant(self) -> f64 { + self.determinant + } + + /// Return the certified absolute error bound. + /// + /// The exact determinant lies in + /// `[determinant - bound, determinant + bound]`. + #[inline] + #[must_use] + pub const fn absolute_error_bound(self) -> f64 { + self.absolute_error_bound + } +} + /// Finite fixed-size square matrix `D×D`, stored inline. /// /// `Matrix` is designed for small, robustness-sensitive systems where stack @@ -951,6 +986,56 @@ impl Matrix { self.lu(Tolerance::ZERO)?.det() } + /// Evaluate `det_direct()` and its absolute error bound together. + /// + /// Returns `Ok(Some(result))` for D ≤ 4 when the relative-error analysis + /// is valid. The result contains the closed-form determinant and a bound + /// such that `|result.determinant() - det_exact| ≤ + /// result.absolute_error_bound()`. Returns `Ok(None)` when gradual + /// underflow could invalidate that analysis or for D ≥ 5, where no + /// closed-form bound is available. + /// + /// This is the preferred API when both values are needed: it evaluates the + /// determinant arithmetic tree once, so the approximation and bound cannot + /// accidentally come from separate traversals. + /// + /// # Examples + /// ``` + /// use la_stack::prelude::*; + /// + /// # fn main() -> Result<(), LaError> { + /// let matrix = Matrix::<2>::try_from_rows([[1.0, 2.0], [3.0, 4.0]])?; + /// if let Some(estimate) = matrix.det_direct_with_errbound()? { + /// assert_eq!(estimate.determinant(), -2.0); + /// assert!(estimate.absolute_error_bound() >= 0.0); + /// } + /// # Ok(()) + /// # } + /// ``` + /// + /// # Errors + /// Returns [`LaError::NonFinite`] when the determinant or bound computation + /// overflows to NaN or infinity. Underflow-sensitive finite computations + /// return `Ok(None)` because they remain valid inputs for an exact fallback. + #[inline] + pub const fn det_direct_with_errbound( + &self, + ) -> Result, LaError> { + if self.det_bound_inputs_have_wide_exponent_margin() { + let Some(det) = self.det_direct_arithmetic::() else { + cold_path(); + return Ok(None); + }; + return self.det_direct_with_errbound_from_arithmetic(det); + } + + let Some(det) = self.det_direct_arithmetic::() else { + cold_path(); + return Ok(None); + }; + self.det_direct_with_errbound_from_arithmetic(det) + } + /// Conservative absolute error bound for `det_direct()`. /// /// Returns `Ok(Some(bound))` such that `|det_direct() - det_exact| ≤ bound` @@ -971,9 +1056,9 @@ impl Matrix { /// /// # When to use /// - /// Use this to build adaptive-precision logic: when a bound is available and - /// `|det_direct()| > bound`, the f64 sign is provably correct. Otherwise fall - /// back to exact arithmetic. + /// Use [`det_direct_with_errbound`](Self::det_direct_with_errbound) when the + /// determinant and bound are both needed. This accessor is convenient when + /// only the bound is needed. /// /// # Examples /// ``` @@ -985,10 +1070,8 @@ impl Matrix { /// [4.0, 5.0, 6.0], /// [7.0, 8.0, 9.0], /// ])?; - /// if let (Some(bound), Some(det_approx)) = (m.det_errbound()?, m.det_direct()?) { - /// // If |det_approx| > bound, the sign is guaranteed correct. - /// let sign_is_certified = det_approx.abs() > bound; - /// assert!(!sign_is_certified); + /// if let Some(bound) = m.det_errbound()? { + /// assert!(bound >= 0.0); /// } /// # Ok(()) /// # } @@ -1001,11 +1084,9 @@ impl Matrix { /// fn adaptive_det_sign( /// matrix: &Matrix, /// ) -> DeterminantSign { - /// if let (Ok(Some(bound)), Ok(Some(det))) = - /// (matrix.det_errbound(), matrix.det_direct()) - /// { - /// if det.abs() > bound { - /// return if det > 0.0 { + /// if let Ok(Some(estimate)) = matrix.det_direct_with_errbound() { + /// if estimate.determinant().abs() > estimate.absolute_error_bound() { + /// return if estimate.determinant() > 0.0 { /// DeterminantSign::Positive /// } else { /// DeterminantSign::Negative @@ -1042,30 +1123,11 @@ impl Matrix { /// `Ok(None)` instead because they are valid inputs for an exact fallback. #[inline] pub const fn det_errbound(&self) -> Result, LaError> { - let Some(det) = self.det_direct_arithmetic::() else { - cold_path(); - return Ok(None); - }; - self.det_errbound_from_arithmetic(det) - } - - /// Evaluate the determinant and its certified error bound with one shared - /// traversal of the determinant arithmetic tree. - #[cfg(feature = "exact")] - pub(crate) const fn det_filter(&self) -> Option<(f64, f64)> { - if self.det_filter_inputs_have_wide_exponent_margin() { - let Some(det) = self.det_direct_arithmetic::() else { - cold_path(); - return None; - }; - return self.det_filter_from_arithmetic(det); + match self.det_direct_with_errbound() { + Ok(Some(result)) => Ok(Some(result.absolute_error_bound)), + Ok(None) => Ok(None), + Err(error) => Err(error), } - - let Some(det) = self.det_direct_arithmetic::() else { - cold_path(); - return None; - }; - self.det_filter_from_arithmetic(det) } /// Return whether every non-zero entry is large enough that the complete @@ -1075,8 +1137,7 @@ impl Matrix { /// even after the D=4 tree's products, FMAs, and binary64 rounding steps. /// Overflow remains possible and is classified after evaluation. Inputs /// below this conservative threshold use per-operation tracking instead. - #[cfg(feature = "exact")] - const fn det_filter_inputs_have_wide_exponent_margin(&self) -> bool { + const fn det_bound_inputs_have_wide_exponent_margin(&self) -> bool { const MIN_MAGNITUDE_BITS: u64 = 1007_u64 << 52; // 2^-16 const MAGNITUDE_MASK: u64 = !(1_u64 << 63); @@ -1100,19 +1161,25 @@ impl Matrix { } /// Classify a completed determinant tree and construct its matching bound. - #[cfg(feature = "exact")] - const fn det_filter_from_arithmetic( + const fn det_direct_with_errbound_from_arithmetic( &self, det: FilterArithmetic, - ) -> Option<(f64, f64)> { + ) -> Result, LaError> { + let bound = match self.det_errbound_from_arithmetic(det) { + Ok(Some(bound)) => bound, + Ok(None) => return Ok(None), + Err(error) => return Err(error), + }; if !det.value.is_finite() { - return None; + cold_path(); + return Err(LaError::non_finite_computation_scalar( + ArithmeticOperation::Determinant, + )); } - - let Ok(Some(bound)) = self.det_errbound_from_arithmetic(det) else { - return None; - }; - Some((det.value, bound)) + Ok(Some(DeterminantWithErrorBound { + determinant: det.value, + absolute_error_bound: bound, + })) } /// Compute a bound after the matching determinant tree has been evaluated. @@ -1378,11 +1445,9 @@ mod det_errbound_doctests { /// fn adaptive_det_sign( /// matrix: &Matrix, /// ) -> DeterminantSign { - /// if let (Ok(Some(bound)), Ok(Some(det))) = - /// (matrix.det_errbound(), matrix.det_direct()) - /// { - /// if det.abs() > bound { - /// return if det > 0.0 { + /// if let Ok(Some(estimate)) = matrix.det_direct_with_errbound() { + /// if estimate.determinant().abs() > estimate.absolute_error_bound() { + /// return if estimate.determinant() > 0.0 { /// DeterminantSign::Positive /// } else { /// DeterminantSign::Negative @@ -2012,26 +2077,65 @@ mod tests { assert_eq!(Matrix::<5>::identity().det_errbound(), Ok(None)); } - #[cfg(feature = "exact")] #[test] - fn det_filter_wide_exponent_fast_path_matches_tracked_arithmetic() { + fn combined_det_bound_wide_exponent_fast_path_matches_tracked_arithmetic() { let threshold = f64::from_bits(1007_u64 << 52); // 2^-16 let at_threshold = Matrix::<2>::try_from_rows([[threshold, 0.0], [0.0, 2.0]]).unwrap(); - assert!(at_threshold.det_filter_inputs_have_wide_exponent_margin()); + assert!(at_threshold.det_bound_inputs_have_wide_exponent_margin()); let tracked = at_threshold - .det_filter_from_arithmetic( + .det_direct_with_errbound_from_arithmetic( at_threshold .det_direct_arithmetic::() .expect("D=2 has direct arithmetic"), ) .unwrap(); - assert_eq!(at_threshold.det_filter().unwrap(), tracked); + assert_eq!(at_threshold.det_direct_with_errbound().unwrap(), tracked); let just_below = f64::from_bits(threshold.to_bits() - 1); let below_threshold = Matrix::<2>::try_from_rows([[just_below, 0.0], [0.0, 2.0]]).unwrap(); - assert!(!below_threshold.det_filter_inputs_have_wide_exponent_margin()); - assert!(!Matrix::<5>::identity().det_filter_inputs_have_wide_exponent_margin()); + assert!(!below_threshold.det_bound_inputs_have_wide_exponent_margin()); + assert!(!Matrix::<5>::identity().det_bound_inputs_have_wide_exponent_margin()); + } + + #[test] + fn det_direct_with_errbound_covers_zero_and_one_dimensions() { + let empty = Matrix::<0>::zero() + .det_direct_with_errbound() + .unwrap() + .unwrap(); + assert_abs_diff_eq!(empty.determinant(), 1.0, epsilon = 0.0); + assert_abs_diff_eq!(empty.absolute_error_bound(), 0.0, epsilon = 0.0); + + let scalar = Matrix::<1>::try_from_rows([[-7.0]]) + .unwrap() + .det_direct_with_errbound() + .unwrap() + .unwrap(); + assert_abs_diff_eq!(scalar.determinant(), -7.0, epsilon = 0.0); + assert_abs_diff_eq!(scalar.absolute_error_bound(), 0.0, epsilon = 0.0); + } + + #[test] + fn det_direct_with_errbound_pairs_the_closed_form_values() { + let matrix = Matrix::<2>::try_from_rows([[1.0, 2.0], [3.0, 4.0]]).unwrap(); + let estimate = matrix.det_direct_with_errbound().unwrap().unwrap(); + + assert_abs_diff_eq!( + estimate.determinant(), + matrix.det_direct().unwrap().unwrap(), + epsilon = 0.0 + ); + assert_abs_diff_eq!( + estimate.absolute_error_bound(), + ERR_COEFF_2 * (4.0_f64 + 6.0_f64), + epsilon = 0.0 + ); + } + + #[test] + fn det_direct_with_errbound_d5_returns_none() { + assert_eq!(Matrix::<5>::identity().det_direct_with_errbound(), Ok(None)); } #[test] diff --git a/src/vector.rs b/src/vector.rs index 2649f47..01f5bc2 100644 --- a/src/vector.rs +++ b/src/vector.rs @@ -419,8 +419,19 @@ mod tests { } // Mirror delaunay-style multi-dimension tests. + gen_vector_tests!(1); gen_vector_tests!(2); gen_vector_tests!(3); gen_vector_tests!(4); gen_vector_tests!(5); + + #[test] + fn zero_dimension_vector_has_zero_dot_and_norm() { + let vector = Vector::<0>::try_new([]).unwrap(); + + assert!(vector.as_array().is_empty()); + assert!(vector.into_array().is_empty()); + assert_eq!(vector.dot(&Vector::zero()), Ok(0.0)); + assert_eq!(vector.norm2_sq(), Ok(0.0)); + } } diff --git a/tests/exact_bench_config.rs b/tests/exact_bench_config.rs index 93c6668..4cf0f91 100644 --- a/tests/exact_bench_config.rs +++ b/tests/exact_bench_config.rs @@ -12,7 +12,7 @@ use std::error::Error; use exact_bench::{ ExactBenchConfigError, ExactInput, I16Range, SplitMix64, ValidatedExactInput, hilbert_input, large_entries_3x3_input, make_matrix_rows, make_random_input_corpus, make_vector_array, - near_singular_3x3_input, validate_exact_fixture, + near_singular_3x3_input, validate_exact_fixture, validate_f64_determinant_benchmarks, }; use la_stack::{Matrix, Vector}; use pastey::paste; @@ -28,7 +28,8 @@ fn baseline_input() -> ExactInput { } fn validate_baseline_and_random_corpus() { - let _ = validate_exact_fixture(baseline_input::()); + let baseline = validate_exact_fixture(baseline_input::()); + validate_f64_determinant_benchmarks(&baseline); for input in make_random_input_corpus::() { let _ = validate_exact_fixture(input); } @@ -147,6 +148,18 @@ fn validated_fixture_exposes_only_checked_inputs() { assert_eq!(validated.rhs(), expected_rhs); } +#[test] +fn fixture_validation_covers_independently_scaled_exact_solve_inputs() { + let large = 2.0_f64.powi(500); + let tiny = 2.0_f64.powi(-1000); + let matrix = Matrix::<2>::try_from_rows([[large, 0.0], [0.0, large]]) + .unwrap_or_else(|error| panic!("scaled fixture matrix must be finite: {error}")); + let rhs = Vector::<2>::try_new([tiny, -2.0 * tiny]) + .unwrap_or_else(|error| panic!("scaled fixture RHS must be finite: {error}")); + + let _ = validate_exact_fixture(ExactInput { matrix, rhs }); +} + #[test] #[should_panic(expected = "exact solve oracle check failed")] fn fixture_validation_rejects_singular_solve_input() { diff --git a/tests/prelude_exports.rs b/tests/prelude_exports.rs index 59c2aba..e9c233c 100644 --- a/tests/prelude_exports.rs +++ b/tests/prelude_exports.rs @@ -16,6 +16,11 @@ fn common_prelude_supports_downstream_composition() -> Result<(), LaError> { let matrix = Matrix::<2>::identity(); let vector = Vector::<2>::try_new([1.0, 2.0])?; let tolerance = Tolerance::try_new(0.0)?; + let estimate: Option = matrix.det_direct_with_errbound()?; + if let Some(estimate) = estimate { + assert_abs_diff_eq!(estimate.determinant(), 1.0, epsilon = 0.0); + assert!(estimate.absolute_error_bound() >= 0.0); + } let lu: Lu<2> = matrix.lu(tolerance)?; let ldlt: Ldlt<2> = matrix.ldlt(tolerance)?; diff --git a/tests/proptest_factorizations.rs b/tests/proptest_factorizations.rs index 0d730b2..955f8f6 100644 --- a/tests/proptest_factorizations.rs +++ b/tests/proptest_factorizations.rs @@ -196,8 +196,6 @@ macro_rules! gen_factorization_proptests { ) { // Construct A = P^{-1} * L * U, where P swaps the first two rows. // This ensures det(A) has an extra sign flip vs det(LU). - prop_assume!($d >= 2); - let mut l = [[0.0f64; $d]; $d]; for i in 0..$d { for j in 0..$d { diff --git a/tests/proptest_matrix.rs b/tests/proptest_matrix.rs index 1f7a9b3..f14b72a 100644 --- a/tests/proptest_matrix.rs +++ b/tests/proptest_matrix.rs @@ -176,7 +176,45 @@ macro_rules! gen_matrix_proptests { } // Mirror delaunay-style multi-dimension tests. +gen_matrix_proptests!(1); gen_matrix_proptests!(2); gen_matrix_proptests!(3); gen_matrix_proptests!(4); gen_matrix_proptests!(5); + +#[test] +fn zero_dimension_matrix_obeys_empty_product_and_bounds_contracts() { + let mut matrix = Matrix::<0>::try_from_rows([]).unwrap(); + + assert!(matrix.as_rows().is_empty()); + assert_eq!(matrix.get(0, 0), None); + assert!(matches!( + matrix.try_get(0, 0), + Err(LaError::IndexOutOfBounds { + row: 0, + col: 0, + dim: 0, + .. + }) + )); + assert!(matches!( + matrix.set(0, 0, 1.0), + Err(LaError::IndexOutOfBounds { + row: 0, + col: 0, + dim: 0, + .. + }) + )); + assert_eq!(matrix.inf_norm(), Ok(0.0)); + assert_eq!(matrix.det(), Ok(1.0)); + assert!( + matrix + .lu(DEFAULT_SINGULAR_TOL) + .unwrap() + .solve(Vector::<0>::zero()) + .unwrap() + .into_array() + .is_empty() + ); +} diff --git a/tests/proptest_vector.rs b/tests/proptest_vector.rs index 04b33a6..29db94a 100644 --- a/tests/proptest_vector.rs +++ b/tests/proptest_vector.rs @@ -63,7 +63,18 @@ macro_rules! gen_vector_proptests { } // Mirror delaunay-style multi-dimension tests. +gen_vector_proptests!(1); gen_vector_proptests!(2); gen_vector_proptests!(3); gen_vector_proptests!(4); gen_vector_proptests!(5); + +#[test] +fn zero_dimension_vector_obeys_empty_sum_contracts() { + let vector = Vector::<0>::try_new([]).unwrap(); + + assert!(vector.as_array().is_empty()); + assert!(vector.into_array().is_empty()); + assert_eq!(vector.dot(&Vector::zero()), Ok(0.0)); + assert_eq!(vector.norm2_sq(), Ok(0.0)); +} diff --git a/tests/semgrep/docs/public_examples.md b/tests/semgrep/docs/public_examples.md index d21b9bd..85b29e5 100644 --- a/tests/semgrep/docs/public_examples.md +++ b/tests/semgrep/docs/public_examples.md @@ -9,3 +9,10 @@ let value = Some(1_u8).unwrap(); // ok: la-stack.rust.no-unwrap-expect-in-markdown-examples let value = maybe_value?; ``` + + + +```bash +just fix +just check +``` diff --git a/tests/semgrep/src/project_rules/portable_policy.rs b/tests/semgrep/src/project_rules/portable_policy.rs index fda78ef..9219ee6 100644 --- a/tests/semgrep/src/project_rules/portable_policy.rs +++ b/tests/semgrep/src/project_rules/portable_policy.rs @@ -2,6 +2,21 @@ use num_traits::NumCast; +pub fn stdio_diagnostic_fixture() { + // ruleid: la-stack.rust.no-stdio-diagnostics-in-src + eprintln!("fixture diagnostic"); +} + +pub fn nonfinite_default_fixture(value: Option) -> f64 { + // ruleid: la-stack.rust.no-nonfinite-unwrap-defaults + value.unwrap_or(f64::NAN) +} + +// ruleid: la-stack.rust.public-error-enums-non-exhaustive +pub enum FixtureError { + Example, +} + // ruleid: la-stack.rust.no-module-scope-cfg-test-use #[cfg(test)] use crate::FixtureOnlyImport; diff --git a/tests/vs_linalg_inputs.rs b/tests/vs_linalg_inputs.rs index d9f500e..d245bd8 100644 --- a/tests/vs_linalg_inputs.rs +++ b/tests/vs_linalg_inputs.rs @@ -16,14 +16,17 @@ use la_stack::{DEFAULT_SINGULAR_TOL, Matrix, Vector}; pub mod vs_linalg_common; use vs_linalg_common::{ - faer_det_from_ldlt, faer_det_from_partial_piv_lu, faer_perm_sign, la_stack_dot, - la_stack_tolerance, make_balanced_dynamic_range_rows, make_ill_conditioned_matrix_rows, - make_matrix_rows, make_pivoting_matrix_rows, make_vector_array, matrix_entry, - nalgebra_inf_norm, vector_entry, + PreparedFaerLuDet, faer_det_from_ldlt, faer_perm_sign, la_stack_dot, la_stack_tolerance, + make_balanced_dynamic_range_rows, make_ill_conditioned_matrix_rows, make_matrix_rows, + make_pivoting_matrix_rows, make_vector_array, matrix_entry, nalgebra_inf_norm, vector_entry, }; /// Assert scalar agreement with a tolerance that scales for larger magnitudes. fn assert_close(label: &str, actual: f64, expected: f64) { + assert!( + actual.is_finite() && expected.is_finite(), + "{label}: comparison requires finite values, actual={actual:?}, expected={expected:?}", + ); let scale = actual.abs().max(expected.abs()).max(1.0); let diff = (actual - expected).abs(); assert!( @@ -78,10 +81,14 @@ where let la_lu_det = la_lu .det() .unwrap_or_else(|err| panic!("la_stack LU determinant failed: {err}")); + let la_matrix_det = a + .det() + .unwrap_or_else(|err| panic!("la_stack Matrix determinant failed: {err}")); + assert_close("la_stack_det", la_matrix_det, la_lu_det); assert_close("nalgebra_det_from_lu", na_lu.determinant(), la_lu_det); assert_close( "faer_det_from_lu", - faer_det_from_partial_piv_lu(&fa_lu), + PreparedFaerLuDet::new(&fa_lu).det(), la_lu_det, ); @@ -197,11 +204,26 @@ fn faer_lu_determinant_includes_odd_row_permutation_sign() { ); assert_close( "faer determinant with one pivot swap", - faer_det_from_partial_piv_lu(&lu), + PreparedFaerLuDet::new(&lu).det(), -6.0, ); } +#[test] +fn scalar_agreement_rejects_non_finite_values() { + for (actual, expected) in [ + (f64::INFINITY, f64::INFINITY), + (f64::NEG_INFINITY, -1.0), + (f64::NAN, 1.0), + (1.0, f64::NAN), + ] { + assert!( + std::panic::catch_unwind(|| assert_close("non-finite regression", actual, expected)) + .is_err() + ); + } +} + #[test] fn faer_permutation_sign_handles_valid_cycle_parities() { let empty = PermRef::new_checked(&[], &[], 0); diff --git a/uv.lock b/uv.lock index 50a7066..dc3c36a 100644 --- a/uv.lock +++ b/uv.lock @@ -398,11 +398,11 @@ dev = [ dev = [ { name = "actionlint-py", specifier = "==1.7.12.24" }, { name = "pytest", specifier = "==9.1.1" }, - { name = "ruff", specifier = "==0.15.20" }, - { name = "semgrep", specifier = "==1.168.0" }, + { name = "ruff", specifier = "==0.15.21" }, + { name = "semgrep", specifier = "==1.169.0" }, { name = "shellcheck-py", specifier = "==0.11.0.1" }, { name = "shfmt-py", specifier = "==4.0.0" }, - { name = "ty", specifier = "==0.0.56" }, + { name = "ty", specifier = "==0.0.58" }, { name = "yamllint", specifier = "==1.38.0" }, ] @@ -946,27 +946,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.20" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" }, - { url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" }, - { url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" }, - { url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" }, - { url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" }, - { url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" }, - { url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" }, - { url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" }, - { url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" }, - { url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" }, - { url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" }, - { url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" }, - { url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" }, - { url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" }, - { url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" }, - { url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" }, - { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" }, +version = "0.15.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/36/6f65aa9989acdec45d417192d8f4e7921931d8a6cf87ac74bce3eed98a8e/ruff-0.15.21.tar.gz", hash = "sha256:d0cfc841c572283c36548f82664a54ce6565567f1b0d5b4cf2caac693d8b7500", size = 4769401, upload-time = "2026-07-09T20:01:34.005Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/c6/ede15cac6839f3dbce52565c8f5164a8210e669c7bc4decb03e5bdf47d0d/ruff-0.15.21-py3-none-linux_armv6l.whl", hash = "sha256:63ea0e965e5d73c90e95b2434beeafc70820536717f561b32ab6e777cb9bdf5d", size = 10854342, upload-time = "2026-07-09T20:00:53.998Z" }, + { url = "https://files.pythonhosted.org/packages/28/9d/d825b07ee7ea9e2d61df92a860033c94e06e7300d50a1c2653aac27d24fe/ruff-0.15.21-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0f212c5d7d54c01bbfe6dcab02b724a39300f3e34ed7acbe995ccb320a2c58bd", size = 11139539, upload-time = "2026-07-09T20:00:57.809Z" }, + { url = "https://files.pythonhosted.org/packages/f5/de/3b107712e642f063c7a9e0887c427b22cb44097de5aab36c05f2e280670c/ruff-0.15.21-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e6312e41bc96791299614995ea3a977c5857c3b5662b1ecef6755b02b87cb646", size = 10595437, upload-time = "2026-07-09T20:01:00.006Z" }, + { url = "https://files.pythonhosted.org/packages/9a/6f/b4523cc90ba239ede441447a19d0c968846a3012e5a0b0c5b62831a3d5e3/ruff-0.15.21-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01d65b4831c6b2a4ba8ee6faa84049d44d982b7a706e622c4094c509e51673be", size = 10990053, upload-time = "2026-07-09T20:01:02.187Z" }, + { url = "https://files.pythonhosted.org/packages/92/cc/c6a9872a5375f0628875481cf2f66b13d7d865bf3ca2e57f91c7e762d976/ruff-0.15.21-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c5a913a589120ce67933d5d05fd6ddbcc2481c6a054980ee767f7414c72b4fd", size = 10666096, upload-time = "2026-07-09T20:01:04.299Z" }, + { url = "https://files.pythonhosted.org/packages/ab/97/c621f7a17e097f1790fa3af6374138823b330b2d03fc38337945daca212c/ruff-0.15.21-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef04b681d02ad4dc9620f00f83ac5c22f652d0e9a9cfe431d219b16ad5ccc41", size = 11537011, upload-time = "2026-07-09T20:01:06.771Z" }, + { url = "https://files.pythonhosted.org/packages/ea/51/d928727e476e25ccc57c6f449ffd80241a651a973ad949d39cfb2a771d28/ruff-0.15.21-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16d090c0740916594157e75b80d666eab8e78083b39b3b0e1d698f4670a17b86", size = 12347101, upload-time = "2026-07-09T20:01:08.859Z" }, + { url = "https://files.pythonhosted.org/packages/1e/88/8cd62026802b16018ad06931d87997cf795ba2a6239ab659606c87d96bf0/ruff-0.15.21-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a10e74757dd65004d779b73e2f3c5210156d9980b41224d50d2ebcf1db51e67", size = 11572001, upload-time = "2026-07-09T20:01:11.092Z" }, + { url = "https://files.pythonhosted.org/packages/b2/97/f63084cf55444fc110e8cb985ebfcc592af47f597d44453d778cb81bc156/ruff-0.15.21-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bab0905d2f29e0d9fbc3c373ed23db0095edaa3f71f1f4f519ec15134d9e85c8", size = 11549239, upload-time = "2026-07-09T20:01:13.27Z" }, + { url = "https://files.pythonhosted.org/packages/9d/77/f107da4a2874b7715914b03f09ba9c54424de3ff8a1cc5d015d3ee2ce0ac/ruff-0.15.21-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:00eca240af5789fec6fe7df74c088cc1f9644ed83027113468efba7c92b94075", size = 11535340, upload-time = "2026-07-09T20:01:15.206Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e9/601deb322d3303a7bf212b0100ead6f2ee3f6a044d89c30f2f92bf83c731/ruff-0.15.21-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:262ab31557a75141325e32d3357f3597645a7f084e732b6b054dde428ecd9341", size = 10964048, upload-time = "2026-07-09T20:01:17.723Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2e/0f2176d1e99c15192caea19c8c3a0a955246b4cb4de795042eeb616345cd/ruff-0.15.21-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:659c4e7a4212f83306045ec7c5e5a356d16d9a6ef4ae0c7a4d872914fc655d9d", size = 10667055, upload-time = "2026-07-09T20:01:19.73Z" }, + { url = "https://files.pythonhosted.org/packages/48/60/abd74a02e0c4214f12a68becfd30af7165cfdcb0e661ecdc60bbb949c09a/ruff-0.15.21-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9e866eab611a5f959d36df2d10e446973a3610bc42b0c15b31dc27977d59c233", size = 11242043, upload-time = "2026-07-09T20:01:21.947Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c6/583075d8ccabb4b229345edcaf1545eb3d8d6be90f686a479d7e94088bbf/ruff-0.15.21-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e89bc93c0d3803ba870b55c29671bad9dc6d94bb1eb181b056b52eb05b52854f", size = 11648064, upload-time = "2026-07-09T20:01:24.023Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3c/37d0ecb729a7cc2d393ea7dce316fc585680f35d93b8d62139d7d0a3700c/ruff-0.15.21-py3-none-win32.whl", hash = "sha256:01f8d5be84823c172b389e123174f781f9daf86d6c58719d603f941932195cdd", size = 10896555, upload-time = "2026-07-09T20:01:26.941Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b8/e43466b2a6067ce91e669068f6e28d6c719a920f014b070d5c8731725de3/ruff-0.15.21-py3-none-win_amd64.whl", hash = "sha256:d4b8d9a2f0f12b816b50447f6eccb9f4bb01a6b82c86b50fb3b5354b458dc6d3", size = 12038772, upload-time = "2026-07-09T20:01:29.497Z" }, + { url = "https://files.pythonhosted.org/packages/dd/75/e90ab9aeece218a9fc5a5bc3ec97d0ee6bb3c4ff95869463c1de58e29a1c/ruff-0.15.21-py3-none-win_arm64.whl", hash = "sha256:6e83115d4b9377c1cbc13abf0e051f069fab0ef815ea0504a8a008cee24dd0a8", size = 11375265, upload-time = "2026-07-09T20:01:31.772Z" }, ] [[package]] @@ -980,7 +980,7 @@ wheels = [ [[package]] name = "semgrep" -version = "1.168.0" +version = "1.169.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, @@ -1011,15 +1011,15 @@ dependencies = [ { name = "urllib3" }, { name = "wcmatch" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ed/33/c40bd1f104d66817c082773cb9b6ba65ff50c53ccd118f8da747205dac0b/semgrep-1.168.0.tar.gz", hash = "sha256:a072b1734b5c54e39cbbe957b10cf0b83a114b9b9fc762b4ea51afe2f114cd11", size = 497911, upload-time = "2026-06-24T19:37:38.805Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8d/0c/3f0bd4d2fac226c2c3f9acc4d8782806e0d593a14e22b2f16e12156a9a94/semgrep-1.169.0.tar.gz", hash = "sha256:46932f875b8dff4cb731cd4c908443a0f2f585edbb0a5baa92c4fc033246fdea", size = 499932, upload-time = "2026-07-10T16:49:23.956Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/04/bfeed9429e302db73c54b1f06993361b7abd5d9e4abcdfee0031e35f90a3/semgrep-1.168.0-cp310.cp311.cp312.cp313.cp314.py310.py311.py312.py313.py314-none-macosx_10_14_x86_64.whl", hash = "sha256:1a5d6b5b47347b22bcb1d6f15a5e51a21e734244884624c3494e09bc1de955cd", size = 45011229, upload-time = "2026-06-24T19:38:58.978Z" }, - { url = "https://files.pythonhosted.org/packages/f6/61/c3aaf9b1d435707af74f7008063402107b9e2532337aa8b7cffea00e11bc/semgrep-1.168.0-cp310.cp311.cp312.cp313.cp314.py310.py311.py312.py313.py314-none-macosx_11_0_arm64.whl", hash = "sha256:e6b85d84e815ff86f56dcb6b4a6370b98f1bc0e402144daafa9b41aa00ea9a90", size = 49030891, upload-time = "2026-06-24T19:39:02.608Z" }, - { url = "https://files.pythonhosted.org/packages/76/31/e4545bb66c7334660541ababf771189a123259f6bf5d945d20ca2045d2ab/semgrep-1.168.0-cp310.cp311.cp312.cp313.cp314.py310.py311.py312.py313.py314-none-manylinux_2_34_aarch64.whl", hash = "sha256:fa22f0a41ee3857ecec1b3883b2920b1d41d07ef1bdd7e216da3c519210ae5fb", size = 70903799, upload-time = "2026-06-24T19:39:05.888Z" }, - { url = "https://files.pythonhosted.org/packages/01/6c/3398532fe8ced8d3f01fad16231f496b878c83c534d9266df1cbe4aaea35/semgrep-1.168.0-cp310.cp311.cp312.cp313.cp314.py310.py311.py312.py313.py314-none-manylinux_2_34_x86_64.whl", hash = "sha256:09dfacb0530ed4a17bd2deb7914e9a25fc3581d5d84d5365cdac77bbebed8081", size = 68761991, upload-time = "2026-06-24T19:39:09.255Z" }, - { url = "https://files.pythonhosted.org/packages/35/d4/bff2a3216900c4d564ca84fb2f4ca2811e2127b684edd90124e07b68732d/semgrep-1.168.0-cp310.cp311.cp312.cp313.cp314.py310.py311.py312.py313.py314-none-musllinux_1_2_aarch64.whl", hash = "sha256:d288699c4c056cc3d7cf82830e00f9c07fac33227c3a5290a2113bd1be63da46", size = 77644734, upload-time = "2026-06-24T19:39:12.826Z" }, - { url = "https://files.pythonhosted.org/packages/b1/7c/e10a59a82120eb2561281edbbb688419688d8c9b589ca9a32bd47968ae2e/semgrep-1.168.0-cp310.cp311.cp312.cp313.cp314.py310.py311.py312.py313.py314-none-musllinux_1_2_x86_64.whl", hash = "sha256:c4a664f4dda097fbdf657d64f79217e94c4d87502c16c991fac569f07aaecb8e", size = 75161809, upload-time = "2026-06-24T19:39:16.695Z" }, - { url = "https://files.pythonhosted.org/packages/eb/b4/3fc0345031d40f49354b1052ac4faf603dcb92019dacdc4b8fd4e3639b9b/semgrep-1.168.0-cp310.cp311.cp312.cp313.cp314.py310.py311.py312.py313.py314-none-win_amd64.whl", hash = "sha256:86e99b095b80492b6cfe5087bf4a3b0175a1132c9c9dcd25a96c3745cc58d85a", size = 56931035, upload-time = "2026-06-24T19:39:20.217Z" }, + { url = "https://files.pythonhosted.org/packages/24/53/c64cc34ce1c9a41d69638cf0ea41108fe0ae517cf38aaafb8e50efe88f6a/semgrep-1.169.0-cp310.cp311.cp312.cp313.cp314.py310.py311.py312.py313.py314-none-macosx_10_14_x86_64.whl", hash = "sha256:b8c776de8de61aeb59a5cd479276225a2325bf1195e4a4af1f9530e76bb5f827", size = 45013963, upload-time = "2026-07-10T16:50:39.515Z" }, + { url = "https://files.pythonhosted.org/packages/a8/be/723abdc06372373ebb40ffc922d97570c2e421f39173147e45805d438bf0/semgrep-1.169.0-cp310.cp311.cp312.cp313.cp314.py310.py311.py312.py313.py314-none-macosx_11_0_arm64.whl", hash = "sha256:41c366ab1ecd04b5e55c8e2b67e0dfa50a744d79eb09be962353a8b98242b878", size = 49033137, upload-time = "2026-07-10T16:50:42.63Z" }, + { url = "https://files.pythonhosted.org/packages/f5/b6/03559145888b9b99a411df90e54f321d9d012ef58dc541afd2e0a3916b45/semgrep-1.169.0-cp310.cp311.cp312.cp313.cp314.py310.py311.py312.py313.py314-none-manylinux_2_34_aarch64.whl", hash = "sha256:ffc783021040bba9784289bcedf8719d7f5c52c73eee3b8bd1814e21422837ac", size = 70906982, upload-time = "2026-07-10T16:50:45.681Z" }, + { url = "https://files.pythonhosted.org/packages/3c/c0/e76a28610aa5a0c2e3fb98cc3438009343013642e1d03563e5d4d68ae5c9/semgrep-1.169.0-cp310.cp311.cp312.cp313.cp314.py310.py311.py312.py313.py314-none-manylinux_2_34_x86_64.whl", hash = "sha256:48d899e7e31803fcbf69e69a9876a7a1ade2eddb4ef85828b6576eaa0b4940da", size = 68766411, upload-time = "2026-07-10T16:50:49.386Z" }, + { url = "https://files.pythonhosted.org/packages/5c/4c/b1a95e8eb5e57ba1f8a7a8e9febf2fcc80f61a118f431ad1302cc449b644/semgrep-1.169.0-cp310.cp311.cp312.cp313.cp314.py310.py311.py312.py313.py314-none-musllinux_1_2_aarch64.whl", hash = "sha256:ad0c8cb56f3e9ce5ac81795fc3395fd05d9a65726a2492d4ec64190ec5816911", size = 77653156, upload-time = "2026-07-10T16:50:52.769Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/12b3fbf368da3eb7ed5ce760bf002aea5b6404f9e42155d3904dd02708e6/semgrep-1.169.0-cp310.cp311.cp312.cp313.cp314.py310.py311.py312.py313.py314-none-musllinux_1_2_x86_64.whl", hash = "sha256:c5ffdf474a40302281af43cc2f2eefb31de5f46a6dbffcc2f4099638ed3cbfa2", size = 75167351, upload-time = "2026-07-10T16:50:56.469Z" }, + { url = "https://files.pythonhosted.org/packages/d8/6a/519c3b25dd3cb92659e9b97aa8070fe6d76093ebbb62030612f8e7d99622/semgrep-1.169.0-cp310.cp311.cp312.cp313.cp314.py310.py311.py312.py313.py314-none-win_amd64.whl", hash = "sha256:54336981cab97b95a5f06694b2f10a36f0af86738bdc9a2050fa1865d2e44d06", size = 56940387, upload-time = "2026-07-10T16:51:00.493Z" }, ] [[package]] @@ -1101,27 +1101,27 @@ wheels = [ [[package]] name = "ty" -version = "0.0.56" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/55/07/fb29aea5235b0aa8ecfc4d1cc6ddf9fba8b863d67d96c6d345694d644c43/ty-0.0.56.tar.gz", hash = "sha256:84d114dc3796361c0fc72945016eabd74d46b9ee64f198cb0e485719704681e5", size = 6050123, upload-time = "2026-07-01T16:44:56.036Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/48/bce79e7ca5c1cc529d3e0d37ddd1121aea4b68a4f749974ad1cc77161871/ty-0.0.56-py3-none-linux_armv6l.whl", hash = "sha256:186d4a53e15747c947e1ec3d7eec8e345d8e40a1ca10e634c585db52497e87dd", size = 11643066, upload-time = "2026-07-01T16:44:18.374Z" }, - { url = "https://files.pythonhosted.org/packages/80/d1/22555d8a1d719661f10050f3865d877bbf497da908961c75fe22217dd18a/ty-0.0.56-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:aae1a980fd9535da0469b7ba2b2e1b54a907743a5e0f442dd57eee9f5bfd034c", size = 11407487, upload-time = "2026-07-01T16:44:20.956Z" }, - { url = "https://files.pythonhosted.org/packages/cf/2d/b3b7a74ce8bc59ef48843ad80179bb0d9598bbd6cfc0d11d519bdf6b1352/ty-0.0.56-py3-none-macosx_11_0_arm64.whl", hash = "sha256:afd3058c0a6c5f241e814734f133008c93ee805f61c9cf4ce7412b8822b5d9ad", size = 10962270, upload-time = "2026-07-01T16:44:22.959Z" }, - { url = "https://files.pythonhosted.org/packages/64/ac/6c2fd7de0304a8a7218a756af74f7e62a5e8540fdb175e0a869e51042345/ty-0.0.56-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:058b52f7a823ac13aae3cae30809dd6b5145794b64d8478f9ef38c75d79b4483", size = 11471406, upload-time = "2026-07-01T16:44:25.327Z" }, - { url = "https://files.pythonhosted.org/packages/50/b6/11d861156861c03c7726b74558f9a0e0092661aff83a4fda1279df28c425/ty-0.0.56-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c66e00c1522add1f2bbdd2e45828c953b35c306b7bef03ec9169c75a63699a0", size = 11445612, upload-time = "2026-07-01T16:44:27.531Z" }, - { url = "https://files.pythonhosted.org/packages/fb/ba/09df108582090f3c0770ec4bc8675affed60248f6793a78d909be16211d9/ty-0.0.56-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40903d71c669a30691b5a5d5728056c7877a1bd6be4f233a38883a8b28cf34d7", size = 12093889, upload-time = "2026-07-01T16:44:29.548Z" }, - { url = "https://files.pythonhosted.org/packages/d7/f7/dbb4b4ccb69cd64c209ae55b1ab788ace8222c2bc1f6845be9e7cbedbf25/ty-0.0.56-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:63fe3947fe0c46c69a7d950e6832ee70a9ec17321fefbff3d2e3c20baf9e5bd0", size = 12666337, upload-time = "2026-07-01T16:44:31.586Z" }, - { url = "https://files.pythonhosted.org/packages/86/e9/73f903fe4a3d9ea02f26f57c1eb07e3b1029ec92b0e8c2364718893440e3/ty-0.0.56-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71a0c1a72f9854532e710e119b6871ffe4542c8a65146f1f65dcd78fecd885b4", size = 12280247, upload-time = "2026-07-01T16:44:33.637Z" }, - { url = "https://files.pythonhosted.org/packages/d6/90/cebd222495832f1a00dcd321ba25f3cab804221a4991b992c2178bec68ee/ty-0.0.56-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70d1665596494e24d8ebd198438872b5a56ec3cae5f2bcf6c673be797acc4e3c", size = 11991107, upload-time = "2026-07-01T16:44:36.122Z" }, - { url = "https://files.pythonhosted.org/packages/b7/07/8f7337a07250f42d975cdb6decf47fc5b421e6c7da5e3e7be1e85f63a7e5/ty-0.0.56-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:778f99e51558afc1dbbe48ee38ab6aae7b31390ed8c1a1ef1499b295e9f1e82f", size = 12298970, upload-time = "2026-07-01T16:44:38.243Z" }, - { url = "https://files.pythonhosted.org/packages/3c/b9/a52cd59034a48f5f18c6b155cc2cc36861d874b6d0af204b12c898024c3d/ty-0.0.56-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:867bc5708e0066bb4ff6c7db524bd5deea2676c62bfe71d3303138b3be850af0", size = 11425683, upload-time = "2026-07-01T16:44:40.473Z" }, - { url = "https://files.pythonhosted.org/packages/1d/2e/48e42d33357d52eefb695c0c3fcfc96879b73668a7447d1d1e0ad774fedc/ty-0.0.56-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a6012f4189c928edb330a37deb9930f982380bd4aa7c4b8e0428eec9651c7551", size = 11469258, upload-time = "2026-07-01T16:44:42.513Z" }, - { url = "https://files.pythonhosted.org/packages/d5/01/ad1b4138be1e3fa97863af3925aa2134f17a593240c35dc38c3429fb5ad1/ty-0.0.56-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8ee83de1a7ff4cc32837ec06134ce391d441bc5b35ecd8d3cfe053f120f3e4c1", size = 11758736, upload-time = "2026-07-01T16:44:44.567Z" }, - { url = "https://files.pythonhosted.org/packages/09/34/9d81967ff240eaa57e9249728ef7b7790747cf6d3c9a98ec86b2cfdcc8ee/ty-0.0.56-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:62619b3b0e2c6248ef30d3f0e2f2217ae9893040585be07f32324242f197cd6f", size = 12100242, upload-time = "2026-07-01T16:44:46.584Z" }, - { url = "https://files.pythonhosted.org/packages/c3/36/f51d4666d2de6cf33c1f3a1fcc4bb6b70b197dd6ceaa491eef71d78fe8e8/ty-0.0.56-py3-none-win32.whl", hash = "sha256:b30687bb5cd9729d34c889a289edf32770388d9bb05243e534e723fb45e0381b", size = 11093759, upload-time = "2026-07-01T16:44:49.171Z" }, - { url = "https://files.pythonhosted.org/packages/5e/b4/8fb5d4acfa4afb152245b20fa263069a7547bd1f8e4bfca4eda280c897d7/ty-0.0.56-py3-none-win_amd64.whl", hash = "sha256:ad4c8c47b6f4e3f9ed3fc0b1a5d650088d229e17dd8f63c1826d6bbe94cc3235", size = 12100327, upload-time = "2026-07-01T16:44:51.26Z" }, - { url = "https://files.pythonhosted.org/packages/b8/fc/6a183e71edde90d0c35c2303f23f7a45b6891d1a2c45daf7b8f869831e19/ty-0.0.56-py3-none-win_arm64.whl", hash = "sha256:57538f273d444a5f1293fa7860e967178afe3917611fc5eff16b64e1204fe0d6", size = 11538780, upload-time = "2026-07-01T16:44:53.8Z" }, +version = "0.0.58" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/09/4c/26c90732658903aeb1d289208f7b7b492fa21029e0c4d6c51bdd6f8f5e51/ty-0.0.58.tar.gz", hash = "sha256:8f22484174e65c630660a454bf81b80cae7a3a7e70479f19c170d6cd87949258", size = 6133665, upload-time = "2026-07-10T03:09:30.542Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/e1/5d1aa2a75829459834689f080e4be7a9d8828ce14b939ebed69161a35811/ty-0.0.58-py3-none-linux_armv6l.whl", hash = "sha256:47412850b6fbef61c42f244f6a51aa2f2c9e91f08cfbafd2d1e3730d2419d317", size = 11706915, upload-time = "2026-07-10T03:08:51.028Z" }, + { url = "https://files.pythonhosted.org/packages/96/e1/929eda9cc72a9afe39a03c76f946a503508d37343cc8ff2e64226afda105/ty-0.0.58-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:79deb7bb4e5b3a1eee6ab9abc724d6ce3559d4977982707f310a139ee11fc703", size = 11532079, upload-time = "2026-07-10T03:08:53.771Z" }, + { url = "https://files.pythonhosted.org/packages/07/43/ebc58b3fc7d86a7abba2829f1674f7d4ae3a08f9794c1f31b707950c871f/ty-0.0.58-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5a28af3187e661708a386d44a4fc32896a5f589fb07b734a11ab2f516e7572b7", size = 11092983, upload-time = "2026-07-10T03:08:56.025Z" }, + { url = "https://files.pythonhosted.org/packages/92/6e/9547dbb8e51e47749cfb721a02b4fc862f9a932fa0f66a34a4d6dc429bb1/ty-0.0.58-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21a6977e34bc362fb378add46e59d5d56331c1c36727e6904767217ca8479718", size = 11490492, upload-time = "2026-07-10T03:08:58.49Z" }, + { url = "https://files.pythonhosted.org/packages/d9/76/9f83b51b5e7795d6c8d76b4bb1bb7cebf4c10d609e846c02ab138e404556/ty-0.0.58-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3ac23e6bf6105ceca46632debf1b10b98125aaf60202aeae02f6abfeea242b3d", size = 11503696, upload-time = "2026-07-10T03:09:00.741Z" }, + { url = "https://files.pythonhosted.org/packages/47/9d/9fd48a0696c680f74e50f31cd54e524eeb58c97ea9bb1c3b8e04230ba215/ty-0.0.58-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bb6df6a8c6a21894807a49851370ec7fc64aa910296c78ada31db0ef19359112", size = 12158653, upload-time = "2026-07-10T03:09:02.998Z" }, + { url = "https://files.pythonhosted.org/packages/bc/ea/b5de845d2d8edae04d901c7585af7f04a057e18b26311f7fa4ca62b2da30/ty-0.0.58-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9df5847eebc026cde088b44420a03f7c6c169a7db747176bdf0a656eb1144713", size = 12723019, upload-time = "2026-07-10T03:09:05.291Z" }, + { url = "https://files.pythonhosted.org/packages/17/1c/54083b23eeff1e101f50b6df6a2c7f1e14b31abe0577c91bcae9e2c9395d/ty-0.0.58-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53a331a7f1f85872c810676a0f16096ef98c1b95c8c9a573fe7fde64d0a93e7c", size = 12275715, upload-time = "2026-07-10T03:09:07.564Z" }, + { url = "https://files.pythonhosted.org/packages/22/88/16925434b06faa49d36aa7e7508a6821ec6feacb429ca2fd80a3d52716b4/ty-0.0.58-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb0b05cd479fdcedc2e6781d376ee1a33569f37ae7f58357004635f615c4374c", size = 12033075, upload-time = "2026-07-10T03:09:10.222Z" }, + { url = "https://files.pythonhosted.org/packages/58/2b/b55708dd483982ae03d14da3667e3f0346cd384e11cca3dd3674a8b598c1/ty-0.0.58-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9a0012786077e5becbb6add9fe51eda1d4d36d249afd3ae6cd141d554af15ae3", size = 12367729, upload-time = "2026-07-10T03:09:12.338Z" }, + { url = "https://files.pythonhosted.org/packages/91/a3/99ad66652956408f7e9ac3db6b4a416199d773b6c073cf95b0eea126d340/ty-0.0.58-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4ede96d7f6d149156da254e784b062b817736b68d4c6d660555a3d02d96966fb", size = 11439798, upload-time = "2026-07-10T03:09:14.518Z" }, + { url = "https://files.pythonhosted.org/packages/ee/23/344ceed4fe02ed498711e1f4a47b6e311fb1e9c4fecc19d31894be7a3472/ty-0.0.58-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:47608e58f73901b989402e6f283249cda4c2314282ec368aa74ab61761c62bd5", size = 11512695, upload-time = "2026-07-10T03:09:16.852Z" }, + { url = "https://files.pythonhosted.org/packages/55/cf/3801831812c468f3fd0b3043a80f557a9aa90e6c27375763d7c3121e03f2/ty-0.0.58-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9757de17cc17e4c6bc26e18d4e26dea52ffee53d41a10d9087c6465e6ab12e2b", size = 11812253, upload-time = "2026-07-10T03:09:19.151Z" }, + { url = "https://files.pythonhosted.org/packages/7b/80/bce4f245787b77d1ec9feec7d9161eade5e01a77dbc132e016b24df83b0d/ty-0.0.58-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f3776d54c1d935fcb8f814bd58efba402c86c555f93e1144c59d087e7aa8b906", size = 12123918, upload-time = "2026-07-10T03:09:21.636Z" }, + { url = "https://files.pythonhosted.org/packages/46/00/bab3d6268e7e88c792bf7cdde81bd11b31aa587eaba75196502b729747c8/ty-0.0.58-py3-none-win32.whl", hash = "sha256:8f50ec0ac3b42baa4c75895dc367071dc86b00ab440d29fdc72c633286a94815", size = 11230897, upload-time = "2026-07-10T03:09:23.871Z" }, + { url = "https://files.pythonhosted.org/packages/9c/68/d9504c895864aaa84a840dce6ac7f8e681f6938be1882c8d4f60832dbe57/ty-0.0.58-py3-none-win_amd64.whl", hash = "sha256:de8847b3a65475ae4773bddd3126bfcf29f017e88967c4dcc9c75d743a4d3e5c", size = 12299376, upload-time = "2026-07-10T03:09:26.292Z" }, + { url = "https://files.pythonhosted.org/packages/26/04/c9847cb680b5fe8e1f7d7b483edd5cedcc29394496e7b8ed40d96be796ba/ty-0.0.58-py3-none-win_arm64.whl", hash = "sha256:7334bb38789878f60677f2eb9c1de4bfdf4583e2443790989c77da9b10fe0989", size = 11710495, upload-time = "2026-07-10T03:09:28.478Z" }, ] [[package]]