Skip to content

Commit db2fad5

Browse files
committed
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
1 parent 4ac5af9 commit db2fad5

45 files changed

Lines changed: 2529 additions & 597 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/dependabot.yml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,19 @@ updates:
2828
dependencies:
2929
patterns:
3030
- "*"
31+
32+
# Enable version updates for Python development dependencies locked by uv
33+
- package-ecosystem: "uv"
34+
directory: "/"
35+
schedule:
36+
interval: "weekly"
37+
cooldown:
38+
default-days: 7
39+
open-pull-requests-limit: 10
40+
labels:
41+
- "dependencies"
42+
- "python"
43+
groups:
44+
dependencies:
45+
patterns:
46+
- "*"

.github/workflows/benchmarks.yml

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ on:
1818
- "benches/**"
1919
- "tests/exact_bench_config.rs"
2020
- "tests/vs_linalg_inputs.rs"
21+
- ".config/nextest.toml"
2122
- "Cargo.toml"
2223
- "Cargo.lock"
2324
- "justfile"
@@ -31,6 +32,7 @@ on:
3132
- "benches/**"
3233
- "tests/exact_bench_config.rs"
3334
- "tests/vs_linalg_inputs.rs"
35+
- ".config/nextest.toml"
3436
- "Cargo.toml"
3537
- "Cargo.lock"
3638
- "justfile"
@@ -67,10 +69,30 @@ jobs:
6769
cache: true
6870
cache-bin: false
6971

72+
- name: Read just version
73+
id: just_version
74+
run: |
75+
version="$(grep '^just_version :=' justfile | cut -d '"' -f 2)"
76+
echo "version=$version" >> "$GITHUB_OUTPUT"
77+
78+
- name: Install just
79+
uses: taiki-e/cache-cargo-install-action@417450f3c33ee20393705369577571770643d4c7 # v3.0.7
80+
with:
81+
tool: just@${{ steps.just_version.outputs.version }}
82+
83+
- name: Read cargo-nextest version
84+
id: cargo_nextest_version
85+
run: |
86+
version="$(just --evaluate cargo_nextest_version)"
87+
echo "version=$version" >> "$GITHUB_OUTPUT"
88+
89+
- name: Install cargo-nextest
90+
uses: taiki-e/cache-cargo-install-action@417450f3c33ee20393705369577571770643d4c7 # v3.0.7
91+
with:
92+
tool: cargo-nextest@${{ steps.cargo_nextest_version.outputs.version }}
93+
7094
- name: Validate benchmark inputs
71-
run: >
72-
cargo test --locked --features bench,exact
73-
--test vs_linalg_inputs --test exact_bench_config
95+
run: just test-bench-inputs
7496

7597
# ── PR: find and download the latest main baseline ──────────────
7698
- name: Find latest main baseline

.github/workflows/codeql.yml

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,6 @@ jobs:
3333
with:
3434
persist-credentials: false
3535

36-
- name: Install Rust toolchain
37-
if: matrix.language == 'rust'
38-
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0
39-
with:
40-
cache: true
41-
cache-bin: false
42-
# toolchain, components, etc. are specified in rust-toolchain.toml
43-
4436
- name: Initialize CodeQL
4537
if: matrix.language != 'rust'
4638
uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0

.github/workflows/release-benchmarks.yml

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ name: Release Benchmarks
55
# or save dependency caches.
66

77
permissions:
8-
contents: write
8+
contents: read
99

1010
on:
1111
release:
@@ -24,6 +24,8 @@ jobs:
2424
release-baseline:
2525
runs-on: ubuntu-latest
2626
timeout-minutes: 60
27+
outputs:
28+
release-asset: ${{ steps.package-baseline.outputs.asset }}
2729

2830
steps:
2931
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
@@ -36,10 +38,17 @@ jobs:
3638
with:
3739
cache: false
3840

41+
- name: Install benchmark validation tools
42+
run: |
43+
set -euo pipefail
44+
45+
just_version="$(grep '^just_version :=' justfile | cut -d '"' -f 2)"
46+
cargo install --locked just --version "$just_version"
47+
nextest_version="$(just --evaluate cargo_nextest_version)"
48+
cargo install --locked cargo-nextest --version "$nextest_version"
49+
3950
- name: Validate benchmark inputs
40-
run: >
41-
cargo test --locked --features bench,exact
42-
--test vs_linalg_inputs --test exact_bench_config
51+
run: just test-bench-inputs
4352

4453
- name: Save release Criterion baseline
4554
env:
@@ -69,19 +78,32 @@ jobs:
6978
retention-days: 30
7079
if-no-files-found: error
7180

81+
publish-baseline:
82+
needs: release-baseline
83+
permissions:
84+
contents: write
85+
runs-on: ubuntu-latest
86+
timeout-minutes: 10
87+
88+
steps:
89+
- name: Download release baseline
90+
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
91+
with:
92+
name: bench-baseline-${{ github.event.release.tag_name }}
93+
7294
- name: Attach baseline to GitHub Release
7395
env:
7496
GH_TOKEN: ${{ github.token }}
7597
RELEASE_TAG: ${{ github.event.release.tag_name }}
76-
RELEASE_ASSET: ${{ steps.package-baseline.outputs.asset }}
98+
RELEASE_ASSET: ${{ needs.release-baseline.outputs.release-asset }}
7799
run: |
78100
set -euo pipefail
79101
80102
gh release upload "$RELEASE_TAG" "$RELEASE_ASSET" --clobber
81103
82104
- name: Release baseline summary
83105
env:
84-
RELEASE_ASSET: ${{ steps.package-baseline.outputs.asset }}
106+
RELEASE_ASSET: ${{ needs.release-baseline.outputs.release-asset }}
85107
run: |
86108
set -euo pipefail
87109

.github/workflows/rust-clippy.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@
33

44
name: "Clippy Security Analysis"
55

6+
concurrency:
7+
group: >-
8+
clippy-${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.ref }}
9+
cancel-in-progress: true
10+
611
on:
712
pull_request:
813
branches: ["main"]

AGENTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -309,8 +309,8 @@ just examples # Run all examples
309309
- Format: `cargo fmt` (or `just fmt`)
310310
- Integration tests: `just test-integration`
311311
- Benchmark-input smoke tests: `just test-bench-inputs`
312-
- Lint (Clippy): `cargo clippy --all-targets --all-features -- -D warnings` (or `just clippy`)
313-
- Lint (Clippy, exact feature): `cargo clippy --features exact --all-targets -- -D warnings` (or `just clippy-exact`)
312+
- Lint (Clippy, canonical default and all-feature passes): `just clippy`
313+
- Lint (Clippy, focused exact-feature pass): `just clippy-exact`
314314
- Lint/validate: `just check`
315315
- Cargo manifest/lockfile synchronization: `just cargo-lock-check`
316316
- Unused dependency check: `just unused-deps` (uses `cargo-machete`)

CONTRIBUTING.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@ clarity, and the fixed-dimension stack-allocation model.
77
## Getting Started
88

99
Install Rust through [rustup](https://rustup.rs/), Git, Python 3.14,
10-
[uv](https://docs.astral.sh/uv/), and `just`. Install `just` from its locked
11-
dependency graph:
10+
[`uv` 0.11.28](https://docs.astral.sh/uv/), and `jq`. Install the repository's
11+
pinned `just` version from its locked dependency graph:
1212

1313
```bash
14-
cargo install --locked just
14+
cargo install --locked just --version 1.56.0
1515
```
1616

1717
Set up the remaining development tools and validate the checkout:

README.md

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
[![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.18158926.svg)](https://doi.org/10.5281/zenodo.18158926)
44
[![Crates.io](https://img.shields.io/crates/v/la-stack.svg)](https://crates.io/crates/la-stack)
55
[![Downloads](https://img.shields.io/crates/d/la-stack.svg)](https://crates.io/crates/la-stack)
6-
[![License](https://img.shields.io/crates/l/la-stack.svg)](./LICENSE)
6+
[![License](https://img.shields.io/crates/l/la-stack.svg)](https://github.com/acgetchell/la-stack/blob/v0.4.3/LICENSE)
77
[![Docs.rs](https://docs.rs/la-stack/badge.svg)](https://docs.rs/la-stack)
88
[![CI](https://github.com/acgetchell/la-stack/actions/workflows/ci.yml/badge.svg)](https://github.com/acgetchell/la-stack/actions/workflows/ci.yml)
99
[![rust-clippy analyze][clippy-badge]][clippy-workflow]
@@ -328,26 +328,25 @@ filter and uses fraction-free Bareiss elimination in `BigInt`.
328328
Because `Matrix` stores only finite entries, arithmetic range failures in the
329329
filter are inconclusive rather than errors and the exact fallback is total.
330330

331-
### Adaptive precision with `det_errbound()`
331+
### Adaptive precision with `det_direct_with_errbound()`
332332

333-
`det_errbound()` returns the conservative absolute error bound used by the fast
334-
filter when the relative-error analysis is valid. It returns `None` when a
335-
D ≤ 4 computation may be affected by gradual underflow, as well as for
336-
unsupported D ≥ 5 dimensions. This method does NOT require the `exact` feature
337-
— it uses pure f64 arithmetic and is available by default. This enables
338-
building custom adaptive-precision logic for geometric predicates:
333+
`det_direct_with_errbound()` returns a closed-form determinant together with
334+
the conservative absolute error bound used by the fast filter, computed from
335+
one shared traversal. It returns `None` when a D ≤ 4 computation may be
336+
affected by gradual underflow, as well as for unsupported D ≥ 5 dimensions.
337+
This method does NOT require the `exact` feature — it uses pure f64 arithmetic
338+
and is available by default. Use `det_errbound()` when only the bound is needed.
339+
The paired API enables custom adaptive-precision logic for geometric predicates:
339340

340341
```rust,ignore
341342
use la_stack::prelude::*;
342343
343344
fn adaptive_det_sign<const D: usize>(
344345
matrix: &Matrix<D>,
345346
) -> DeterminantSign {
346-
if let (Ok(Some(bound)), Ok(Some(det))) =
347-
(matrix.det_errbound(), matrix.det_direct())
348-
{
349-
if det.abs() > bound {
350-
return if det > 0.0 {
347+
if let Ok(Some(estimate)) = matrix.det_direct_with_errbound() {
348+
if estimate.determinant().abs() > estimate.absolute_error_bound() {
349+
return if estimate.determinant() > 0.0 {
351350
DeterminantSign::Positive
352351
} else {
353352
DeterminantSign::Negative
@@ -422,6 +421,7 @@ out of the common prelude.
422421
|---|---|---|---|
423422
| `Vector<D>` | `[f64; D]` | Finite fixed-length vector for input and computation | `try_new`, `as_array`, `into_array`, `dot`, `norm2_sq` |
424423
| `Matrix<D>` | `[[f64; D]; D]` | Finite square matrix for input and computation | See below |
424+
| `DeterminantWithErrorBound` | two private `f64` fields | Paired direct determinant and certified absolute bound | `determinant`, `absolute_error_bound` |
425425
| `Lu<D>` | `Matrix<D>` + pivot array | Factorization for solves/det | `solve`, `det` |
426426
| `Ldlt<D>` | `Matrix<D>` | Factorization for symmetric SPD/PSD solves/det | `solve`, `det` |
427427
| `Tolerance` | finite non-negative `f64` | Validated numerical threshold | `try_new`, `get` |
@@ -431,7 +431,7 @@ out of the common prelude.
431431
Storage shown above reflects the intentional `f64` scalar model.
432432

433433
`Matrix<D>` key methods: `as_rows`, `into_rows`, `lu`, `ldlt`, `det`,
434-
`det_direct`, `det_errbound`,
434+
`det_direct`, `det_direct_with_errbound`, `det_errbound`,
435435
`det_exact`¹, `det_exact_f64`¹, `det_exact_rounded_f64`¹, `det_sign_exact`¹,
436436
`solve_exact`¹, `solve_exact_f64`¹, `solve_exact_rounded_f64`¹.
437437
Matrix and vector constructors validate non-finite inputs at public API
@@ -469,7 +469,7 @@ breaking callers.
469469
Raw data:
470470
[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)
471471
Historical provenance status:
472-
[docs/assets/bench/vs_linalg_lu_solve_median.provenance.json](docs/assets/bench/vs_linalg_lu_solve_median.provenance.json)
472+
[docs/assets/bench/vs_linalg_lu_solve_median.provenance.json][benchmark-provenance]
473473

474474
Representative benchmark: `lu_solve` factors the matrix and solves one
475475
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
540540

541541
A short contributor workflow:
542542

543+
Install Rust through [rustup](https://rustup.rs/), Git, Python 3.14,
544+
[`uv` 0.11.28](https://docs.astral.sh/uv/), and `jq`. Then install the pinned
545+
`just` release from its locked dependency graph:
546+
543547
```bash
544-
cargo install --locked just
548+
cargo install --locked just --version 1.56.0
545549
just setup # install/verify dev tools + sync Python deps
546550
just check # lint/validate (non-mutating)
547551
just fix # apply auto-fixes (mutating)
@@ -585,10 +589,11 @@ for the repository's AI-assisted development note.
585589

586590
## 📄 License
587591

588-
BSD 3-Clause License. See [LICENSE](./LICENSE).
592+
BSD 3-Clause License. See [LICENSE](https://github.com/acgetchell/la-stack/blob/v0.4.3/LICENSE).
589593

590594
[audit-badge]: https://github.com/acgetchell/la-stack/actions/workflows/audit.yml/badge.svg
591595
[audit-workflow]: https://github.com/acgetchell/la-stack/actions/workflows/audit.yml
596+
[benchmark-provenance]: https://github.com/acgetchell/la-stack/blob/v0.4.3/docs/assets/bench/vs_linalg_lu_solve_median.provenance.json
592597
[clippy-badge]: https://github.com/acgetchell/la-stack/actions/workflows/rust-clippy.yml/badge.svg
593598
[clippy-workflow]: https://github.com/acgetchell/la-stack/actions/workflows/rust-clippy.yml
594599
[lu-solve-benchmark]: https://raw.githubusercontent.com/acgetchell/la-stack/v0.4.3/docs/assets/bench/vs_linalg_lu_solve_median.svg

benches/common/exact.rs

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,86 @@ fn assert_rounded_scalar(actual: Result<f64, LaError>, exact: &BigRational) {
493493
}
494494
}
495495

496+
/// Check a floating-point determinant against an independent exact oracle.
497+
fn assert_approximate_determinant(actual: f64, exact: &BigRational, operation: &str) {
498+
assert!(
499+
actual.is_finite(),
500+
"{operation} produced a non-finite result"
501+
);
502+
let Some(expected) = exact.to_f64() else {
503+
panic!("{operation} oracle does not round to binary64");
504+
};
505+
assert!(
506+
expected.is_finite(),
507+
"{operation} oracle rounds outside finite binary64"
508+
);
509+
510+
let scale = expected.abs().max(1.0);
511+
let tolerance = 1024.0 * f64::EPSILON * scale;
512+
assert!(
513+
(actual - expected).abs() <= tolerance,
514+
"{operation} result {actual:?} differs from exact-oracle rounding {expected:?} by more than {tolerance:?}",
515+
);
516+
}
517+
518+
/// Validate the floating-point determinant operations used by Criterion.
519+
///
520+
/// This runs during setup, outside timed closures. The deterministic `det` and
521+
/// `det_direct` results are compared with the independent Leibniz oracle; on
522+
/// current revisions the combined direct result is additionally checked
523+
/// against its certified absolute bound.
524+
///
525+
/// # Panics
526+
///
527+
/// Panics if either floating-point operation fails, falls outside its documented
528+
/// dimension, or disagrees with the independent exact oracle.
529+
pub fn validate_f64_determinant_benchmarks<const D: usize>(input: &ValidatedExactInput<D>) {
530+
let exact = determinant_leibniz(input.matrix());
531+
let determinant = require_ok(input.matrix().det(), "f64 determinant oracle check");
532+
assert_approximate_determinant(determinant, &exact, "f64 determinant");
533+
534+
let direct = require_ok(
535+
input.matrix().det_direct(),
536+
"direct f64 determinant oracle check",
537+
);
538+
if D <= 4 {
539+
let Some(direct) = direct else {
540+
panic!("det_direct must support benchmark dimension {D}");
541+
};
542+
assert_approximate_determinant(direct, &exact, "direct f64 determinant");
543+
544+
#[cfg(not(la_stack_v0_4_3_api))]
545+
{
546+
let estimate = require_ok(
547+
input.matrix().det_direct_with_errbound(),
548+
"combined direct determinant oracle check",
549+
);
550+
let Some(estimate) = estimate else {
551+
panic!("the baseline fixture must have a certified D={D} determinant bound");
552+
};
553+
assert_eq!(estimate.determinant().to_bits(), direct.to_bits());
554+
let observed_error = (rational_from_f64(direct) - &exact).abs();
555+
let certified_bound = rational_from_f64(estimate.absolute_error_bound());
556+
assert!(
557+
observed_error <= certified_bound,
558+
"direct determinant error {observed_error} exceeds certified bound {certified_bound}",
559+
);
560+
}
561+
} else {
562+
assert!(direct.is_none(), "det_direct unexpectedly supports D={D}");
563+
564+
#[cfg(not(la_stack_v0_4_3_api))]
565+
assert!(
566+
require_ok(
567+
input.matrix().det_direct_with_errbound(),
568+
"combined direct determinant scope check",
569+
)
570+
.is_none(),
571+
"combined direct determinant unexpectedly supports D={D}",
572+
);
573+
}
574+
}
575+
496576
/// Verify `A · x = b` exactly using independently reconstructed binary64 inputs.
497577
fn assert_exact_residual<const D: usize>(input: &ExactInput<D>, solution: &[BigRational; D]) {
498578
for row in 0..D {

0 commit comments

Comments
 (0)