Skip to content

Commit 928f62b

Browse files
committed
feat(exact): report determinant scale overflow precisely
- Add a typed LaError::DeterminantScaleOverflow path for exact determinant scale exponent failures - Convert det_exact_f64 directly from the shared Bareiss integer/exponent pair while preserving Overflow for finite-f64 conversion failures - Reuse vector finiteness scanning across raw and proof-bearing constructors - Harden docs version sync checks for reordered inline-table dependency snippets and pruned Markdown traversal Closes #139
1 parent 90a4200 commit 928f62b

10 files changed

Lines changed: 277 additions & 87 deletions

File tree

CHANGELOG.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,30 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [Unreleased]
9+
10+
### Added
11+
12+
- Guard README dependency snippets [`7137fee`](https://github.com/acgetchell/la-stack/commit/7137fee16ab33e08f4dc6a60e02417e3e7c4e020)
13+
14+
- Add a generic docs-version sync check that compares Markdown dependency
15+
snippets against the Cargo package name and version
16+
17+
- Run the docs-version check from the repository Semgrep policy lane
18+
- Refresh README determinant examples with explicit fallible handling and
19+
hidden doctest mirrors
20+
21+
- Update CI uv pins to 0.11.19
22+
23+
### Documentation
24+
25+
- Sync citation metadata for v0.4.2 [`f473ec5`](https://github.com/acgetchell/la-stack/commit/f473ec50946e5f668e9ad9a2d978e499dcb10f04)
26+
27+
- Update CITATION.cff with the v0.4.2 version and release date.
28+
- Align the Python utility package metadata and lockfile with the crate release.
29+
- Add citation metadata validation to the release checklist and config lint flow.
30+
- Include CITATION.cff in YAML/CFF formatting checks.
31+
832
## [0.4.2] - 2026-06-04
933

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

82107
### Fixed
83108

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

654+
[Unreleased]: https://github.com/acgetchell/la-stack/compare/v0.4.2...HEAD
629655
[0.4.2]: https://github.com/acgetchell/la-stack/compare/v0.4.1...v0.4.2
630656
[0.4.1]: https://github.com/acgetchell/la-stack/compare/v0.4.0...v0.4.1
631657
[0.4.0]: https://github.com/acgetchell/la-stack/compare/v0.3.0...v0.4.0

scripts/check_docs_version_sync.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import os
56
import re
67
import sys
78
import tomllib
@@ -68,12 +69,16 @@ def _read_cargo_package_info(cargo_toml: Path) -> PackageInfo:
6869

6970

7071
def _iter_markdown_files(root: Path) -> list[Path]:
71-
return sorted(path for path in root.rglob("*.md") if path.is_file() and not (set(path.relative_to(root).parts) & SKIP_DIRS))
72+
markdown_files: list[Path] = []
73+
for dirpath, dirnames, filenames in os.walk(root):
74+
dirnames[:] = [dirname for dirname in dirnames if not (set((Path(dirpath) / dirname).relative_to(root).parts) & SKIP_DIRS)]
75+
markdown_files.extend(Path(dirpath) / filename for filename in filenames if filename.endswith(".md"))
76+
return sorted(markdown_files)
7277

7378

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

7883

7984
def _dependency_snippets(path: Path, package_name: str) -> list[DependencySnippet]:

scripts/tests/test_check_docs_version_sync.py

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
from __future__ import annotations
22

3+
from typing import TYPE_CHECKING
4+
35
import check_docs_version_sync
46

7+
if TYPE_CHECKING:
8+
from pathlib import Path
9+
510

6-
def test_find_version_mismatches_accepts_matching_dependency_snippets(tmp_path) -> None:
11+
def test_find_version_mismatches_accepts_matching_dependency_snippets(tmp_path: Path) -> None:
712
(tmp_path / "Cargo.toml").write_text(
813
"\n".join(
914
[
@@ -28,7 +33,7 @@ def test_find_version_mismatches_accepts_matching_dependency_snippets(tmp_path)
2833
assert check_docs_version_sync.find_version_mismatches(tmp_path) == []
2934

3035

31-
def test_find_version_mismatches_reports_stale_dependency_snippets(tmp_path) -> None:
36+
def test_find_version_mismatches_reports_stale_dependency_snippets(tmp_path: Path) -> None:
3237
(tmp_path / "Cargo.toml").write_text(
3338
"\n".join(
3439
[
@@ -54,3 +59,32 @@ def test_find_version_mismatches_reports_stale_dependency_snippets(tmp_path) ->
5459
assert mismatches[0].snippet.version == "1.2.2"
5560
assert mismatches[0].package.name == "other-crate"
5661
assert mismatches[0].package.version == "1.2.3"
62+
63+
64+
def test_find_version_mismatches_handles_reordered_inline_table_keys(tmp_path: Path) -> None:
65+
(tmp_path / "Cargo.toml").write_text(
66+
"\n".join(
67+
[
68+
"[package]",
69+
'name = "other-crate"',
70+
'version = "1.2.3"',
71+
]
72+
),
73+
encoding="utf-8",
74+
)
75+
docs = tmp_path / "docs"
76+
docs.mkdir()
77+
install_doc = docs / "install.md"
78+
install_doc.write_text(
79+
'other-crate = { features = ["exact"], version = "1.2.2" }\n',
80+
encoding="utf-8",
81+
)
82+
83+
mismatches = check_docs_version_sync.find_version_mismatches(tmp_path)
84+
85+
assert len(mismatches) == 1
86+
assert mismatches[0].snippet.path == install_doc
87+
assert mismatches[0].snippet.line == 1
88+
assert mismatches[0].snippet.version == "1.2.2"
89+
assert mismatches[0].package.name == "other-crate"
90+
assert mismatches[0].package.version == "1.2.3"

src/exact.rs

Lines changed: 56 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,10 @@
1313
//! `e - e_min`), and Bareiss elimination runs entirely in `BigInt`
1414
//! arithmetic — no `BigRational`, no GCD, no denominator tracking.
1515
//! The result is `(det_int, total_exp)` where `det = det_int × 2^(D × e_min)`.
16-
//! `bareiss_det` wraps this with `bigint_exp_to_bigrational` to reconstruct
17-
//! a reduced `BigRational`; `det_sign_exact` reads the sign directly from
18-
//! `det_int` (the scale factor is always positive).
16+
//! `det_exact` wraps this with `bigint_exp_to_bigrational` to reconstruct a
17+
//! reduced `BigRational`; `det_exact_f64` converts the same pair directly to
18+
//! `f64`; and `det_sign_exact` reads the sign directly from `det_int` (the
19+
//! scale factor is always positive).
1920
//!
2021
//! `det_sign_exact` adds a two-stage adaptive-precision optimisation inspired
2122
//! by Shewchuk's robust geometric predicates:
@@ -142,7 +143,8 @@ fn bigint_exp_to_bigrational(mut value: BigInt, mut exp: i32) -> BigRational {
142143
let exp_abs = exp.unsigned_abs();
143144
let reduce = tz.min(u64::from(exp_abs));
144145
value >>= reduce;
145-
let reduce = u32::try_from(reduce).unwrap_or(u32::MAX);
146+
#[allow(clippy::cast_possible_truncation)]
147+
let reduce = reduce as u32;
146148
let remaining_abs = exp_abs - reduce;
147149
exp = match remaining_abs {
148150
0 => 0,
@@ -158,6 +160,31 @@ fn bigint_exp_to_bigrational(mut value: BigInt, mut exp: i32) -> BigRational {
158160
}
159161
}
160162

163+
/// Convert a `BigInt × 2^exp` determinant pair to finite `f64` without first
164+
/// reducing a public `BigRational` determinant value.
165+
fn bigint_exp_to_finite_f64(value: BigInt, exp: i32) -> Result<f64, LaError> {
166+
if value == BigInt::from(0) {
167+
return Ok(0.0);
168+
}
169+
170+
let exact = if exp >= 0 {
171+
BigRational::new_raw(value << exp.cast_unsigned(), BigInt::from(1u32))
172+
} else {
173+
BigRational::new_raw(value, BigInt::from(1u32) << exp.unsigned_abs())
174+
};
175+
176+
let Some(val) = exact.to_f64() else {
177+
cold_path();
178+
return Err(LaError::Overflow { index: None });
179+
};
180+
if val.is_finite() {
181+
Ok(val)
182+
} else {
183+
cold_path();
184+
Err(LaError::Overflow { index: None })
185+
}
186+
}
187+
161188
// -----------------------------------------------------------------------
162189
// Shared integer-Bareiss primitives
163190
// -----------------------------------------------------------------------
@@ -396,11 +423,11 @@ fn bareiss_det_int_finite<const D: usize>(m: &FiniteMatrix<D>) -> Result<(BigInt
396423
// det(original) = det_int × 2^(D × e_min)
397424
let Ok(d_i32) = i32::try_from(D) else {
398425
cold_path();
399-
return Err(LaError::unsupported_dimension(D, i32::MAX as usize));
426+
return Err(LaError::determinant_scale_overflow(D, e_min));
400427
};
401428
let Some(total_exp) = e_min.checked_mul(d_i32) else {
402429
cold_path();
403-
return Err(LaError::Overflow { index: None });
430+
return Err(LaError::determinant_scale_overflow(D, e_min));
404431
};
405432

406433
Ok((det_int, total_exp))
@@ -489,21 +516,15 @@ impl<const D: usize> FiniteMatrix<D> {
489516
/// Exact determinant converted to a finite `f64`.
490517
///
491518
/// # Errors
519+
/// Returns [`LaError::DeterminantScaleOverflow`] if determinant scaling
520+
/// overflows the internal exponent representation.
521+
///
492522
/// Returns [`LaError::Overflow`] if the exact determinant cannot be
493523
/// represented as a finite `f64`.
494524
#[inline]
495525
fn det_exact_f64(&self) -> Result<f64, LaError> {
496-
let exact = self.det_exact()?;
497-
let Some(val) = exact.to_f64() else {
498-
cold_path();
499-
return Err(LaError::Overflow { index: None });
500-
};
501-
if val.is_finite() {
502-
Ok(val)
503-
} else {
504-
cold_path();
505-
Err(LaError::Overflow { index: None })
506-
}
526+
let (det_int, total_exp) = bareiss_det_int_finite(self)?;
527+
bigint_exp_to_finite_f64(det_int, total_exp)
507528
}
508529

509530
/// Exact linear solve for finite inputs.
@@ -548,6 +569,9 @@ impl<const D: usize> FiniteMatrix<D> {
548569
/// Returns [`LaError::NonFinite`] if a direct determinant or error-bound
549570
/// computation detects a non-finite condition that is not an inconclusive
550571
/// scalar overflow.
572+
///
573+
/// Returns [`LaError::DeterminantScaleOverflow`] if determinant scaling
574+
/// overflows the internal exponent representation.
551575
#[inline]
552576
fn det_sign_exact(&self) -> Result<i8, LaError> {
553577
match (self.det_direct(), self.det_errbound()) {
@@ -607,11 +631,8 @@ impl<const D: usize> Matrix<D> {
607631
/// # Errors
608632
/// Returns [`LaError::NonFinite`] if stored matrix entries are NaN or infinity.
609633
///
610-
/// Returns [`LaError::Overflow`] if determinant scaling overflows the internal
611-
/// exponent representation.
612-
///
613-
/// Returns [`LaError::UnsupportedDimension`] if `D` cannot be represented in
614-
/// the internal determinant exponent calculation.
634+
/// Returns [`LaError::DeterminantScaleOverflow`] if determinant scaling
635+
/// overflows the internal exponent representation.
615636
#[inline]
616637
pub fn det_exact(&self) -> Result<BigRational, LaError> {
617638
FiniteMatrix::new(*self)?.det_exact()
@@ -621,10 +642,12 @@ impl<const D: usize> Matrix<D> {
621642
///
622643
/// Requires the `exact` Cargo feature.
623644
///
624-
/// Computes the exact [`BigRational`] determinant via [`det_exact`](Self::det_exact)
625-
/// and converts it to the nearest `f64`. This is useful when you want the
626-
/// most accurate f64 determinant possible without committing to `BigRational`
627-
/// in your downstream code.
645+
/// Computes the exact determinant with the same integer Bareiss core used by
646+
/// [`det_exact`](Self::det_exact), then converts the exact scaled integer
647+
/// result to the nearest `f64` without first materializing the public
648+
/// [`BigRational`] determinant. This is useful when you want the most accurate
649+
/// f64 determinant possible without committing to `BigRational` in your
650+
/// downstream code.
628651
///
629652
/// # Examples
630653
/// ```
@@ -641,8 +664,10 @@ impl<const D: usize> Matrix<D> {
641664
/// # Errors
642665
/// Returns [`LaError::NonFinite`] if stored matrix entries are NaN or infinity.
643666
///
644-
/// Returns [`LaError::Overflow`] if determinant scaling overflows the internal
645-
/// exponent representation or if the exact determinant is too large to
667+
/// Returns [`LaError::DeterminantScaleOverflow`] if determinant scaling
668+
/// overflows the internal exponent representation.
669+
///
670+
/// Returns [`LaError::Overflow`] if the exact determinant is too large to
646671
/// represent as a finite `f64`.
647672
#[inline]
648673
pub fn det_exact_f64(&self) -> Result<f64, LaError> {
@@ -778,7 +803,9 @@ impl<const D: usize> Matrix<D> {
778803
///
779804
/// # Errors
780805
/// Returns [`LaError::NonFinite`] if stored matrix entries are NaN or infinity.
781-
/// This exact sign path has no additional runtime errors for finite matrices.
806+
///
807+
/// Returns [`LaError::DeterminantScaleOverflow`] if determinant scaling
808+
/// overflows the internal exponent representation.
782809
#[inline]
783810
pub fn det_sign_exact(&self) -> Result<i8, LaError> {
784811
FiniteMatrix::new(*self)?.det_sign_exact()

src/ldlt.rs

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -282,7 +282,6 @@ mod tests {
282282
use crate::DEFAULT_SINGULAR_TOL;
283283
use crate::matrix::FiniteMatrix;
284284

285-
use core::assert_matches;
286285
use core::hint::black_box;
287286

288287
use approx::assert_abs_diff_eq;
@@ -571,25 +570,6 @@ mod tests {
571570
);
572571
}
573572

574-
#[test]
575-
fn invalid_tolerance_rejected() {
576-
assert_eq!(
577-
Tolerance::new(-1.0),
578-
Err(LaError::InvalidTolerance { value: -1.0 })
579-
);
580-
581-
assert_matches!(
582-
Tolerance::new(f64::NAN),
583-
Err(LaError::InvalidTolerance { value }) if value.is_nan()
584-
);
585-
assert_eq!(
586-
Tolerance::new(f64::INFINITY),
587-
Err(LaError::InvalidTolerance {
588-
value: f64::INFINITY,
589-
})
590-
);
591-
}
592-
593573
macro_rules! gen_solve_vec_boundary_tests {
594574
($d:literal) => {
595575
paste! {

0 commit comments

Comments
 (0)