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
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,30 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- Guard README dependency snippets [`7137fee`](https://github.com/acgetchell/la-stack/commit/7137fee16ab33e08f4dc6a60e02417e3e7c4e020)

- Add a generic docs-version sync check that compares Markdown dependency
snippets against the Cargo package name and version

- Run the docs-version check from the repository Semgrep policy lane
- Refresh README determinant examples with explicit fallible handling and
hidden doctest mirrors

- Update CI uv pins to 0.11.19

### Documentation

- Sync citation metadata for v0.4.2 [`f473ec5`](https://github.com/acgetchell/la-stack/commit/f473ec50946e5f668e9ad9a2d978e499dcb10f04)

- Update CITATION.cff with the v0.4.2 version and release date.
- Align the Python utility package metadata and lockfile with the crate release.
- Add citation metadata validation to the release checklist and config lint flow.
- Include CITATION.cff in YAML/CFF formatting checks.

## [0.4.2] - 2026-06-04

### Added
Expand Down Expand Up @@ -78,6 +102,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- State that LU and LDLT solve_vec use floating-point substitution without a certified absolute rounding-error bound.
- Clarify that inf_norm reports NonFinite for unchecked stored NaN/∞ as well as row-sum overflow.
- Exercise the unchecked finite-proof fixture path directly in exact tests.
- Update v0.4.2 release notes [`7e11f93`](https://github.com/acgetchell/la-stack/commit/7e11f930b94bbba99c1c426e68a515bbefb8c489)

### Fixed

Expand Down Expand Up @@ -626,6 +651,7 @@ Older releases are archived by minor series:
- [0.2.x](docs/archive/changelog/0.2.md)
- [0.1.x](docs/archive/changelog/0.1.md)

[Unreleased]: https://github.com/acgetchell/la-stack/compare/v0.4.2...HEAD
[0.4.2]: https://github.com/acgetchell/la-stack/compare/v0.4.1...v0.4.2
[0.4.1]: https://github.com/acgetchell/la-stack/compare/v0.4.0...v0.4.1
[0.4.0]: https://github.com/acgetchell/la-stack/compare/v0.3.0...v0.4.0
9 changes: 7 additions & 2 deletions scripts/check_docs_version_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import os
import re
import sys
import tomllib
Expand Down Expand Up @@ -68,12 +69,16 @@ def _read_cargo_package_info(cargo_toml: Path) -> PackageInfo:


def _iter_markdown_files(root: Path) -> list[Path]:
return sorted(path for path in root.rglob("*.md") if path.is_file() and not (set(path.relative_to(root).parts) & SKIP_DIRS))
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"))
return sorted(markdown_files)


def _dependency_regex(package_name: str) -> re.Pattern[str]:
escaped_name = re.escape(package_name)
return re.compile(rf'(?<![\w.-]){escaped_name}\s*=\s*(?:"(?P<plain>[^"]+)"|\{{\s*version\s*=\s*"(?P<table>[^"]+)")')
return re.compile(rf'(?<![\w.-]){escaped_name}\s*=\s*(?:"(?P<plain>[^"]+)"|\{{[^}}]*version\s*=\s*"(?P<table>[^"]+)"[^}}]*\}})')


def _dependency_snippets(path: Path, package_name: str) -> list[DependencySnippet]:
Expand Down
38 changes: 36 additions & 2 deletions scripts/tests/test_check_docs_version_sync.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
from __future__ import annotations

from typing import TYPE_CHECKING

import check_docs_version_sync

if TYPE_CHECKING:
from pathlib import Path


def test_find_version_mismatches_accepts_matching_dependency_snippets(tmp_path) -> None:
def test_find_version_mismatches_accepts_matching_dependency_snippets(tmp_path: Path) -> None:
(tmp_path / "Cargo.toml").write_text(
"\n".join(
[
Expand All @@ -28,7 +33,7 @@ def test_find_version_mismatches_accepts_matching_dependency_snippets(tmp_path)
assert check_docs_version_sync.find_version_mismatches(tmp_path) == []


def test_find_version_mismatches_reports_stale_dependency_snippets(tmp_path) -> None:
def test_find_version_mismatches_reports_stale_dependency_snippets(tmp_path: Path) -> None:
(tmp_path / "Cargo.toml").write_text(
"\n".join(
[
Expand All @@ -54,3 +59,32 @@ def test_find_version_mismatches_reports_stale_dependency_snippets(tmp_path) ->
assert mismatches[0].snippet.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",
)
docs = tmp_path / "docs"
docs.mkdir()
install_doc = docs / "install.md"
install_doc.write_text(
'other-crate = { features = ["exact"], version = "1.2.2" }\n',
encoding="utf-8",
)

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].package.name == "other-crate"
assert mismatches[0].package.version == "1.2.3"
141 changes: 104 additions & 37 deletions src/exact.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@
//! `e - e_min`), and Bareiss elimination runs 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)`.
//! `bareiss_det` wraps this with `bigint_exp_to_bigrational` to reconstruct
//! a reduced `BigRational`; `det_sign_exact` reads the sign directly from
//! `det_int` (the scale factor is always positive).
//! `det_exact` wraps this with `bigint_exp_to_bigrational` to reconstruct a
//! reduced `BigRational`; `det_exact_f64` converts the same pair directly to
//! `f64`; and `det_sign_exact` reads the sign directly from `det_int` (the
//! scale factor is always positive).
//!
//! `det_sign_exact` adds a two-stage adaptive-precision optimisation inspired
//! by Shewchuk's robust geometric predicates:
Expand Down Expand Up @@ -142,7 +143,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;
let reduce = u32::try_from(reduce).unwrap_or(u32::MAX);
#[allow(clippy::cast_possible_truncation)]
let reduce = reduce as u32;
let remaining_abs = exp_abs - reduce;
exp = match remaining_abs {
0 => 0,
Expand All @@ -158,6 +160,31 @@ fn bigint_exp_to_bigrational(mut value: BigInt, mut exp: i32) -> BigRational {
}
}

/// Convert a `BigInt × 2^exp` determinant pair to finite `f64` without first
/// reducing a public `BigRational` determinant value.
fn bigint_exp_to_finite_f64(value: BigInt, exp: i32) -> Result<f64, LaError> {
if value == BigInt::from(0) {
return Ok(0.0);
}

let exact = if exp >= 0 {
BigRational::new_raw(value << exp.cast_unsigned(), BigInt::from(1u32))
} else {
BigRational::new_raw(value, BigInt::from(1u32) << exp.unsigned_abs())
};

let Some(val) = exact.to_f64() else {
cold_path();
return Err(LaError::Overflow { index: None });
};
if val.is_finite() {
Ok(val)
} else {
cold_path();
Err(LaError::Overflow { index: None })
}
}

// -----------------------------------------------------------------------
// Shared integer-Bareiss primitives
// -----------------------------------------------------------------------
Expand Down Expand Up @@ -353,6 +380,28 @@ fn bareiss_forward_eliminate<const D: usize>(
BareissResult::Upper { sign }
}

/// 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`].
///
/// # Errors
/// Returns [`LaError::DeterminantScaleOverflow`] if `D` cannot fit in the
/// internal `i32` exponent multiplier or if `D × e_min` overflows `i32`.
fn determinant_scale_exp<const D: usize>(e_min: i32) -> Result<i32, LaError> {
let Ok(d_i32) = i32::try_from(D) else {
cold_path();
return Err(LaError::determinant_scale_overflow(D, e_min));
};
let Some(total_exp) = e_min.checked_mul(d_i32) else {
cold_path();
return Err(LaError::determinant_scale_overflow(D, e_min));
};
Ok(total_exp)
}

/// Compute the exact determinant using integer-only Bareiss elimination.
///
/// Returns `(det_int, scale_exp)` where the true determinant is
Expand Down Expand Up @@ -393,16 +442,7 @@ fn bareiss_det_int_finite<const D: usize>(m: &FiniteMatrix<D>) -> Result<(BigInt
a[D - 1][D - 1].clone()
};

// det(original) = det_int × 2^(D × e_min)
let Ok(d_i32) = i32::try_from(D) else {
cold_path();
return Err(LaError::unsupported_dimension(D, i32::MAX as usize));
};
let Some(total_exp) = e_min.checked_mul(d_i32) else {
cold_path();
return Err(LaError::Overflow { index: None });
};

let total_exp = determinant_scale_exp::<D>(e_min)?;
Ok((det_int, total_exp))
}

Expand Down Expand Up @@ -489,21 +529,15 @@ impl<const D: usize> FiniteMatrix<D> {
/// Exact determinant converted to a finite `f64`.
///
/// # Errors
/// Returns [`LaError::DeterminantScaleOverflow`] if determinant scaling
/// overflows the internal exponent representation.
///
/// Returns [`LaError::Overflow`] if the exact determinant cannot be
/// represented as a finite `f64`.
#[inline]
fn det_exact_f64(&self) -> Result<f64, LaError> {
let exact = self.det_exact()?;
let Some(val) = exact.to_f64() else {
cold_path();
return Err(LaError::Overflow { index: None });
};
if val.is_finite() {
Ok(val)
} else {
cold_path();
Err(LaError::Overflow { index: None })
}
let (det_int, total_exp) = bareiss_det_int_finite(self)?;
bigint_exp_to_finite_f64(det_int, total_exp)
}

/// Exact linear solve for finite inputs.
Expand Down Expand Up @@ -548,6 +582,9 @@ impl<const D: usize> FiniteMatrix<D> {
/// Returns [`LaError::NonFinite`] if a direct determinant or error-bound
/// computation detects a non-finite condition that is not an inconclusive
/// scalar overflow.
///
/// Returns [`LaError::DeterminantScaleOverflow`] if determinant scaling
/// overflows the internal exponent representation.
#[inline]
fn det_sign_exact(&self) -> Result<i8, LaError> {
match (self.det_direct(), self.det_errbound()) {
Expand Down Expand Up @@ -607,11 +644,8 @@ impl<const D: usize> Matrix<D> {
/// # Errors
/// Returns [`LaError::NonFinite`] if stored matrix entries are NaN or infinity.
///
/// Returns [`LaError::Overflow`] if determinant scaling overflows the internal
/// exponent representation.
///
/// Returns [`LaError::UnsupportedDimension`] if `D` cannot be represented in
/// the internal determinant exponent calculation.
/// Returns [`LaError::DeterminantScaleOverflow`] if determinant scaling
/// overflows the internal exponent representation.
#[inline]
pub fn det_exact(&self) -> Result<BigRational, LaError> {
FiniteMatrix::new(*self)?.det_exact()
Expand All @@ -621,10 +655,12 @@ impl<const D: usize> Matrix<D> {
///
/// Requires the `exact` Cargo feature.
///
/// Computes the exact [`BigRational`] determinant via [`det_exact`](Self::det_exact)
/// and converts it to the nearest `f64`. This is useful when you want the
/// most accurate f64 determinant possible without committing to `BigRational`
/// in your downstream code.
/// Computes the exact determinant with the same integer Bareiss core used by
/// [`det_exact`](Self::det_exact), then converts the exact scaled integer
/// result to the nearest `f64` without first materializing the public
/// [`BigRational`] determinant. This is useful when you want the most accurate
/// f64 determinant possible without committing to `BigRational` in your
/// downstream code.
///
/// # Examples
/// ```
Expand All @@ -641,8 +677,10 @@ impl<const D: usize> Matrix<D> {
/// # Errors
/// Returns [`LaError::NonFinite`] if stored matrix entries are NaN or infinity.
///
/// Returns [`LaError::Overflow`] if determinant scaling overflows the internal
/// exponent representation or if the exact determinant is too large to
/// Returns [`LaError::DeterminantScaleOverflow`] if determinant scaling
/// overflows the internal exponent representation.
///
/// Returns [`LaError::Overflow`] if the exact determinant is too large to
/// represent as a finite `f64`.
#[inline]
pub fn det_exact_f64(&self) -> Result<f64, LaError> {
Expand Down Expand Up @@ -778,7 +816,9 @@ impl<const D: usize> Matrix<D> {
///
/// # Errors
/// Returns [`LaError::NonFinite`] if stored matrix entries are NaN or infinity.
/// This exact sign path has no additional runtime errors for finite matrices.
///
/// Returns [`LaError::DeterminantScaleOverflow`] if determinant scaling
/// overflows the internal exponent representation.
#[inline]
pub fn det_sign_exact(&self) -> Result<i8, LaError> {
FiniteMatrix::new(*self)?.det_sign_exact()
Expand Down Expand Up @@ -1340,6 +1380,33 @@ mod tests {
assert_eq!(component_to_bigint(negative, 1), BigInt::from(-20));
}

#[test]
fn determinant_scale_exp_multiplies_dimension_and_min_exponent() {
assert_eq!(determinant_scale_exp::<4>(-1074), Ok(-4296));
}

#[test]
fn determinant_scale_exp_rejects_dimension_too_large_for_i32() {
assert_eq!(
determinant_scale_exp::<{ i32::MAX as usize + 1 }>(-1074),
Err(LaError::DeterminantScaleOverflow {
dim: i32::MAX as usize + 1,
min_exponent: -1074,
})
);
}

#[test]
fn determinant_scale_exp_rejects_exponent_product_overflow() {
assert_eq!(
determinant_scale_exp::<3_000_000>(-1074),
Err(LaError::DeterminantScaleOverflow {
dim: 3_000_000,
min_exponent: -1074,
})
);
}

// -----------------------------------------------------------------------
// bareiss_det_int tests
// -----------------------------------------------------------------------
Expand Down
20 changes: 0 additions & 20 deletions src/ldlt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,6 @@ mod tests {
use crate::DEFAULT_SINGULAR_TOL;
use crate::matrix::FiniteMatrix;

use core::assert_matches;
use core::hint::black_box;

use approx::assert_abs_diff_eq;
Expand Down Expand Up @@ -571,25 +570,6 @@ mod tests {
);
}

#[test]
fn invalid_tolerance_rejected() {
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,
})
);
}

macro_rules! gen_solve_vec_boundary_tests {
($d:literal) => {
paste! {
Expand Down
Loading
Loading