Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/audit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion CITATION.cff
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
47 changes: 25 additions & 22 deletions REFERENCES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion clippy.toml
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions docs/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion rust-toolchain.toml
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
30 changes: 15 additions & 15 deletions scripts/tests/test_archive_performance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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",
)

Expand All @@ -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(
Expand Down Expand Up @@ -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] = []
Expand All @@ -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"
Expand All @@ -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()
Expand Down Expand Up @@ -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] = []

Expand All @@ -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")
Expand Down Expand Up @@ -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)
Expand All @@ -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")
Expand Down
23 changes: 21 additions & 2 deletions src/exact.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand All @@ -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);

Expand Down Expand Up @@ -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}"
);
}
Expand Down
41 changes: 41 additions & 0 deletions src/matrix.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading