From 7fd6d8efd3cf6c12be5a591d87addd6e4961fa82 Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Sat, 5 Sep 2026 20:10:56 -0700 Subject: [PATCH] perf!: optimize exact conversion and dense 4D determinants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Avoid redundant fraction reduction in strict RationalVector conversion. - Share minors in dense exact 4×4 determinants while preserving the sparse fast path. - Add adversarial solve benchmarks across D=2,3,4,5,8,16,32,64 and exact-arithmetic diagnostics. - Include Gaussian reference working-copy costs in benchmark timings. - Document the decision to retain existing LU/LDLT solve finalization after finding no repeatable speedup. - Align tooling with Rust 1.98.1 and refresh dependency and tool pins. BREAKING CHANGE: la-stack now requires Rust 1.98.1. Closes #234 --- AGENTS.md | 2 +- CONTRIBUTING.md | 2 +- Cargo.lock | 12 +- Cargo.toml | 2 +- README.md | 4 +- benches/common/exact_diagnostics.rs | 325 ++++++++++++++++++++++ benches/common/vs_linalg.rs | 118 ++++++++ benches/exact.rs | 95 +++++-- benches/vs_linalg.rs | 116 ++++++-- clippy.toml | 2 +- docs/BENCHMARKING.md | 80 ++++++ docs/performance/solve-finalization.md | 34 +++ justfile | 6 +- rust-toolchain.toml | 2 +- src/exact.rs | 112 +++++++- src/lu.rs | 103 +++++++ src/rational.rs | 27 +- tests/canonical_conversion_allocations.rs | 62 +++++ tests/exact_bench_config.rs | 45 ++- tests/exact_conversion_boundaries.rs | 103 +++++++ tests/solve_finalization.rs | 146 ++++++++++ tests/vs_linalg_inputs.rs | 24 +- uv.lock | 12 +- 23 files changed, 1343 insertions(+), 91 deletions(-) create mode 100644 benches/common/exact_diagnostics.rs create mode 100644 docs/performance/solve-finalization.md create mode 100644 tests/canonical_conversion_allocations.rs create mode 100644 tests/solve_finalization.rs diff --git a/AGENTS.md b/AGENTS.md index eaaa394..e1a6b66 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -210,7 +210,7 @@ When user requests commit message generation: ### Rust -- The current MSRV and pinned contributor/CI toolchain are Rust 1.98.0. Keep +- The current MSRV and pinned contributor/CI toolchain are Rust 1.98.1. Keep `Cargo.toml`, `rust-toolchain.toml`, and `clippy.toml` aligned when that baseline changes deliberately. - Rust's `f64::algebraic_*` operations are forbidden in all repository-owned diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dd6f360..d67146d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,7 +6,7 @@ clarity, and the fixed-dimension stack-allocation model. ## Getting Started -Install Rust 1.98.0 through [rustup](https://rustup.rs/), Git, the +Install Rust 1.98.1 through [rustup](https://rustup.rs/), Git, the [GitHub CLI](https://cli.github.com/), Python 3.14, [`uv` 0.12.5](https://docs.astral.sh/uv/), and `jq`. Authenticate the GitHub CLI for repository operations, then install the repository's pinned `just` diff --git a/Cargo.lock b/Cargo.lock index 2ab9514..f7cb4ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -217,9 +217,9 @@ dependencies = [ [[package]] name = "crossbeam-deque" -version = "0.8.7" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" dependencies = [ "crossbeam-epoch", "crossbeam-utils", @@ -227,18 +227,18 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.20" +version = "0.9.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.22" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" [[package]] name = "crunchy" diff --git a/Cargo.toml b/Cargo.toml index c1adb68..882f3c0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ name = "la-stack" version = "0.4.5" edition = "2024" -rust-version = "1.98.0" +rust-version = "1.98.1" 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 0d1338d..652e64d 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,7 @@ cases better served by broader linear-algebra libraries. ## 🚀 Quickstart -The minimum supported Rust version (MSRV) is 1.98.0. +The minimum supported Rust version (MSRV) is 1.98.1. Add this to your `Cargo.toml`: @@ -900,7 +900,7 @@ cargo run --features exact --example rational_input_5x5 A short contributor workflow: -Install Rust 1.98.0 through [rustup](https://rustup.rs/), Git, +Install Rust 1.98.1 through [rustup](https://rustup.rs/), Git, [GitHub CLI](https://cli.github.com/), Python 3.14, [`uv` 0.12.5](https://docs.astral.sh/uv/), and `jq`. Then install the pinned `just` release from its locked dependency graph: diff --git a/benches/common/exact_diagnostics.rs b/benches/common/exact_diagnostics.rs new file mode 100644 index 0000000..8788f9a --- /dev/null +++ b/benches/common/exact_diagnostics.rs @@ -0,0 +1,325 @@ +#![forbid(unsafe_code)] + +//! Independently checked conversion and determinant diagnostics. + +use core::array::from_fn; + +use la_stack::{ + BigInt, BigRational, DeterminantSign, ExactF64Conversion, LaError, Matrix, RationalVector, + UnrepresentableReason, +}; +use num_bigint::Sign; + +use crate::bench_utils::OrAbort; +use crate::rational_bench::rational_determinant_gaussian; + +/// Canonical conversion workloads, including expected strict rejections. +#[derive(Clone, Copy, Debug)] +pub enum ConversionKind { + /// Every component is exactly one half. + Dyadic, + /// A final one-third component needs rounding. + NonDyadic, + /// A final ratio has wide numerator and denominator storage. + Wide256, + /// A final ratio has still wider numerator and denominator storage. + Wide1024, + /// The final component is the smallest positive binary64 subnormal. + MinSubnormal, + /// A negative half-subnormal rounds to negative zero. + NegativeUnderflow, + /// An integer just below the overflow midpoint rounds to `f64::MAX`. + BelowOverflow, + /// The exact overflow midpoint has no finite rounded result. + OverflowMidpoint, +} + +impl ConversionKind { + /// Every diagnostic family, in stable order. + pub const ALL: [Self; 8] = [ + Self::Dyadic, + Self::NonDyadic, + Self::Wide256, + Self::Wide1024, + Self::MinSubnormal, + Self::NegativeUnderflow, + Self::BelowOverflow, + Self::OverflowMidpoint, + ]; + + /// Stable group label. + #[must_use] + pub const fn name(self) -> &'static str { + match self { + Self::Dyadic => "dyadic", + Self::NonDyadic => "non_dyadic", + Self::Wide256 => "wide256", + Self::Wide1024 => "wide1024", + Self::MinSubnormal => "min_subnormal", + Self::NegativeUnderflow => "negative_underflow", + Self::BelowOverflow => "below_overflow", + Self::OverflowMidpoint => "overflow_midpoint", + } + } +} + +type ConversionBits = Result; + +/// A final component and independently known strict/rounded binary64 outcomes. +fn conversion_component(kind: ConversionKind) -> (BigRational, ConversionBits, ConversionBits) { + let half = 0.5_f64.to_bits(); + let requires_rounding = Err(UnrepresentableReason::RequiresRounding); + let not_finite = Err(UnrepresentableReason::NotFinite); + match kind { + ConversionKind::Dyadic => (BigRational::new(1.into(), 2.into()), Ok(half), Ok(half)), + ConversionKind::NonDyadic => ( + BigRational::new(1.into(), 3.into()), + requires_rounding, + Ok(0x3fd5_5555_5555_5555), + ), + ConversionKind::Wide256 | ConversionKind::Wide1024 => { + let bits = if matches!(kind, ConversionKind::Wide256) { + 256_u32 + } else { + 1024 + }; + ( + BigRational::new( + (BigInt::from(1_u8) << bits) + BigInt::from(1_u8), + (BigInt::from(1_u8) << (bits + 1)) - BigInt::from(1_u8), + ), + requires_rounding, + Ok(half), + ) + } + ConversionKind::MinSubnormal => ( + BigRational::new(1.into(), BigInt::from(1_u8) << 1074_u32), + Ok(1), + Ok(1), + ), + ConversionKind::NegativeUnderflow => ( + BigRational::new((-1).into(), BigInt::from(1_u8) << 1075_u32), + requires_rounding, + Ok(1_u64 << 63), + ), + ConversionKind::BelowOverflow | ConversionKind::OverflowMidpoint => { + // f64::MAX + half its final ULP = 2^1024 - 2^970. + let midpoint = (BigInt::from(1_u8) << 1024_u32) - (BigInt::from(1_u8) << 970_u32); + if matches!(kind, ConversionKind::BelowOverflow) { + ( + BigRational::from_integer(midpoint - 1_u8), + requires_rounding, + Ok(f64::MAX.to_bits()), + ) + } else { + (BigRational::from_integer(midpoint), not_finite, not_finite) + } + } + } +} + +/// Construct canonical values and verify strict/rounded bits and typed errors. +/// +/// # Panics +/// Panics for an empty vector or a mismatch with the expected typed result. +pub fn canonical_conversion_input(kind: ConversionKind) -> RationalVector { + assert!(D > 0); + let mut data = from_fn(|_| BigRational::new(1.into(), 2.into())); + let (component, strict, rounded) = conversion_component(kind); + data[D - 1] = component; + let expected = |outcome: ConversionBits| { + outcome + .map(|bits| { + let mut values = [0.5_f64.to_bits(); D]; + values[D - 1] = bits; + values + }) + .map_err(|reason| LaError::unrepresentable(Some(D - 1), reason)) + }; + let vector = RationalVector::try_new(data).or_abort("canonical conversion input"); + assert_eq!( + vector + .try_to_f64() + .map(|v| v.into_array().map(f64::to_bits)), + expected(strict) + ); + assert_eq!( + vector + .as_array() + .try_to_f64() + .map(|v| v.into_array().map(f64::to_bits)), + expected(strict) + ); + assert_eq!( + vector + .to_rounded_f64() + .map(|v| v.into_array().map(f64::to_bits)), + expected(rounded) + ); + assert_eq!( + vector + .as_array() + .to_rounded_f64() + .map(|v| v.into_array().map(f64::to_bits)), + expected(rounded) + ); + vector +} + +/// Small D=4 shapes that must remain distinct from dense determinant workloads. +#[derive(Clone, Copy, Debug)] +pub enum Det4Kind { + /// All first-row coefficients are active. + Dense, + /// Only one first-row cofactor is needed. + Sparse, + /// Repeated rows force the exact sign fallback. + Singular, + /// A positive nonzero determinant that requires exact sign fallback. + NearSingularPositive, + /// A row swap reverses the near-singular determinant's exact sign. + NearSingularNegative, + /// Exactly scaled rows span subnormals through 2^900, preserving determinant. + MixedExponents, + /// Dense, extreme diagonal entries produce an exact result beyond binary64. + LargeEntries, +} + +impl Det4Kind { + /// Every shape and adversarial family, in stable order. + pub const ALL: [Self; 7] = [ + Self::Dense, + Self::Sparse, + Self::Singular, + Self::NearSingularPositive, + Self::NearSingularNegative, + Self::MixedExponents, + Self::LargeEntries, + ]; + + /// Stable group label. + #[must_use] + pub const fn name(self) -> &'static str { + match self { + Self::Dense => "dense", + Self::Sparse => "sparse", + Self::Singular => "singular", + Self::NearSingularPositive => "near_singular_positive", + Self::NearSingularNegative => "near_singular_negative", + Self::MixedExponents => "mixed_exponents", + Self::LargeEntries => "large_entries", + } + } +} + +const DENSE_ROWS: [[f64; 4]; 4] = [ + [11.0, 2.0, -3.0, 4.0], + [2.0, 13.0, 5.0, -1.0], + [3.0, -2.0, 17.0, 6.0], + [-1.0, 4.0, 2.0, 19.0], +]; + +/// Build exactly representable determinant fixtures, including row scalings. +fn det4_rows(kind: Det4Kind) -> [[f64; 4]; 4] { + let mut rows = DENSE_ROWS; + match kind { + Det4Kind::Dense => {} + Det4Kind::Sparse => rows[0] = [0.0, 2.0, 0.0, 0.0], + Det4Kind::Singular => rows[1] = rows[0], + Det4Kind::NearSingularPositive | Det4Kind::NearSingularNegative => { + let perturbation = f64::from_bits(0x3cd0_0000_0000_0000); // 2^-50 + rows = [ + [1.0, 1.0, 1.0, 1.0], + [1.0, 1.0 + perturbation, 1.0, 1.0], + [1.0, 1.0, 2.0, 1.0], + [1.0, 1.0, 1.0, 2.0], + ]; + if matches!(kind, Det4Kind::NearSingularNegative) { + rows.swap(2, 3); + } + } + Det4Kind::MixedExponents => { + // Exact row scales 2^[900, -1074, 700, -526] multiply to one. + let scales = [ + f64::from_bits((1023 + 900) << 52), + f64::from_bits(1), + f64::from_bits((1023 + 700) << 52), + f64::from_bits((1023 - 526) << 52), + ]; + for (row, scale) in rows.iter_mut().zip(scales) { + for value in row { + *value *= scale; + } + } + } + Det4Kind::LargeEntries => { + let big = f64::MAX / 2.0; + rows = from_fn(|i| from_fn(|j| if i == j { big } else { 1.0 })); + } + } + rows +} + +/// Validate determinant-only fixtures against rational Gaussian elimination. +/// +/// # Panics +/// Panics if either exact API disagrees with the independent determinant. +pub fn exact_det4_input(kind: Det4Kind) -> Matrix<4> { + let rows = det4_rows(kind); + let exact_rows = + rows.map(|row| row.map(|value| BigRational::from_float(value).or_abort("exact input"))); + let expected = rational_determinant_gaussian(exact_rows); + if matches!(kind, Det4Kind::MixedExponents) { + let base = DENSE_ROWS + .map(|row| row.map(|value| BigRational::from_float(value).or_abort("dense input"))); + assert_eq!(expected, rational_determinant_gaussian(base)); + } + if matches!(kind, Det4Kind::LargeEntries) { + assert_eq!( + expected.try_to_f64(), + Err(LaError::unrepresentable( + None, + UnrepresentableReason::NotFinite + )) + ); + } + if matches!( + kind, + Det4Kind::NearSingularPositive | Det4Kind::NearSingularNegative + ) { + let numerator = if matches!(kind, Det4Kind::NearSingularPositive) { + 1 + } else { + -1 + }; + assert_eq!( + expected, + BigRational::new(numerator.into(), BigInt::from(1_u8) << 50_u32) + ); + } + let expected_sign = match expected.numer().sign() { + Sign::Minus => DeterminantSign::Negative, + Sign::NoSign => DeterminantSign::Zero, + Sign::Plus => DeterminantSign::Positive, + }; + let matrix = Matrix::try_from_rows(rows).or_abort("determinant diagnostic input"); + assert_eq!( + matrix.det_exact().or_abort("determinant diagnostic"), + expected + ); + assert_eq!(matrix.det_sign_exact(), expected_sign); + if matches!( + kind, + Det4Kind::Singular | Det4Kind::NearSingularPositive | Det4Kind::NearSingularNegative + ) { + let estimate = matrix + .det_direct_with_errbound() + .or_abort("finite diagnostic filter"); + assert!( + estimate.is_none_or( + |estimate| estimate.determinant().abs() <= estimate.absolute_error_bound() + ) + ); + } + matrix +} diff --git a/benches/common/vs_linalg.rs b/benches/common/vs_linalg.rs index 0c5cc29..5856b17 100644 --- a/benches/common/vs_linalg.rs +++ b/benches/common/vs_linalg.rs @@ -7,6 +7,9 @@ use faer::perm::PermRef; use la_stack::{LaError, Matrix, Tolerance, Vector}; use nalgebra::SMatrix; +#[cfg(not(la_stack_v0_4_3_api))] +use crate::bench_utils::OrAbort; + /// Evaluate la-stack's dot product through the ownership contract used by the /// selected library revision. /// @@ -243,6 +246,121 @@ pub fn make_pivoting_matrix_rows() -> [[f64; D]; D] { rows } +/// Diagnostic solve families, separate from the headline comparison matrix. +#[derive(Clone, Copy, Debug)] +#[cfg(not(la_stack_v0_4_3_api))] +pub enum LuSolveScenario { + /// Rotate the well-conditioned rows, requiring repeated LU row swaps. + Pivoting, + /// Dense SPD matrix J + 2^-16 I, with condition number 1 + D * 2^16. + DenseIllConditioned, +} + +#[cfg(not(la_stack_v0_4_3_api))] +impl LuSolveScenario { + /// Stable diagnostic group label. + #[must_use] + pub const fn name(self) -> &'static str { + match self { + Self::Pivoting => "pivoting", + Self::DenseIllConditioned => "dense_ill_conditioned", + } + } +} + +/// A diagnostic system checked against a known solution and a scaled residual. +#[must_use] +#[cfg(not(la_stack_v0_4_3_api))] +pub struct ValidatedLuSolveInput { + matrix: Matrix, + rhs: Vector, + expected: [f64; D], +} + +#[cfg(not(la_stack_v0_4_3_api))] +impl ValidatedLuSolveInput { + /// Borrow the finite matrix. + pub const fn matrix(&self) -> &Matrix { + &self.matrix + } + + /// Return the finite right-hand side. + pub const fn rhs(&self) -> Vector { + self.rhs + } + + /// Validate another implementation before timing it on this system. + /// + /// # Panics + /// Panics if a non-finite value, excessive forward error, or excessive + /// scaled residual is observed. + pub fn validate_solution(&self, solution: &[f64; D]) { + let mut residual = 0.0_f64; + let mut matrix_norm = 0.0_f64; + let mut solution_norm = 0.0_f64; + let mut rhs_norm = 0.0_f64; + for (&actual, &expected) in solution.iter().zip(&self.expected) { + assert!(actual.is_finite()); + assert!((actual - expected).abs() <= 1e-7 * expected.abs().max(1.0)); + solution_norm = solution_norm.max(actual.abs()); + } + for (row, &rhs) in self.matrix.as_rows().iter().zip(self.rhs.as_array()) { + // Deliberately use an ordinary product/sum residual, independently + // of the timed triangular FMA recurrence. + let observed: f64 = row.iter().zip(solution).map(|(&a, &x)| a * x).sum(); + residual = residual.max((observed - rhs).abs()); + matrix_norm = matrix_norm.max(row.iter().map(|value| value.abs()).sum()); + rhs_norm = rhs_norm.max(rhs.abs()); + } + assert!(residual <= 1e-12 * matrix_norm.mul_add(solution_norm, rhs_norm)); + } +} + +/// Construct a diagnostic system and validate its complete LU solve. +/// +/// The dense family uses an exactly representable RHS for x[i] = i+1; +/// it exercises cancellation without diagonal or sparse shortcuts. +/// +/// # Panics +/// Panics for dimensions outside the diagnostic domain or a failed oracle check. +#[cfg(not(la_stack_v0_4_3_api))] +pub fn validated_lu_solve_input( + scenario: LuSolveScenario, +) -> ValidatedLuSolveInput { + use core::array::from_fn; + + assert!((2..=64).contains(&D)); + let expected = from_fn(|i| f64::from(u32::try_from(i + 1).or_abort("solution index"))); + let (rows, rhs) = match scenario { + LuSolveScenario::Pivoting => { + let mut rows = make_matrix_rows::(); + rows.rotate_left(1); + let rhs = rows.map(|row| row.iter().zip(&expected).map(|(&a, &x)| a * x).sum()); + (rows, rhs) + } + LuSolveScenario::DenseIllConditioned => { + const DELTA: f64 = 1.0 / 65_536.0; + let rows = from_fn(|i| from_fn(|j| if i == j { 1.0 + DELTA } else { 1.0 })); + let total = f64::from(u32::try_from(D * (D + 1) / 2).or_abort("solution sum")); + let rhs = expected.map(|value| DELTA.mul_add(value, total)); + (rows, rhs) + } + }; + let input = ValidatedLuSolveInput { + matrix: Matrix::try_from_rows(rows).or_abort("diagnostic matrix"), + rhs: Vector::try_new(rhs).or_abort("diagnostic RHS"), + expected, + }; + let solution = input + .matrix + .lu(la_stack_tolerance(0.0).or_abort("diagnostic tolerance")) + .or_abort("diagnostic LU") + .solve(input.rhs) + .or_abort("diagnostic solve"); + input.validate_solution(solution.as_array()); + input +} + /// Build a positive-definite diagonal matrix spanning 112 binary exponents at D=8. /// /// Each successive pivot is `2^-16` times the previous one. Benchmarks use a diff --git a/benches/exact.rs b/benches/exact.rs index 22aaaf7..d9329a1 100644 --- a/benches/exact.rs +++ b/benches/exact.rs @@ -33,10 +33,10 @@ use std::hint::black_box; -#[cfg(not(any(la_stack_pre_rational_input_api, la_stack_v0_4_3_api)))] -use criterion::BatchSize; use criterion::{BenchmarkGroup, Criterion, Throughput, measurement::WallTime}; +#[cfg(not(any(la_stack_pre_rational_input_api, la_stack_v0_4_3_api)))] +use la_stack::ExactF64Conversion; use la_stack::{Matrix, Vector}; #[path = "common/bench_utils.rs"] @@ -49,9 +49,8 @@ pub mod exact_bench; pub mod rational_bench; #[cfg(not(any(la_stack_pre_rational_input_api, la_stack_v0_4_3_api)))] -use rational_bench::{ - RationalInputKind, rational_determinant_gaussian, rational_input, rational_solve_gaussian, -}; +#[path = "common/exact_diagnostics.rs"] +pub mod exact_diagnostics; use bench_utils::OrAbort; use exact_bench::{ @@ -59,6 +58,12 @@ use exact_bench::{ large_entries_3x3_input, make_matrix_rows, make_random_input_corpus, make_vector_array, near_singular_3x3_input, validate_exact_fixture, validate_f64_determinant_benchmarks, }; +#[cfg(not(any(la_stack_pre_rational_input_api, la_stack_v0_4_3_api)))] +use exact_diagnostics::{ConversionKind, Det4Kind, canonical_conversion_input, exact_det4_input}; +#[cfg(not(any(la_stack_pre_rational_input_api, la_stack_v0_4_3_api)))] +use rational_bench::{ + RationalInputKind, rational_determinant_gaussian, rational_input, rational_solve_gaussian, +}; /// Exact operation measured by a benchmark group. #[derive(Clone, Copy)] @@ -108,8 +113,9 @@ const CORPUS_AND_EXTREME_OPERATIONS: &[ExactOperation] = &[ /// Compare exact-input row clearing and Bareiss elimination with direct /// `BigRational` Gaussian elimination. /// -/// The consuming Gaussian references clone their inputs in Criterion's untimed -/// setup phase, so both sides estimate computation over already-accepted input. +/// Both algorithms measure a complete operation on borrowed, accepted input. +/// The consuming Gaussian references therefore make their required working +/// copies inside the timed closure, just as row clearing builds its workspace. #[cfg(not(any(la_stack_pre_rational_input_api, la_stack_v0_4_3_api)))] fn bench_rational_input(criterion: &mut Criterion, kind: RationalInputKind) { let input = rational_input::(kind); @@ -132,14 +138,11 @@ fn bench_rational_input(criterion: &mut Criterion, kind: Rationa }); }); group.bench_function("det_big_rational_gaussian", |bencher| { - bencher.iter_batched( - || black_box(input.matrix().as_rows()).clone(), - |rows| { - let determinant = rational_determinant_gaussian(rows); - black_box(determinant); - }, - BatchSize::SmallInput, - ); + bencher.iter(|| { + let rows = black_box(input.matrix().as_rows()).clone(); + let determinant = rational_determinant_gaussian(rows); + black_box(determinant); + }); }); group.bench_function("solve_row_cleared_bareiss", |bencher| { bencher.iter(|| { @@ -150,20 +153,13 @@ fn bench_rational_input(criterion: &mut Criterion, kind: Rationa }); }); group.bench_function("solve_big_rational_gaussian", |bencher| { - bencher.iter_batched( - || { - ( - black_box(input.matrix().as_rows()).clone(), - black_box(input.rhs().as_array()).clone(), - ) - }, - |(rows, rhs)| { - let solution = rational_solve_gaussian(rows, rhs) - .or_abort("BigRational Gaussian benchmark solve"); - black_box(solution); - }, - BatchSize::SmallInput, - ); + bencher.iter(|| { + let rows = black_box(input.matrix().as_rows()).clone(); + let rhs = black_box(input.rhs().as_array()).clone(); + let solution = + rational_solve_gaussian(rows, rhs).or_abort("BigRational Gaussian benchmark solve"); + black_box(solution); + }); }); group.finish(); @@ -382,6 +378,42 @@ macro_rules! gen_random_corpus_benches_for_dim { }}; } +#[cfg(not(any(la_stack_pre_rational_input_api, la_stack_v0_4_3_api)))] +fn bench_canonical_conversion(c: &mut Criterion) { + for kind in ConversionKind::ALL { + let input = canonical_conversion_input::(kind); + let mut group = c.benchmark_group(format!("canonical_conversion_{}_d{D}", kind.name())); + group.bench_function("strict_result", |b| { + b.iter(|| black_box(black_box(&input).try_to_f64())); + }); + group.bench_function("rounded_result", |b| { + b.iter(|| black_box(black_box(&input).to_rounded_f64())); + }); + group.finish(); + } +} + +#[cfg(not(any(la_stack_pre_rational_input_api, la_stack_v0_4_3_api)))] +fn bench_det4_diagnostics(c: &mut Criterion) { + for kind in Det4Kind::ALL { + let input = exact_det4_input(kind); + let mut group = c.benchmark_group(format!("det4_diagnostic_{}", kind.name())); + group.bench_function("det_exact", |b| { + b.iter(|| { + black_box( + black_box(&input) + .det_exact() + .or_abort("determinant diagnostic"), + ) + }); + }); + group.bench_function("det_sign_exact", |b| { + b.iter(|| black_box(black_box(&input).det_sign_exact())); + }); + group.finish(); + } +} + fn main() { let mut c = Criterion::default().configure_from_args(); @@ -406,6 +438,11 @@ fn main() { #[cfg(not(any(la_stack_pre_rational_input_api, la_stack_v0_4_3_api)))] { + bench_canonical_conversion::<2>(&mut c); + bench_canonical_conversion::<3>(&mut c); + bench_canonical_conversion::<4>(&mut c); + bench_canonical_conversion::<5>(&mut c); + bench_det4_diagnostics(&mut c); // === Already-exact rational-input comparisons === // // These compare the production row-cleared integer Bareiss backend with diff --git a/benches/vs_linalg.rs b/benches/vs_linalg.rs index 13fae4e..291286b 100644 --- a/benches/vs_linalg.rs +++ b/benches/vs_linalg.rs @@ -30,6 +30,8 @@ pub mod vs_linalg_common; use bench_utils::OrAbort; #[cfg(not(any(la_stack_pre_rational_input_api, la_stack_v0_4_3_api)))] use vs_linalg_common::norm_scenarios; +#[cfg(not(la_stack_v0_4_3_api))] +use vs_linalg_common::{LuSolveScenario, validated_lu_solve_input}; use vs_linalg_common::{ PreparedFaerLuDet, delaunay_scaled_norm, faer_det_from_ldlt, iterative_hypot, la_stack_dot, la_stack_norm_inf, la_stack_norm_squared, la_stack_tolerance, make_balanced_dynamic_range_rows, @@ -662,26 +664,106 @@ fn register_stress_benchmarks(group: &mut BenchmarkGroup<'_, WallTime>) { }); } +/// Complete and reusable-factor solves on validated diagnostic systems. +#[cfg(not(la_stack_v0_4_3_api))] +fn register_lu_solve_diagnostics(c: &mut Criterion) +where + Const: DimMin, Output = Const>, +{ + for scenario in [ + LuSolveScenario::Pivoting, + LuSolveScenario::DenseIllConditioned, + ] { + let input = validated_lu_solve_input::(scenario); + let a = *input.matrix(); + let rhs = input.rhs(); + let zero = la_stack_tolerance(0.0).or_abort("diagnostic tolerance"); + let lu = a.lu(zero).or_abort("diagnostic LU"); + let na = SMatrix::::from_fn(|i, j| a.as_rows()[i][j]); + let nrhs = SVector::::from_fn(|i, _| rhs.as_array()[i]); + let nlu = na.lu(); + let nx = nlu.solve(&nrhs).or_abort("diagnostic nalgebra solve"); + input.validate_solution(&std::array::from_fn(|i| nx[i])); + let fa = Mat::from_fn(D, D, |i, j| a.as_rows()[i][j]); + let frhs = Mat::from_fn(D, 1, |i, _| rhs.as_array()[i]); + let flu = fa.partial_piv_lu(); + let fx = flu.solve(&frhs); + input.validate_solution(&std::array::from_fn(|i| fx[(i, 0)])); + + let mut group = c.benchmark_group(format!("lu_solve_{}_d{D}", scenario.name())); + group.bench_function("la_stack_lu_solve", |b| { + b.iter(|| { + black_box( + black_box(a) + .lu(zero) + .or_abort("diagnostic LU") + .solve(black_box(rhs)) + .or_abort("diagnostic solve"), + ) + }); + }); + group.bench_function("la_stack_solve_from_lu", |b| { + b.iter(|| { + black_box( + black_box(&lu) + .solve(black_box(rhs)) + .or_abort("diagnostic solve"), + ) + }); + }); + group.bench_function("nalgebra_lu_solve", |b| { + b.iter(|| { + black_box( + black_box(na) + .lu() + .solve(black_box(&nrhs)) + .or_abort("diagnostic nalgebra solve"), + ) + }); + }); + group.bench_function("nalgebra_solve_from_lu", |b| { + b.iter(|| { + black_box( + black_box(&nlu) + .solve(black_box(&nrhs)) + .or_abort("diagnostic nalgebra solve"), + ) + }); + }); + group.bench_function("faer_lu_solve", |b| { + b.iter(|| black_box(black_box(&fa).partial_piv_lu().solve(black_box(&frhs)))); + }); + group.bench_function("faer_solve_from_lu", |b| { + b.iter(|| black_box(black_box(&flu).solve(black_box(&frhs)))); + }); + group.finish(); + } +} + macro_rules! define_vs_linalg_benches_for_dim { ($fn_name:ident, $d:literal $(, $register_stress:ident)?) => { fn $fn_name(c: &mut Criterion) { - let mut group = c.benchmark_group(concat!("d", stringify!($d))); - register_determinant_benchmarks::<$d>(&mut group); - register_factorization_benchmarks::<$d>(&mut group); - register_lu_solve_benchmarks::<$d>(&mut group); - register_ldlt_solve_benchmarks::<$d>(&mut group); - register_precomputed_lu_solve_benchmarks::<$d>(&mut group); - register_precomputed_ldlt_solve_benchmarks::<$d>(&mut group); - register_precomputed_lu_determinant_benchmarks::<$d>(&mut group); - register_precomputed_ldlt_determinant_benchmarks::<$d>(&mut group); - register_vector_benchmarks::<$d>(&mut group); - #[cfg(not(any(la_stack_pre_rational_input_api, la_stack_v0_4_3_api)))] - register_norm_scenario_benchmarks::<$d>(&mut group); - register_matrix_norm_benchmarks::<$d>(&mut group); - $( - $register_stress(&mut group); - )? - group.finish(); + { + let mut group = c.benchmark_group(concat!("d", stringify!($d))); + register_determinant_benchmarks::<$d>(&mut group); + register_factorization_benchmarks::<$d>(&mut group); + register_lu_solve_benchmarks::<$d>(&mut group); + register_ldlt_solve_benchmarks::<$d>(&mut group); + register_precomputed_lu_solve_benchmarks::<$d>(&mut group); + register_precomputed_ldlt_solve_benchmarks::<$d>(&mut group); + register_precomputed_lu_determinant_benchmarks::<$d>(&mut group); + register_precomputed_ldlt_determinant_benchmarks::<$d>(&mut group); + register_vector_benchmarks::<$d>(&mut group); + #[cfg(not(any(la_stack_pre_rational_input_api, la_stack_v0_4_3_api)))] + register_norm_scenario_benchmarks::<$d>(&mut group); + register_matrix_norm_benchmarks::<$d>(&mut group); + $( + $register_stress(&mut group); + )? + group.finish(); + } + #[cfg(not(la_stack_v0_4_3_api))] + register_lu_solve_diagnostics::<$d>(c); } }; } diff --git a/clippy.toml b/clippy.toml index d507f62..8725e55 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.98.0" +msrv = "1.98.1" # Lint levels are owned by Cargo.toml. diff --git a/docs/BENCHMARKING.md b/docs/BENCHMARKING.md index 69708de..3660d3f 100644 --- a/docs/BENCHMARKING.md +++ b/docs/BENCHMARKING.md @@ -473,6 +473,23 @@ range even though the final result is one. These rows keep pivoting, ill-conditioning, and scaled-product cold paths visible alongside the shared well-conditioned peer fixture. +Local `lu_solve_{pivoting,dense_ill_conditioned}_d{D}` groups cover the same +eight README dimensions. They measure both complete LU solves and solves from +precomputed factors for all three libraries. The pivoting input cyclically +rotates the baseline matrix rows; the dense input is `J + 2^-16 I`, where `J` +is the all-ones matrix, with 2-norm condition number `1 + D * 2^16`. Both use +manufactured solution references and scaled-residual checks before timing. +The pivoting RHS uses rounded dot products; the dense RHS is exactly +representable. These diagnostic calculations provide no rigorous absolute +rounding-error bound. The groups are +separate from the release-signal registry and README plots. + +Run both diagnostic families across all eight dimensions with: + +```bash +just bench-vs-linalg '^lu_solve_' +``` + The main comparable metrics are: - `det_via_lu` — factor the matrix and compute determinant from the LU factor @@ -496,6 +513,10 @@ Cholesky: Read these as SPD factorization/solve/determinant comparisons, not as identical algorithm comparisons across all three crates. +The [solve finalization decision](performance/solve-finalization.md) records +why #234 retained the existing result construction after testing all eight +README dimensions. It links the regression tests and current benchmark command. + Release-signal reports compare latest la-stack measurements against a saved la-stack baseline, and show saved nalgebra/faer baseline timings as context where a matching peer benchmark exists. That keeps iteration cheap while still @@ -531,6 +552,65 @@ random-corpus groups, and adversarial-input groups: storage. See the [row-clearing study](performance/rational-row-clearing.md) for fixtures, allocation evidence, focused timing commands, and limitations. +Rational Gaussian determinant and solve references use direct `bencher.iter`, +including their required working copies inside the timed operation. Both +implementations therefore start from borrowed, accepted input and include +workspace preparation and result destruction. Earlier Gaussian measurements +used `iter_batched` and excluded input cloning; do not compare those timings +directly with the current complete-operation measurements. Remeasure both +revisions using the same current harness. + +Two additional local diagnostic families are excluded from the release signal: + +- `canonical_conversion_*_d{2..5}` isolates strict and rounded conversion of + already-canonical vectors. The dyadic, non-dyadic, and wide-component inputs + are joined by the smallest subnormal, negative half-subnormal, and integers + immediately below and exactly at the overflow midpoint. Before timing, both + canonical and raw-array conversions must return the known output bits + (including negative zero) or the exact typed reason and component index. +- `det4_diagnostic_*` checks determinant values and signs against rational + Gaussian elimination. Dense, sparse, and singular controls are joined by + positive/negative nonzero near-singular determinants, mixed row exponents, + and extreme diagonal entries. The singular and near-singular inputs must + leave the floating-point sign filter inconclusive. Near-singular determinants + are independently checked against ±2^-50. Mixed-exponent rows are scaled by + `2^[900, -1074, 700, -526]`; these exact scales preserve the dense determinant + while including subnormal entries. Large entries use `f64::MAX / 2` on the + diagonal and one elsewhere, producing an exact result beyond binary64 range. + +These complement the existing LU pivoting and dense ill-conditioned solve +groups at D=2, 3, 4, 5, 8, 16, 32, and 64, plus the exact near-singular, +large-entry, and Hilbert families described above. The local adversarial +diagnostics can be smoke-tested without collecting timing estimates: + +```bash +cargo bench --locked --features bench,exact --bench exact -- \ + '^canonical_conversion_|^det4_diagnostic_' --test +``` + +Allocation evidence for canonical conversion uses a separate test executable: + +```bash +cargo test --locked --release --features bench,exact \ + --test canonical_conversion_allocations -- --nocapture --test-threads=1 +``` + +The counting allocator is not linked into Criterion timing executables. + +The local performance audit on 2026-09-05 retained two exact-arithmetic +optimizations: strict conversion uses the existing proof that rational vectors +are canonical, and dense exact D=4 determinants share six lower-row minors. +Matching before/after runs supported both changes. LU loop-expansion and +combined-check prototypes were removed after failing performance acceptance +across the required dimensions. + +Those experiments used Rust 1.98.0 on an Apple M4 Max / AArch64 with Criterion +0.8.2, 50 samples, one-second warm-up, and three-second measurement. Correctness +gates ran before timing. The benchmarks above remain runnable with the current +toolchain; historical timing estimates, source fingerprints, and discarded +prototypes are optional local analysis artifacts rather than prerequisites for +building, testing, or benchmarking the crate. + The f64-input random-corpus and adversarial groups run the same exact-arithmetic benches (`det_sign_exact`, `det_exact`, `solve_exact`, `solve_exact_f64_result`, `solve_exact_rounded_f64`) so tables are comparable diff --git a/docs/performance/solve-finalization.md b/docs/performance/solve-finalization.md new file mode 100644 index 0000000..dc4460d --- /dev/null +++ b/docs/performance/solve-finalization.md @@ -0,0 +1,34 @@ +# Keep existing solve finalization (#234) + +**Decision:** retain the existing LU and LDLT result finalization. The safe +alternative provided no repeatable solve speedup at D=2, 3, 4, 5, 8, 16, 32, +or 64, so the prototype was removed. + +The experiment measured solves using precomputed factors, with identical +inputs and Criterion settings for both implementations and a reverse-order +repeat. On Rust 1.98.0 / AArch64, the inspected compiler output already removed +the final finite-value scan through D=8. At larger dimensions where a scan +remained, the alternative still showed no repeatable overall improvement. +This supports keeping the simpler implementation; it does not prove that +finalization has zero cost in every compiler or calling context. + +The retained tests in +[`tests/solve_finalization.rs`](../../tests/solve_finalization.rs) exercise +division, forward-substitution, and back-substitution overflow across all eight +dimensions, preserving the exact typed error and failing-step order. + +Correctness tests can be rerun with: + +```bash +cargo test --locked --test solve_finalization +``` + +The existing benchmark suite can measure current precomputed-factor solves: + +```bash +just bench-vs-linalg 'la_stack_solve_from_(lu|ldlt)$' +``` + +Tests verify correctness; benchmarks measure performance on the current +toolchain and machine. The original prototype and measurement evidence are +local experimental artifacts rather than required repository files. diff --git a/justfile b/justfile index 95838df..209c204 100644 --- a/justfile +++ b/justfile @@ -23,14 +23,14 @@ cargo_machete_version := "0.9.2" cargo_nextest_version := "0.9.143" cargo_update_version := "22.1.1" clippy_sarif_version := "0.8.0" -dprint_version := "0.57.1" +dprint_version := "0.57.4" git_cliff_version := "2.14.1" just_version := "1.58.0" -rumdl_version := "0.2.65" +rumdl_version := "0.2.66" sarif_fmt_version := "0.8.0" taplo_version := "0.10.0" typos_version := "1.50.1" -uv_version := "0.12.9" +uv_version := "0.12.10" zizmor_version := "1.30.0" # Internal helpers: ensure external tooling is installed diff --git a/rust-toolchain.toml b/rust-toolchain.toml index a260e23..fd11025 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,6 +1,6 @@ [toolchain] # Pin to MSRV as specified in Cargo.toml -channel = "1.98.0" +channel = "1.98.1" # Essential repository components. Keep checkout and CI setup lean; workflows # install additional targets or components when they need them. diff --git a/src/exact.rs b/src/exact.rs index e659dd3..756a266 100644 --- a/src/exact.rs +++ b/src/exact.rs @@ -325,8 +325,11 @@ fn negative_exponent_from_magnitude(magnitude: u64) -> i32 { /// /// # Errors /// Returns [`LaError::Unrepresentable`] with -/// [`UnrepresentableReason::RequiresRounding`] when the rational denominator is -/// not a power of two and the rounded value would still be finite. +/// [`UnrepresentableReason::RequiresRounding`] when the value needs rounding +/// but the rounded result would be finite, including non-dyadic values and +/// dyadic values outside binary64's exact precision or exponent range. +/// Returns [`UnrepresentableReason::NotFinite`] for a raw zero denominator or +/// when rounding cannot produce a finite result. fn exact_rational_to_finite_f64(exact: &BigRational, index: Option) -> Result { if exact.denom().sign() == Sign::NoSign { cold_path(); @@ -368,7 +371,19 @@ fn positive_power_of_two_exponent(value: &BigInt) -> Option { (value.bits().checked_sub(1) == Some(exponent)).then_some(exponent) } -/// Strictly convert a reduced rational with a positive denominator. +/// Convert a canonical rational to finite binary64 without rounding. +/// +/// Callers must provide a reduced value with a positive denominator, as +/// guaranteed by [`RationalVector`] storage or by normalization in +/// [`exact_rational_to_finite_f64`]. This lets canonical vectors avoid another +/// reduction while raw rational inputs still pass through normalization when +/// needed. The optional `index` identifies the failing solution component. +/// +/// # Errors +/// Returns [`LaError::Unrepresentable`] with +/// [`UnrepresentableReason::RequiresRounding`] if only a rounded finite result +/// is available, or [`UnrepresentableReason::NotFinite`] if rounding cannot +/// produce a finite result. fn reduced_rational_to_finite_f64( exact: &BigRational, index: Option, @@ -476,6 +491,25 @@ impl ExactF64Conversion for [BigRational; D] { } } +impl ExactF64Conversion for RationalVector { + type Output = Vector; + + #[inline] + fn try_to_f64(&self) -> Result { + let mut result = [0.0; D]; + for (index, value) in self.as_array().iter().enumerate() { + // Canonical storage already proves reduction and a positive denominator. + result[index] = reduced_rational_to_finite_f64(value, Some(index))?; + } + Vector::try_new(result) + } + + #[inline] + fn to_rounded_f64(&self) -> Result { + self.as_array().to_rounded_f64() + } +} + /// Convert a `BigInt × 2^exp` pair to an exactly represented finite `f64`. /// /// This avoids allocating a [`BigRational`] when determinant and solve paths @@ -1039,8 +1073,33 @@ fn det3_big_int(a: &[[BigInt; D]; D]) -> BigInt { } /// Compute a 4×4 determinant from a scaled integer matrix. +/// +/// [`det_big_int`] dispatches here only for D=4. When every first-row entry is +/// non-zero, sharing six lower-row minors avoids repeating their products; +/// otherwise, separate cofactors skip work for zero entries. Both expansions +/// use exact [`BigInt`] arithmetic, preserving the determinant value and sign +/// required by the public exact APIs. #[inline] fn det4_big_int(a: &[[BigInt; D]; D]) -> BigInt { + if a[0][..4].iter().all(|value| value.sign() != Sign::NoSign) { + // Six lower-row minors serve all four cofactors. Consume each + // temporary on its last use so BigInt can reuse its storage. + let m01 = &a[2][0] * &a[3][1] - &a[2][1] * &a[3][0]; + let m02 = &a[2][0] * &a[3][2] - &a[2][2] * &a[3][0]; + let m03 = &a[2][0] * &a[3][3] - &a[2][3] * &a[3][0]; + let m12 = &a[2][1] * &a[3][2] - &a[2][2] * &a[3][1]; + let m13 = &a[2][1] * &a[3][3] - &a[2][3] * &a[3][1]; + let m23 = &a[2][2] * &a[3][3] - &a[2][3] * &a[3][2]; + let c00 = &a[1][1] * &m23 - &a[1][2] * &m13 + &a[1][3] * &m12; + let mut det = &a[0][0] * c00; + let c01 = &a[1][0] * m23 - &a[1][2] * &m03 + &a[1][3] * &m02; + det -= &a[0][1] * c01; + let c02 = &a[1][0] * m13 - &a[1][1] * m03 + &a[1][3] * &m01; + det += &a[0][2] * c02; + let c03 = &a[1][0] * m12 - &a[1][1] * m02 + &a[1][2] * m01; + return det - &a[0][3] * c03; + } + let mut det = BigInt::from(0); if a[0][0].sign() != Sign::NoSign { @@ -1753,9 +1812,54 @@ mod tests { }; // ----------------------------------------------------------------------- - // Test helpers + // D=4 determinant regression + #[test] + fn det4_matches_bareiss_across_sparse_wide_and_singular_inputs() { + let coefficients = [ + [11_i32, 2, -3, 4], + [2, 13, 5, -1], + [3, -2, 17, 6], + [-1, 4, 2, 19], + ]; + for shift in [0_u32, 80, 256, 1024] { + for mask in 0..16_u8 { + let mut rows: [[BigInt; 4]; 4] = from_fn(|i| { + from_fn(|j| { + if i == 0 && mask & (1 << j) == 0 { + BigInt::from(0) + } else { + (BigInt::from(coefficients[i][j]) << shift) + BigInt::from(i + j) + } + }) + }); + for variant in 0..3 { + if variant == 1 { + rows.swap(0, 2); + } else if variant == 2 { + rows[1] = rows[0].clone(); + } + let mut eliminated = rows.clone(); + let expected = match bareiss_forward_eliminate(&mut eliminated, None) { + BareissResult::Upper { odd_swaps } => { + let det = take(&mut eliminated[3][3]); + if odd_swaps { -det } else { det } + } + BareissResult::Singular { .. } => BigInt::from(0), + }; + assert_eq!( + det4_big_int(&rows), + expected, + "shift={shift}, mask={mask}, variant={variant}" + ); + } + } + } + } + // ----------------------------------------------------------------------- + // Test helpers + /// Build an exact `BigRational` from an `f64` via IEEE 754 bit decomposition. /// /// Thin wrapper over [`decompose_f64`] that packs the mantissa/exponent diff --git a/src/lu.rs b/src/lu.rs index 665431d..bb7b35d 100644 --- a/src/lu.rs +++ b/src/lu.rs @@ -428,6 +428,109 @@ mod tests { use crate::DEFAULT_SINGULAR_TOL; const TWO_NEG_800: f64 = f64::from_bits(223_u64 << 52); + + /// Check analytical solution bits after rotating equations to exercise pivoting. + fn assert_triangular_solution( + rows: [[f64; D]; D], + rhs: [f64; D], + expected: [u64; D], + label: &str, + ) { + for rotation in [0, 1, D - 1] { + let mut rows = rows; + let mut rhs = rhs; + rows.rotate_left(rotation); + rhs.rotate_left(rotation); + let actual = Matrix::try_from_rows(rows) + .unwrap() + .lu(DEFAULT_SINGULAR_TOL) + .unwrap() + .solve(Vector::try_new(rhs).unwrap()); + assert_eq!( + actual.map(|solution| solution.into_array().map(f64::to_bits)), + Ok(expected), + "{label}, D={D}, rotation={rotation}", + ); + } + } + + fn assert_solve_arithmetic_order() { + // 1 - (1 - 2^-53)(1 + 2^-52) = -2^-53 + 2^-105 exactly. + // Separate multiplication/subtraction rounds the product to 1 instead. + // Scale the RHS by 2^-969, 1, and 2^970, covering subnormal solutions + // and large finite values. Expected bits come from the identity above; + // the subnormal result is -(2^-1022 - 2^-1074). + for (exponent, cancelled_bits) in [ + (54_u64, 0x800f_ffff_ffff_ffff), + (1023, 0xbc9f_ffff_ffff_fffe), + (1993, 0xf93f_ffff_ffff_fffe), + ] { + let scale = f64::from_bits(exponent << 52); + let perturbed = f64::from_bits((exponent << 52) | 1); + for forward in [false, true] { + let (row, col) = if forward { (D - 1, 0) } else { (0, D - 1) }; + let mut rows = Matrix::::identity().into_rows(); + rows[row][col] = f64::from_bits(0x3fef_ffff_ffff_ffff); + let mut rhs = [0.0; D]; + rhs[row] = scale; + rhs[col] = perturbed; + let mut expected = [0; D]; + expected[row] = cancelled_bits; + expected[col] = perturbed.to_bits(); + assert_triangular_solution( + rows, + rhs, + expected, + &format!("fused cancellation, forward={forward}, exponent={exponent}"), + ); + } + } + + if D >= 3 { + // Ascending columns evaluate (1 - 2^53) + 2^53 = 1 exactly. + // Reversing them rounds 1 + 2^53 to 2^53 and yields 0 instead. + for forward in [false, true] { + let (row, first, second) = if forward { + (D - 1, 0, 1) + } else { + (0, 1, D - 1) + }; + let mut rows = Matrix::::identity().into_rows(); + rows[row][first] = 0.5; + rows[row][second] = 0.5; + let mut rhs = [0.0; D]; + rhs[row] = 1.0; + rhs[first] = f64::from_bits(1077_u64 << 52); // 2^54 + rhs[second] = -rhs[first]; + assert_triangular_solution( + rows, + rhs, + rhs.map(f64::to_bits), + &format!("ascending columns, forward={forward}"), + ); + } + } + } + + macro_rules! gen_solve_order_tests { + ($d:literal) => { + paste! { + #[test] + fn []() { + assert_solve_arithmetic_order::<$d>(); + } + } + }; + } + + gen_solve_order_tests!(2); + gen_solve_order_tests!(3); + gen_solve_order_tests!(4); + gen_solve_order_tests!(5); + gen_solve_order_tests!(8); + gen_solve_order_tests!(16); + gen_solve_order_tests!(32); + gen_solve_order_tests!(64); const TWO_POS_800: f64 = f64::from_bits(1823_u64 << 52); #[test] diff --git a/src/rational.rs b/src/rational.rs index 02f6679..1875594 100644 --- a/src/rational.rs +++ b/src/rational.rs @@ -15,7 +15,7 @@ use num_bigint::{BigInt, Sign}; use num_rational::BigRational; use crate::exact::{det_big_int, solve_big_int}; -use crate::{DeterminantSign, ExactF64Conversion, LaError, Vector}; +use crate::{DeterminantSign, LaError}; /// Exact rational square matrix with compile-time dimension `D`. /// @@ -45,8 +45,12 @@ pub struct RationalMatrix { /// Construction validates that every denominator is non-zero and canonicalizes /// every entry to lowest terms with a positive denominator. Solutions returned /// by [`RationalMatrix::solve`] and [`crate::Matrix::solve_exact`] also use this -/// type, making any later conversion to [`Vector`] explicit through -/// [`ExactF64Conversion`]. +/// type, making any later conversion to [`Vector`](crate::Vector) explicit through +/// [`ExactF64Conversion`](crate::ExactF64Conversion). +/// Use [`try_to_f64`](crate::ExactF64Conversion::try_to_f64) when every component +/// must remain exact, or [`to_rounded_f64`](crate::ExactF64Conversion::to_rounded_f64) +/// to opt into round-to-nearest, ties-to-even. Both conversions reject results +/// that cannot be rounded to finite binary64 values. #[derive(Clone, Debug, Eq, PartialEq)] #[must_use] pub struct RationalVector { @@ -323,18 +327,6 @@ impl RationalVector { } } -impl ExactF64Conversion for RationalVector { - type Output = Vector; - - fn try_to_f64(&self) -> Result { - self.data.try_to_f64() - } - - fn to_rounded_f64(&self) -> Result { - self.data.to_rounded_f64() - } -} - /// Reduce one validated rational and make its denominator positive before /// publishing it through the exact-input storage types. fn canonicalize_rational(value: BigRational) -> BigRational { @@ -378,7 +370,10 @@ mod tests { use pastey::paste; use super::*; - use crate::{NonFiniteLocation, NonFiniteOrigin, SingularityReason, UnrepresentableReason}; + use crate::{ + ExactF64Conversion, NonFiniteLocation, NonFiniteOrigin, SingularityReason, + UnrepresentableReason, + }; fn ratio(numerator: i64, denominator: i64) -> BigRational { BigRational::new(BigInt::from(numerator), BigInt::from(denominator)) diff --git a/tests/canonical_conversion_allocations.rs b/tests/canonical_conversion_allocations.rs new file mode 100644 index 0000000..ea4e3a6 --- /dev/null +++ b/tests/canonical_conversion_allocations.rs @@ -0,0 +1,62 @@ +//! Allocation evidence for preserving canonical rational conversion proofs. + +#![cfg(all(feature = "bench", feature = "exact"))] +#![forbid(unsafe_code)] + +#[path = "../benches/common/bench_utils.rs"] +mod bench_utils; +#[path = "../benches/common/exact_diagnostics.rs"] +pub mod exact_diagnostics; +#[path = "../benches/common/rational.rs"] +pub mod rational_bench; + +use std::hint::black_box; + +use allocation_counter::measure; +use la_stack::ExactF64Conversion; +use pastey::paste; + +use exact_diagnostics::{ConversionKind, canonical_conversion_input}; + +fn record(kind: ConversionKind, operation: &str, run: impl Fn()) { + let counts = measure(&run); + assert_eq!(counts.count_current, 0); + assert_eq!(counts.bytes_current, 0); + for _ in 0..2 { + assert_eq!(measure(&run), counts); + } + println!( + "conversion_allocation,{},{D},{operation},{},{}", + kind.name(), + counts.count_total, + counts.bytes_total + ); +} + +fn conversion_allocations() { + for kind in ConversionKind::ALL { + let input = canonical_conversion_input::(kind); + record::(kind, "canonical", || { + let _ = black_box(black_box(&input).try_to_f64()); + }); + record::(kind, "raw", || { + let _ = black_box(black_box(input.as_array()).try_to_f64()); + }); + } +} + +macro_rules! gen_conversion_allocation_tests { + ($d:literal) => { + paste! { + #[test] + fn []() { + conversion_allocations::<$d>(); + } + } + }; +} + +gen_conversion_allocation_tests!(2); +gen_conversion_allocation_tests!(3); +gen_conversion_allocation_tests!(4); +gen_conversion_allocation_tests!(5); diff --git a/tests/exact_bench_config.rs b/tests/exact_bench_config.rs index d20a706..254771b 100644 --- a/tests/exact_bench_config.rs +++ b/tests/exact_bench_config.rs @@ -8,16 +8,26 @@ mod bench_utils; #[path = "../benches/common/exact.rs"] pub mod exact_bench; +#[cfg(not(any(la_stack_pre_rational_input_api, la_stack_v0_4_3_api)))] +#[path = "../benches/common/exact_diagnostics.rs"] +pub mod exact_diagnostics; +#[cfg(not(any(la_stack_pre_rational_input_api, la_stack_v0_4_3_api)))] +#[path = "../benches/common/rational.rs"] +pub mod rational_bench; + use core::array::from_fn; use std::error::Error; +use la_stack::{Matrix, Vector}; +use pastey::paste; + use exact_bench::{ ExactBenchConfigError, ExactInput, I16Range, SplitMix64, ValidatedExactInput, hilbert_input, large_entries_3x3_input, make_matrix_rows, make_random_input_corpus, make_vector_array, near_singular_3x3_input, validate_exact_fixture, validate_f64_determinant_benchmarks, }; -use la_stack::{Matrix, Vector}; -use pastey::paste; +#[cfg(not(any(la_stack_pre_rational_input_api, la_stack_v0_4_3_api)))] +use exact_diagnostics::{ConversionKind, Det4Kind, canonical_conversion_input, exact_det4_input}; fn baseline_input() -> ExactInput { let Ok(matrix) = Matrix::::try_from_rows(make_matrix_rows::()) else { @@ -30,6 +40,10 @@ fn baseline_input() -> ExactInput { } fn validate_baseline_and_random_corpus() { + #[cfg(not(any(la_stack_pre_rational_input_api, la_stack_v0_4_3_api)))] + for kind in ConversionKind::ALL { + let _ = canonical_conversion_input::(kind); + } let baseline = validate_exact_fixture(baseline_input::()); validate_f64_determinant_benchmarks(&baseline); for input in make_random_input_corpus::() { @@ -37,6 +51,33 @@ fn validate_baseline_and_random_corpus() { } } +#[cfg(not(any(la_stack_pre_rational_input_api, la_stack_v0_4_3_api)))] +#[test] +fn determinant_diagnostic_fixtures_are_correct() { + for kind in Det4Kind::ALL { + let _ = exact_det4_input(kind); + } +} + +/// Keep the filter-resolved control and typed non-finite fallback distinct. +#[cfg(not(any(la_stack_pre_rational_input_api, la_stack_v0_4_3_api)))] +#[test] +fn determinant_extreme_diagnostic_filter_paths_are_stable() { + use la_stack::{ArithmeticOperation, LaError}; + + let mixed = exact_det4_input(Det4Kind::MixedExponents); + let estimate = mixed.det_direct_with_errbound().unwrap().unwrap(); + assert!(estimate.determinant().abs() > estimate.absolute_error_bound()); + + let large = exact_det4_input(Det4Kind::LargeEntries); + assert_eq!( + large.det_direct_with_errbound(), + Err(LaError::non_finite_computation_scalar( + ArithmeticOperation::DeterminantErrorBound, + )), + ); +} + /// Report whether `det_sign_exact` can certify this fixture through its direct filter. /// /// Both a missing bound and a direct-path error select the exact fallback. diff --git a/tests/exact_conversion_boundaries.rs b/tests/exact_conversion_boundaries.rs index 48e8c0e..301dc00 100644 --- a/tests/exact_conversion_boundaries.rs +++ b/tests/exact_conversion_boundaries.rs @@ -3,7 +3,10 @@ #![forbid(unsafe_code)] #![cfg(feature = "exact")] +use core::cmp::Ordering; + use la_stack::prelude::*; +use pastey::paste; const POSITIVE_ZERO_BITS: u64 = 0; const NEGATIVE_ZERO_BITS: u64 = 1_u64 << 63; @@ -46,6 +49,106 @@ fn raw_rational(numerator: i32, denominator: i32) -> BigRational { BigRational::new_raw(BigInt::from(numerator), BigInt::from(denominator)) } +fn canonical_vector_conversions_preserve_raw_contract() { + let success = [ + (raw_rational(0, -7), 0.0_f64.to_bits()), + (raw_rational(3, -6), (-0.5_f64).to_bits()), + ( + BigRational::new(1.into(), BigInt::from(1_u8) << 1074_u32), + 1, + ), + ( + BigRational::from_float(f64::MAX).unwrap(), + f64::MAX.to_bits(), + ), + ]; + for (value, expected) in success { + let raw = std::array::from_fn::<_, D, _>(|_| value.clone()); + let canonical = RationalVector::try_new(raw.clone()).unwrap(); + for actual in [ + canonical.try_to_f64(), + canonical.to_rounded_f64(), + raw.try_to_f64(), + raw.to_rounded_f64(), + ] { + assert_eq!( + actual.unwrap().into_array().map(f64::to_bits), + [expected; D] + ); + } + } + + let failures = [ + ( + raw_rational(2, 6), + UnrepresentableReason::RequiresRounding, + Ok(0x3fd5_5555_5555_5555), // nearest binary64 to 1/3 + ), + ( + BigRational::new((-1).into(), BigInt::from(1_u8) << 1075_u32), + UnrepresentableReason::RequiresRounding, + Ok(NEGATIVE_ZERO_BITS), + ), + ( + BigRational::from_integer(BigInt::from(1_u8) << 1024_u32), + UnrepresentableReason::NotFinite, + Err(UnrepresentableReason::NotFinite), + ), + ]; + for (value, reason, rounded) in failures { + for index in 0..D { + let raw = std::array::from_fn(|i| match i.cmp(&index) { + Ordering::Equal => value.clone(), + Ordering::Greater => BigRational::from_integer(BigInt::from(1_u8) << 1024_u32), + Ordering::Less => raw_rational(3, -6), + }); + let canonical = RationalVector::::try_new(raw.clone()).unwrap(); + assert_unrepresentable(&canonical.try_to_f64(), Some(index), reason); + assert_unrepresentable(&raw.try_to_f64(), Some(index), reason); + // Rounding may recover this component, but must then report the + // first later overflow rather than retaining the strict error. + let expected_rounded = match rounded { + Err(reason) => Err(LaError::unrepresentable(Some(index), reason)), + Ok(_) if index + 1 < D => Err(LaError::unrepresentable( + Some(index + 1), + UnrepresentableReason::NotFinite, + )), + Ok(bits) => { + let mut expected = [(-0.5_f64).to_bits(); D]; + expected[index] = bits; + Ok(expected) + } + }; + for (path, actual) in [ + ("canonical", canonical.to_rounded_f64()), + ("raw", raw.to_rounded_f64()), + ] { + assert_eq!( + actual.map(|v| v.into_array().map(f64::to_bits)), + expected_rounded, + "{path}, D={D}, index={index}, value={value}", + ); + } + } + } +} + +macro_rules! gen_canonical_conversion_tests { + ($d:literal) => { + paste! { + #[test] + fn []() { + canonical_vector_conversions_preserve_raw_contract::<$d>(); + } + } + }; +} + +gen_canonical_conversion_tests!(2); +gen_canonical_conversion_tests!(3); +gen_canonical_conversion_tests!(4); +gen_canonical_conversion_tests!(5); + #[test] fn raw_rational_conversion_uses_the_mathematical_quotient() { let cases = [ diff --git a/tests/solve_finalization.rs b/tests/solve_finalization.rs new file mode 100644 index 0000000..8f0f08a --- /dev/null +++ b/tests/solve_finalization.rs @@ -0,0 +1,146 @@ +#![forbid(unsafe_code)] + +//! Regression coverage for finite solve results and substitution error order. + +use la_stack::{ArithmeticOperation, DEFAULT_SINGULAR_TOL, LaError, Matrix, Vector}; +use pastey::paste; + +fn assert_diagonal_overflow() { + // Exercise every output coordinate, including the last one finalized by LU. + for index in 0..D { + let mut rows = Matrix::::identity().into_rows(); + rows[index][index] = 1.0e-11; + let a = Matrix::try_from_rows(rows).unwrap(); + let mut rhs = [0.0; D]; + rhs[index] = 1.0e300; + let b = Vector::try_new(rhs).unwrap(); + assert_eq!( + a.lu(DEFAULT_SINGULAR_TOL).unwrap().solve(b), + Err(LaError::non_finite_computation_step( + ArithmeticOperation::LuSolve, + index, + )), + ); + assert_eq!( + a.ldlt(DEFAULT_SINGULAR_TOL).unwrap().solve(b), + Err(LaError::non_finite_computation_step( + ArithmeticOperation::LdltSolve, + index, + )), + ); + } +} + +fn assert_forward_overflow() { + let mut rows = Matrix::::identity().into_rows(); + rows[D - 1][0] = -1.0; + let mut rhs = [0.0; D]; + rhs[0] = f64::MAX; + rhs[D - 1] = f64::MAX; + let b = Vector::try_new(rhs).unwrap(); + let lu = Matrix::try_from_rows(rows) + .unwrap() + .lu(DEFAULT_SINGULAR_TOL) + .unwrap(); + assert_eq!( + lu.solve(b), + Err(LaError::non_finite_computation_step( + ArithmeticOperation::LuSolve, + D - 1, + )), + ); + + // A = L Lᵀ with L[D-1, 0] = -1 and unit diagonal, so the same + // forward substitution overflows before either solve reaches finalization. + rows[0][D - 1] = -1.0; + rows[D - 1][D - 1] = 2.0; + let ldlt = Matrix::try_from_rows(rows) + .unwrap() + .ldlt(DEFAULT_SINGULAR_TOL) + .unwrap(); + assert_eq!( + ldlt.solve(b), + Err(LaError::non_finite_computation_step( + ArithmeticOperation::LdltSolve, + D - 1, + )), + ); +} + +fn assert_back_substitution_overflow() { + // Test each possible failing row. In the all-rows case, a final ascending + // scan would report 0 instead of the required descending solve step D-2. + for failing_row in 0..D - 1 { + for all_rows in [false, true] { + let mut upper = Matrix::::identity().into_rows(); + let mut spd = upper; + let mut last_diagonal = 1.0; + for i in 0..D - 1 { + if all_rows || i == failing_row { + upper[i][D - 1] = 2.0; + spd[i][D - 1] = 2.0; + spd[D - 1][i] = 2.0; + last_diagonal += 4.0; + } + } + // A = L Lᵀ, where L's final row contains the selected multipliers. + spd[D - 1][D - 1] = last_diagonal; + let mut rhs = [0.0; D]; + rhs[D - 1] = 1.0e308; + let b = Vector::try_new(rhs).unwrap(); + let expected_index = if all_rows { D - 2 } else { failing_row }; + let lu = Matrix::try_from_rows(upper) + .unwrap() + .lu(DEFAULT_SINGULAR_TOL) + .unwrap(); + assert_eq!( + lu.solve(b), + Err(LaError::non_finite_computation_step( + ArithmeticOperation::LuSolve, + expected_index, + )), + ); + let ldlt = Matrix::try_from_rows(spd) + .unwrap() + .ldlt(DEFAULT_SINGULAR_TOL) + .unwrap(); + assert_eq!( + ldlt.solve(b), + Err(LaError::non_finite_computation_step( + ArithmeticOperation::LdltSolve, + expected_index, + )), + ); + } + } +} + +macro_rules! gen_tests { + ($d:literal) => { + paste! { + #[test] + fn []() { + assert_diagonal_overflow::<$d>(); + } + + #[test] + fn []() { + assert_forward_overflow::<$d>(); + } + + #[test] + fn []() { + assert_back_substitution_overflow::<$d>(); + } + } + }; +} + +gen_tests!(2); +gen_tests!(3); +gen_tests!(4); +gen_tests!(5); +gen_tests!(8); +gen_tests!(16); +gen_tests!(32); +gen_tests!(64); diff --git a/tests/vs_linalg_inputs.rs b/tests/vs_linalg_inputs.rs index 5e5aca9..8340efa 100644 --- a/tests/vs_linalg_inputs.rs +++ b/tests/vs_linalg_inputs.rs @@ -14,11 +14,15 @@ use nalgebra::{Const, DimMin, SMatrix, SVector}; use la_stack::ExactF64Conversion; use la_stack::{DEFAULT_SINGULAR_TOL, Matrix, Vector}; +#[path = "../benches/common/bench_utils.rs"] +mod bench_utils; #[path = "../benches/common/vs_linalg.rs"] pub mod vs_linalg_common; #[cfg(not(la_stack_v0_4_3_api))] -use vs_linalg_common::make_balanced_dynamic_range_rows; +use vs_linalg_common::{ + LuSolveScenario, make_balanced_dynamic_range_rows, validated_lu_solve_input, +}; use vs_linalg_common::{ PreparedFaerLuDet, faer_det_from_ldlt, faer_perm_sign, la_stack_dot, la_stack_norm_inf, la_stack_norm_squared, la_stack_tolerance, make_ill_conditioned_matrix_rows, make_matrix_rows, @@ -73,6 +77,24 @@ fn assert_lu_agreement() where Const: DimMin, Output = Const>, { + #[cfg(not(la_stack_v0_4_3_api))] + for scenario in [ + LuSolveScenario::Pivoting, + LuSolveScenario::DenseIllConditioned, + ] { + let input = validated_lu_solve_input::(scenario); + let na = SMatrix::::from_fn(|i, j| input.matrix().as_rows()[i][j]); + let nrhs = SVector::::from_fn(|i, _| input.rhs().as_array()[i]); + let nx = na.lu().solve(&nrhs).unwrap_or_else(|| { + panic!("nalgebra diagnostic solve failed: scenario={scenario:?}, D={D}") + }); + input.validate_solution(&nalgebra_vector_to_array(&nx)); + + let fa = Mat::from_fn(D, D, |i, j| input.matrix().as_rows()[i][j]); + let frhs = Mat::from_fn(D, 1, |i, _| input.rhs().as_array()[i]); + let fx = fa.partial_piv_lu().solve(&frhs); + input.validate_solution(&faer_column_to_array(&fx)); + } let a = Matrix::::try_from_rows(make_matrix_rows::()) .unwrap_or_else(|err| panic!("la_stack matrix construction failed: {err}")); let rhs = Vector::::try_new(make_vector_array::(0.0)) diff --git a/uv.lock b/uv.lock index d4efedb..b364149 100644 --- a/uv.lock +++ b/uv.lock @@ -19,15 +19,15 @@ wheels = [ [[package]] name = "anyio" -version = "4.15.0" +version = "4.15.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "typing-extensions", marker = "python_full_version < '3.15'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ea/9a/c15a60547004a3f3cea20296c934f827ddd7bdba225a2e7e9fcb5ec48c80/anyio-4.15.0.tar.gz", hash = "sha256:b5c620ed540725e2579c31b17bb995b3bf02c9281c9cace04c7d186380bab85e", size = 276504, upload-time = "2026-09-02T21:46:36.957Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/21/a6/2b21ce5ebe4d8938a247c9b0dbb7271566ae559b01795c83ea4bb2660ed7/anyio-4.15.0-py3-none-any.whl", hash = "sha256:7ecd9937369ffce8bba0b5ccb9b3a9507b101b0ed50256aecfbab27e6c2acb99", size = 131908, upload-time = "2026-09-02T21:46:35.485Z" }, + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, ] [[package]] @@ -1126,15 +1126,15 @@ wheels = [ [[package]] name = "sse-starlette" -version = "3.4.10" +version = "3.4.11" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "starlette" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1e/e1/8a41e88e825ea26c44333897c7ffe35fe60153a2cfc097a5bd1d209ad281/sse_starlette-3.4.10.tar.gz", hash = "sha256:c6c87280d8feb4e55a8d79633782766b9cac6a26da5c79a145d00aa404117a86", size = 33720, upload-time = "2026-09-03T09:36:24.08Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/54/6767bb789b2f2fed6e0f953df949cd39dc263a384c1b65a95232598621d6/sse_starlette-3.4.11.tar.gz", hash = "sha256:1bae716c02f3e6f294be41ff333220692dae7c3cbab077c900f159676719dade", size = 34972, upload-time = "2026-09-05T12:11:04.607Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ae/3c/96018a51c7301a64f7b0579d9ce8f9b69dd39ca8ed5aa100ba3feadee503/sse_starlette-3.4.10-py3-none-any.whl", hash = "sha256:710f5f5b0527409903a22a91699db02f76f4c2eb9204e882e4ee7cada76bdf75", size = 17120, upload-time = "2026-09-03T09:36:22.56Z" }, + { url = "https://files.pythonhosted.org/packages/98/6a/2ba3ed4a69babf3afdddf7d8314a48d87562c0a442206bbc2a1b50d5efc0/sse_starlette-3.4.11-py3-none-any.whl", hash = "sha256:c7b2244bdff016fe7f64e10075e89a3e6bbf899649cc89b0fe884b5545042453", size = 17122, upload-time = "2026-09-05T12:11:03.195Z" }, ] [[package]]