diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml index 6a28319..7e35cf3 100644 --- a/.github/workflows/audit.yml +++ b/.github/workflows/audit.yml @@ -7,11 +7,13 @@ on: - .github/workflows/audit.yml - "**/Cargo.toml" - "**/Cargo.lock" + - rust-toolchain.toml pull_request: paths: - .github/workflows/audit.yml - "**/Cargo.toml" - "**/Cargo.lock" + - rust-toolchain.toml schedule: - cron: "0 6 * * 1" # Monday at 6 AM UTC workflow_dispatch: diff --git a/AGENTS.md b/AGENTS.md index 434b138..f700bd7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -202,6 +202,9 @@ When user requests commit message generation: ### Rust +- The current MSRV and pinned contributor/CI toolchain are Rust 1.97.0. Keep + `Cargo.toml`, `rust-toolchain.toml`, and `clippy.toml` aligned when that + baseline changes deliberately. - Prefer borrowed APIs by default: take references (`&T`, `&mut T`, `&[T]`) as arguments and return borrowed views (`&T`, `&[T]`) when possible. Only take ownership or return `Vec`/allocated data when required. diff --git a/CITATION.cff b/CITATION.cff index 4f7b4db..9fa1f58 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -3,7 +3,7 @@ message: "If you use this software, please cite it as below." type: software title: "la-stack: Fast, stack-allocated linear algebra for fixed dimensions in Rust" version: 0.4.3 -date-released: 2026-06-04 +date-released: 2026-06-09 url: "https://github.com/acgetchell/la-stack" repository-code: "https://github.com/acgetchell/la-stack" identifiers: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 210381b..81f0844 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,7 +6,7 @@ clarity, and the fixed-dimension stack-allocation model. ## Getting Started -Install Rust through [rustup](https://rustup.rs/), Git, Python 3.14, +Install Rust 1.97.0 through [rustup](https://rustup.rs/), Git, Python 3.14, [`uv` 0.11.28](https://docs.astral.sh/uv/), and `jq`. Install the repository's pinned `just` version from its locked dependency graph: diff --git a/Cargo.toml b/Cargo.toml index fcbf98b..ba84c60 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ name = "la-stack" version = "0.4.3" edition = "2024" -rust-version = "1.96" +rust-version = "1.97.0" license = "BSD-3-Clause" description = "Fast, stack-allocated linear algebra for fixed dimensions" readme = "README.md" diff --git a/README.md b/README.md index b471fe5..38f162c 100644 --- a/README.md +++ b/README.md @@ -540,7 +540,7 @@ cargo run --features exact --example exact_solve_3x3 A short contributor workflow: -Install Rust through [rustup](https://rustup.rs/), Git, Python 3.14, +Install Rust 1.97.0 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: diff --git a/REFERENCES.md b/REFERENCES.md index 8fb08ec..e54134d 100644 --- a/REFERENCES.md +++ b/REFERENCES.md @@ -6,7 +6,8 @@ If you use this library in your research or project, please cite it using the in [CITATION.cff](CITATION.cff). This file contains structured citation metadata that can be processed by GitHub and other platforms. -A Zenodo DOI will be added for tagged releases. +Tagged releases are archived on Zenodo under the all-versions concept DOI +[10.5281/zenodo.18158926](https://doi.org/10.5281/zenodo.18158926). ## AI-Assisted Development Tools @@ -46,27 +47,29 @@ See `src/exact.rs` for the full architecture description. ### Exact linear system solve (hybrid Bareiss / BigRational) -`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 (`decompose_f64`) - -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 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. +`solve_exact()`, `solve_exact_f64()`, and `solve_exact_rounded_f64()` share the determinant +path's exact f64 decomposition and integer scaling. Matrix and RHS entries are decomposed via +IEEE 754 bit extraction [9]. Each collection is scaled independently to its own minimum +exponent, producing a `BigInt` matrix and RHS without inflating one side to accommodate the +other's range. 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 RHS. After back-substitution, multiplying by the exact power-of-two scale ratio +`2^(e_rhs − e_matrix)` recovers the solution to the original `A x = b` system. + +### f64 → integer decomposition (`decompose_proven_finite_f64`) + +Both the determinant and solve paths convert their finite-by-construction entries via +`decompose_proven_finite_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 exact-integer starting point. Solves and D ≥ 5 determinants +then apply Bareiss elimination; D ≤ 4 determinants use direct expansions. The test-only +fallible wrapper `decompose_f64` verifies rejection of non-finite raw scalars, while the +test-only `f64_to_big_rational` helper packages the same decomposition into a single +`BigRational`. See Goldberg [10] for background on IEEE 754 representation and exact rational +reconstruction. ### LDL^T factorization (symmetric SPD/PSD) diff --git a/clippy.toml b/clippy.toml index 6979f37..0894395 100644 --- a/clippy.toml +++ b/clippy.toml @@ -1,6 +1,6 @@ # Clippy configuration for the la-stack crate. # Keep lint behavior aligned with Cargo.toml and rust-toolchain.toml. -msrv = "1.96.0" +msrv = "1.97.0" # Lint levels are owned by Cargo.toml. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 3439dd0..60fb305 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -14,6 +14,10 @@ to crates.io. ## Conventions and environment +The current MSRV and pinned release-validation toolchain are Rust 1.97.0. Keep +`Cargo.toml`, `rust-toolchain.toml`, and `clippy.toml` aligned whenever a future +release deliberately changes that baseline. + Set these variables to avoid repeating the version string: ```bash diff --git a/docs/roadmap.md b/docs/roadmap.md index 85872e9..fb798e2 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -138,6 +138,10 @@ the small fixed-dimension API model. - [#155](https://github.com/acgetchell/la-stack/issues/155) - Investigate `Vector::dot` and `Vector::norm2_sq` performance against `nalgebra` and `faer`. +- [#174](https://github.com/acgetchell/la-stack/issues/174) - Raise the stable + Rust baseline to 1.97.0 and audit its new bit helpers. Retain the existing + primitive and `num-bigint` operations where the new helpers do not simplify + current hot paths or preserve benchmark performance. The goal is targeted profiling and implementation cleanup for operations where `vs_linalg` shows a meaningful peer-crate gap. Release scope should stay limited diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 3ca73be..825be0b 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,6 +1,6 @@ [toolchain] # Pin to MSRV as specified in Cargo.toml -channel = "1.96.0" +channel = "1.97.0" # Essential repository components. Keep checkout and CI setup lean; workflows # install additional targets or components when they need them. diff --git a/scripts/tests/test_archive_performance.py b/scripts/tests/test_archive_performance.py index 6f12900..2e56c4d 100644 --- a/scripts/tests/test_archive_performance.py +++ b/scripts/tests/test_archive_performance.py @@ -127,7 +127,7 @@ def _write_current_benchmark_tooling(worktree: Path) -> None: 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 / "rust-toolchain.toml").write_text('[toolchain]\nchannel = "1.97.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", @@ -590,17 +590,17 @@ def fake_run_safe(command: str, args: Sequence[str], cwd: Path | None = None, ** def test_benchmark_env_uses_current_repo_toolchain(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") + (tmp_path / "rust-toolchain.toml").write_text('[toolchain]\nchannel = "1.97.0"\n', encoding="utf-8") env = archive_performance._benchmark_env(tmp_path) assert env is not None - assert env["RUSTUP_TOOLCHAIN"] == "1.96.0" + assert env["RUSTUP_TOOLCHAIN"] == "1.97.0" def test_benchmark_env_respects_existing_toolchain_override(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("RUSTUP_TOOLCHAIN", "nightly") - (tmp_path / "rust-toolchain.toml").write_text('[toolchain]\nchannel = "1.96.0"\n', encoding="utf-8") + (tmp_path / "rust-toolchain.toml").write_text('[toolchain]\nchannel = "1.97.0"\n', encoding="utf-8") assert archive_performance._benchmark_env(tmp_path) is None @@ -643,7 +643,7 @@ def test_comparison_benchmark_env_preserves_flags_and_selects_v043_adapter( 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', + '[toolchain]\nchannel = "1.97.0"\n', encoding="utf-8", ) @@ -655,7 +655,7 @@ def test_comparison_benchmark_env_preserves_flags_and_selects_v043_adapter( 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" + assert current["RUSTUP_TOOLCHAIN"] == "1.97.0" def test_comparison_benchmark_env_extends_encoded_rustflags( @@ -1101,7 +1101,7 @@ def test_generate_report_generates_release_baseline_locally( # noqa: PLR0915 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") + (tmp_path / "rust-toolchain.toml").write_text('[toolchain]\nchannel = "1.97.0"\n', encoding="utf-8") current = tmp_path / "docs" / "PERFORMANCE.md" archive_dir = tmp_path / "docs" / "archive" / "performance" calls: list[RunnerCall] = [] @@ -1124,7 +1124,7 @@ 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 == "just" and args == ["bench-save-baseline", "v0.4.2"]: - assert kwargs["env"]["RUSTUP_TOOLCHAIN"] == "1.96.0" + assert kwargs["env"]["RUSTUP_TOOLCHAIN"] == "1.97.0" assert cwd is not None assert "bench-latest" in (cwd / "justfile").read_text(encoding="utf-8") criterion_dir = cwd / "target" / "criterion" @@ -1135,7 +1135,7 @@ def fake_run_safe(command: str, args: Sequence[str], cwd: Path | None = None, ** 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 kwargs["env"]["RUSTUP_TOOLCHAIN"] == "1.97.0" assert cwd is not None criterion_dir = cwd / "target" / "criterion" / "exact_d2" / "det_exact" assert not (criterion_dir / "new").exists() @@ -1202,7 +1202,7 @@ def fake_run_safe(command: str, args: Sequence[str], cwd: Path | None = 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") + (tmp_path / "rust-toolchain.toml").write_text('[toolchain]\nchannel = "1.97.0"\n', encoding="utf-8") output = tmp_path / "target" / "bench-reports" / "performance.md" calls: list[RunnerCall] = [] @@ -1224,14 +1224,14 @@ 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 == "just" and args == ["bench-save-baseline", "v0.4.2", "vs_linalg"]: - assert kwargs["env"]["RUSTUP_TOOLCHAIN"] == "1.96.0" + assert kwargs["env"]["RUSTUP_TOOLCHAIN"] == "1.97.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") if command == "just" and args == ["bench-vs-linalg-la-stack"]: - assert kwargs["env"]["RUSTUP_TOOLCHAIN"] == "1.96.0" + assert kwargs["env"]["RUSTUP_TOOLCHAIN"] == "1.97.0" if command == "uv": report = Path(args[args.index("--output") + 1]) report.write_text(_report("0.4.3", "v0.4.2"), encoding="utf-8") @@ -1547,7 +1547,7 @@ def fail_replace(src: Path, dst: Path) -> None: def test_generate_and_promote_uses_temp_worktree_and_current_diff(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") + (tmp_path / "rust-toolchain.toml").write_text('[toolchain]\nchannel = "1.97.0"\n', encoding="utf-8") current = tmp_path / "docs" / "PERFORMANCE.md" archive_dir = tmp_path / "docs" / "archive" / "performance" current.parent.mkdir(parents=True) @@ -1573,13 +1573,13 @@ def fake_run_git_with_input(args: Sequence[str], input_data: str | bytes, cwd: P def fake_run_safe(command: str, args: Sequence[str], cwd: Path | None = None, **kwargs: Any) -> SimpleNamespace: calls.append((command, tuple(args), cwd)) if command == "just" and args == ["bench-save-baseline", "v0.4.2"]: - assert kwargs["env"]["RUSTUP_TOOLCHAIN"] == "1.96.0" + assert kwargs["env"]["RUSTUP_TOOLCHAIN"] == "1.97.0" assert cwd is not None criterion_dir = cwd / "target" / "criterion" criterion_dir.mkdir(parents=True) (criterion_dir / "baseline.txt").write_text("baseline\n", encoding="utf-8") if command == "just" and args == ["bench-latest"]: - assert kwargs["env"]["RUSTUP_TOOLCHAIN"] == "1.96.0" + assert kwargs["env"]["RUSTUP_TOOLCHAIN"] == "1.97.0" if command == "uv": output = Path(args[args.index("--output") + 1]) output.write_text(_report("0.4.3", "v0.4.2"), encoding="utf-8") diff --git a/src/exact.rs b/src/exact.rs index 9515a6a..ae21675 100644 --- a/src/exact.rs +++ b/src/exact.rs @@ -2088,6 +2088,20 @@ mod tests { ); } + #[test] + fn decompose_f64_normalizes_mixed_subnormal_mantissa() { + let value = f64::from_bits(0x000C_0000_0000_0000); + assert!(value.is_subnormal()); + assert_eq!( + decompose_f64(value), + Ok(Component::NonZero { + mantissa: NonZeroU64::new(3).unwrap(), + exponent: -1024, + is_negative: false, + }) + ); + } + #[test] fn decompose_f64_power_of_two() { assert_eq!( @@ -2111,6 +2125,10 @@ mod tests { let value = f64::from_bits(bits); prop_assume!(value.is_finite()); + if let Ok(Component::NonZero { mantissa, .. }) = decompose_f64(value) { + prop_assert_eq!(mantissa.get() & 1, 1); + } + let exact = f64_to_big_rational(value); let reconstructed = exact_rational_to_finite_f64(&exact, None); @@ -3553,8 +3571,9 @@ mod tests { for &v in &values { 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(), + assert_eq!( + v.to_bits(), + back.to_bits(), "round-trip failed for {v}: got {back}" ); } diff --git a/src/matrix.rs b/src/matrix.rs index 2c209c1..32c6aaa 100644 --- a/src/matrix.rs +++ b/src/matrix.rs @@ -1882,6 +1882,26 @@ mod tests { assert_eq!(m.det(), Err(expected)); } + #[test] + fn det_errbound_d3_dense_reports_legitimate_overflow() { + // The unscaled matrix has determinant 54, so scaling every entry by + // 1.6e102 gives a determinant of approximately 2.21e308. + let scale = 1.6e102; + let m = black_box( + Matrix::<3>::try_from_rows([ + [4.0 * scale, scale, scale], + [scale, 4.0 * scale, scale], + [scale, scale, 4.0 * scale], + ]) + .unwrap(), + ); + let expected = + LaError::non_finite_computation_scalar(ArithmeticOperation::DeterminantErrorBound); + + assert_eq!(m.det_errbound(), Err(expected)); + assert_eq!(m.det_direct_with_errbound(), Err(expected)); + } + #[test] fn det_direct_d3_nonsingular() { // [[2,1,0],[0,3,1],[1,0,2]] → det = 2*(6-0) - 1*(0-1) + 0 = 13 @@ -1955,6 +1975,27 @@ mod tests { assert_eq!(m.det(), Err(expected)); } + #[test] + fn det_errbound_d4_dense_reports_legitimate_overflow() { + // The unscaled matrix has determinant 189, so scaling every entry by + // 3.2e76 gives a determinant of approximately 1.98e308. + let scale = 3.2e76; + let m = black_box( + Matrix::<4>::try_from_rows([ + [4.0 * scale, scale, scale, scale], + [scale, 4.0 * scale, scale, scale], + [scale, scale, 4.0 * scale, scale], + [scale, scale, scale, 4.0 * scale], + ]) + .unwrap(), + ); + let expected = + LaError::non_finite_computation_scalar(ArithmeticOperation::DeterminantErrorBound); + + assert_eq!(m.det_errbound(), Err(expected)); + assert_eq!(m.det_direct_with_errbound(), Err(expected)); + } + #[test] fn det_direct_d4_skips_zero_coefficient_cofactors_that_would_overflow() { let m = black_box( diff --git a/tests/exact_bench_config.rs b/tests/exact_bench_config.rs index 501c32f..7d7498e 100644 --- a/tests/exact_bench_config.rs +++ b/tests/exact_bench_config.rs @@ -139,6 +139,22 @@ gen_exact_benchmark_fixture_tests!(3); gen_exact_benchmark_fixture_tests!(4); gen_exact_benchmark_fixture_tests!(5); +macro_rules! gen_det_sign_exact_baseline_filter_path_tests { + ($d:literal, $expected:literal) => { + paste! { + #[test] + fn []() { + assert_eq!(fast_filter_is_conclusive(baseline_input::<$d>()), $expected); + } + } + }; +} + +gen_det_sign_exact_baseline_filter_path_tests!(2, true); +gen_det_sign_exact_baseline_filter_path_tests!(3, true); +gen_det_sign_exact_baseline_filter_path_tests!(4, true); +gen_det_sign_exact_baseline_filter_path_tests!(5, false); + #[test] fn exact_adversarial_benchmark_fixtures_are_correct() { for input in [near_singular_3x3_input(), large_entries_3x3_input()] { @@ -149,12 +165,7 @@ fn exact_adversarial_benchmark_fixtures_are_correct() { } #[test] -fn det_sign_exact_benchmark_filter_paths_are_stable() { - assert!(fast_filter_is_conclusive(baseline_input::<2>())); - assert!(fast_filter_is_conclusive(baseline_input::<3>())); - assert!(fast_filter_is_conclusive(baseline_input::<4>())); - assert!(!fast_filter_is_conclusive(baseline_input::<5>())); - +fn det_sign_exact_adversarial_benchmark_filter_paths_are_stable() { assert!(!fast_filter_is_conclusive(near_singular_3x3_input())); assert!(!fast_filter_is_conclusive(large_entries_3x3_input())); assert!(fast_filter_is_conclusive(hilbert_input::<4>()));