From 703746e576bf15cca6a4c1e259292b4334946409 Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Wed, 2 Sep 2026 20:38:46 -0700 Subject: [PATCH 1/4] feat(exact): accept rational matrix inputs - add fixed-size rational matrix and vector types with exact determinant signs, determinant values, and solves - provide stable runtime dispatch through D=8 with explicit exact-to-f64 conversion - preserve typed diagnostics while reusing row-cleared Bareiss elimination - add release-tracked Criterion comparisons against BigRational Gaussian elimination - document the two-domain scalar model and the f64 precision boundary Refs #216 --- Cargo.toml | 6 +- README.md | 151 +++++- benches/exact.rs | 225 ++++++++- docs/BENCHMARKING.md | 11 + docs/mathematical_basis.md | 26 + examples/rational_input_5x5.rs | 50 ++ scripts/archive_performance.py | 11 +- scripts/bench_compare.py | 70 ++- scripts/performance_artifacts.py | 27 +- scripts/tests/test_archive_performance.py | 27 +- scripts/tests/test_bench_compare.py | 142 +++++- scripts/tests/test_performance_artifacts.py | 24 +- src/exact.rs | 93 ++-- src/lib.rs | 241 ++++++++- src/rational.rs | 515 ++++++++++++++++++++ tests/prelude_exports.rs | 16 + tests/proptest_rational.rs | 161 ++++++ 17 files changed, 1708 insertions(+), 88 deletions(-) create mode 100644 examples/rational_input_5x5.rs create mode 100644 src/rational.rs create mode 100644 tests/proptest_rational.rs diff --git a/Cargo.toml b/Cargo.toml index 091d521..9533966 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -67,6 +67,10 @@ required-features = [ "exact" ] name = "exact_solve_3x3" required-features = [ "exact" ] +[[example]] +name = "rational_input_5x5" +required-features = [ "exact" ] + [[bench]] name = "vs_linalg" harness = false @@ -91,7 +95,7 @@ unsafe_code = "forbid" missing_docs = { level = "deny", priority = 0 } dead_code = { level = "deny", priority = 0 } unreachable_pub = { level = "deny", priority = 0 } -unexpected_cfgs = { level = "deny", priority = 0, check-cfg = [ 'cfg(docsrs)', 'cfg(la_stack_v0_4_3_api)' ] } +unexpected_cfgs = { level = "deny", priority = 0, check-cfg = [ 'cfg(docsrs)', 'cfg(la_stack_pre_rational_input_api)', 'cfg(la_stack_v0_4_3_api)' ] } [lints.rustdoc] bare_urls = "deny" diff --git a/README.md b/README.md index 8ec78c7..6c3e539 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,8 @@ while keeping the API intentionally small and explicit. - `Vector` for fixed-length `f64` vectors backed by `[f64; D]` - `Matrix` for fixed-size square `f64` matrices backed by `[[f64; D]; D]` +- `RationalVector` and `RationalMatrix` for + exact rational inputs behind the optional `"exact"` feature - `Lu` for LU factorization with partial pivoting (solve + det) - `Ldlt` for no-pivot factorization intended for exactly symmetric positive-definite matrices (solve + det; typed pivot diagnostics) @@ -38,10 +40,11 @@ factorization tolerances are rejection thresholds, not accuracy guarantees. For D≤4, direct determinants can be paired with a conservative absolute roundoff bound when its range preconditions hold. -With `features = ["exact"]`, stored binary64 inputs are lifted losslessly to -rationals for exact determinant signs, determinant values, and solves. Exactness -starts at the stored values and cannot recover information rounded away before -construction. See the +With `features = ["exact"]`, callers can either lift stored binary64 inputs +losslessly or supply already-exact rational inputs for exact determinant signs, +determinant values, and solves. Exactness over binary64 input starts at the +stored values and cannot recover information rounded away before construction. +See the [mathematical basis](https://github.com/acgetchell/la-stack/blob/v0.4.5/docs/mathematical_basis.md) for the algorithms, validity boundaries, and supporting references. @@ -73,7 +76,9 @@ for current release planning. ## 🚫 Anti-goals -- Alternate floating-point scalar families: `la-stack` supports `f64` and optional exact arithmetic, not `f32` / `f16` APIs +- Alternate scalar families: `la-stack` deliberately supports finite `f64` and + optional exact `BigRational` input domains, not `f32`, `f16`, complex, or + generic scalar APIs - Bare-metal performance: use [`blas`](https://crates.io/crates/blas) or [`lapack`](https://crates.io/crates/lapack) with a native backend selected through [`blas-src`](https://crates.io/crates/blas-src), @@ -94,12 +99,17 @@ for current release planning. ## 🔢 Scalar types -The scalar model is intentionally limited to `f64` for floating-point work and -exact rationals behind the optional `"exact"` feature. This matches the crate's -focus on small, robustness-sensitive numerical and computational geometry -workloads. When `f64` precision is insufficient (e.g. near-degenerate geometric -configurations), the optional `"exact"` feature provides arbitrary-precision -arithmetic via `BigRational` (see below). +The public scalar model deliberately has exactly two input domains: + +- finite `f64` through `Matrix` and `Vector` for floating-point work; +- arbitrary-precision `BigRational` through `RationalMatrix` and + `RationalVector` behind the optional `"exact"` feature. + +This is not a generic scalar-parameterized API. Exact support intentionally +covers the robustness-sensitive operations that require it: determinant sign, +determinant value, and linear solve, followed by explicit strict or rounded +conversion when an `f64` result is required. It does not promise a +`BigRational` counterpart for every floating-point helper or factorization. Lower-precision `f32` / `f16` throughput-oriented workloads are outside the crate's scope; they usually indicate large-matrix or accelerator-oriented use @@ -119,7 +129,8 @@ la-stack = "0.4.5" ### Feature flags - `default`: no runtime dependencies -- `exact`: `BigRational` exact determinant and solve APIs +- `exact`: exact determinant signs, determinant values, and solves over stored + `f64` values or caller-supplied `BigRational` inputs - `bench`: repository-development gate used only by benchmark targets and benchmark-input tests; application crates should not enable it @@ -270,11 +281,16 @@ rationals (this pulls in `num-bigint`, `num-rational`, and `num-traits` for la-stack = { version = "0.4.5", features = ["exact"] } ``` -These routines are exact with respect to the finite binary64 values stored in -`Matrix` and `Vector`. They treat each stored value as the exact rational number -represented by its bits, so the exact determinant or solve stage introduces no -further roundoff. They cannot recover information already lost when source -values were rounded to `f64` before construction. +The feature exposes two deliberate input domains: + +- `Matrix` / `Vector` store finite binary64 inputs. Their exact methods + treat each stored bit pattern as its exact rational value, so the determinant + or solve stage introduces no further roundoff. They cannot recover information + already lost before construction. +- `RationalMatrix` / `RationalVector` accept coefficients already + assembled as `BigRational`. They preserve derived differences, squared norms, + affine coefficients, and other rational expressions without an intermediate + `f64` conversion. **Determinants:** @@ -297,6 +313,16 @@ values were rounded to `f64` before construction. - **`ExactF64Conversion`** — converts an existing exact determinant or solution under the strict or rounded contract without repeating exact elimination +**Already-exact rational input:** + +- **`RationalMatrix::det_sign()`** — returns the exact sign without constructing + a rational determinant +- **`RationalMatrix::det()`** — returns the exact `BigRational` determinant +- **`RationalMatrix::solve(&rhs)`** — returns a `RationalVector` exact + solution +- **`try_with_rational_matrix!`** — dispatches a runtime-selected dimension + through D=8 to a const-generic rational matrix on stable Rust + Exact determinant value and conversion methods return `LaError::DeterminantScaleOverflow` if the aggregate power-of-two scaling exceeds the internal exponent representation. Exact solve methods return @@ -309,6 +335,73 @@ finite value and `UnrepresentableReason::NotFinite` otherwise. Rounded conversions opt into nearest-even rounding but still report `NotFinite` when no finite `f64` exists. +The following 5×5 system has exact determinant 2^-60. Its exact rational inputs +therefore produce a unique solution through the general Bareiss path. Supplying +the same coefficients as `f64` inputs loses the `2^-60` perturbation at `1.0`, +making the leading rows identical and the binary64 system singular. + +```rust,ignore +use la_stack::prelude::*; + +fn main() -> Result<(), LaError> { + // This is far below one binary64 ULP at 1.0, so 1.0 + 2^-60 rounds to 1.0. + let epsilon = BigRational::new(1.into(), (1_u64 << 60).into()); + let one = BigRational::from_integer(1.into()); + let zero = BigRational::from_integer(0.into()); + + // The leading block is [[1, 1], [1, 1 + 2^-60]]. The remaining diagonal + // extends the example to D=5, where the general Bareiss path is used. + let matrix = RationalMatrix::<5>::try_from_fn(|row, col| match (row, col) { + (0, 0 | 1) | (1, 0) => one.clone(), + (1, 1) => &one + &epsilon, + _ if row == col => one.clone(), + _ => zero.clone(), + })?; + assert_eq!(matrix.det_sign(), DeterminantSign::Positive); + assert_eq!(matrix.det(), epsilon); + + let rhs = RationalVector::try_new([ + zero, + -&epsilon, + BigRational::from_integer(2.into()), + BigRational::from_integer(3.into()), + BigRational::from_integer(4.into()), + ])?; + let exact_solution = matrix.solve(&rhs)?; + assert_eq!( + exact_solution.as_array(), + &[ + BigRational::from_integer(1.into()), + BigRational::from_integer((-1).into()), + BigRational::from_integer(2.into()), + BigRational::from_integer(3.into()), + BigRational::from_integer(4.into()), + ] + ); + + // Supplying the same coefficients as f64 inputs destroys the perturbation + // and makes the matrix singular, even though the exact solution is integral. + let epsilon_f64 = epsilon.try_to_f64()?; + assert_eq!(1.0 + epsilon_f64, 1.0); + let f64_matrix = Matrix::<5>::try_from_rows([ + [1.0, 1.0, 0.0, 0.0, 0.0], + [1.0, 1.0 + epsilon_f64, 0.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 1.0], + ])?; + let f64_rhs = Vector::<5>::try_new([0.0, -epsilon_f64, 2.0, 3.0, 4.0])?; + let f64_solve = f64_matrix + .lu(DEFAULT_SINGULAR_TOL) + .and_then(|lu| lu.solve(f64_rhs)); + assert!(matches!( + f64_solve, + Err(LaError::Singular { .. }) + )); + Ok(()) +} +``` + ```rust,ignore use la_stack::prelude::*; @@ -369,8 +462,9 @@ fn main() -> Result<(), LaError> { } ``` -With the `exact` feature enabled, `DeterminantSign`, `ExactF64Conversion`, -`BigInt`, and `BigRational` are re-exported from the crate root and prelude, +With the `exact` feature enabled, `RationalMatrix`, `RationalVector`, +`DeterminantSign`, `ExactF64Conversion`, `BigInt`, and `BigRational` are +re-exported from the crate root and prelude, alongside the most commonly needed `num-traits` items (`FromPrimitive`, `ToPrimitive`, `Signed`). This lets consumers construct exact values (`BigRational::from_f64`, `from_i64`), query sign (`is_positive` / @@ -468,14 +562,19 @@ out of the common prelude. |---|---|---|---| | `Vector` | `[f64; D]` | Finite fixed-length vector for input and computation | `try_new`, `as_array`, `into_array`, `dot`, `norm2_sq` | | `Matrix` | `[[f64; D]; D]` | Finite square matrix for input and computation | See below | +| `RationalVector`¹ | `[BigRational; D]` | Exact rational right-hand side and solution | `try_new`, `try_from_fn`, `as_array`, `into_array`, `get` | +| `RationalMatrix`¹ | `[[BigRational; D]; D]` | Exact rational matrix for determinant and solve operations | `try_from_rows`, `try_from_fn`, `as_rows`, `det_sign`, `det`, `solve` | | `DeterminantWithErrorBound` | Opaque validated pair | Paired direct determinant and certified absolute bound | `determinant`, `absolute_error_bound` | | `Lu` | Inline factors + permutation | Factorization for solves/det | `solve`, `det` | | `Ldlt` | Inline factors | No-pivot SPD factorization for solves/det | `solve`, `det` | | `Tolerance` | finite non-negative `f64` | Validated numerical threshold | `try_new`, `get` | | `LaError` | typed variants and reasons | Structured, actionable failure reporting | See error semantics below | | `DeterminantSign`¹ | enum | Exact determinant sign | `as_i8` | +| `ExactF64Conversion`¹ | trait | Strict or explicitly rounded conversion of exact results to `f64` | `try_to_f64`, `to_rounded_f64` | -Storage shown above reflects the intentional `f64` scalar model. +`Matrix` and `Vector` use the intentional inline `f64` scalar model. +The exact-feature rational types retain fixed-size outer arrays while their +`BigRational` scalars use arbitrary-precision integer storage. For a runtime dimension from 0 through `MAX_STACK_MATRIX_DISPATCH_DIM` (7), `try_with_stack_matrix!` dispatches to a concrete `Matrix` while preserving @@ -484,6 +583,13 @@ inline stack storage. Larger dimensions produce closure's declared `Result` error type; the macro does not introduce a dynamically sized matrix representation. +With the exact feature, a runtime dimension from 0 through +`MAX_RATIONAL_MATRIX_DISPATCH_DIM` (8) can similarly be dispatched with +`try_with_rational_matrix!` to a concrete `RationalMatrix`. Larger +dimensions produce `LaError::UnsupportedDimension`; the rational macro also +preserves the const-generic representation rather than introducing a +dynamically sized matrix type. + `Matrix` key methods: `as_rows`, `into_rows`, `lu`, `ldlt`, `det`, `det_direct`, `det_direct_with_errbound`, `det_errbound`, `det_exact`¹, `det_exact_f64`¹, `det_exact_rounded_f64`¹, `det_sign_exact`¹, @@ -547,6 +653,11 @@ release-comparison workflow details, see [docs/BENCHMARKING.md](https://github.com/acgetchell/la-stack/blob/v0.4.5/docs/BENCHMARKING.md). For the current release-to-release performance snapshot, see [docs/PERFORMANCE.md](https://github.com/acgetchell/la-stack/blob/v0.4.5/docs/PERFORMANCE.md). +The exact release suite includes the already-exact rational-input groups for +D=2 through D=8. Those rows report `RationalMatrix::det_sign`, `det`, and +`solve` alongside straightforward `BigRational` Gaussian determinant and solve +references, with Criterion point estimates and confidence intervals generated +for every release. diff --git a/benches/exact.rs b/benches/exact.rs index a004754..75a87ac 100644 --- a/benches/exact.rs +++ b/benches/exact.rs @@ -3,7 +3,7 @@ //! Benchmarks for exact arithmetic operations. //! //! These benchmarks measure the performance of the `exact` feature's -//! arbitrary-precision methods. They are organised into three classes: +//! arbitrary-precision methods. They are organised into four classes: //! //! 1. **General-case benches** (`exact_d{2..5}`) — a single //! well-conditioned diagonally-dominant matrix per dimension. These @@ -22,6 +22,10 @@ //! fixed-seed corpus of diagonally-dominant random matrices per dimension. //! Every measured iteration executes the full corpus in its stable order, //! so current and baseline revisions receive identical workloads. +//! 4. **Exact-input rational benches** (`rational_input_d{2..8}`) — compare +//! row-denominator clearing plus integer Bareiss elimination with +//! straightforward cubic `BigRational` Gaussian elimination on identical +//! rational matrices and right-hand sides. //! //! Fallible exact-to-f64 conversions use a `_result` suffix. Those rows measure //! the full `Result` path, including valid `Err(Unrepresentable)` outcomes for @@ -31,6 +35,8 @@ use std::hint::black_box; 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::{BigInt, BigRational, DeterminantSign, RationalMatrix, RationalVector}; use la_stack::{Matrix, Vector}; #[path = "common/bench_utils.rs"] @@ -90,6 +96,207 @@ const CORPUS_AND_EXTREME_OPERATIONS: &[ExactOperation] = &[ ExactOperation::SolveExactRoundedF64, ]; +/// Independently validated exact-input rational benchmark fixture. +#[cfg(not(any(la_stack_pre_rational_input_api, la_stack_v0_4_3_api)))] +#[must_use] +struct ValidatedRationalInput { + matrix: RationalMatrix, + rhs: RationalVector, +} + +/// Deterministic, strictly diagonally-dominant exact-input rational fixture. +#[cfg(not(any(la_stack_pre_rational_input_api, la_stack_v0_4_3_api)))] +fn rational_input() -> ValidatedRationalInput { + let rows = std::array::from_fn(|row| { + std::array::from_fn(|col| { + if row == col { + let diagonal = 2 * D + row + 1; + BigRational::from_integer(BigInt::from(diagonal)) + } else { + let raw_numerator = (row * 3 + col * 5) % 5; + let numerator = i64::try_from(raw_numerator).or_abort("rational numerator") - 2; + let denominator = (row + col) % 7 + 2; + BigRational::new(BigInt::from(numerator), BigInt::from(denominator)) + } + }) + }); + let expected_solution = std::array::from_fn(|index| { + BigRational::new(BigInt::from(index + 1), BigInt::from(index + 2)) + }); + let rhs_data = rational_matvec(&rows, &expected_solution); + let matrix = RationalMatrix::try_from_rows(rows.clone()) + .or_abort("rational benchmark matrix construction"); + let rhs = + RationalVector::try_new(rhs_data.clone()).or_abort("rational benchmark RHS construction"); + + let reference_determinant = rational_determinant_gaussian(rows.clone()); + assert_eq!(matrix.det(), reference_determinant); + assert_eq!(matrix.det_sign(), determinant_sign(&reference_determinant)); + let reference_solution = rational_solve_gaussian(rows, rhs_data) + .or_abort("rational Gaussian benchmark validation solve"); + assert_eq!(reference_solution, expected_solution); + assert_eq!( + matrix + .solve(&rhs) + .or_abort("row-cleared Bareiss benchmark validation solve") + .into_array(), + expected_solution + ); + + ValidatedRationalInput { matrix, rhs } +} + +/// Exact rational matrix-vector multiplication used only to assemble and +/// validate benchmark fixtures. +#[cfg(not(any(la_stack_pre_rational_input_api, la_stack_v0_4_3_api)))] +fn rational_matvec( + rows: &[[BigRational; D]; D], + vector: &[BigRational; D], +) -> [BigRational; D] { + std::array::from_fn(|row| { + rows[row] + .iter() + .zip(vector.iter()) + .map(|(coefficient, component)| coefficient * component) + .sum() + }) +} + +/// Straightforward cubic rational Gaussian determinant reference. +#[cfg(not(any(la_stack_pre_rational_input_api, la_stack_v0_4_3_api)))] +fn rational_determinant_gaussian(mut rows: [[BigRational; D]; D]) -> BigRational { + let zero = BigRational::from_integer(BigInt::from(0)); + let mut determinant = BigRational::from_integer(BigInt::from(1)); + let mut odd_swaps = false; + + for pivot_col in 0..D { + let Some(pivot_row) = (pivot_col..D).find(|&row| rows[row][pivot_col] != zero) else { + return zero; + }; + if pivot_row != pivot_col { + rows.swap(pivot_col, pivot_row); + odd_swaps = !odd_swaps; + } + + let pivot = rows[pivot_col][pivot_col].clone(); + let pivot_entries = rows[pivot_col].clone(); + determinant *= &pivot; + for row_entries in rows.iter_mut().skip(pivot_col + 1) { + let factor = &row_entries[pivot_col] / &pivot; + for (entry, pivot_entry) in row_entries + .iter_mut() + .zip(pivot_entries.iter()) + .skip(pivot_col + 1) + { + *entry -= &factor * pivot_entry; + } + row_entries[pivot_col] = zero.clone(); + } + } + + if odd_swaps { -determinant } else { determinant } +} + +/// Straightforward cubic rational Gaussian solve reference. +#[cfg(not(any(la_stack_pre_rational_input_api, la_stack_v0_4_3_api)))] +fn rational_solve_gaussian( + mut rows: [[BigRational; D]; D], + mut rhs: [BigRational; D], +) -> Option<[BigRational; D]> { + let zero = BigRational::from_integer(BigInt::from(0)); + for pivot_col in 0..D { + let pivot_row = (pivot_col..D).find(|&row| rows[row][pivot_col] != zero)?; + if pivot_row != pivot_col { + rows.swap(pivot_col, pivot_row); + rhs.swap(pivot_col, pivot_row); + } + + let pivot = rows[pivot_col][pivot_col].clone(); + let pivot_entries = rows[pivot_col].clone(); + let pivot_rhs = rhs[pivot_col].clone(); + for (row_entries, rhs_entry) in rows.iter_mut().zip(rhs.iter_mut()).skip(pivot_col + 1) { + let factor = &row_entries[pivot_col] / &pivot; + for (entry, pivot_entry) in row_entries + .iter_mut() + .zip(pivot_entries.iter()) + .skip(pivot_col + 1) + { + *entry -= &factor * pivot_entry; + } + let rhs_update = &factor * &pivot_rhs; + *rhs_entry -= rhs_update; + row_entries[pivot_col] = zero.clone(); + } + } + + let mut solution = std::array::from_fn(|_| zero.clone()); + for row in (0..D).rev() { + let mut value = rhs[row].clone(); + for (coefficient, component) in rows[row].iter().zip(solution.iter()).skip(row + 1) { + value -= coefficient * component; + } + solution[row] = value / &rows[row][row]; + } + Some(solution) +} + +#[cfg(not(any(la_stack_pre_rational_input_api, la_stack_v0_4_3_api)))] +fn determinant_sign(value: &BigRational) -> DeterminantSign { + let zero = BigRational::from_integer(BigInt::from(0)); + match value.cmp(&zero) { + std::cmp::Ordering::Less => DeterminantSign::Negative, + std::cmp::Ordering::Equal => DeterminantSign::Zero, + std::cmp::Ordering::Greater => DeterminantSign::Positive, + } +} + +/// Compare exact-input row clearing and Bareiss elimination with direct +/// `BigRational` Gaussian elimination. +#[cfg(not(any(la_stack_pre_rational_input_api, la_stack_v0_4_3_api)))] +fn bench_rational_input(criterion: &mut Criterion) { + let input = rational_input::(); + let mut group = criterion.benchmark_group(format!("rational_input_d{D}")); + + group.bench_function("det_sign_row_cleared_bareiss", |bencher| { + bencher.iter(|| { + let sign = black_box(&input.matrix).det_sign(); + let _ = black_box(sign); + }); + }); + group.bench_function("det_row_cleared_bareiss", |bencher| { + bencher.iter(|| { + let determinant = black_box(&input.matrix).det(); + black_box(determinant); + }); + }); + group.bench_function("det_big_rational_gaussian", |bencher| { + 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(|| { + let solution = black_box(&input.matrix) + .solve(black_box(&input.rhs)) + .or_abort("row-cleared Bareiss rational benchmark solve"); + let _ = black_box(solution); + }); + }); + group.bench_function("solve_big_rational_gaussian", |bencher| { + 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(); +} + /// Execute one exact operation on a borrowed, independently validated input. fn run_exact_operation(operation: ExactOperation, input: &ValidatedExactInput) { match operation { @@ -325,6 +532,22 @@ fn main() { gen_random_corpus_benches_for_dim!(&mut c, 5); } + #[cfg(not(any(la_stack_pre_rational_input_api, la_stack_v0_4_3_api)))] + { + // === Already-exact rational-input comparisons === + // + // These compare the production row-cleared integer Bareiss backend with + // straightforward BigRational Gaussian elimination on dimensions needed + // by downstream geometric predicates and runtime-selected basis systems. + bench_rational_input::<2>(&mut c); + bench_rational_input::<3>(&mut c); + bench_rational_input::<4>(&mut c); + bench_rational_input::<5>(&mut c); + bench_rational_input::<6>(&mut c); + bench_rational_input::<7>(&mut c); + bench_rational_input::<8>(&mut c); + } + // === Adversarial / extreme-input groups === // // Each group runs the same five exact-arithmetic benches diff --git a/docs/BENCHMARKING.md b/docs/BENCHMARKING.md index 40ecf40..453b097 100644 --- a/docs/BENCHMARKING.md +++ b/docs/BENCHMARKING.md @@ -480,6 +480,11 @@ random-corpus groups, and adversarial-input groups: - `exact_hilbert_4x4` / `exact_hilbert_5x5` — classically ill-conditioned matrices whose binary64 entries have varied mantissas and exponents, stressing the `decompose_f64 -> BigInt` scaling path. +- `rational_input_d{2..8}` — already-exact, diagonally-dominant rational + systems. These compare public `RationalMatrix::det_sign`, `det`, and `solve` + calls using row-denominator clearing plus integer Bareiss elimination with + straightforward cubic `BigRational` Gaussian determinant and solve + references on identical matrices and right-hand sides. Each random-corpus and adversarial group runs the same exact-arithmetic benches (`det_sign_exact`, `det_exact`, `solve_exact`, @@ -497,6 +502,12 @@ and first failing component. These checks run outside timed Criterion closures. Any disagreement or unexpected error fails setup instead of becoming an artificially fast measurement. +The rational-input groups are part of the canonical exact release signal, so +every release benchmark archive and generated performance report includes their +Criterion point estimates and confidence intervals. When the comparison +baseline predates the rational-input API, the report retains current-only rows +with an explicit coverage note and does not calculate a cross-release ratio. + The proof-bearing fixture is therefore a prerequisite correctness gate, not a claim that every timed sample is revalidated. Criterion closures remain free of oracle work so their measurements cover only the named operation; the operation diff --git a/docs/mathematical_basis.md b/docs/mathematical_basis.md index 972201e..ec56a53 100644 --- a/docs/mathematical_basis.md +++ b/docs/mathematical_basis.md @@ -170,6 +170,31 @@ finite output exists only after rounding; `NotFinite` means even the rounded result cannot be finite. The explicit rounded conversions use round-to-nearest, ties-to-even \[9-10\]. A nonzero exact value may consequently round to zero. +## Exact arithmetic over rational inputs + +`RationalMatrix` and `RationalVector` are a separate input domain for +coefficients assembled exactly before linear algebra begins. For each matrix +row `i`, the implementation selects a positive least common multiple `sᵢ` of +the rational denominators and forms the integer row `A_int[i, :] = sᵢ A[i, :]`. +Therefore + +```text +sign(det(A_int)) = sign(det(A)) +det(A) = det(A_int) / ∏ᵢ sᵢ. +``` + +The sign path reads the `BigInt` determinant sign directly and never constructs +the rational determinant value. Solves include the corresponding right-hand +side denominator in `sᵢ`, so each augmented row is multiplied by the same +positive factor and the solution set is unchanged. The integer determinant and +solve states use the same direct-expansion/Bareiss backend as exact operations +over binary64 inputs, followed by rational back-substitution for solves \[7\]. + +The rational types are const-generic and shape-safe after construction. The +`try_with_rational_matrix!` helper provides explicit runtime dispatch through +D=8 without unstable generic const expressions. Conversion to binary64 remains +a separate strict or explicitly rounded `ExactF64Conversion` operation. + ## Tolerances and typed errors `Tolerance::try_new` accepts finite values greater than or equal to zero. @@ -205,6 +230,7 @@ required by `Matrix::ldlt`. | `D ≤ 4` error-bounded determinant/sign test | `det_direct_with_errbound` | Sign is certified when estimate magnitude exceeds bound; otherwise inconclusive | | Exact determinant sign | `det_sign_exact` | Exact for stored binary64 entries | | Exact determinant value or solve | `det_exact`, `solve_exact` | Exact for represented inputs | +| Exact operations over preassembled rationals | `RationalMatrix::det_sign`, `det`, `solve` | No intermediate binary64 reconstruction | | Binary64 output from an exact result | Strict or rounded conversions | Strict conversion forbids rounding | ## Geometry relationship and scope diff --git a/examples/rational_input_5x5.rs b/examples/rational_input_5x5.rs new file mode 100644 index 0000000..7e325e8 --- /dev/null +++ b/examples/rational_input_5x5.rs @@ -0,0 +1,50 @@ +#![forbid(unsafe_code)] + +//! Solve a rational 5×5 system that becomes singular after conversion to binary64. + +use la_stack::prelude::*; + +fn main() -> Result<(), LaError> { + let epsilon = BigRational::new(1.into(), (1_u64 << 60).into()); + let one = BigRational::from_integer(1.into()); + let zero = BigRational::from_integer(0.into()); + + let matrix = RationalMatrix::<5>::try_from_fn(|row, col| match (row, col) { + (0, 0 | 1) | (1, 0) => one.clone(), + (1, 1) => &one + &epsilon, + _ if row == col => one.clone(), + _ => zero.clone(), + })?; + let rhs = RationalVector::try_new([ + zero, + -&epsilon, + BigRational::from_integer(2.into()), + BigRational::from_integer(3.into()), + BigRational::from_integer(4.into()), + ])?; + + let exact_solution = matrix.solve(&rhs)?; + println!("exact determinant: {}", matrix.det()); + println!("exact determinant sign: {:?}", matrix.det_sign()); + println!("exact solution: {:?}", exact_solution.as_array()); + + let epsilon_f64 = epsilon.try_to_f64()?; + assert_eq!(1.0 + epsilon_f64, 1.0); + let f64_matrix = Matrix::<5>::try_from_rows([ + [1.0, 1.0, 0.0, 0.0, 0.0], + [1.0, 1.0 + epsilon_f64, 0.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 1.0], + ])?; + let f64_rhs = Vector::<5>::try_new([0.0, -epsilon_f64, 2.0, 3.0, 4.0])?; + let f64_solve = f64_matrix + .lu(DEFAULT_SINGULAR_TOL) + .and_then(|lu| lu.solve(f64_rhs)); + assert!(matches!(&f64_solve, Err(LaError::Singular { .. }))); + + println!("1.0 + 2^-60 supplied as f64: {}", 1.0 + epsilon_f64); + println!("solve from f64 inputs: {f64_solve:?}"); + + Ok(()) +} diff --git a/scripts/archive_performance.py b/scripts/archive_performance.py index 4620ed3..1156516 100644 --- a/scripts/archive_performance.py +++ b/scripts/archive_performance.py @@ -81,6 +81,8 @@ _BENCHMARK_HARNESS_METADATA = ".la-stack-benchmark-harness.json" _BENCHMARK_INPUT_GATE = ("just", "test-bench-inputs") _COMPARISON_LINT_CAP = "--cap-lints=warn" +_PRE_RATIONAL_INPUT_API_CFG = "la_stack_pre_rational_input_api" +_PRE_RATIONAL_INPUT_API_TAGS = frozenset({"v0.4.4", "v0.4.5"}) _V0_4_3_API_CFG = "la_stack_v0_4_3_api" _V0_4_3_TAG = "v0.4.3" type BaselineSource = Literal["local", "github-assets"] @@ -929,7 +931,12 @@ def _append_rustflag(env: dict[str, str], flag: str) -> None: def _baseline_api_compatibility(baseline_tag: str) -> str | None: """Return the shared-harness API adapter required by one baseline tag.""" - return _V0_4_3_API_CFG if normalize_tag(baseline_tag) == _V0_4_3_TAG else None + normalized = normalize_tag(baseline_tag) + if normalized == _V0_4_3_TAG: + return _V0_4_3_API_CFG + if normalized in _PRE_RATIONAL_INPUT_API_TAGS: + return _PRE_RATIONAL_INPUT_API_CFG + return None def _comparison_benchmark_env(checkout: Path, *, baseline_tag: str | None = None) -> dict[str, str]: @@ -1006,7 +1013,7 @@ def _selected_criterion_groups(criterion_dir: Path, *, suite: str) -> list[Path] for child in criterion_dir.iterdir(): if not child.is_dir(): continue - is_exact = child.name.startswith("exact_") + is_exact = child.name.startswith("exact_") or re.fullmatch(r"rational_input_d[0-9]+", child.name) is not None is_vs_linalg = re.fullmatch(r"d[0-9]+", child.name) is not None if (suite in {"all", "exact"} and is_exact) or (suite in {"all", "vs_linalg"} and is_vs_linalg): groups.append(child) diff --git a/scripts/bench_compare.py b/scripts/bench_compare.py index 8b665e2..484574f 100644 --- a/scripts/bench_compare.py +++ b/scripts/bench_compare.py @@ -59,8 +59,9 @@ # Groups and the benchmarks within each group that we track. # # Mirrors the structure of `benches/exact.rs`: general-case per-dimension -# groups (`exact_d{2..5}`), fixed-seed full-corpus groups, plus -# adversarial/extreme-input groups that share a fixed five-bench layout +# groups (`exact_d{2..5}`), fixed-seed full-corpus groups, rational-input +# comparisons (`rational_input_d{2..8}`), plus adversarial/extreme-input groups +# that share a fixed five-bench layout # (`det_sign_exact`, `det_exact`, `solve_exact`, `solve_exact_f64_result`, # `solve_exact_rounded_f64`). _EXTREME_BENCHES: list[str] = [ @@ -90,6 +91,14 @@ *_EXACT_DIMENSION_BENCHES[1:], ] +_RATIONAL_INPUT_BENCHES: list[str] = [ + "det_sign_row_cleared_bareiss", + "det_row_cleared_bareiss", + "det_big_rational_gaussian", + "solve_row_cleared_bareiss", + "solve_big_rational_gaussian", +] + EXACT_GROUPS: dict[str, list[str]] = { "exact_d2": _EXACT_DIMENSION_BENCHES_WITH_DIRECT, "exact_d3": _EXACT_DIMENSION_BENCHES_WITH_DIRECT, @@ -99,6 +108,7 @@ "exact_random_corpus_d3": _RANDOM_CORPUS_BENCHES, "exact_random_corpus_d4": _RANDOM_CORPUS_BENCHES, "exact_random_corpus_d5": _RANDOM_CORPUS_BENCHES, + **{f"rational_input_d{dimension}": _RATIONAL_INPUT_BENCHES for dimension in range(2, 9)}, "exact_near_singular_3x3": _EXTREME_BENCHES, "exact_large_entries_3x3": _EXTREME_BENCHES, "exact_hilbert_4x4": _EXTREME_BENCHES, @@ -155,17 +165,26 @@ "la_stack_det_from_ldlt_balanced_range", ] _V0_4_3_API_COMPATIBILITY = "la_stack_v0_4_3_api" -_V0_4_3_UNAVAILABLE_BASELINE_ROWS: frozenset[tuple[str, str]] = frozenset( - { - ("exact_d2", "det_direct_with_errbound"), - ("exact_d3", "det_direct_with_errbound"), - ("exact_d4", "det_direct_with_errbound"), - ("d8", "la_stack_det_from_lu_balanced_range"), - ("d8", "la_stack_det_from_ldlt_balanced_range"), - } +_PRE_RATIONAL_INPUT_API_COMPATIBILITY = "la_stack_pre_rational_input_api" +_PRE_RATIONAL_INPUT_API_BASELINES = frozenset({"v0.4.4", "v0.4.5"}) +_RATIONAL_INPUT_ROWS: frozenset[tuple[str, str]] = frozenset( + (group, bench) for group, benches in EXACT_GROUPS.items() if group.startswith("rational_input_d") for bench in benches +) +_V0_4_3_UNAVAILABLE_BASELINE_ROWS: frozenset[tuple[str, str]] = ( + frozenset( + { + ("exact_d2", "det_direct_with_errbound"), + ("exact_d3", "det_direct_with_errbound"), + ("exact_d4", "det_direct_with_errbound"), + ("d8", "la_stack_det_from_lu_balanced_range"), + ("d8", "la_stack_det_from_ldlt_balanced_range"), + } + ) + | _RATIONAL_INPUT_ROWS ) _UNAVAILABLE_BASELINE_ROWS_BY_COMPATIBILITY: dict[str, frozenset[tuple[str, str]]] = { _V0_4_3_API_COMPATIBILITY: _V0_4_3_UNAVAILABLE_BASELINE_ROWS, + _PRE_RATIONAL_INPUT_API_COMPATIBILITY: _RATIONAL_INPUT_ROWS, } VS_LINALG_RELEASE_SIGNAL_BENCHES_BY_DIM: dict[int, list[str]] = { 8: VS_LINALG_D8_RELEASE_SIGNAL_BENCHES, @@ -780,9 +799,14 @@ def _validate_validation_metadata(data: Mapping[str, object], *, path: Path) -> def _validate_baseline_api_compatibility(data: Mapping[str, object], *, baseline: str, path: Path) -> None: - """Bind the only supported compatibility adapter to its baseline.""" + """Bind each supported compatibility adapter to its baseline releases.""" compatibility = data.get("baseline_api_compatibility") - expected = _V0_4_3_API_COMPATIBILITY if baseline == "v0.4.3" else "none" + if baseline == "v0.4.3": + expected = _V0_4_3_API_COMPATIBILITY + elif baseline in _PRE_RATIONAL_INPUT_API_BASELINES: + expected = _PRE_RATIONAL_INPUT_API_COMPATIBILITY + else: + expected = "none" if compatibility != expected: msg = f"validation.baseline_api_compatibility in {path} must be {expected!r} for baseline {baseline!r}, got {compatibility!r}" raise ValueError(msg) @@ -1362,10 +1386,13 @@ def _group_heading(group: str) -> str: # exact_d3 -> "D=3", exact_random_corpus_d3 -> # "Random corpus D=3", exact_near_singular_3x3 -> # "Near-singular 3x3", exact_hilbert_4x4 -> "Hilbert 4x4", etc. - if group.startswith("exact_random_corpus_d"): - return f"Random corpus D={group.removeprefix('exact_random_corpus_d')}" - if group.startswith("exact_d"): - return f"D={group.removeprefix('exact_d')}" + for prefix, label in ( + ("exact_random_corpus_d", "Random corpus D="), + ("rational_input_d", "Rational input D="), + ("exact_d", "D="), + ): + if group.startswith(prefix): + return f"{label}{group.removeprefix(prefix)}" if group == "exact_near_singular_3x3": return "Near-singular 3x3" if group == "exact_large_entries_3x3": @@ -1879,6 +1906,17 @@ def _provenance_markdown( " because v0.4.3 predates the paired API; the comparable `det_errbound` baselines remain required.", ] ) + if ( + include_compatibility_rows + and compatibility in {_V0_4_3_API_COMPATIBILITY, _PRE_RATIONAL_INPUT_API_COMPATIBILITY} + and criterion.suite in {"all", "exact"} + ): + lines.extend( + [ + "- Baseline-unavailable rows: `rational_input_d{2..8}/*` were not timed because the baseline", + " predates the exact rational-input API; current samples remain required, but no cross-release speedup is claimed.", + ] + ) return lines diff --git a/scripts/performance_artifacts.py b/scripts/performance_artifacts.py index 5bbee61..a0cc896 100644 --- a/scripts/performance_artifacts.py +++ b/scripts/performance_artifacts.py @@ -409,7 +409,21 @@ def _validate_measurement_provenance(measurement: Mapping[str, object], *, mode: return measurement_status -def _validate_validation_provenance(validation: Mapping[str, object], *, baseline: str) -> str: +def _expected_baseline_api_compatibility(*, current: str, baseline: str) -> str: + """Return the compatibility adapter required by one release pair.""" + if baseline == "v0.4.3": + return "la_stack_v0_4_3_api" + if baseline in {"v0.4.4", "v0.4.5"} and current not in {"v0.4.4", "v0.4.5"}: + return "la_stack_pre_rational_input_api" + return "none" + + +def _validate_validation_provenance( + validation: Mapping[str, object], + *, + current: str, + baseline: str, +) -> str: """Validate fixture-gate evidence for both compared revisions.""" if validation.get("command") != ["just", "test-bench-inputs"]: msg = "benchmark provenance validation.command must be ['just', 'test-bench-inputs']" @@ -426,7 +440,10 @@ def _validate_validation_provenance(validation: Mapping[str, object], *, baselin for field in ("current_git_clean", "baseline_git_clean"): _required_provenance_bool(validation, field, context="validation") compatibility = _required_provenance_string(validation, "baseline_api_compatibility", context="validation") - expected_compatibility = "la_stack_v0_4_3_api" if baseline == "v0.4.3" else "none" + expected_compatibility = _expected_baseline_api_compatibility( + current=current, + baseline=baseline, + ) if compatibility != expected_compatibility: msg = f"benchmark provenance validation.baseline_api_compatibility must be {expected_compatibility!r} for baseline {baseline!r}, got {compatibility!r}" raise ValueError(msg) @@ -530,7 +547,11 @@ def _validate_benchmark_provenance(data: Mapping[str, object], *, context: Artif if measurement_status != expected_status: msg = f"benchmark provenance mode {mode!r} requires measurement.status {expected_status!r}, got {measurement_status!r}" raise ValueError(msg) - compatibility = _validate_validation_provenance(validation, baseline=context.release.baseline) + compatibility = _validate_validation_provenance( + validation, + current=context.release.current, + baseline=context.release.baseline, + ) _validate_current_revision_consistency(publication, validation) if measurement_status == "recorded": _validate_recorded_measurement_consistency( diff --git a/scripts/tests/test_archive_performance.py b/scripts/tests/test_archive_performance.py index 6d296bc..d022f1e 100644 --- a/scripts/tests/test_archive_performance.py +++ b/scripts/tests/test_archive_performance.py @@ -272,8 +272,9 @@ def test_purge_selected_new_samples_preserves_named_baselines_and_other_suites(t criterion_dir = tmp_path / "criterion" exact_new = criterion_dir / "exact_d2" / "det_exact" / "new" exact_baseline = criterion_dir / "exact_d2" / "det_exact" / "v0.4.2" + rational_new = criterion_dir / "rational_input_d8" / "det_row_cleared_bareiss" / "new" linalg_new = criterion_dir / "d2" / "la_stack_lu" / "new" - for directory in (exact_new, exact_baseline, linalg_new): + for directory in (exact_new, exact_baseline, rational_new, linalg_new): directory.mkdir(parents=True) (directory / "estimates.json").write_text("{}\n", encoding="utf-8") @@ -282,8 +283,9 @@ def test_purge_selected_new_samples_preserves_named_baselines_and_other_suites(t suite="exact", ) - assert removed == [exact_new] + assert removed == [exact_new, rational_new] assert not exact_new.exists() + assert not rational_new.exists() assert exact_baseline.is_dir() assert linalg_new.is_dir() @@ -877,6 +879,27 @@ def test_comparison_benchmark_env_preserves_flags_and_selects_v043_adapter( assert current["RUSTUP_TOOLCHAIN"] == "1.97.0" +@pytest.mark.parametrize("baseline_tag", ["v0.4.4", "0.4.5"]) +def test_comparison_benchmark_env_selects_pre_rational_adapter( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + baseline_tag: str, +) -> None: + monkeypatch.delenv("CARGO_ENCODED_RUSTFLAGS", raising=False) + monkeypatch.delenv("RUSTUP_TOOLCHAIN", raising=False) + (tmp_path / "rust-toolchain.toml").write_text( + '[toolchain]\nchannel = "1.98.0"\n', + encoding="utf-8", + ) + + baseline = archive_performance._comparison_benchmark_env( + tmp_path, + baseline_tag=baseline_tag, + ) + + assert baseline["RUSTFLAGS"] == ("--cap-lints=warn --cfg=la_stack_pre_rational_input_api") + + def test_comparison_benchmark_env_extends_encoded_rustflags( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, diff --git a/scripts/tests/test_bench_compare.py b/scripts/tests/test_bench_compare.py index d4931e2..ebafa49 100644 --- a/scripts/tests/test_bench_compare.py +++ b/scripts/tests/test_bench_compare.py @@ -161,6 +161,10 @@ def _build_criterion_tree(criterion_dir: Path, stat: str = "median") -> None: _write_estimates(random_group / "solve_exact_f64_result" / "new" / "estimates.json", stat, 54000.0) _write_estimates(random_group / "solve_exact_rounded_f64" / "new" / "estimates.json", stat, 55000.0) + rational_group = criterion_dir / "rational_input_d8" + for index, bench in enumerate(bench_compare._RATIONAL_INPUT_BENCHES, start=1): + _write_estimates(rational_group / bench / "new" / "estimates.json", stat, 100000.0 * index) + def _build_vs_linalg_tree(criterion_dir: Path, stat: str = "median") -> None: """Create fake Criterion vs_linalg data with latest la-stack and baseline peer rows.""" @@ -223,6 +227,9 @@ def test_near_singular(self) -> None: def test_random_corpus(self) -> None: assert bench_compare._group_heading("exact_random_corpus_d4") == "Random corpus D=4" + def test_rational_input(self) -> None: + assert bench_compare._group_heading("rational_input_d8") == "Rational input D=8" + def test_large_entries(self) -> None: assert bench_compare._group_heading("exact_large_entries_3x3") == "Large entries 3x3" @@ -245,6 +252,14 @@ def test_exact_registry_only_tracks_supported_direct_determinant_filters() -> No assert not set(filter_benches) & set(bench_compare.EXACT_GROUPS["exact_d5"]) +def test_exact_registry_tracks_every_rational_input_release_dimension() -> None: + expected = bench_compare._RATIONAL_INPUT_BENCHES + assert [group for group in bench_compare.EXACT_GROUPS if group.startswith("rational_input_d")] == [ + f"rational_input_d{dimension}" for dimension in range(2, 9) + ] + assert all(bench_compare.EXACT_GROUPS[f"rational_input_d{dimension}"] == expected for dimension in range(2, 9)) + + # --------------------------------------------------------------------------- # read_estimate # --------------------------------------------------------------------------- @@ -375,12 +390,13 @@ def test_criterion_estimate_rejects_partial_interval_even_when_constructed_direc def test_collect_results(tmp_path: Path) -> None: _build_criterion_tree(tmp_path) results = bench_compare._collect_results(tmp_path, "new", "median") - assert len(results) == 18 # 6 benches x 2 dims + 3 near-singular + 3 random corpus + assert len(results) == 23 # Existing fixtures plus five rational-input rows. groups = {r.group for r in results} assert "exact_d2" in groups assert "exact_d3" in groups assert "exact_random_corpus_d3" in groups assert "exact_near_singular_3x3" in groups + assert "rational_input_d8" in groups def test_collect_results_empty_dir(tmp_path: Path) -> None: @@ -509,6 +525,33 @@ def test_v043_adapter_reports_missing_current_for_unavailable_paired_row(tmp_pat assert not gap.missing_baseline +def test_pre_rational_adapter_retains_current_rational_rows_without_baselines(tmp_path: Path) -> None: + for group, bench in bench_compare._RATIONAL_INPUT_ROWS: + _write_estimates(tmp_path / group / bench / "new" / "estimates.json", "median", 10.0) + + policy = bench_compare.ComparisonPolicy(baseline_api_compatibility="la_stack_pre_rational_input_api") + collection = bench_compare._collect_comparisons( + tmp_path, + "v0.4.5", + "median", + suite="exact", + policy=policy, + ) + rows = bench_compare._unavailable_artifact_rows( + tmp_path, + baseline_name="v0.4.5", + stat="median", + suite="exact", + scope="release-signal", + policy=policy, + ) + + assert not [gap for gap in collection.gaps if gap.group.startswith("rational_input_d")] + assert len(rows) == len(bench_compare._RATIONAL_INPUT_ROWS) + assert all(row.coverage_status == "current-only" for row in rows) + assert all(row.baseline is None and row.current is not None for row in rows) + + def test_collect_comparisons_reports_wholly_absent_selected_suite(tmp_path: Path) -> None: group = tmp_path / "exact_d2" _write_estimates(group / "det" / "new" / "estimates.json", "median", 10.0) @@ -712,6 +755,7 @@ def test_snapshot_uses_one_table_per_suite_with_case_column(tmp_path: Path) -> N assert "| D=3 |" in tables assert "| Random corpus D=3 |" in tables assert "| Near-singular 3x3 |" in tables + assert "| Rational input D=8 |" in tables def test_snapshot_tables_uses_stat_label(tmp_path: Path) -> None: @@ -821,6 +865,7 @@ def test_read_schema2_provenance_records_versions_dirty_source_and_both_gates(tm assert "exact_d2/det_direct_with_errbound" in rendered assert "exact_d3/det_direct_with_errbound" in rendered assert "exact_d4/det_direct_with_errbound" in rendered + assert "rational_input_d{2..8}/*" in rendered assert "**Publication and validation environment**:" in rendered assert all(len(line) <= 160 for line in markdown) assert "the comparable `det_errbound` baselines remain required" in rendered @@ -907,10 +952,31 @@ def test_read_schema2_provenance_rejects_v043_adapter_for_other_baseline(tmp_pat data["baseline"] = "v0.4.4" (tmp_path / ".la-stack-benchmark-harness.json").write_text(json.dumps(data), encoding="utf-8") - with pytest.raises(ValueError, match=r"must be 'none' for baseline 'v0\.4\.4'"): + with pytest.raises( + ValueError, + match=r"must be 'la_stack_pre_rational_input_api' for baseline 'v0\.4\.4'", + ): _read_harness_provenance(tmp_path, baseline="v0.4.4") +def test_read_schema2_provenance_accepts_pre_rational_adapter_for_v045(tmp_path: Path) -> None: + data = _schema2_provenance_data() + data["baseline"] = "v0.4.5" + measurement = data["measurement"] + validation = data["validation"] + assert isinstance(measurement, dict) + assert isinstance(validation, dict) + cast("dict[str, object]", measurement)["baseline_api_compatibility"] = "la_stack_pre_rational_input_api" + cast("dict[str, object]", validation)["baseline_api_compatibility"] = "la_stack_pre_rational_input_api" + (tmp_path / ".la-stack-benchmark-harness.json").write_text(json.dumps(data), encoding="utf-8") + + provenance = _read_harness_provenance(tmp_path, baseline="v0.4.5") + + assert provenance is not None + assert provenance.validation is not None + assert provenance.validation["baseline_api_compatibility"] == "la_stack_pre_rational_input_api" + + def test_read_schema2_provenance_rejects_mode_status_contradiction(tmp_path: Path) -> None: data = _schema2_provenance_data() data["mode"] = "historical-assets" @@ -1367,6 +1433,78 @@ def test_main_v043_comparison_allows_only_unavailable_balanced_baselines( assert "no correctness-compatible benchmark row" in rendered +def test_main_v045_comparison_publishes_current_only_rational_rows( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + criterion_dir = tmp_path / "criterion" + for group, benches in bench_compare.EXACT_GROUPS.items(): + for bench in benches: + _write_estimates( + criterion_dir / group / bench / "new" / "estimates.json", + "median", + 10.0, + ) + if (group, bench) not in bench_compare._RATIONAL_INPUT_ROWS: + _write_estimates( + criterion_dir / group / bench / "v0.4.5" / "estimates.json", + "median", + 20.0, + ) + + provenance = _schema2_provenance_data() + provenance["baseline"] = "v0.4.5" + criterion = provenance["criterion"] + measurement = provenance["measurement"] + validation = provenance["validation"] + assert isinstance(criterion, dict) + assert isinstance(measurement, dict) + assert isinstance(validation, dict) + cast("dict[str, object]", criterion)["suite"] = "exact" + cast("dict[str, object]", measurement)["baseline_api_compatibility"] = "la_stack_pre_rational_input_api" + cast("dict[str, object]", validation)["baseline_api_compatibility"] = "la_stack_pre_rational_input_api" + (criterion_dir / ".la-stack-benchmark-harness.json").write_text(json.dumps(provenance), encoding="utf-8") + output = tmp_path / "report.md" + artifact_paths = bench_compare.ArtifactPaths( + csv=tmp_path / "performance.csv", + provenance=tmp_path / "performance.provenance.json", + ) + monkeypatch.setattr( + bench_compare, + "_report_source", + lambda _root: bench_compare.ReportSource( + version="0.4.6", + commit="current-commit", + ref="test", + revision_timestamp="2026-09-02 12:00:00 UTC", + ), + ) + + rc = bench_compare.main( + [ + "v0.4.5", + "--suite", + "exact", + "--criterion-dir", + str(criterion_dir), + "--csv-output", + str(artifact_paths.csv), + "--provenance-output", + str(artifact_paths.provenance), + "--output", + str(output), + ] + ) + + assert rc == 0 + rendered = output.read_text(encoding="utf-8") + assert rendered == bench_compare.render_release_artifacts(artifact_paths) + assert "rational_input_d2/det_sign_row_cleared_bareiss" in rendered + assert "rational_input_d8/solve_big_rational_gaussian" in rendered + assert rendered.count("current-only") == len(bench_compare._RATIONAL_INPUT_ROWS) + assert "no correctness-compatible benchmark row" in rendered + + def test_generate_markdown_labels_absent_provenance_unavailable(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(bench_compare, "_get_git_source_date", lambda _root: "2026-06-01 12:34:56 UTC") report = bench_compare._generate_markdown( diff --git a/scripts/tests/test_performance_artifacts.py b/scripts/tests/test_performance_artifacts.py index 2622dd9..43b23b1 100644 --- a/scripts/tests/test_performance_artifacts.py +++ b/scripts/tests/test_performance_artifacts.py @@ -4,6 +4,7 @@ import hashlib import io import json +from collections.abc import Mapping from pathlib import Path from types import MappingProxyType from typing import cast @@ -36,7 +37,12 @@ def _timing(value: float) -> TimingEstimate: def _context(*, current: str = "v0.4.4", baseline: str = "v0.4.3") -> ArtifactContext: - compatibility = "la_stack_v0_4_3_api" if baseline == "v0.4.3" else "none" + if baseline == "v0.4.3": + compatibility = "la_stack_v0_4_3_api" + elif baseline in {"v0.4.4", "v0.4.5"} and current not in {"v0.4.4", "v0.4.5"}: + compatibility = "la_stack_pre_rational_input_api" + else: + compatibility = "none" return ArtifactContext( release=ReleasePair(current=current, baseline=baseline), statistic="median", @@ -373,6 +379,22 @@ def test_artifact_loader_rejects_arbitrary_compatibility_adapter() -> None: load_bundle_bytes(csv_payload, (json.dumps(provenance) + "\n").encode(), source="invalid compatibility fixture") +def test_artifact_loader_accepts_pre_rational_adapter_for_v045() -> None: + bundle = _bundle() + context = _context(current="v0.4.6", baseline="v0.4.5") + csv_payload, provenance_payload = serialize_bundle(PerformanceBundle(context=context, rows=bundle.rows)) + + loaded = load_bundle_bytes( + csv_payload, + provenance_payload, + source="pre-rational compatibility fixture", + ) + + validation = loaded.context.benchmark_provenance["validation"] + assert isinstance(validation, Mapping) + assert validation["baseline_api_compatibility"] == "la_stack_pre_rational_input_api" + + def test_artifact_loader_requires_recorded_measurement_compatibility() -> None: csv_payload, provenance_payload = serialize_bundle(_bundle()) provenance = json.loads(provenance_payload) diff --git a/src/exact.rs b/src/exact.rs index caaf404..60c1ecb 100644 --- a/src/exact.rs +++ b/src/exact.rs @@ -137,10 +137,11 @@ impl DeterminantSign { /// Convert an already-computed exact result to finite binary64 output. /// -/// This extension trait is implemented for [`BigRational`] determinants and -/// `[BigRational; D]` exact solutions. It lets callers retain the exact value, -/// try the strict no-rounding contract, and recover with explicit rounding -/// without repeating determinant evaluation or linear-system elimination. +/// This extension trait is implemented for [`BigRational`] determinants, +/// `[BigRational; D]` exact solutions, and [`crate::RationalVector`] solutions. +/// It lets callers retain the exact value, try the strict no-rounding contract, +/// and recover with explicit rounding without repeating determinant evaluation +/// or linear-system elimination. /// [`BigRational::new_raw`] values are interpreted by their mathematical /// quotient: denominator signs and common factors do not change the result. A /// zero denominator is rejected as [`UnrepresentableReason::NotFinite`]. @@ -1077,6 +1078,33 @@ fn det4_big_int(a: &[[BigInt; D]; D]) -> BigInt { det } +/// Compute the determinant of an integer matrix with direct expansions for +/// D≤4 and fraction-free Bareiss elimination otherwise. +pub(crate) fn determinant_big_int(mut a: [[BigInt; D]; D]) -> BigInt { + if D == 0 { + return BigInt::from(1); + } + + match D { + 1 => take(&mut a[0][0]), + 2 => det2_big_int(&a), + 3 => det3_big_int(&a), + 4 => det4_big_int(&a), + _ => { + let odd_swaps = match bareiss_forward_eliminate(&mut a, None) { + BareissResult::Upper { odd_swaps } => odd_swaps, + BareissResult::Singular { .. } => { + cold_path(); + return BigInt::from(0); + } + }; + + let determinant = take(&mut a[D - 1][D - 1]); + if odd_swaps { -determinant } else { determinant } + } + } +} + /// Outcome of a Bareiss forward-elimination pass. #[derive(Debug)] enum BareissResult { @@ -1217,25 +1245,8 @@ fn scaled_det_int_decomposed( return (BigInt::from(0), ScaleExponent::ZERO); } let scale = ScaleExponent::for_decomposed(decomposed); - let mut a = build_big_int_matrix(decomposed.components(), scale); - let det_int = match D { - 1 => take(&mut a[0][0]), - 2 => det2_big_int(&a), - 3 => det3_big_int(&a), - 4 => det4_big_int(&a), - _ => { - let odd_swaps = match bareiss_forward_eliminate(&mut a, None) { - BareissResult::Upper { odd_swaps } => odd_swaps, - BareissResult::Singular { .. } => { - cold_path(); - return (BigInt::from(0), ScaleExponent::ZERO); - } - }; - - let det = take(&mut a[D - 1][D - 1]); - if odd_swaps { -det } else { det } - } - }; + let a = build_big_int_matrix(decomposed.components(), scale); + let det_int = determinant_big_int(a); (det_int, scale) } @@ -1311,9 +1322,30 @@ fn bareiss_solve_components( } else { (independent_matrix_scale, independent_rhs_scale) }; - let mut a = build_big_int_matrix(matrix.components(), matrix_scale); - let mut rhs = build_big_int_vec(rhs.components(), rhs_scale); + let a = build_big_int_matrix(matrix.components(), matrix_scale); + let rhs = build_big_int_vec(rhs.components(), rhs_scale); + let mut x = solve_big_int(a, rhs)?; + let solution_scale_exp = rhs_scale + .get() + .checked_sub(matrix_scale.get()) + .unwrap_or_else(|| unreachable!("finite f64 scale difference cannot overflow i32")); + if solution_scale_exp != 0 { + let solution_scale = big_int_exp_to_big_rational(BigInt::from(1_u8), solution_scale_exp); + for component in &mut x { + *component *= &solution_scale; + } + } + + Ok(x) +} + +/// Solve an integer system with fraction-free forward elimination and rational +/// back-substitution. +pub(crate) fn solve_big_int( + mut a: [[BigInt; D]; D], + mut rhs: [BigInt; D], +) -> Result<[BigRational; D], LaError> { match bareiss_forward_eliminate(&mut a, Some(&mut rhs)) { BareissResult::Upper { .. } => {} BareissResult::Singular { pivot_col } => { @@ -1333,17 +1365,6 @@ fn bareiss_solve_components( x[i] = sum / &a_ii; } - let solution_scale_exp = rhs_scale - .get() - .checked_sub(matrix_scale.get()) - .unwrap_or_else(|| unreachable!("finite f64 scale difference cannot overflow i32")); - if solution_scale_exp != 0 { - let solution_scale = big_int_exp_to_big_rational(BigInt::from(1_u8), solution_scale_exp); - for component in &mut x { - *component *= &solution_scale; - } - } - Ok(x) } diff --git a/src/lib.rs b/src/lib.rs index 713f55d..6c61a29 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -145,6 +145,65 @@ mod readme_doctests { /// ``` fn exact_arithmetic_example() {} + #[cfg(feature = "exact")] + /// ```rust + /// use la_stack::prelude::*; + /// + /// # fn main() -> Result<(), LaError> { + /// let epsilon = BigRational::new(1.into(), (1_u64 << 60).into()); + /// let one = BigRational::from_integer(1.into()); + /// let zero = BigRational::from_integer(0.into()); + /// + /// let matrix = RationalMatrix::<5>::try_from_fn(|row, col| match (row, col) { + /// (0, 0 | 1) | (1, 0) => one.clone(), + /// (1, 1) => &one + &epsilon, + /// _ if row == col => one.clone(), + /// _ => zero.clone(), + /// })?; + /// assert_eq!(matrix.det_sign(), DeterminantSign::Positive); + /// assert_eq!(matrix.det(), epsilon); + /// + /// let rhs = RationalVector::try_new([ + /// zero, + /// -&epsilon, + /// BigRational::from_integer(2.into()), + /// BigRational::from_integer(3.into()), + /// BigRational::from_integer(4.into()), + /// ])?; + /// let exact_solution = matrix.solve(&rhs)?; + /// assert_eq!( + /// exact_solution.as_array(), + /// &[ + /// BigRational::from_integer(1.into()), + /// BigRational::from_integer((-1).into()), + /// BigRational::from_integer(2.into()), + /// BigRational::from_integer(3.into()), + /// BigRational::from_integer(4.into()), + /// ] + /// ); + /// + /// let epsilon_f64 = epsilon.try_to_f64()?; + /// assert_eq!(1.0 + epsilon_f64, 1.0); + /// let f64_matrix = Matrix::<5>::try_from_rows([ + /// [1.0, 1.0, 0.0, 0.0, 0.0], + /// [1.0, 1.0 + epsilon_f64, 0.0, 0.0, 0.0], + /// [0.0, 0.0, 1.0, 0.0, 0.0], + /// [0.0, 0.0, 0.0, 1.0, 0.0], + /// [0.0, 0.0, 0.0, 0.0, 1.0], + /// ])?; + /// let f64_rhs = Vector::<5>::try_new([0.0, -epsilon_f64, 2.0, 3.0, 4.0])?; + /// let f64_solve = f64_matrix + /// .lu(DEFAULT_SINGULAR_TOL) + /// .and_then(|lu| lu.solve(f64_rhs)); + /// assert!(matches!( + /// f64_solve, + /// Err(LaError::Singular { .. }) + /// )); + /// # Ok(()) + /// # } + /// ``` + fn rational_input_example() {} + #[cfg(feature = "exact")] /// ```rust /// use la_stack::prelude::*; @@ -201,6 +260,8 @@ mod exact; mod ldlt; mod lu; mod matrix; +#[cfg(feature = "exact")] +mod rational; mod scaled_product; mod tolerance; mod vector; @@ -217,6 +278,9 @@ pub use num_rational::BigRational; #[cfg(feature = "exact")] #[cfg_attr(docsrs, doc(cfg(feature = "exact")))] pub use num_traits::{FromPrimitive, Signed, ToPrimitive}; +#[cfg(feature = "exact")] +#[cfg_attr(docsrs, doc(cfg(feature = "exact")))] +pub use rational::{RationalMatrix, RationalVector}; // --------------------------------------------------------------------------- // Error-bound constants for `Matrix::det_direct_with_errbound()` and @@ -366,6 +430,15 @@ pub const ERR_COEFF_4: f64 = 12.0 * EPS + 128.0 * EPS * EPS; /// dispatch surface explicit. pub const MAX_STACK_MATRIX_DISPATCH_DIM: usize = 7; +/// Largest dimension supported by [`try_with_rational_matrix!`]. +/// +/// This bound covers exact downstream geometric systems through dimension 8 +/// while keeping runtime-to-const dispatch explicit and available on stable +/// Rust. +#[cfg(feature = "exact")] +#[cfg_attr(docsrs, doc(cfg(feature = "exact")))] +pub const MAX_RATIONAL_MATRIX_DISPATCH_DIM: usize = 8; + pub use error::{ ArithmeticOperation, FactorizationKind, InvalidToleranceReason, LaError, NonFiniteLocation, NonFiniteOrigin, PositiveSemidefiniteViolation, SingularityReason, UnrepresentableReason, @@ -457,6 +530,92 @@ macro_rules! try_with_stack_matrix { }}; } +/// Fallibly dispatch a runtime dimension to a concrete exact rational matrix. +/// +/// The macro creates a zero [`RationalMatrix`] with the selected const-generic +/// dimension, then evaluates the supplied closure body. Dimensions `0..=8` are +/// supported on stable Rust. The closure may fill the matrix through +/// [`RationalMatrix::set`] or replace it with a value built by +/// [`RationalMatrix::try_from_fn`]. +/// +/// # Errors +/// Returns [`LaError::UnsupportedDimension`] (converted through +/// `From`) when the requested dimension is greater than +/// [`MAX_RATIONAL_MATRIX_DISPATCH_DIM`]. The closure body may return any other +/// error representable by its declared `Result` type. +/// +/// # Examples +/// ``` +/// use la_stack::prelude::*; +/// +/// # fn main() -> Result<(), LaError> { +/// let requested = 3usize; +/// let sign = try_with_rational_matrix!(requested, |mut matrix| -> Result< +/// DeterminantSign, +/// LaError, +/// > { +/// for index in 0..requested { +/// matrix.set(index, index, BigRational::from_integer(1.into()))?; +/// } +/// Ok(matrix.det_sign()) +/// })?; +/// assert_eq!(sign, DeterminantSign::Positive); +/// # Ok(()) +/// # } +/// ``` +#[cfg(feature = "exact")] +#[macro_export] +macro_rules! try_with_rational_matrix { + ($dim:expr, |$matrix:ident| -> $ret:ty $body:block $(,)?) => {{ + let __la_stack_requested_dim: usize = $dim; + match __la_stack_requested_dim { + 0 => $crate::try_with_rational_matrix!(@arm 0, $matrix, $ret, $body), + 1 => $crate::try_with_rational_matrix!(@arm 1, $matrix, $ret, $body), + 2 => $crate::try_with_rational_matrix!(@arm 2, $matrix, $ret, $body), + 3 => $crate::try_with_rational_matrix!(@arm 3, $matrix, $ret, $body), + 4 => $crate::try_with_rational_matrix!(@arm 4, $matrix, $ret, $body), + 5 => $crate::try_with_rational_matrix!(@arm 5, $matrix, $ret, $body), + 6 => $crate::try_with_rational_matrix!(@arm 6, $matrix, $ret, $body), + 7 => $crate::try_with_rational_matrix!(@arm 7, $matrix, $ret, $body), + 8 => $crate::try_with_rational_matrix!(@arm 8, $matrix, $ret, $body), + requested => Err(::core::convert::From::from( + $crate::LaError::unsupported_dimension( + requested, + $crate::MAX_RATIONAL_MATRIX_DISPATCH_DIM, + ), + )), + } + }}; + ($dim:expr, |mut $matrix:ident| -> $ret:ty $body:block $(,)?) => {{ + let __la_stack_requested_dim: usize = $dim; + match __la_stack_requested_dim { + 0 => $crate::try_with_rational_matrix!(@arm_mut 0, $matrix, $ret, $body), + 1 => $crate::try_with_rational_matrix!(@arm_mut 1, $matrix, $ret, $body), + 2 => $crate::try_with_rational_matrix!(@arm_mut 2, $matrix, $ret, $body), + 3 => $crate::try_with_rational_matrix!(@arm_mut 3, $matrix, $ret, $body), + 4 => $crate::try_with_rational_matrix!(@arm_mut 4, $matrix, $ret, $body), + 5 => $crate::try_with_rational_matrix!(@arm_mut 5, $matrix, $ret, $body), + 6 => $crate::try_with_rational_matrix!(@arm_mut 6, $matrix, $ret, $body), + 7 => $crate::try_with_rational_matrix!(@arm_mut 7, $matrix, $ret, $body), + 8 => $crate::try_with_rational_matrix!(@arm_mut 8, $matrix, $ret, $body), + requested => Err(::core::convert::From::from( + $crate::LaError::unsupported_dimension( + requested, + $crate::MAX_RATIONAL_MATRIX_DISPATCH_DIM, + ), + )), + } + }}; + (@arm $d:literal, $matrix:ident, $ret:ty, $body:block) => {{ + let __la_stack_body = |$matrix: $crate::RationalMatrix<$d>| -> $ret { $body }; + __la_stack_body($crate::RationalMatrix::<$d>::zero()) + }}; + (@arm_mut $d:literal, $matrix:ident, $ret:ty, $body:block) => {{ + let __la_stack_body = |mut $matrix: $crate::RationalMatrix<$d>| -> $ret { $body }; + __la_stack_body($crate::RationalMatrix::<$d>::zero()) + }}; +} + /// Common imports for ergonomic usage. /// /// This prelude re-exports the primary types and common constants: [`Matrix`], @@ -471,8 +630,9 @@ macro_rules! try_with_stack_matrix { /// [`ERR_COEFF_2`], [`ERR_COEFF_3`], and [`ERR_COEFF_4`] explicitly from the /// crate root; those raw coefficients intentionally stay out of the prelude. /// -/// When the `exact` feature is enabled, `DeterminantSign`, -/// `ExactF64Conversion`, `BigInt`, and `BigRational` are also re-exported. +/// When the `exact` feature is enabled, `RationalMatrix`, `RationalVector`, +/// `DeterminantSign`, `ExactF64Conversion`, `BigInt`, and `BigRational` are also +/// re-exported. /// `ExactF64Conversion` converts an already-computed exact determinant or /// solution under either the strict or explicitly rounded binary64 contract, /// without repeating exact elimination. The number types let callers construct @@ -492,8 +652,9 @@ pub mod prelude { #[cfg(feature = "exact")] #[cfg_attr(docsrs, doc(cfg(feature = "exact")))] pub use crate::{ - BigInt, BigRational, DeterminantSign, ExactF64Conversion, FromPrimitive, Signed, - ToPrimitive, + BigInt, BigRational, DeterminantSign, ExactF64Conversion, FromPrimitive, + MAX_RATIONAL_MATRIX_DISPATCH_DIM, RationalMatrix, RationalVector, Signed, ToPrimitive, + try_with_rational_matrix, }; } @@ -536,6 +697,62 @@ mod tests { gen_stack_matrix_dispatch_tests!(6); gen_stack_matrix_dispatch_tests!(7); + #[cfg(feature = "exact")] + macro_rules! gen_rational_matrix_dispatch_tests { + ($d:literal) => { + paste! { + #[test] + fn []() { + let requested = $d; + let got = try_with_rational_matrix!( + requested, + |mut matrix| -> Result { + let mut index = 0; + while index < $d { + matrix.set( + index, + index, + BigRational::from_integer(BigInt::from(1)), + )?; + index += 1; + } + Ok(matrix.det_sign()) + }, + ); + + assert_eq!(got, Ok(DeterminantSign::Positive)); + } + } + }; + } + + #[cfg(feature = "exact")] + gen_rational_matrix_dispatch_tests!(1); + #[cfg(feature = "exact")] + gen_rational_matrix_dispatch_tests!(2); + #[cfg(feature = "exact")] + gen_rational_matrix_dispatch_tests!(3); + #[cfg(feature = "exact")] + gen_rational_matrix_dispatch_tests!(4); + #[cfg(feature = "exact")] + gen_rational_matrix_dispatch_tests!(5); + #[cfg(feature = "exact")] + gen_rational_matrix_dispatch_tests!(6); + #[cfg(feature = "exact")] + gen_rational_matrix_dispatch_tests!(7); + #[cfg(feature = "exact")] + gen_rational_matrix_dispatch_tests!(8); + + #[cfg(feature = "exact")] + #[test] + fn try_with_rational_matrix_dispatches_zero_dimension() { + let got = try_with_rational_matrix!(0usize, |matrix| -> Result { + Ok(matrix.det_sign()) + }); + + assert_eq!(got, Ok(DeterminantSign::Positive)); + } + #[test] fn try_with_stack_matrix_supports_zero_dimension() { let got = try_with_stack_matrix!(0usize, |m| -> Result, LaError> { @@ -597,4 +814,20 @@ mod tests { })) ); } + + #[cfg(feature = "exact")] + #[test] + fn try_with_rational_matrix_reports_unsupported_dimension() { + let got = try_with_rational_matrix!(9usize, |matrix| -> Result { + Ok(matrix.det()) + }); + + assert_eq!( + got, + Err(LaError::UnsupportedDimension { + requested: 9, + max: MAX_RATIONAL_MATRIX_DISPATCH_DIM, + }) + ); + } } diff --git a/src/rational.rs b/src/rational.rs new file mode 100644 index 0000000..0c4f490 --- /dev/null +++ b/src/rational.rs @@ -0,0 +1,515 @@ +#![forbid(unsafe_code)] + +//! Exact-input fixed-size matrices and vectors. +//! +//! [`RationalMatrix`] and [`RationalVector`] preserve caller-supplied +//! [`BigRational`] coefficients without a binary64 round trip. Determinants and +//! solves clear denominators with a positive scale per row, then reuse the +//! crate's fraction-free [`BigInt`] Bareiss backend. The positive row scales +//! preserve determinant sign; determinant values divide by their product; and +//! solves apply the same row scale to the matrix and right-hand side. + +use std::array::from_fn; + +use num_bigint::{BigInt, Sign}; +use num_rational::BigRational; + +use crate::exact::{determinant_big_int, solve_big_int}; +use crate::{DeterminantSign, ExactF64Conversion, LaError, Vector}; + +/// Exact rational square matrix with compile-time dimension `D`. +/// +/// Construction validates that every denominator is non-zero. The private +/// storage then carries that invariant, so determinant and solve methods do not +/// repeat input validation. Unlike [`crate::Matrix`], entries are already exact +/// rational values rather than finite binary64 values interpreted exactly. +/// +/// Direct field construction is intentionally unavailable: +/// +/// ```compile_fail +/// use la_stack::{BigRational, RationalMatrix}; +/// +/// let _ = RationalMatrix::<1> { +/// rows: [[BigRational::from_integer(1.into())]], +/// }; +/// ``` +#[derive(Clone, Debug, Eq, PartialEq)] +#[must_use] +pub struct RationalMatrix { + rows: [[BigRational; D]; D], +} + +/// Exact rational vector with compile-time dimension `D`. +/// +/// Construction validates that every denominator is non-zero. Solutions +/// returned by [`RationalMatrix::solve`] also use this type, making any later +/// conversion to [`Vector`] explicit through [`ExactF64Conversion`]. +#[derive(Clone, Debug, Eq, PartialEq)] +#[must_use] +pub struct RationalVector { + data: [BigRational; D], +} + +impl RationalMatrix { + /// Try to create an exact matrix from row-major rational storage. + /// + /// Raw, non-reduced [`BigRational::new_raw`] values and negative + /// denominators are accepted and interpreted as their mathematical + /// quotient. A raw zero denominator is not a rational value and is + /// rejected at this construction boundary. + /// + /// # Examples + /// ``` + /// use la_stack::prelude::*; + /// + /// # fn main() -> Result<(), LaError> { + /// let matrix = RationalMatrix::<2>::try_from_rows([ + /// [ + /// BigRational::new(1.into(), 3.into()), + /// BigRational::from_integer(2.into()), + /// ], + /// [ + /// BigRational::from_integer(1.into()), + /// BigRational::new(5.into(), 2.into()), + /// ], + /// ])?; + /// assert_eq!( + /// matrix.det(), + /// BigRational::new((-7).into(), 6.into()) + /// ); + /// # Ok(()) + /// # } + /// ``` + /// + /// # Errors + /// Returns [`LaError::NonFinite`] at the first matrix cell whose raw + /// rational denominator is zero. + pub fn try_from_rows(rows: [[BigRational; D]; D]) -> Result { + for (row_index, row) in rows.iter().enumerate() { + for (col_index, value) in row.iter().enumerate() { + if value.denom().sign() == Sign::NoSign { + return Err(LaError::non_finite_input_matrix(row_index, col_index)); + } + } + } + Ok(Self { rows }) + } + + /// Try to create an exact matrix by evaluating a function at every cell. + /// + /// The function is evaluated once per cell in row-major order. + /// + /// # Errors + /// Returns [`LaError::NonFinite`] at the first generated cell whose raw + /// rational denominator is zero. + pub fn try_from_fn( + mut make_entry: impl FnMut(usize, usize) -> BigRational, + ) -> Result { + let rows = from_fn(|row| from_fn(|col| make_entry(row, col))); + Self::try_from_rows(rows) + } + + /// Return the all-zero exact matrix. + pub fn zero() -> Self { + Self { + rows: from_fn(|_| from_fn(|_| BigRational::from_integer(BigInt::from(0)))), + } + } + + /// Borrow the row-major exact storage. + #[must_use] + pub const fn as_rows(&self) -> &[[BigRational; D]; D] { + &self.rows + } + + /// Consume the matrix and return its row-major exact storage. + #[must_use] + pub fn into_rows(self) -> [[BigRational; D]; D] { + self.rows + } + + /// Borrow one entry, returning `None` for an out-of-bounds index. + #[must_use] + pub fn get(&self, row: usize, col: usize) -> Option<&BigRational> { + self.rows.get(row)?.get(col) + } + + /// Replace one exact entry while preserving the non-zero-denominator + /// invariant. + /// + /// # Errors + /// Returns [`LaError::IndexOutOfBounds`] when `(row, col)` lies outside the + /// matrix, or [`LaError::NonFinite`] when `value` has a raw zero + /// denominator. + pub fn set(&mut self, row: usize, col: usize, value: BigRational) -> Result<(), LaError> { + if row >= D || col >= D { + return Err(LaError::index_out_of_bounds(row, col, D)); + } + if value.denom().sign() == Sign::NoSign { + return Err(LaError::non_finite_input_matrix(row, col)); + } + self.rows[row][col] = value; + Ok(()) + } + + /// Return the provably exact determinant sign. + /// + /// This path clears denominators and reads the sign of the resulting + /// integer determinant. It does not construct a rational determinant. + pub fn det_sign(&self) -> DeterminantSign { + let (integer_rows, _) = self.integer_rows(); + match determinant_big_int(integer_rows).sign() { + Sign::Minus => DeterminantSign::Negative, + Sign::NoSign => DeterminantSign::Zero, + Sign::Plus => DeterminantSign::Positive, + } + } + + /// Return the exact determinant. + /// + /// Denominators are cleared independently per row. If row `i` uses + /// positive scale `sᵢ`, the integer determinant is divided by `∏ᵢ sᵢ`. + #[must_use] + pub fn det(&self) -> BigRational { + let (integer_rows, row_scales) = self.integer_rows(); + let determinant_denominator = row_scales.iter().product(); + BigRational::new(determinant_big_int(integer_rows), determinant_denominator) + } + + /// Solve `A x = b` exactly. + /// + /// Each augmented row is multiplied by one positive common denominator, + /// then fraction-free Bareiss forward elimination runs in [`BigInt`]. Only + /// the `O(D²)` back-substitution phase constructs [`BigRational`] values. + /// + /// # Errors + /// Returns [`LaError::Singular`] with exact-singularity metadata when a + /// pivot column contains no non-zero entry. + pub fn solve(&self, rhs: &RationalVector) -> Result, LaError> { + let row_scales: [BigInt; D] = from_fn(|row| { + common_denominator( + self.rows[row] + .iter() + .chain(core::iter::once(&rhs.data[row])), + ) + }); + let integer_rows = + from_fn(|row| from_fn(|col| integer_at_scale(&self.rows[row][col], &row_scales[row]))); + let integer_rhs = from_fn(|row| integer_at_scale(&rhs.data[row], &row_scales[row])); + solve_big_int(integer_rows, integer_rhs).map(|data| RationalVector { data }) + } + + /// Clear matrix denominators with one positive common denominator per row. + fn integer_rows(&self) -> ([[BigInt; D]; D], [BigInt; D]) { + let row_scales: [BigInt; D] = from_fn(|row| common_denominator(self.rows[row].iter())); + let integer_rows = + from_fn(|row| from_fn(|col| integer_at_scale(&self.rows[row][col], &row_scales[row]))); + (integer_rows, row_scales) + } +} + +impl RationalVector { + /// Try to create an exact vector from rational storage. + /// + /// Raw, non-reduced values and negative denominators are accepted. A raw + /// zero denominator is rejected. + /// + /// # Errors + /// Returns [`LaError::NonFinite`] at the first vector entry whose raw + /// rational denominator is zero. + pub fn try_new(data: [BigRational; D]) -> Result { + for (index, value) in data.iter().enumerate() { + if value.denom().sign() == Sign::NoSign { + return Err(LaError::non_finite_input_vector(index)); + } + } + Ok(Self { data }) + } + + /// Try to create an exact vector by evaluating a function at every index. + /// + /// # Errors + /// Returns [`LaError::NonFinite`] at the first generated entry whose raw + /// rational denominator is zero. + pub fn try_from_fn(make_entry: impl FnMut(usize) -> BigRational) -> Result { + Self::try_new(from_fn(make_entry)) + } + + /// Return the all-zero exact vector. + pub fn zero() -> Self { + Self { + data: from_fn(|_| BigRational::from_integer(BigInt::from(0))), + } + } + + /// Borrow the exact backing array. + #[must_use] + pub const fn as_array(&self) -> &[BigRational; D] { + &self.data + } + + /// Consume the vector and return its exact backing array. + #[must_use] + pub fn into_array(self) -> [BigRational; D] { + self.data + } + + /// Borrow one entry, returning `None` for an out-of-bounds index. + #[must_use] + pub fn get(&self, index: usize) -> Option<&BigRational> { + self.data.get(index) + } +} + +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() + } +} + +/// Return a positive least common multiple of all raw denominator magnitudes. +fn common_denominator<'a>(values: impl Iterator) -> BigInt { + values.fold(BigInt::from(1), |scale, value| { + least_common_multiple(scale, denominator_magnitude(value)) + }) +} + +/// Return the positive magnitude of a validated non-zero denominator. +fn denominator_magnitude(value: &BigRational) -> BigInt { + match value.denom().sign() { + Sign::Minus => -value.denom(), + Sign::Plus => value.denom().clone(), + Sign::NoSign => unreachable!("RationalMatrix and RationalVector validate denominators"), + } +} + +/// Convert a rational to an integer using a positive divisible scale. +fn integer_at_scale(value: &BigRational, scale: &BigInt) -> BigInt { + let denominator = denominator_magnitude(value); + let multiplier = scale / denominator; + let numerator = match value.denom().sign() { + Sign::Minus => -value.numer(), + Sign::Plus => value.numer().clone(), + Sign::NoSign => unreachable!("RationalMatrix and RationalVector validate denominators"), + }; + numerator * multiplier +} + +/// Return the positive least common multiple of two positive integers. +fn least_common_multiple(lhs: BigInt, rhs: BigInt) -> BigInt { + let gcd = greatest_common_divisor(lhs.clone(), rhs.clone()); + (lhs / gcd) * rhs +} + +/// Euclidean greatest common divisor for positive integers. +fn greatest_common_divisor(mut lhs: BigInt, mut rhs: BigInt) -> BigInt { + let zero = BigInt::from(0); + while rhs != zero { + let remainder = &lhs % &rhs; + lhs = rhs; + rhs = remainder; + } + lhs +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{NonFiniteLocation, NonFiniteOrigin, SingularityReason}; + use pastey::paste; + + fn ratio(numerator: i64, denominator: i64) -> BigRational { + BigRational::new(BigInt::from(numerator), BigInt::from(denominator)) + } + + #[test] + fn exact_input_preserves_non_binary64_coefficients() { + let matrix = RationalMatrix::<2>::try_from_rows([ + [ratio(1, 3), ratio(1, 10)], + [ratio(2, 7), ratio(3, 11)], + ]) + .unwrap(); + + assert_eq!(matrix.det(), ratio(24, 385)); + assert_eq!(matrix.det_sign(), DeterminantSign::Positive); + } + + #[test] + fn determinant_handles_pivoting_and_singularity() { + let pivoting = RationalMatrix::<3>::try_from_rows([ + [ratio(0, 1), ratio(1, 2), ratio(0, 1)], + [ratio(2, 3), ratio(0, 1), ratio(0, 1)], + [ratio(0, 1), ratio(0, 1), ratio(3, 5)], + ]) + .unwrap(); + assert_eq!(pivoting.det(), ratio(-1, 5)); + assert_eq!(pivoting.det_sign(), DeterminantSign::Negative); + + let singular = RationalMatrix::<3>::try_from_rows([ + [ratio(1, 2), ratio(1, 3), ratio(1, 5)], + [ratio(1, 1), ratio(2, 3), ratio(2, 5)], + [ratio(0, 1), ratio(1, 1), ratio(1, 1)], + ]) + .unwrap(); + assert_eq!(singular.det(), ratio(0, 1)); + assert_eq!(singular.det_sign(), DeterminantSign::Zero); + } + + #[test] + fn row_clearing_accepts_raw_signs_factors_and_dyadic_exponents() { + let matrix = RationalMatrix::<2>::try_from_rows([ + [ + BigRational::new_raw(BigInt::from(6), BigInt::from(-8)), + BigRational::new_raw(BigInt::from(3), BigInt::from(1_u8) << 80_u32), + ], + [ + BigRational::new_raw(BigInt::from(-10), BigInt::from(-20)), + BigRational::new_raw(BigInt::from(14), BigInt::from(21)), + ], + ]) + .unwrap(); + + let expected = + ratio(-1, 2) - BigRational::new(BigInt::from(3), BigInt::from(1_u8) << 81_u32); + assert_eq!(matrix.det(), expected); + assert_eq!(matrix.det_sign(), DeterminantSign::Negative); + } + + #[test] + fn exact_solve_scales_matrix_and_rhs_together() { + let matrix = RationalMatrix::<3>::try_from_rows([ + [ratio(0, 1), ratio(1, 3), ratio(1, 5)], + [ratio(2, 7), ratio(1, 11), ratio(0, 1)], + [ratio(1, 13), ratio(0, 1), ratio(3, 2)], + ]) + .unwrap(); + let expected = [ratio(2, 3), ratio(-4, 5), ratio(7, 9)]; + let rhs = RationalVector::try_from_fn(|row| { + matrix.as_rows()[row] + .iter() + .zip(expected.iter()) + .map(|(coefficient, solution)| coefficient * solution) + .sum() + }) + .unwrap(); + + assert_eq!(matrix.solve(&rhs).unwrap().into_array(), expected); + } + + #[test] + fn singular_solve_preserves_exact_pivot_metadata() { + let matrix = RationalMatrix::<2>::try_from_rows([ + [ratio(1, 2), ratio(1, 3)], + [ratio(1, 1), ratio(2, 3)], + ]) + .unwrap(); + let rhs = RationalVector::<2>::zero(); + + assert!(matches!( + matrix.solve(&rhs), + Err(LaError::Singular { + pivot_col: 1, + reason: SingularityReason::Exact, + .. + }) + )); + } + + #[test] + fn constructors_reject_raw_zero_denominators_with_locations() { + let matrix_error = RationalMatrix::<1>::try_from_rows([[BigRational::new_raw( + BigInt::from(1), + BigInt::from(0), + )]]) + .unwrap_err(); + assert!(matches!( + matrix_error, + LaError::NonFinite { + location: NonFiniteLocation::MatrixCell { row: 0, col: 0, .. }, + origin: NonFiniteOrigin::Input, + .. + } + )); + + let vector_error = + RationalVector::<1>::try_new([BigRational::new_raw(BigInt::from(1), BigInt::from(0))]) + .unwrap_err(); + assert!(matches!( + vector_error, + LaError::NonFinite { + location: NonFiniteLocation::VectorEntry { index: 0, .. }, + origin: NonFiniteOrigin::Input, + .. + } + )); + } + + #[test] + fn zero_dimension_uses_empty_determinant_and_unique_solve() { + let matrix = RationalMatrix::<0>::zero(); + assert_eq!(matrix.det(), ratio(1, 1)); + assert_eq!(matrix.det_sign(), DeterminantSign::Positive); + assert_eq!( + matrix.solve(&RationalVector::zero()).unwrap().into_array(), + [] + ); + } + + #[test] + fn rational_vector_conversion_is_explicit() { + let vector = RationalVector::<2>::try_new([ratio(1, 2), ratio(1, 3)]).unwrap(); + assert!(matches!( + vector.try_to_f64(), + Err(LaError::Unrepresentable { index: Some(1), .. }) + )); + let rounded = vector.to_rounded_f64().unwrap().into_array(); + assert_eq!(rounded[0].to_bits(), 0.5_f64.to_bits()); + assert_eq!(rounded[1].to_bits(), (1.0_f64 / 3.0).to_bits()); + } + + macro_rules! gen_pivoting_tests { + ($d:literal) => { + paste! { + #[test] + fn []() { + let matrix = RationalMatrix::<$d>::try_from_fn(|row, col| { + let is_permutation_entry = (row == 0 && col == 1) + || (row == 1 && col == 0) + || (row >= 2 && row == col); + BigRational::from_integer(BigInt::from(u8::from(is_permutation_entry))) + }) + .unwrap(); + let expected = from_fn(|index| { + BigRational::new(BigInt::from(1), BigInt::from(index + 2)) + }); + let rhs = RationalVector::try_from_fn(|row| { + matrix.as_rows()[row] + .iter() + .zip(expected.iter()) + .map(|(coefficient, component)| coefficient * component) + .sum() + }) + .unwrap(); + + assert_eq!(matrix.det_sign(), DeterminantSign::Negative); + assert_eq!(matrix.det(), ratio(-1, 1)); + assert_eq!(matrix.solve(&rhs).unwrap().into_array(), expected); + } + } + }; + } + + gen_pivoting_tests!(2); + gen_pivoting_tests!(3); + gen_pivoting_tests!(4); + gen_pivoting_tests!(5); + gen_pivoting_tests!(6); + gen_pivoting_tests!(7); + gen_pivoting_tests!(8); +} diff --git a/tests/prelude_exports.rs b/tests/prelude_exports.rs index b936443..459be77 100644 --- a/tests/prelude_exports.rs +++ b/tests/prelude_exports.rs @@ -93,4 +93,20 @@ fn exact_prelude_supports_downstream_composition() { assert_eq!(half.to_f64(), Some(0.5)); assert_eq!(two.to_i64(), Some(2)); assert_eq!(DeterminantSign::Positive.as_i8(), 1); + + let rational_matrix = RationalMatrix::<1>::try_from_rows([[half]]).unwrap(); + let rational_rhs = RationalVector::<1>::try_new([two]).unwrap(); + assert_eq!( + rational_matrix.solve(&rational_rhs).unwrap().into_array(), + [BigRational::from_integer(BigInt::from(4))] + ); + let dispatched = + try_with_rational_matrix!(MAX_RATIONAL_MATRIX_DISPATCH_DIM, |matrix| -> Result< + DeterminantSign, + LaError, + > { + Ok(matrix.det_sign()) + },) + .unwrap(); + assert_eq!(dispatched, DeterminantSign::Zero); } diff --git a/tests/proptest_rational.rs b/tests/proptest_rational.rs new file mode 100644 index 0000000..f18e328 --- /dev/null +++ b/tests/proptest_rational.rs @@ -0,0 +1,161 @@ +#![forbid(unsafe_code)] + +//! Independent property checks for exact rational-input matrices through D=8. + +#![cfg(feature = "exact")] + +use std::array::from_fn; + +use pastey::paste; +use proptest::prelude::*; + +use la_stack::prelude::*; + +#[path = "common/proptest_config.rs"] +mod proptest_config; +use proptest_config::with_default_cases; + +fn zero() -> BigRational { + BigRational::from_integer(BigInt::from(0)) +} + +fn rational(numerator: i16, denominator: u8) -> BigRational { + BigRational::new( + BigInt::from(numerator), + BigInt::from(u32::from(denominator)), + ) +} + +fn determinant_sign(value: &BigRational) -> DeterminantSign { + if value.is_positive() { + DeterminantSign::Positive + } else if value.is_negative() { + DeterminantSign::Negative + } else { + DeterminantSign::Zero + } +} + +/// Straightforward rational Gaussian elimination, independent of the +/// production denominator-clearing and integer Bareiss backend. +fn rational_determinant_gaussian(mut rows: [[BigRational; D]; D]) -> BigRational { + let zero = zero(); + let mut determinant = BigRational::from_integer(BigInt::from(1)); + let mut odd_swaps = false; + + for pivot_col in 0..D { + let Some(pivot_row) = (pivot_col..D).find(|&row| rows[row][pivot_col] != zero) else { + return zero; + }; + if pivot_row != pivot_col { + rows.swap(pivot_col, pivot_row); + odd_swaps = !odd_swaps; + } + + let pivot = rows[pivot_col][pivot_col].clone(); + let pivot_entries = rows[pivot_col].clone(); + determinant *= &pivot; + for row_entries in rows.iter_mut().skip(pivot_col + 1) { + let factor = &row_entries[pivot_col] / &pivot; + for (entry, pivot_entry) in row_entries + .iter_mut() + .zip(pivot_entries.iter()) + .skip(pivot_col + 1) + { + *entry -= &factor * pivot_entry; + } + row_entries[pivot_col] = zero.clone(); + } + } + + if odd_swaps { -determinant } else { determinant } +} + +fn rational_matvec( + rows: &[[BigRational; D]; D], + vector: &[BigRational; D], +) -> [BigRational; D] { + from_fn(|row| { + rows[row] + .iter() + .zip(vector.iter()) + .map(|(coefficient, component)| coefficient * component) + .sum() + }) +} + +macro_rules! gen_rational_properties { + ($d:literal) => { + paste! { + proptest! { + #![proptest_config(with_default_cases(12))] + + #[test] + fn []( + entries in proptest::collection::vec((-5_i16..=5_i16, 1_u8..=9_u8), $d * $d), + solution_entries in proptest::collection::vec((-5_i16..=5_i16, 1_u8..=9_u8), $d), + ) { + let rows: [[BigRational; $d]; $d] = from_fn(|row| { + from_fn(|col| { + let (numerator, denominator) = entries[row * $d + col]; + rational(numerator, denominator) + }) + }); + let expected_determinant = rational_determinant_gaussian(rows.clone()); + let matrix = RationalMatrix::try_from_rows(rows.clone()).unwrap(); + + prop_assert_eq!(matrix.det(), expected_determinant.clone()); + prop_assert_eq!(matrix.det_sign(), determinant_sign(&expected_determinant)); + + if expected_determinant != zero() { + let expected_solution: [BigRational; $d] = from_fn(|index| { + let (numerator, denominator) = solution_entries[index]; + rational(numerator, denominator) + }); + let rhs_array = rational_matvec(&rows, &expected_solution); + let rhs = RationalVector::try_new(rhs_array.clone()).unwrap(); + let actual_solution = matrix.solve(&rhs).unwrap(); + + prop_assert_eq!(actual_solution.as_array(), &expected_solution); + prop_assert_eq!( + rational_matvec(&rows, actual_solution.as_array()), + rhs_array, + ); + } + } + + #[test] + fn []( + entries in proptest::collection::vec((-5_i16..=5_i16, 1_u8..=9_u8), $d * $d), + ) { + let mut rows: [[BigRational; $d]; $d] = from_fn(|row| { + from_fn(|col| { + let (numerator, denominator) = entries[row * $d + col]; + rational(numerator, denominator) + }) + }); + rows[$d - 1] = rows[0].clone(); + let matrix = RationalMatrix::try_from_rows(rows).unwrap(); + + prop_assert_eq!(matrix.det_sign(), DeterminantSign::Zero); + prop_assert_eq!(matrix.det(), zero()); + prop_assert!(matches!( + matrix.solve(&RationalVector::zero()), + Err(LaError::Singular { + reason: SingularityReason::Exact, + .. + }) + ), "duplicate rows must produce exact singularity"); + } + } + } + }; +} + +gen_rational_properties!(2); +gen_rational_properties!(3); +gen_rational_properties!(4); +gen_rational_properties!(5); +gen_rational_properties!(6); +gen_rational_properties!(7); +gen_rational_properties!(8); From d8f9897f59cce0a220efdbbcbaaf9180861889e9 Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Thu, 3 Sep 2026 11:00:30 -0700 Subject: [PATCH 2/4] fix(exact)!: harden rational APIs and release comparisons - canonicalize signed and unreduced rational inputs at construction boundaries - preserve invariant-bearing RationalVector solutions across both exact input domains - retain typed singularity, conversion, and runtime-dispatch diagnostics - make release comparisons capability-aware for pre-rational benchmark baselines - clarify exact-input guarantees, f64 precision loss, and benchmark provenance BREAKING CHANGE: Matrix::solve_exact now returns RationalVector instead of [BigRational; D]. Use as_array() or into_array() when raw storage is required. Resolves #216 --- README.md | 30 ++- benches/common/exact.rs | 21 +- benches/exact.rs | 25 ++- docs/BENCHMARKING.md | 27 ++- docs/mathematical_basis.md | 30 ++- examples/exact_solve_3x3.rs | 4 +- examples/rational_input_5x5.rs | 2 +- scripts/archive_performance.py | 102 +++++++-- scripts/bench_compare.py | 88 +++++--- scripts/performance_artifacts.py | 122 +++++++++- scripts/tests/test_archive_performance.py | 10 +- scripts/tests/test_bench_compare.py | 28 ++- scripts/tests/test_criterion_dim_plot.py | 6 +- scripts/tests/test_performance_artifacts.py | 111 +++++++++- src/exact.rs | 113 +++++----- src/lib.rs | 84 +++++-- src/rational.rs | 232 ++++++++++++++++++-- tests/proptest_exact.rs | 10 +- tests/proptest_rational.rs | 7 +- 19 files changed, 819 insertions(+), 233 deletions(-) diff --git a/README.md b/README.md index 6c3e539..3786703 100644 --- a/README.md +++ b/README.md @@ -304,7 +304,8 @@ The feature exposes two deliberate input domains: **Linear system solve:** -- **`solve_exact(b)`** — solves `Ax = b` exactly, returning `[BigRational; D]` +- **`solve_exact(b)`** — solves `Ax = b` exactly, returning a + `RationalVector` - **`solve_exact_f64(b)`** — solves `Ax = b` exactly, returning `Vector` only when every component is exactly representable as `f64` - **`solve_exact_rounded_f64(b)`** — solves `Ax = b` exactly, returning each @@ -323,9 +324,11 @@ The feature exposes two deliberate input domains: - **`try_with_rational_matrix!`** — dispatches a runtime-selected dimension through D=8 to a const-generic rational matrix on stable Rust -Exact determinant value and conversion methods return -`LaError::DeterminantScaleOverflow` if the aggregate power-of-two scaling -exceeds the internal exponent representation. Exact solve methods return +The `Matrix::det_exact*` value and conversion methods return +`LaError::DeterminantScaleOverflow` if their aggregate power-of-two scaling +exceeds the internal exponent representation. `RationalMatrix::det()` is +infallible because it clears rational row denominators without an exponent-scale +conversion. The exact solve methods for both input domains return `LaError::Singular` with `SingularityReason::Exact` when the stored matrix is exactly singular. @@ -341,6 +344,8 @@ the same coefficients as `f64` inputs loses the `2^-60` perturbation at `1.0`, making the leading rows identical and the binary64 system singular. ```rust,ignore +use core::assert_matches; + use la_stack::prelude::*; fn main() -> Result<(), LaError> { @@ -382,7 +387,7 @@ fn main() -> Result<(), LaError> { // Supplying the same coefficients as f64 inputs destroys the perturbation // and makes the matrix singular, even though the exact solution is integral. let epsilon_f64 = epsilon.try_to_f64()?; - assert_eq!(1.0 + epsilon_f64, 1.0); + assert_eq!((1.0 + epsilon_f64).to_bits(), 1.0_f64.to_bits()); let f64_matrix = Matrix::<5>::try_from_rows([ [1.0, 1.0, 0.0, 0.0, 0.0], [1.0, 1.0 + epsilon_f64, 0.0, 0.0, 0.0], @@ -394,10 +399,10 @@ fn main() -> Result<(), LaError> { let f64_solve = f64_matrix .lu(DEFAULT_SINGULAR_TOL) .and_then(|lu| lu.solve(f64_rhs)); - assert!(matches!( + assert_matches!( f64_solve, Err(LaError::Singular { .. }) - )); + ); Ok(()) } ``` @@ -648,16 +653,17 @@ correctness-gate result in the adjacent JSON sidecar. The publication workflow requires complete canonical-dimension coverage and regenerates the CSV, SVG, README table, and provenance together. -For the full per-kernel comparison methodology, input construction, and -release-comparison workflow details, see +For the full per-kernel comparison methodology, algorithm citations, input +construction, and release-comparison workflow details, see [docs/BENCHMARKING.md](https://github.com/acgetchell/la-stack/blob/v0.4.5/docs/BENCHMARKING.md). For the current release-to-release performance snapshot, see [docs/PERFORMANCE.md](https://github.com/acgetchell/la-stack/blob/v0.4.5/docs/PERFORMANCE.md). The exact release suite includes the already-exact rational-input groups for D=2 through D=8. Those rows report `RationalMatrix::det_sign`, `det`, and `solve` alongside straightforward `BigRational` Gaussian determinant and solve -references, with Criterion point estimates and confidence intervals generated -for every release. +references. Releases produced with the rational-input harness include Criterion +point estimates and confidence intervals for these rows; comparisons against a +pre-API baseline retain them as explicit current-only measurements. @@ -685,6 +691,7 @@ The `examples/` directory contains small, runnable programs: - **`exact_det_3x3`** — exact determinant value of a near-singular 3×3 matrix (requires `exact` feature) - **`exact_sign_3x3`** — exact determinant sign of a near-singular 3×3 matrix (requires `exact` feature) - **`exact_solve_3x3`** — exact solve of a near-singular 3×3 system vs f64 LU (requires `exact` feature) +- **`rational_input_5x5`** — exact rational solve of a 5×5 system that becomes singular as f64 (requires `exact` feature) ```bash just examples @@ -696,6 +703,7 @@ cargo run --example const_det_4x4 cargo run --features exact --example exact_det_3x3 cargo run --features exact --example exact_sign_3x3 cargo run --features exact --example exact_solve_3x3 +cargo run --features exact --example rational_input_5x5 ``` ## 🤝 Contributing diff --git a/benches/common/exact.rs b/benches/common/exact.rs index 8b58bba..2ba383a 100644 --- a/benches/common/exact.rs +++ b/benches/common/exact.rs @@ -613,17 +613,21 @@ pub fn validate_exact_fixture(input: ExactInput) -> Validated .matrix .solve_exact(input.rhs) .or_abort("exact solve oracle check"); - assert_exact_residual(&input, &solution); + assert_exact_residual(&input, solution.as_array()); let strict_solution = input.matrix.solve_exact_f64(input.rhs); - let first_failure = solution.iter().enumerate().find_map(|(index, value)| { - expected_strict_f64(value) - .err() - .map(|reason| (index, reason)) - }); + let first_failure = solution + .as_array() + .iter() + .enumerate() + .find_map(|(index, value)| { + expected_strict_f64(value) + .err() + .map(|reason| (index, reason)) + }); match (strict_solution, first_failure) { (Ok(actual), None) => { - for (index, exact) in solution.iter().enumerate() { + for (index, exact) in solution.as_array().iter().enumerate() { let Ok(expected) = expected_strict_f64(exact) else { panic!("strict solution component {index} unexpectedly requires rounding"); }; @@ -650,6 +654,7 @@ pub fn validate_exact_fixture(input: ExactInput) -> Validated let rounding_limit = finite_rounding_limit(); let first_rounded_failure = solution + .as_array() .iter() .position(|exact| exact.abs() >= rounding_limit); match ( @@ -657,7 +662,7 @@ pub fn validate_exact_fixture(input: ExactInput) -> Validated first_rounded_failure, ) { (Ok(rounded), None) => { - for (actual, exact) in rounded.as_array().iter().copied().zip(&solution) { + for (actual, exact) in rounded.as_array().iter().copied().zip(solution.as_array()) { assert_nearest_even_f64(actual, exact); } } diff --git a/benches/exact.rs b/benches/exact.rs index 75a87ac..6efee38 100644 --- a/benches/exact.rs +++ b/benches/exact.rs @@ -178,11 +178,12 @@ fn rational_determinant_gaussian(mut rows: [[BigRational; D]; D] odd_swaps = !odd_swaps; } - let pivot = rows[pivot_col][pivot_col].clone(); - let pivot_entries = rows[pivot_col].clone(); - determinant *= &pivot; - for row_entries in rows.iter_mut().skip(pivot_col + 1) { - let factor = &row_entries[pivot_col] / &pivot; + let (pivot_rows, rows_below) = rows.split_at_mut(pivot_col + 1); + let pivot_entries = &pivot_rows[pivot_col]; + let pivot = &pivot_entries[pivot_col]; + determinant *= pivot; + for row_entries in rows_below { + let factor = &row_entries[pivot_col] / pivot; for (entry, pivot_entry) in row_entries .iter_mut() .zip(pivot_entries.iter()) @@ -211,11 +212,13 @@ fn rational_solve_gaussian( rhs.swap(pivot_col, pivot_row); } - let pivot = rows[pivot_col][pivot_col].clone(); - let pivot_entries = rows[pivot_col].clone(); - let pivot_rhs = rhs[pivot_col].clone(); - for (row_entries, rhs_entry) in rows.iter_mut().zip(rhs.iter_mut()).skip(pivot_col + 1) { - let factor = &row_entries[pivot_col] / &pivot; + let (pivot_rows, rows_below) = rows.split_at_mut(pivot_col + 1); + let pivot_entries = &pivot_rows[pivot_col]; + let pivot = &pivot_entries[pivot_col]; + let (pivot_rhs_entries, rhs_below) = rhs.split_at_mut(pivot_col + 1); + let pivot_rhs = &pivot_rhs_entries[pivot_col]; + for (row_entries, rhs_entry) in rows_below.iter_mut().zip(rhs_below) { + let factor = &row_entries[pivot_col] / pivot; for (entry, pivot_entry) in row_entries .iter_mut() .zip(pivot_entries.iter()) @@ -223,7 +226,7 @@ fn rational_solve_gaussian( { *entry -= &factor * pivot_entry; } - let rhs_update = &factor * &pivot_rhs; + let rhs_update = &factor * pivot_rhs; *rhs_entry -= rhs_update; row_entries[pivot_col] = zero.clone(); } diff --git a/docs/BENCHMARKING.md b/docs/BENCHMARKING.md index 453b097..94a7ee4 100644 --- a/docs/BENCHMARKING.md +++ b/docs/BENCHMARKING.md @@ -79,12 +79,14 @@ The SPD rows compare la-stack LDLT, faer LDLT, and nalgebra Cholesky. They are labelled by algorithm because nalgebra does not expose a dense LDLT factorization in the dependency version used here. -**`exact`** (`benches/exact.rs`) measures exact-arithmetic methods -(`det_exact`, `solve_exact`, `det_sign_exact`, strict `*_result` conversions, -and lossy `*_rounded_f64` conversions) alongside the f64 `det` baseline across -D=2-5. Its supported D=2-4 range also includes `det_direct`, the paired -`det_direct_with_errbound`, and the bound-only `det_errbound`. Use this suite -to understand exact-arithmetic cost and track optimization progress. +**`exact`** (`benches/exact.rs`) measures exact-arithmetic methods over lifted +binary64 inputs (`det_exact`, `solve_exact`, `det_sign_exact`, strict `*_result` +conversions, and lossy `*_rounded_f64` conversions) alongside the f64 `det` +baseline across D=2-5. Its supported D=2-4 range also includes `det_direct`, the +paired `det_direct_with_errbound`, and the bound-only `det_errbound`. The same +suite compares row-cleared Bareiss operations with direct `BigRational` Gaussian +operations over already-exact rational inputs across D=2-8. Use it to understand +exact-arithmetic cost and track optimization progress. ## Common Workflows @@ -484,7 +486,7 @@ random-corpus groups, and adversarial-input groups: systems. These compare public `RationalMatrix::det_sign`, `det`, and `solve` calls using row-denominator clearing plus integer Bareiss elimination with straightforward cubic `BigRational` Gaussian determinant and solve - references on identical matrices and right-hand sides. + references on identical matrices and right-hand sides \[7, 11-12\]. Each random-corpus and adversarial group runs the same exact-arithmetic benches (`det_sign_exact`, `det_exact`, `solve_exact`, @@ -502,11 +504,12 @@ and first failing component. These checks run outside timed Criterion closures. Any disagreement or unexpected error fails setup instead of becoming an artificially fast measurement. -The rational-input groups are part of the canonical exact release signal, so -every release benchmark archive and generated performance report includes their -Criterion point estimates and confidence intervals. When the comparison -baseline predates the rational-input API, the report retains current-only rows -with an explicit coverage note and does not calculate a cross-release ratio. +The rational-input groups are part of the canonical exact release signal. +Releases produced with the rational-input harness include their Criterion point +estimates and confidence intervals. When the comparison baseline predates the +rational-input API, the report retains current-only rows with an explicit +coverage note and does not calculate a cross-release ratio; historical reports +whose shared harness predates these groups omit them on both sides. The proof-bearing fixture is therefore a prerequisite correctness gate, not a claim that every timed sample is revalidated. Criterion closures remain free of diff --git a/docs/mathematical_basis.md b/docs/mathematical_basis.md index ec56a53..0f0c4b1 100644 --- a/docs/mathematical_basis.md +++ b/docs/mathematical_basis.md @@ -1,11 +1,13 @@ # Mathematical basis -`la-stack` provides fixed-dimension numerical linear algebra over finite IEEE -754 binary64 values. For a compile-time dimension `D`, `Matrix` is a dense -`D × D` square matrix and `Vector` is a length-`D` vector over the finite -binary64 set `F`. The default algorithms operate in binary64 and are therefore -approximate. The optional `exact` feature instead lifts each stored binary64 -value to the exact rational number it represents. +`la-stack` provides fixed-dimension numerical linear algebra over two deliberate +input domains. `Matrix` and `Vector` store finite IEEE 754 binary64 values; +their default algorithms operate in binary64 and are therefore approximate. The +optional `exact` feature can either lift each of those stored values to the exact +rational number it represents or accept caller-supplied `BigRational` values +through `RationalMatrix` and `RationalVector`. For either domain, +`Matrix` and `RationalMatrix` are dense `D × D` square matrices and the +corresponding vector type has length `D`. This document separates three questions that are easy to conflate: @@ -30,9 +32,12 @@ x = (-1)^s m × 2^e, ``` for integer `m` and exponent `e` \[9-11\]. Both signed-zero bit patterns -represent rational zero. Exact APIs exploit this representation, but exactness -begins only after construction: they cannot recover information lost when a -decimal value or an earlier computation was rounded to `f64`. +represent rational zero. Exact methods on `Matrix` and `Vector` exploit this +representation, but their exactness begins only after binary64 construction: +they cannot recover information lost when a decimal value or an earlier +computation was rounded to `f64`. Caller-supplied `RationalMatrix` and +`RationalVector` values instead enter the exact domain directly and do not pass +through binary64. `Matrix`, `Vector`, `Lu`, and `Ldlt` use inline fixed-size storage. Arbitrary-precision `BigInt` and `BigRational` values allocate when the `exact` feature is @@ -173,8 +178,11 @@ ties-to-even \[9-10\]. A nonzero exact value may consequently round to zero. ## Exact arithmetic over rational inputs `RationalMatrix` and `RationalVector` are a separate input domain for -coefficients assembled exactly before linear algebra begins. For each matrix -row `i`, the implementation selects a positive least common multiple `sᵢ` of +coefficients assembled exactly before linear algebra begins. Their constructors +reject zero denominators and store each accepted quotient in lowest terms with +a positive denominator, so equivalent raw representations have identical +storage and denominator-clearing cost. For each matrix row `i`, the +implementation selects a positive least common multiple `sᵢ` of the rational denominators and forms the integer row `A_int[i, :] = sᵢ A[i, :]`. Therefore diff --git a/examples/exact_solve_3x3.rs b/examples/exact_solve_3x3.rs index 7d1d302..41777a5 100644 --- a/examples/exact_solve_3x3.rs +++ b/examples/exact_solve_3x3.rs @@ -58,7 +58,9 @@ fn main() -> Result<(), LaError> { ); println!( "solve_exact(): x = [{}, {}, {}]", - exact_x[0], exact_x[1], exact_x[2] + exact_x.as_array()[0], + exact_x.as_array()[1], + exact_x.as_array()[2] ); match exact_x.try_to_f64() { Ok(x) => { diff --git a/examples/rational_input_5x5.rs b/examples/rational_input_5x5.rs index 7e325e8..eb01373 100644 --- a/examples/rational_input_5x5.rs +++ b/examples/rational_input_5x5.rs @@ -29,7 +29,7 @@ fn main() -> Result<(), LaError> { println!("exact solution: {:?}", exact_solution.as_array()); let epsilon_f64 = epsilon.try_to_f64()?; - assert_eq!(1.0 + epsilon_f64, 1.0); + assert_eq!((1.0 + epsilon_f64).to_bits(), 1.0_f64.to_bits()); let f64_matrix = Matrix::<5>::try_from_rows([ [1.0, 1.0, 0.0, 0.0, 0.0], [1.0, 1.0 + epsilon_f64, 0.0, 0.0, 0.0], diff --git a/scripts/archive_performance.py b/scripts/archive_performance.py index 1156516..7bdd8ff 100644 --- a/scripts/archive_performance.py +++ b/scripts/archive_performance.py @@ -36,7 +36,14 @@ from bench_compare import HOW_TO_UPDATE_SECTION, render_release_artifacts from benchmark_contract import benchmark_contract_digest -from performance_artifacts import ArtifactPaths, ensure_distinct_paths, load_bundle, publish_bundle +from performance_artifacts import ( + NO_API_COMPATIBILITY, + ArtifactPaths, + ensure_distinct_paths, + load_bundle, + publish_bundle, + resolve_shared_harness_compatibility, +) from subprocess_utils import ( ExecutableNotFoundError, cpu_description, @@ -81,10 +88,6 @@ _BENCHMARK_HARNESS_METADATA = ".la-stack-benchmark-harness.json" _BENCHMARK_INPUT_GATE = ("just", "test-bench-inputs") _COMPARISON_LINT_CAP = "--cap-lints=warn" -_PRE_RATIONAL_INPUT_API_CFG = "la_stack_pre_rational_input_api" -_PRE_RATIONAL_INPUT_API_TAGS = frozenset({"v0.4.4", "v0.4.5"}) -_V0_4_3_API_CFG = "la_stack_v0_4_3_api" -_V0_4_3_TAG = "v0.4.3" type BaselineSource = Literal["local", "github-assets"] type BenchmarkSuite = Literal["all", "exact", "vs_linalg"] type ComparisonScope = Literal["release-signal", "all-benches"] @@ -214,6 +217,7 @@ class BaselineRun: git_clean: bool source_state_sha256: str api_compatibility: str | None + shared_harness_rational_inputs: bool = False def normalize_tag(tag: str) -> str: @@ -822,6 +826,7 @@ def _write_local_run_provenance( } metadata = { "baseline": config.baseline_tag, + "current": config.current_tag, "criterion": _criterion_metadata( worktree=worktree, config=config, @@ -844,6 +849,7 @@ def _write_local_run_provenance( "current_revision": "passed", "current_source_state_sha256": publication["source_state_sha256"], "harness": "shared-current", + "shared_harness_rational_inputs": baseline_run.shared_harness_rational_inputs, }, } _write_text( @@ -862,6 +868,7 @@ def _write_historical_asset_provenance( publication = _environment_metadata(worktree, harness_sha256=baseline_run.harness_sha256) metadata = { "baseline": config.baseline_tag, + "current": config.current_tag, "criterion": _criterion_metadata( worktree=worktree, config=config, @@ -887,6 +894,7 @@ def _write_historical_asset_provenance( "current_revision": "passed", "current_source_state_sha256": publication["source_state_sha256"], "harness": "shared-current", + "shared_harness_rational_inputs": baseline_run.shared_harness_rational_inputs, }, } _write_text( @@ -929,17 +937,33 @@ def _append_rustflag(env: dict[str, str], flag: str) -> None: env["RUSTFLAGS"] = f"{rustflags} {flag}".strip() -def _baseline_api_compatibility(baseline_tag: str) -> str | None: - """Return the shared-harness API adapter required by one baseline tag.""" - normalized = normalize_tag(baseline_tag) - if normalized == _V0_4_3_TAG: - return _V0_4_3_API_CFG - if normalized in _PRE_RATIONAL_INPUT_API_TAGS: - return _PRE_RATIONAL_INPUT_API_CFG - return None +def _shared_harness_rational_inputs(checkout: Path) -> bool: + """Return whether the installed exact benchmark harness emits rational-input groups.""" + exact_bench = checkout / "benches" / "exact.rs" + if not exact_bench.is_file(): + return False + source = _read_text(exact_bench) + return "fn bench_rational_input<" in source and "rational_input_d{D}" in source + +def _baseline_api_compatibility( + *, + current_tag: str, + baseline_tag: str, + shared_harness_rational_inputs: bool, +) -> str | None: + """Return the baseline adapter selected by the shared compatibility resolver.""" + resolved = resolve_shared_harness_compatibility( + current=normalize_tag(current_tag), + baseline=normalize_tag(baseline_tag), + shared_harness_rational_inputs=shared_harness_rational_inputs, + ) + if resolved.baseline_api_compatibility == NO_API_COMPATIBILITY: + return None + return resolved.baseline_api_compatibility -def _comparison_benchmark_env(checkout: Path, *, baseline_tag: str | None = None) -> dict[str, str]: + +def _comparison_benchmark_env(checkout: Path, *, api_compatibility: str | None = None) -> dict[str, str]: """Build a comparable benchmark environment for current or historical code.""" env = _benchmark_env(checkout) if env is None: @@ -950,10 +974,8 @@ def _comparison_benchmark_env(checkout: Path, *, baseline_tag: str | None = None # performance comparison; the cap changes diagnostics, not code generation. _append_rustflag(env, _COMPARISON_LINT_CAP) - if baseline_tag is not None: - compatibility = _baseline_api_compatibility(baseline_tag) - if compatibility is not None: - _append_rustflag(env, f"--cfg={compatibility}") + if api_compatibility is not None: + _append_rustflag(env, f"--cfg={api_compatibility}") return env @@ -1134,7 +1156,15 @@ def _fallback_current_command(*, suite: str) -> tuple[str, ...]: raise ValueError(msg) -def _generate_release_baseline(*, baseline_tag: str, suite: str, repo_root: Path, target_worktree: Path, tmp_dir: Path) -> BaselineRun: +def _generate_release_baseline( # noqa: PLR0913 + *, + current_tag: str, + baseline_tag: str, + suite: str, + repo_root: Path, + target_worktree: Path, + tmp_dir: Path, +) -> BaselineRun: baseline_worktree = tmp_dir / "baseline-worktree" with _temporary_detached_worktree( repo_root=repo_root, @@ -1151,8 +1181,13 @@ def _generate_release_baseline(*, baseline_tag: str, suite: str, repo_root: Path suite=suite, baseline_worktree=baseline_worktree, ) - api_compatibility = _baseline_api_compatibility(baseline_tag) - benchmark_env = _comparison_benchmark_env(repo_root, baseline_tag=baseline_tag) + shared_harness_rational_inputs = _shared_harness_rational_inputs(target_worktree) + api_compatibility = _baseline_api_compatibility( + current_tag=current_tag, + baseline_tag=baseline_tag, + shared_harness_rational_inputs=shared_harness_rational_inputs, + ) + benchmark_env = _comparison_benchmark_env(repo_root, api_compatibility=api_compatibility) _run_benchmark_input_gate(baseline_worktree, env=benchmark_env) _progress(f"running {suite} baseline benchmarks for {baseline_tag}") _run_tool( @@ -1180,11 +1215,21 @@ def _generate_release_baseline(*, baseline_tag: str, suite: str, repo_root: Path git_clean=_git_clean(baseline_worktree), source_state_sha256=_source_state_digest(baseline_worktree), api_compatibility=api_compatibility, + shared_harness_rational_inputs=shared_harness_rational_inputs, ) -def _prepare_local_release_baseline(*, baseline_tag: str, suite: str, repo_root: Path, target_worktree: Path, tmp_dir: Path) -> BaselineRun: +def _prepare_local_release_baseline( # noqa: PLR0913 + *, + current_tag: str, + baseline_tag: str, + suite: str, + repo_root: Path, + target_worktree: Path, + tmp_dir: Path, +) -> BaselineRun: return _generate_release_baseline( + current_tag=current_tag, baseline_tag=baseline_tag, suite=suite, repo_root=repo_root, @@ -1195,6 +1240,7 @@ def _prepare_local_release_baseline(*, baseline_tag: str, suite: str, repo_root: def _validate_release_revision( *, + current_tag: str, revision: str, repo_root: Path, harness_source: Path, @@ -1212,10 +1258,15 @@ def _validate_release_revision( source=harness_source, destination=validation_worktree, ) - api_compatibility = _baseline_api_compatibility(revision) + shared_harness_rational_inputs = _shared_harness_rational_inputs(harness_source) + api_compatibility = _baseline_api_compatibility( + current_tag=current_tag, + baseline_tag=revision, + shared_harness_rational_inputs=shared_harness_rational_inputs, + ) _run_benchmark_input_gate( validation_worktree, - env=_comparison_benchmark_env(repo_root, baseline_tag=revision), + env=_comparison_benchmark_env(repo_root, api_compatibility=api_compatibility), ) return BaselineRun( commit=_checkout_commit(validation_worktree), @@ -1224,6 +1275,7 @@ def _validate_release_revision( git_clean=_git_clean(validation_worktree), source_state_sha256=_source_state_digest(validation_worktree), api_compatibility=api_compatibility, + shared_harness_rational_inputs=shared_harness_rational_inputs, ) @@ -1408,6 +1460,7 @@ def _generated_report_in_temp_worktree( tmp_dir=tmp_dir, ) baseline_run = _validate_release_revision( + current_tag=config.current_tag, revision=config.baseline_tag, repo_root=config.repo_root, harness_source=worktree, @@ -1430,6 +1483,7 @@ def _generated_report_in_temp_worktree( ) else: baseline_run = _prepare_local_release_baseline( + current_tag=config.current_tag, baseline_tag=config.baseline_tag, suite=config.suite, repo_root=config.repo_root, diff --git a/scripts/bench_compare.py b/scripts/bench_compare.py index 484574f..f356467 100644 --- a/scripts/bench_compare.py +++ b/scripts/bench_compare.py @@ -38,6 +38,8 @@ from criterion_dim_plot import METRICS from performance_artifacts import ( + PRE_RATIONAL_INPUT_API_COMPATIBILITY, + V0_4_3_API_COMPATIBILITY, ArtifactContext, ArtifactPaths, PerformanceBundle, @@ -49,6 +51,7 @@ freeze_mapping, load_bundle, publish_bundle, + resolve_shared_harness_compatibility, ) from subprocess_utils import ExecutableNotFoundError, find_project_root, format_exception_diagnostics, run_git_command @@ -164,27 +167,22 @@ "la_stack_det_from_lu_balanced_range", "la_stack_det_from_ldlt_balanced_range", ] -_V0_4_3_API_COMPATIBILITY = "la_stack_v0_4_3_api" -_PRE_RATIONAL_INPUT_API_COMPATIBILITY = "la_stack_pre_rational_input_api" -_PRE_RATIONAL_INPUT_API_BASELINES = frozenset({"v0.4.4", "v0.4.5"}) +_V0_4_3_API_COMPATIBILITY = V0_4_3_API_COMPATIBILITY +_PRE_RATIONAL_INPUT_API_COMPATIBILITY = PRE_RATIONAL_INPUT_API_COMPATIBILITY _RATIONAL_INPUT_ROWS: frozenset[tuple[str, str]] = frozenset( (group, bench) for group, benches in EXACT_GROUPS.items() if group.startswith("rational_input_d") for bench in benches ) -_V0_4_3_UNAVAILABLE_BASELINE_ROWS: frozenset[tuple[str, str]] = ( - frozenset( - { - ("exact_d2", "det_direct_with_errbound"), - ("exact_d3", "det_direct_with_errbound"), - ("exact_d4", "det_direct_with_errbound"), - ("d8", "la_stack_det_from_lu_balanced_range"), - ("d8", "la_stack_det_from_ldlt_balanced_range"), - } - ) - | _RATIONAL_INPUT_ROWS +_V0_4_3_UNAVAILABLE_BASELINE_ROWS: frozenset[tuple[str, str]] = frozenset( + { + ("exact_d2", "det_direct_with_errbound"), + ("exact_d3", "det_direct_with_errbound"), + ("exact_d4", "det_direct_with_errbound"), + ("d8", "la_stack_det_from_lu_balanced_range"), + ("d8", "la_stack_det_from_ldlt_balanced_range"), + } ) _UNAVAILABLE_BASELINE_ROWS_BY_COMPATIBILITY: dict[str, frozenset[tuple[str, str]]] = { _V0_4_3_API_COMPATIBILITY: _V0_4_3_UNAVAILABLE_BASELINE_ROWS, - _PRE_RATIONAL_INPUT_API_COMPATIBILITY: _RATIONAL_INPUT_ROWS, } VS_LINALG_RELEASE_SIGNAL_BENCHES_BY_DIM: dict[int, list[str]] = { 8: VS_LINALG_D8_RELEASE_SIGNAL_BENCHES, @@ -375,6 +373,7 @@ class ComparisonPolicy: scope: str = "release-signal" baseline_api_compatibility: str | None = None + shared_harness_rational_inputs: bool = True _DEFAULT_COMPARISON_POLICY = ComparisonPolicy() @@ -398,6 +397,7 @@ class HarnessProvenance: mode: str sha256: str | None baseline: str + current: str | None = None measurement: Mapping[str, object] | None = None publication: Mapping[str, object] | None = None criterion: CriterionProvenance | None = None @@ -608,6 +608,7 @@ def _parse_harness_provenance( if mode not in {"shared-current-harness", "historical-assets"}: msg = f"unsupported or missing mode in {path}: {mode!r}" raise ValueError(msg) + current = _required_metadata_string(data, "current", path) measurement = _required_metadata_object(data, "measurement", path) publication = _required_metadata_object(data, "publication", path) @@ -621,7 +622,7 @@ def _parse_harness_provenance( expected=expected, ) _validate_validation_metadata(validation, path=path) - _validate_baseline_api_compatibility(validation, baseline=baseline, path=path) + _validate_baseline_api_compatibility(validation, current=current, baseline=baseline, path=path) _validate_schema2_consistency( mode=mode, measurement=measurement, @@ -638,6 +639,7 @@ def _parse_harness_provenance( mode=mode, sha256=sha256, baseline=baseline, + current=current, measurement=measurement, publication=publication, criterion=criterion, @@ -796,17 +798,29 @@ def _validate_validation_metadata(data: Mapping[str, object], *, path: Path) -> msg = f"invalid or missing validation.{field} in {path}" raise TypeError(msg) _required_metadata_string(data, "baseline_api_compatibility", path) + if not isinstance(data.get("shared_harness_rational_inputs"), bool): + msg = f"invalid or missing validation.shared_harness_rational_inputs in {path}" + raise TypeError(msg) -def _validate_baseline_api_compatibility(data: Mapping[str, object], *, baseline: str, path: Path) -> None: +def _validate_baseline_api_compatibility( + data: Mapping[str, object], + *, + current: str, + baseline: str, + path: Path, +) -> None: """Bind each supported compatibility adapter to its baseline releases.""" compatibility = data.get("baseline_api_compatibility") - if baseline == "v0.4.3": - expected = _V0_4_3_API_COMPATIBILITY - elif baseline in _PRE_RATIONAL_INPUT_API_BASELINES: - expected = _PRE_RATIONAL_INPUT_API_COMPATIBILITY - else: - expected = "none" + shared_harness_rational_inputs = data.get("shared_harness_rational_inputs") + if not isinstance(shared_harness_rational_inputs, bool): + msg = f"invalid or missing validation.shared_harness_rational_inputs in {path}" + raise TypeError(msg) + expected = resolve_shared_harness_compatibility( + current=current, + baseline=baseline, + shared_harness_rational_inputs=shared_harness_rational_inputs, + ).baseline_api_compatibility if compatibility != expected: msg = f"validation.baseline_api_compatibility in {path} must be {expected!r} for baseline {baseline!r}, got {compatibility!r}" raise ValueError(msg) @@ -979,6 +993,17 @@ def _collect_results(criterion_dir: Path, sample: str, stat: str, suite: str = " return results +def _unavailable_baseline_rows(policy: ComparisonPolicy) -> frozenset[tuple[str, str]]: + """Return rows excluded by the baseline API under the installed shared harness.""" + unavailable: frozenset[tuple[str, str]] = _UNAVAILABLE_BASELINE_ROWS_BY_COMPATIBILITY.get( + policy.baseline_api_compatibility or "", + frozenset[tuple[str, str]](), + ) + if policy.shared_harness_rational_inputs and policy.baseline_api_compatibility in {_V0_4_3_API_COMPATIBILITY, _PRE_RATIONAL_INPUT_API_COMPATIBILITY}: + unavailable |= _RATIONAL_INPUT_ROWS + return unavailable + + def _collect_exact_comparisons( criterion_dir: Path, baseline_name: str, @@ -989,13 +1014,12 @@ def _collect_exact_comparisons( """Compare exact results while retaining every missing expected row.""" comparisons: list[Comparison] = [] gaps: list[CoverageGap] = [] - unavailable_baseline_rows = _UNAVAILABLE_BASELINE_ROWS_BY_COMPATIBILITY.get( - policy.baseline_api_compatibility or "", - frozenset(), - ) + unavailable_baseline_rows = _unavailable_baseline_rows(policy) selected_groups: list[str] = [] for group, benches in EXACT_GROUPS.items(): + if group.startswith("rational_input_d") and not policy.shared_harness_rational_inputs: + continue selected_benches = [bench for bench in benches if _is_selected_comparison_row(group, bench, suite=suite, scope=policy.scope)] if not selected_benches: continue @@ -1141,10 +1165,7 @@ def _collect_vs_linalg_comparisons( """Compare vs_linalg results while retaining one-sided rows.""" comparisons: list[Comparison] = [] gaps: list[CoverageGap] = [] - unavailable_baseline_rows = _UNAVAILABLE_BASELINE_ROWS_BY_COMPATIBILITY.get( - policy.baseline_api_compatibility or "", - frozenset(), - ) + unavailable_baseline_rows = _unavailable_baseline_rows(policy) dim_groups = _vs_linalg_dimension_groups(criterion_dir, policy.scope) for _dim, group_dir in sorted(dim_groups, key=lambda item: item[0]): @@ -1562,7 +1583,7 @@ def _unavailable_artifact_rows( # noqa: PLR0913 ) -> list[PerformanceRow]: """Retain correctness-excluded baseline rows as explicit current-only data.""" compatibility = policy.baseline_api_compatibility - unavailable = _UNAVAILABLE_BASELINE_ROWS_BY_COMPATIBILITY.get(compatibility or "", frozenset()) + unavailable = _unavailable_baseline_rows(policy) rows: list[PerformanceRow] = [] for group, bench in sorted(unavailable): if not _is_selected_comparison_row(group, bench, suite=suite, scope=scope): @@ -1910,6 +1931,7 @@ def _provenance_markdown( include_compatibility_rows and compatibility in {_V0_4_3_API_COMPATIBILITY, _PRE_RATIONAL_INPUT_API_COMPATIBILITY} and criterion.suite in {"all", "exact"} + and validation.get("shared_harness_rational_inputs") is True ): lines.extend( [ @@ -2076,9 +2098,11 @@ def _comparison_policy(scope: str, provenance: HarnessProvenance | None) -> Comp if provenance is None or provenance.validation is None: return ComparisonPolicy(scope=scope) compatibility = provenance.validation.get("baseline_api_compatibility") + shared_harness_rational_inputs = provenance.validation.get("shared_harness_rational_inputs") return ComparisonPolicy( scope=scope, baseline_api_compatibility=compatibility if isinstance(compatibility, str) else None, + shared_harness_rational_inputs=(shared_harness_rational_inputs if isinstance(shared_harness_rational_inputs, bool) else True), ) diff --git a/scripts/performance_artifacts.py b/scripts/performance_artifacts.py index a0cc896..1ddd3b2 100644 --- a/scripts/performance_artifacts.py +++ b/scripts/performance_artifacts.py @@ -6,6 +6,7 @@ import json import math import os +import re import tempfile from collections.abc import Mapping from contextlib import contextmanager @@ -23,6 +24,72 @@ COVERAGE_STATES = ("comparable", "current-only", "baseline-only") type CoverageState = Literal["comparable", "current-only", "baseline-only"] +type ReleaseApiCapability = Literal["legacy-v0.4.3", "pre-rational-input", "rational-input", "unknown"] +type RationalInputCoverage = Literal["excluded", "current-only", "comparable"] + +V0_4_3_API_COMPATIBILITY = "la_stack_v0_4_3_api" +PRE_RATIONAL_INPUT_API_COMPATIBILITY = "la_stack_pre_rational_input_api" +NO_API_COMPATIBILITY = "none" + +_SEMVER_CORE_RE = re.compile( + r"^v?(?P0|[1-9][0-9]*)\.(?P0|[1-9][0-9]*)\.(?P0|[1-9][0-9]*)" + r"(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$" +) + + +@dataclass(frozen=True, slots=True) +class SharedHarnessCompatibility: + """API capabilities and row coverage for one shared-harness comparison.""" + + current: ReleaseApiCapability + baseline: ReleaseApiCapability + shared_harness_rational_inputs: bool + baseline_api_compatibility: str + rational_input_coverage: RationalInputCoverage + + +def _release_api_capability(release: str) -> ReleaseApiCapability: + """Classify a release by the public APIs used by the current harness.""" + match = _SEMVER_CORE_RE.fullmatch(release.strip()) + if match is None: + return "unknown" + version = tuple(int(match.group(part)) for part in ("major", "minor", "patch")) + if version <= (0, 4, 3): + return "legacy-v0.4.3" + if version <= (0, 4, 5): + return "pre-rational-input" + return "rational-input" + + +def resolve_shared_harness_compatibility( + *, + current: str, + baseline: str, + shared_harness_rational_inputs: bool, +) -> SharedHarnessCompatibility: + """Resolve adapters and rational-row coverage from both releases and the installed harness.""" + current_capability = _release_api_capability(current) + baseline_capability = _release_api_capability(baseline) + baseline_adapter = { + "legacy-v0.4.3": V0_4_3_API_COMPATIBILITY, + "pre-rational-input": PRE_RATIONAL_INPUT_API_COMPATIBILITY, + "rational-input": NO_API_COMPATIBILITY, + "unknown": NO_API_COMPATIBILITY, + }[baseline_capability] + if not shared_harness_rational_inputs: + rational_coverage: RationalInputCoverage = "excluded" + elif baseline_capability in {"legacy-v0.4.3", "pre-rational-input"}: + rational_coverage = "current-only" + else: + rational_coverage = "comparable" + return SharedHarnessCompatibility( + current=current_capability, + baseline=baseline_capability, + shared_harness_rational_inputs=shared_harness_rational_inputs, + baseline_api_compatibility=baseline_adapter, + rational_input_coverage=rational_coverage, + ) + CSV_COLUMNS = ( "schema_version", @@ -224,6 +291,7 @@ def __post_init__(self) -> None: msg = f"duplicate benchmark key: {key!r}" raise ValueError(msg) keys.add(key) + _validate_rational_row_coverage(self.context, self.rows) @property def sorted_rows(self) -> tuple[PerformanceRow, ...]: @@ -250,6 +318,34 @@ def _selected_suites(suite: str) -> frozenset[str]: return frozenset({"exact", "vs_linalg"} if suite == "all" else {suite}) +def _validate_rational_row_coverage(context: ArtifactContext, rows: tuple[PerformanceRow, ...]) -> None: + """Bind retained rational rows to the explicitly recorded harness capability.""" + validation = context.benchmark_provenance.get("validation") + if not isinstance(validation, Mapping): + msg = "validated benchmark provenance lost its validation object" + raise TypeError(msg) + shared_harness_rational_inputs = validation.get("shared_harness_rational_inputs") + if not isinstance(shared_harness_rational_inputs, bool): + msg = "validated benchmark provenance lost its shared-harness capability" + raise TypeError(msg) + compatibility = resolve_shared_harness_compatibility( + current=context.release.current, + baseline=context.release.baseline, + shared_harness_rational_inputs=shared_harness_rational_inputs, + ) + rational_rows = tuple(row for row in rows if row.group.startswith("rational_input_d")) + expected = compatibility.rational_input_coverage + if expected == "excluded" and rational_rows: + msg = "rational-input rows must be excluded when the shared harness does not emit them" + raise ValueError(msg) + if expected == "current-only" and any(row.coverage_status != "current-only" for row in rational_rows): + msg = "pre-rational baselines require retained rational-input rows to be current-only" + raise ValueError(msg) + if expected == "comparable" and any(row.coverage_status != "comparable" for row in rational_rows): + msg = "rational-capable baselines require retained rational-input rows to be comparable" + raise ValueError(msg) + + def _paths_alias(first: Path, second: Path) -> bool: """Return whether two paths resolve to the same filesystem target.""" if first.exists() and second.exists() and first.samefile(second): @@ -409,15 +505,6 @@ def _validate_measurement_provenance(measurement: Mapping[str, object], *, mode: return measurement_status -def _expected_baseline_api_compatibility(*, current: str, baseline: str) -> str: - """Return the compatibility adapter required by one release pair.""" - if baseline == "v0.4.3": - return "la_stack_v0_4_3_api" - if baseline in {"v0.4.4", "v0.4.5"} and current not in {"v0.4.4", "v0.4.5"}: - return "la_stack_pre_rational_input_api" - return "none" - - def _validate_validation_provenance( validation: Mapping[str, object], *, @@ -440,12 +527,20 @@ def _validate_validation_provenance( for field in ("current_git_clean", "baseline_git_clean"): _required_provenance_bool(validation, field, context="validation") compatibility = _required_provenance_string(validation, "baseline_api_compatibility", context="validation") - expected_compatibility = _expected_baseline_api_compatibility( + shared_harness_rational_inputs = validation.get("shared_harness_rational_inputs") + if not isinstance(shared_harness_rational_inputs, bool): + msg = "benchmark provenance validation.shared_harness_rational_inputs must be a boolean" + raise TypeError(msg) + resolved = resolve_shared_harness_compatibility( current=current, baseline=baseline, + shared_harness_rational_inputs=shared_harness_rational_inputs, ) - if compatibility != expected_compatibility: - msg = f"benchmark provenance validation.baseline_api_compatibility must be {expected_compatibility!r} for baseline {baseline!r}, got {compatibility!r}" + if compatibility != resolved.baseline_api_compatibility: + msg = ( + "benchmark provenance validation.baseline_api_compatibility must be " + f"{resolved.baseline_api_compatibility!r} for baseline {baseline!r}, got {compatibility!r}" + ) raise ValueError(msg) harness = _required_provenance_string(validation, "harness", context="validation") if harness != "shared-current": @@ -531,6 +626,9 @@ def _validate_benchmark_provenance(data: Mapping[str, object], *, context: Artif if data.get("baseline") != context.release.baseline: msg = f"benchmark provenance baseline {data.get('baseline')!r} does not match release baseline {context.release.baseline!r}" raise ValueError(msg) + if data.get("current") != context.release.current: + msg = f"benchmark provenance current {data.get('current')!r} does not match release current {context.release.current!r}" + raise ValueError(msg) criterion = _required_provenance_object(data, "criterion", context="root") measurement = _required_provenance_object(data, "measurement", context="root") diff --git a/scripts/tests/test_archive_performance.py b/scripts/tests/test_archive_performance.py index d022f1e..8cd709e 100644 --- a/scripts/tests/test_archive_performance.py +++ b/scripts/tests/test_archive_performance.py @@ -871,7 +871,7 @@ def test_comparison_benchmark_env_preserves_flags_and_selects_v043_adapter( current = archive_performance._comparison_benchmark_env(tmp_path) baseline = archive_performance._comparison_benchmark_env( tmp_path, - baseline_tag="v0.4.3", + api_compatibility="la_stack_v0_4_3_api", ) assert current["RUSTFLAGS"] == "-C target-cpu=native --cap-lints=warn" @@ -894,7 +894,11 @@ def test_comparison_benchmark_env_selects_pre_rational_adapter( baseline = archive_performance._comparison_benchmark_env( tmp_path, - baseline_tag=baseline_tag, + api_compatibility=archive_performance._baseline_api_compatibility( + current_tag="v0.4.6", + baseline_tag=baseline_tag, + shared_harness_rational_inputs=True, + ), ) assert baseline["RUSTFLAGS"] == ("--cap-lints=warn --cfg=la_stack_pre_rational_input_api") @@ -909,7 +913,7 @@ def test_comparison_benchmark_env_extends_encoded_rustflags( env = archive_performance._comparison_benchmark_env( tmp_path, - baseline_tag="0.4.3", + api_compatibility="la_stack_v0_4_3_api", ) assert env["CARGO_ENCODED_RUSTFLAGS"] == ("-C\x1ftarget-cpu=native\x1f--cap-lints=warn\x1f--cfg=la_stack_v0_4_3_api") diff --git a/scripts/tests/test_bench_compare.py b/scripts/tests/test_bench_compare.py index ebafa49..894203a 100644 --- a/scripts/tests/test_bench_compare.py +++ b/scripts/tests/test_bench_compare.py @@ -76,6 +76,7 @@ def _schema2_provenance_data() -> dict[str, object]: } return { "baseline": "v0.4.3", + "current": "v0.4.4", "criterion": { "baseline_command": ["just", "bench-save-baseline", "v0.4.3"], "criterion_version": "0.7.0", @@ -115,6 +116,7 @@ def _schema2_provenance_data() -> dict[str, object]: "current_revision": "passed", "current_source_state_sha256": "c" * 64, "harness": "shared-current", + "shared_harness_rational_inputs": True, }, } @@ -253,7 +255,13 @@ def test_exact_registry_only_tracks_supported_direct_determinant_filters() -> No def test_exact_registry_tracks_every_rational_input_release_dimension() -> None: - expected = bench_compare._RATIONAL_INPUT_BENCHES + expected = [ + "det_sign_row_cleared_bareiss", + "det_row_cleared_bareiss", + "det_big_rational_gaussian", + "solve_row_cleared_bareiss", + "solve_big_rational_gaussian", + ] assert [group for group in bench_compare.EXACT_GROUPS if group.startswith("rational_input_d")] == [ f"rational_input_d{dimension}" for dimension in range(2, 9) ] @@ -552,6 +560,23 @@ def test_pre_rational_adapter_retains_current_rational_rows_without_baselines(tm assert all(row.baseline is None and row.current is not None for row in rows) +def test_pre_rational_historical_harness_excludes_rational_registry_rows(tmp_path: Path) -> None: + policy = bench_compare.ComparisonPolicy( + baseline_api_compatibility="la_stack_pre_rational_input_api", + shared_harness_rational_inputs=False, + ) + + collection = bench_compare._collect_comparisons( + tmp_path, + "v0.4.4", + "median", + suite="exact", + policy=policy, + ) + + assert not [gap for gap in collection.gaps if gap.group.startswith("rational_input_d")] + + def test_collect_comparisons_reports_wholly_absent_selected_suite(tmp_path: Path) -> None: group = tmp_path / "exact_d2" _write_estimates(group / "det" / "new" / "estimates.json", "median", 10.0) @@ -1454,6 +1479,7 @@ def test_main_v045_comparison_publishes_current_only_rational_rows( provenance = _schema2_provenance_data() provenance["baseline"] = "v0.4.5" + provenance["current"] = "v0.4.6" criterion = provenance["criterion"] measurement = provenance["measurement"] validation = provenance["validation"] diff --git a/scripts/tests/test_criterion_dim_plot.py b/scripts/tests/test_criterion_dim_plot.py index b6c2b63..b1ddc80 100644 --- a/scripts/tests/test_criterion_dim_plot.py +++ b/scripts/tests/test_criterion_dim_plot.py @@ -434,6 +434,7 @@ def _write_performance_bundle( ), benchmark_provenance={ "baseline": baseline_tag, + "current": current_tag, "criterion": { "baseline_command": ["cargo", "bench", "--baseline", baseline_tag], "criterion_version": "0.7.0", @@ -444,7 +445,7 @@ def _write_performance_bundle( "suite": "all", }, "measurement": { - "baseline_api_compatibility": "none", + "baseline_api_compatibility": "la_stack_v0_4_3_api", "baseline_commit": baseline_commit, "baseline_git_clean": False, "baseline_source_state_sha256": baseline_source_sha256, @@ -459,7 +460,7 @@ def _write_performance_bundle( "publication": environment, "schema": 2, "validation": { - "baseline_api_compatibility": "none", + "baseline_api_compatibility": "la_stack_v0_4_3_api", "baseline_commit": baseline_commit, "baseline_git_clean": False, "baseline_revision": "passed", @@ -470,6 +471,7 @@ def _write_performance_bundle( "current_revision": "passed", "current_source_state_sha256": source_sha256, "harness": "shared-current", + "shared_harness_rational_inputs": False, }, }, ), diff --git a/scripts/tests/test_performance_artifacts.py b/scripts/tests/test_performance_artifacts.py index 43b23b1..934a629 100644 --- a/scripts/tests/test_performance_artifacts.py +++ b/scripts/tests/test_performance_artifacts.py @@ -36,10 +36,90 @@ def _timing(value: float) -> TimingEstimate: ) -def _context(*, current: str = "v0.4.4", baseline: str = "v0.4.3") -> ArtifactContext: - if baseline == "v0.4.3": +@pytest.mark.parametrize( + "case", + [ + ( + "v0.4.5", + "v0.4.4", + False, + "pre-rational-input", + "pre-rational-input", + "la_stack_pre_rational_input_api", + "excluded", + ), + ( + "v0.4.4", + "v0.4.3", + False, + "pre-rational-input", + "legacy-v0.4.3", + "la_stack_v0_4_3_api", + "excluded", + ), + ( + "v0.4.5", + "v0.4.5", + True, + "pre-rational-input", + "pre-rational-input", + "la_stack_pre_rational_input_api", + "current-only", + ), + ( + "v0.4.6-rc.1", + "v0.4.2", + True, + "rational-input", + "legacy-v0.4.3", + "la_stack_v0_4_3_api", + "current-only", + ), + ( + "v1.0.0-alpha.1", + "v0.3.9-beta.2", + True, + "rational-input", + "legacy-v0.4.3", + "la_stack_v0_4_3_api", + "current-only", + ), + ( + "v0.4.6", + "v0.4.6-rc.1", + True, + "rational-input", + "rational-input", + "none", + "comparable", + ), + ], +) +def test_shared_harness_compatibility_uses_literal_release_capability_oracle( + case: tuple[str, str, bool, str, str, str, str], +) -> None: + current, baseline, harness_rational, expected_current, expected_baseline, adapter, coverage = case + resolved = performance_artifacts.resolve_shared_harness_compatibility( + current=current, + baseline=baseline, + shared_harness_rational_inputs=harness_rational, + ) + + assert resolved.current == expected_current + assert resolved.baseline == expected_baseline + assert resolved.baseline_api_compatibility == adapter + assert resolved.rational_input_coverage == coverage + + +def _context( + *, + current: str = "v0.4.4", + baseline: str = "v0.4.3", + shared_harness_rational_inputs: bool = True, +) -> ArtifactContext: + if baseline in {"v0.4.2", "v0.4.3"}: compatibility = "la_stack_v0_4_3_api" - elif baseline in {"v0.4.4", "v0.4.5"} and current not in {"v0.4.4", "v0.4.5"}: + elif baseline in {"v0.4.4", "v0.4.5"}: compatibility = "la_stack_pre_rational_input_api" else: compatibility = "none" @@ -56,6 +136,7 @@ def _context(*, current: str = "v0.4.4", baseline: str = "v0.4.3") -> ArtifactCo ), benchmark_provenance={ "baseline": baseline, + "current": current, "criterion": { "baseline_command": ["just", "bench-save-baseline", baseline], "criterion_version": "0.7.0", @@ -105,6 +186,7 @@ def _context(*, current: str = "v0.4.4", baseline: str = "v0.4.3") -> ArtifactCo "current_revision": "passed", "current_source_state_sha256": "c" * 64, "harness": "shared-current", + "shared_harness_rational_inputs": shared_harness_rational_inputs, }, }, ) @@ -176,6 +258,29 @@ def test_artifact_round_trip_preserves_comparable_and_one_sided_rows() -> None: assert provenance_payload.endswith(b"\n") +def test_bundle_rejects_rational_rows_when_historical_harness_did_not_emit_them() -> None: + rational_row = PerformanceRow( + suite="exact", + scope="release-signal", + benchmark_id="rational_input_d2/det_row_cleared_bareiss", + group="rational_input_d2", + benchmark="det_row_cleared_bareiss", + baseline_benchmark="det_row_cleared_bareiss", + coverage_status="current-only", + coverage_note="Baseline predates rational inputs.", + baseline=None, + current=_timing(12.0), + ) + context = _context( + current="v0.4.5", + baseline="v0.4.4", + shared_harness_rational_inputs=False, + ) + + with pytest.raises(ValueError, match="must be excluded"): + PerformanceBundle(context=context, rows=(rational_row,)) + + def test_artifact_round_trip_allows_same_version_local_comparison() -> None: original = _bundle() bundle = PerformanceBundle( diff --git a/src/exact.rs b/src/exact.rs index 60c1ecb..7d00b6d 100644 --- a/src/exact.rs +++ b/src/exact.rs @@ -93,6 +93,7 @@ use num_rational::BigRational; use num_traits::ToPrimitive; use crate::matrix::Matrix; +use crate::rational::RationalVector; use crate::vector::Vector; use crate::{LaError, UnrepresentableReason}; @@ -138,7 +139,7 @@ impl DeterminantSign { /// Convert an already-computed exact result to finite binary64 output. /// /// This extension trait is implemented for [`BigRational`] determinants, -/// `[BigRational; D]` exact solutions, and [`crate::RationalVector`] solutions. +/// `[BigRational; D]` exact arrays, and [`crate::RationalVector`] solutions. /// It lets callers retain the exact value, try the strict no-rounding contract, /// and recover with explicit rounding without repeating determinant evaluation /// or linear-system elimination. @@ -1080,7 +1081,7 @@ fn det4_big_int(a: &[[BigInt; D]; D]) -> BigInt { /// Compute the determinant of an integer matrix with direct expansions for /// D≤4 and fraction-free Bareiss elimination otherwise. -pub(crate) fn determinant_big_int(mut a: [[BigInt; D]; D]) -> BigInt { +pub(crate) fn det_big_int(mut a: [[BigInt; D]; D]) -> BigInt { if D == 0 { return BigInt::from(1); } @@ -1246,7 +1247,7 @@ fn scaled_det_int_decomposed( } let scale = ScaleExponent::for_decomposed(decomposed); let a = build_big_int_matrix(decomposed.components(), scale); - let det_int = determinant_big_int(a); + let det_int = det_big_int(a); (det_int, scale) } @@ -1342,6 +1343,10 @@ fn bareiss_solve_components( /// Solve an integer system with fraction-free forward elimination and rational /// back-substitution. +/// +/// # Errors +/// Returns [`LaError::Singular`] with exact-singularity metadata and the first +/// pivot column that contains no non-zero entry. pub(crate) fn solve_big_int( mut a: [[BigInt; D]; D], mut rhs: [BigInt; D], @@ -1560,10 +1565,10 @@ impl Matrix { /// Requires the `exact` Cargo feature. /// /// Solves `A x = b` where `A` is `self` and `b` is the given vector. - /// Returns the exact solution as `[BigRational; D]`. Every finite `f64` is - /// exactly representable as a rational, so the conversion is lossless and - /// the result is exact for the stored binary64 entries. It cannot recover - /// precision lost before matrix or vector construction. + /// Returns the exact solution as [`RationalVector`]. Every finite `f64` + /// is exactly representable as a rational, so the conversion is lossless + /// and the result is exact for the stored binary64 entries. It cannot + /// recover precision lost before matrix or vector construction. /// /// # When to use /// @@ -1596,8 +1601,8 @@ impl Matrix { /// let a = Matrix::<2>::try_from_rows([[1.0, 2.0], [3.0, 4.0]])?; /// let b = Vector::<2>::try_new([5.0, 11.0])?; /// let x = a.solve_exact(b)?; - /// assert_eq!(x[0], BigRational::from_integer(1.into())); - /// assert_eq!(x[1], BigRational::from_integer(2.into())); + /// assert_eq!(x.as_array()[0], BigRational::from_integer(1.into())); + /// assert_eq!(x.as_array()[1], BigRational::from_integer(2.into())); /// # Ok(()) /// # } /// ``` @@ -1605,8 +1610,8 @@ impl Matrix { /// # Errors /// Returns [`LaError::Singular`] if the matrix is exactly singular. #[inline] - pub fn solve_exact(&self, b: Vector) -> Result<[BigRational; D], LaError> { - bareiss_solve_finite(self, &b) + pub fn solve_exact(&self, b: Vector) -> Result, LaError> { + bareiss_solve_finite(self, &b).map(RationalVector::from_canonical_array) } /// Exact linear system solve converted to `f64`. @@ -1621,7 +1626,7 @@ impl Matrix { /// /// When callers also need the exact solution or may recover with explicit /// rounding, compute [`solve_exact`](Self::solve_exact) once and use - /// [`ExactF64Conversion`] on the returned array. + /// [`ExactF64Conversion`] on the returned [`RationalVector`]. /// /// # Examples /// ``` @@ -1759,7 +1764,7 @@ mod tests { /// decompose entries into scaled `BigInt` collections, which avoids /// per-entry GCD work in the elimination loops — so this helper /// is not used by them and lives here to keep test assertions concise - /// (e.g. `assert_eq!(x[0], f64_to_big_rational(3.0))`). + /// (e.g. `assert_eq!(x.as_array()[0], f64_to_big_rational(3.0))`). /// /// See `REFERENCES.md` \[9-10\] for the IEEE 754 standard and Goldberg's /// survey of floating-point representation. @@ -2745,7 +2750,7 @@ mod tests { let strict_f64 = a.solve_exact_f64(b).unwrap().into_array(); for i in 0..$d { - assert_eq!(exact[i], f64_to_big_rational(b.as_array()[i])); + assert_eq!(exact.as_array()[i], f64_to_big_rational(b.as_array()[i])); assert_eq!(strict_f64[i].to_bits(), b.as_array()[i].to_bits()); } } @@ -2876,7 +2881,7 @@ mod tests { let x = a.solve_exact(b).unwrap(); for i in 0..$d { - assert_eq!(x[i], f64_to_big_rational(x0[i])); + assert_eq!(x.as_array()[i], f64_to_big_rational(x0[i])); } } } @@ -2897,7 +2902,7 @@ mod tests { let a = Matrix::<0>::zero(); let b = Vector::<0>::zero(); let x = a.solve_exact(b).unwrap(); - assert!(x.is_empty()); + assert!(x.as_array().is_empty()); } #[test] @@ -2906,8 +2911,8 @@ mod tests { let a = Matrix::<2>::try_from_rows([[1.0, 2.0], [3.0, 4.0]]).unwrap(); let b = Vector::<2>::new([5.0, 11.0]); let x = a.solve_exact(b).unwrap(); - assert_eq!(x[0], BigRational::from_integer(BigInt::from(1))); - assert_eq!(x[1], BigRational::from_integer(BigInt::from(2))); + assert_eq!(x.as_array()[0], BigRational::from_integer(BigInt::from(1))); + assert_eq!(x.as_array()[1], BigRational::from_integer(BigInt::from(2))); } #[test] @@ -2918,9 +2923,9 @@ mod tests { let b = Vector::<3>::new([2.0, 3.0, 4.0]); let x = a.solve_exact(b).unwrap(); // x = [3, 2, 4] - assert_eq!(x[0], f64_to_big_rational(3.0)); - assert_eq!(x[1], f64_to_big_rational(2.0)); - assert_eq!(x[2], f64_to_big_rational(4.0)); + assert_eq!(x.as_array()[0], f64_to_big_rational(3.0)); + assert_eq!(x.as_array()[1], f64_to_big_rational(2.0)); + assert_eq!(x.as_array()[2], f64_to_big_rational(4.0)); } #[test] @@ -2929,8 +2934,14 @@ mod tests { let a = Matrix::<2>::try_from_rows([[2.0, 1.0], [1.0, 3.0]]).unwrap(); let b = Vector::<2>::new([1.0, 1.0]); let x = a.solve_exact(b).unwrap(); - assert_eq!(x[0], BigRational::new(BigInt::from(2), BigInt::from(5))); - assert_eq!(x[1], BigRational::new(BigInt::from(1), BigInt::from(5))); + assert_eq!( + x.as_array()[0], + BigRational::new(BigInt::from(2), BigInt::from(5)) + ); + assert_eq!( + x.as_array()[1], + BigRational::new(BigInt::from(1), BigInt::from(5)) + ); } #[test] @@ -2960,11 +2971,11 @@ mod tests { .unwrap(); let b = Vector::<5>::new([10.0, 20.0, 30.0, 40.0, 50.0]); let x = a.solve_exact(b).unwrap(); - assert_eq!(x[0], f64_to_big_rational(20.0)); - assert_eq!(x[1], f64_to_big_rational(10.0)); - assert_eq!(x[2], f64_to_big_rational(30.0)); - assert_eq!(x[3], f64_to_big_rational(40.0)); - assert_eq!(x[4], f64_to_big_rational(50.0)); + assert_eq!(x.as_array()[0], f64_to_big_rational(20.0)); + assert_eq!(x.as_array()[1], f64_to_big_rational(10.0)); + assert_eq!(x.as_array()[2], f64_to_big_rational(30.0)); + assert_eq!(x.as_array()[3], f64_to_big_rational(40.0)); + assert_eq!(x.as_array()[4], f64_to_big_rational(50.0)); } /// Entries near `f64::MAX / 2` are finite but their product would @@ -2991,9 +3002,9 @@ mod tests { let b = Vector::<$d>::new(b_arr); let x = a.solve_exact(b).unwrap(); for i in 0..($d - 1) { - assert_eq!(x[i], BigRational::from_integer(BigInt::from(1))); + assert_eq!(x.as_array()[i], BigRational::from_integer(BigInt::from(1))); } - assert_eq!(x[$d - 1], BigRational::from_integer(BigInt::from(0))); + assert_eq!(x.as_array()[$d - 1], BigRational::from_integer(BigInt::from(0))); } } }; @@ -3029,7 +3040,7 @@ mod tests { let b = Vector::<$d>::new(b_arr); let x = a.solve_exact(b).unwrap(); for i in 0..$d { - assert_eq!(x[i], BigRational::from_integer(BigInt::from(1))); + assert_eq!(x.as_array()[i], BigRational::from_integer(BigInt::from(1))); } } } @@ -3050,11 +3061,11 @@ mod tests { let tiny_rhs = Vector::<2>::new([tiny, -2.0 * tiny]); let small_solution = large_matrix.solve_exact(tiny_rhs).unwrap(); assert_eq!( - small_solution[0], + small_solution.as_array()[0], BigRational::new(BigInt::from(1_u8), BigInt::from(1_u8) << 1500_u32) ); assert_eq!( - small_solution[1], + small_solution.as_array()[1], BigRational::new(BigInt::from(-1_i8), BigInt::from(1_u8) << 1499_u32) ); @@ -3062,7 +3073,7 @@ mod tests { let large_rhs = Vector::<1>::new([large]); let large_solution = tiny_matrix.solve_exact(large_rhs).unwrap(); assert_eq!( - large_solution[0], + large_solution.as_array()[0], BigRational::from_integer(BigInt::from(1_u8) << 1500_u32) ); } @@ -3093,7 +3104,7 @@ mod tests { let b = Vector::<$d>::new(b_arr); let x = a.solve_exact(b).unwrap(); for i in 0..$d { - assert_eq!(x[i], f64_to_big_rational((i + 1) as f64 * tiny)); + assert_eq!(x.as_array()[i], f64_to_big_rational((i + 1) as f64 * tiny)); } } } @@ -3144,9 +3155,9 @@ mod tests { } let b = Vector::<$d>::new(b_arr); let x = a.solve_exact(b).unwrap(); - assert_eq!(x[0], BigRational::new(BigInt::from(1), BigInt::from(2))); - assert_eq!(x[1], BigRational::from_integer(BigInt::from(3))); - for (i, value) in x.iter().enumerate().skip(2) { + assert_eq!(x.as_array()[0], BigRational::new(BigInt::from(1), BigInt::from(2))); + assert_eq!(x.as_array()[1], BigRational::from_integer(BigInt::from(3))); + for (i, value) in x.as_array().iter().enumerate().skip(2) { assert_eq!(value, &f64_to_big_rational((i + 10) as f64)); } } @@ -3196,10 +3207,10 @@ mod tests { let b = Vector::<$d>::new(b_arr); let x = a.solve_exact(b).unwrap(); // x[0..3] = [7/4, -1/2, 7/4]. - assert_eq!(x[0], BigRational::new(BigInt::from(7), BigInt::from(4))); - assert_eq!(x[1], BigRational::new(BigInt::from(-1), BigInt::from(2))); - assert_eq!(x[2], BigRational::new(BigInt::from(7), BigInt::from(4))); - for (i, value) in x.iter().enumerate().skip(3) { + assert_eq!(x.as_array()[0], BigRational::new(BigInt::from(7), BigInt::from(4))); + assert_eq!(x.as_array()[1], BigRational::new(BigInt::from(-1), BigInt::from(2))); + assert_eq!(x.as_array()[2], BigRational::new(BigInt::from(7), BigInt::from(4))); + for (i, value) in x.as_array().iter().enumerate().skip(3) { assert_eq!(value, &f64_to_big_rational((i + 10) as f64)); } } @@ -3267,7 +3278,7 @@ mod tests { let a = Matrix::<$d>::try_from_rows(rows).unwrap(); let b = Vector::<$d>::zero(); let x = a.solve_exact(b).unwrap(); - for xi in &x { + for xi in x.as_array() { assert_eq!(*xi, BigRational::from_integer(BigInt::from(0))); } } @@ -3329,9 +3340,9 @@ mod tests { let b = Vector::<3>::new([6.0 + perturbation, 15.0, 24.0]); let x = a.solve_exact(b).unwrap(); let one = BigRational::from_integer(BigInt::from(1)); - assert_eq!(x[0], one); - assert_eq!(x[1], one); - assert_eq!(x[2], one); + assert_eq!(x.as_array()[0], one); + assert_eq!(x.as_array()[1], one); + assert_eq!(x.as_array()[2], one); } /// Large-entry 3×3 solve (matches the `exact_large_entries_3x3` @@ -3350,9 +3361,9 @@ mod tests { let x = a.solve_exact(b).unwrap(); let zero = BigRational::from_integer(BigInt::from(0)); let one = BigRational::from_integer(BigInt::from(1)); - assert_eq!(x[0], one); - assert_eq!(x[1], zero); - assert_eq!(x[2], zero); + assert_eq!(x.as_array()[0], one); + assert_eq!(x.as_array()[1], zero); + assert_eq!(x.as_array()[2], zero); } /// Determinant of the large-entry 3×3 is roughly `big^3`, which @@ -3432,7 +3443,7 @@ mod tests { } let b = Vector::<$d>::new(b_arr); let x = h.solve_exact(b).unwrap(); - let ax = big_rational_matvec(&h, &x); + let ax = big_rational_matvec(&h, x.as_array()); for i in 0..$d { assert_eq!(ax[i], f64_to_big_rational(b_arr[i])); } @@ -3532,7 +3543,7 @@ mod tests { let a = Matrix::<1>::try_from_rows([[2.0]]).unwrap(); let b = Vector::<1>::new([6.0]); let x = a.solve_exact(b).unwrap(); - assert_eq!(x[0], f64_to_big_rational(3.0)); + assert_eq!(x.as_array()[0], f64_to_big_rational(3.0)); } #[test] diff --git a/src/lib.rs b/src/lib.rs index 6c61a29..9dceb44 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -147,6 +147,8 @@ mod readme_doctests { #[cfg(feature = "exact")] /// ```rust + /// use core::assert_matches; + /// /// use la_stack::prelude::*; /// /// # fn main() -> Result<(), LaError> { @@ -183,7 +185,7 @@ mod readme_doctests { /// ); /// /// let epsilon_f64 = epsilon.try_to_f64()?; - /// assert_eq!(1.0 + epsilon_f64, 1.0); + /// assert_eq!((1.0 + epsilon_f64).to_bits(), 1.0_f64.to_bits()); /// let f64_matrix = Matrix::<5>::try_from_rows([ /// [1.0, 1.0, 0.0, 0.0, 0.0], /// [1.0, 1.0 + epsilon_f64, 0.0, 0.0, 0.0], @@ -195,10 +197,10 @@ mod readme_doctests { /// let f64_solve = f64_matrix /// .lu(DEFAULT_SINGULAR_TOL) /// .and_then(|lu| lu.solve(f64_rhs)); - /// assert!(matches!( + /// assert_matches!( /// f64_solve, /// Err(LaError::Singular { .. }) - /// )); + /// ); /// # Ok(()) /// # } /// ``` @@ -564,6 +566,7 @@ macro_rules! try_with_stack_matrix { /// # } /// ``` #[cfg(feature = "exact")] +#[cfg_attr(docsrs, doc(cfg(feature = "exact")))] #[macro_export] macro_rules! try_with_rational_matrix { ($dim:expr, |$matrix:ident| -> $ret:ty $body:block $(,)?) => {{ @@ -629,18 +632,52 @@ macro_rules! try_with_rational_matrix { /// runtime-to-const matrix dispatch. Advanced custom-filter code should import /// [`ERR_COEFF_2`], [`ERR_COEFF_3`], and [`ERR_COEFF_4`] explicitly from the /// crate root; those raw coefficients intentionally stay out of the prelude. -/// -/// When the `exact` feature is enabled, `RationalMatrix`, `RationalVector`, -/// `DeterminantSign`, `ExactF64Conversion`, `BigInt`, and `BigRational` are also -/// re-exported. -/// `ExactF64Conversion` converts an already-computed exact determinant or -/// solution under either the strict or explicitly rounded binary64 contract, -/// without repeating exact elimination. The number types let callers construct -/// expected exact values without adding `num-bigint` / `num-rational` to their -/// own dependencies. The most commonly needed `num-traits` items are re-exported -/// alongside them: `FromPrimitive` for `BigRational::from_f64` / `from_i64`, -/// `ToPrimitive` for `BigRational::to_f64` / `to_i64`, and `Signed` for -/// `.is_positive()` / `.is_negative()` / `.abs()`. +#[cfg_attr(feature = "exact", doc = "")] +#[cfg_attr( + feature = "exact", + doc = "When the `exact` feature is enabled, [`RationalMatrix`], [`RationalVector`]," +)] +#[cfg_attr( + feature = "exact", + doc = "[`DeterminantSign`], [`ExactF64Conversion`], [`BigInt`], and [`BigRational`]" +)] +#[cfg_attr( + feature = "exact", + doc = "are also re-exported, together with [`MAX_RATIONAL_MATRIX_DISPATCH_DIM`] and" +)] +#[cfg_attr( + feature = "exact", + doc = "[`try_with_rational_matrix!`] for runtime-to-const exact-matrix dispatch." +)] +#[cfg_attr( + feature = "exact", + doc = "[`ExactF64Conversion`] converts an already-computed exact determinant or solution" +)] +#[cfg_attr( + feature = "exact", + doc = "under either the strict or explicitly rounded binary64 contract, without repeating" +)] +#[cfg_attr( + feature = "exact", + doc = "exact elimination. The number types let callers construct expected exact values" +)] +#[cfg_attr( + feature = "exact", + doc = "without adding `num-bigint` / `num-rational` to their own dependencies. The most" +)] +#[cfg_attr( + feature = "exact", + doc = "commonly needed `num-traits` items are re-exported alongside them: [`FromPrimitive`]" +)] +#[cfg_attr( + feature = "exact", + doc = "for `BigRational::from_f64` / `from_i64`, [`ToPrimitive`] for" +)] +#[cfg_attr( + feature = "exact", + doc = "`BigRational::to_f64` / `to_i64`, and [`Signed`] for `is_positive` / `is_negative` /" +)] +#[cfg_attr(feature = "exact", doc = "`abs`.")] pub mod prelude { pub use crate::{ ArithmeticOperation, DEFAULT_SINGULAR_TOL, DeterminantWithErrorBound, FactorizationKind, @@ -830,4 +867,21 @@ mod tests { }) ); } + + #[cfg(feature = "exact")] + #[test] + fn try_with_rational_matrix_converts_unsupported_dimension_error() { + let got = + try_with_rational_matrix!(9usize, |matrix| -> Result { + Ok(matrix.det()) + }); + + assert_eq!( + got, + Err(DownstreamError(LaError::UnsupportedDimension { + requested: 9, + max: MAX_RATIONAL_MATRIX_DISPATCH_DIM, + })) + ); + } } diff --git a/src/rational.rs b/src/rational.rs index 0c4f490..51f2c8b 100644 --- a/src/rational.rs +++ b/src/rational.rs @@ -14,15 +14,16 @@ use std::array::from_fn; use num_bigint::{BigInt, Sign}; use num_rational::BigRational; -use crate::exact::{determinant_big_int, solve_big_int}; +use crate::exact::{det_big_int, solve_big_int}; use crate::{DeterminantSign, ExactF64Conversion, LaError, Vector}; /// Exact rational square matrix with compile-time dimension `D`. /// -/// Construction validates that every denominator is non-zero. The private -/// storage then carries that invariant, so determinant and solve methods do not -/// repeat input validation. Unlike [`crate::Matrix`], entries are already exact -/// rational values rather than finite binary64 values interpreted exactly. +/// Construction validates that every denominator is non-zero and canonicalizes +/// every entry to lowest terms with a positive denominator. The private storage +/// then carries those invariants, so determinant and solve methods do not repeat +/// input validation. Unlike [`crate::Matrix`], entries are already exact rational +/// values rather than finite binary64 values interpreted exactly. /// /// Direct field construction is intentionally unavailable: /// @@ -41,9 +42,11 @@ pub struct RationalMatrix { /// Exact rational vector with compile-time dimension `D`. /// -/// Construction validates that every denominator is non-zero. Solutions -/// returned by [`RationalMatrix::solve`] also use this type, making any later -/// conversion to [`Vector`] explicit through [`ExactF64Conversion`]. +/// 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`]. #[derive(Clone, Debug, Eq, PartialEq)] #[must_use] pub struct RationalVector { @@ -54,9 +57,9 @@ impl RationalMatrix { /// Try to create an exact matrix from row-major rational storage. /// /// Raw, non-reduced [`BigRational::new_raw`] values and negative - /// denominators are accepted and interpreted as their mathematical - /// quotient. A raw zero denominator is not a rational value and is - /// rejected at this construction boundary. + /// denominators are accepted, interpreted as their mathematical quotient, + /// and stored canonically. A raw zero denominator is not a rational value + /// and is rejected at this construction boundary. /// /// # Examples /// ``` @@ -92,13 +95,28 @@ impl RationalMatrix { } } } - Ok(Self { rows }) + Ok(Self { + rows: rows.map(|row| row.map(canonicalize_rational)), + }) } /// Try to create an exact matrix by evaluating a function at every cell. /// /// The function is evaluated once per cell in row-major order. /// + /// # Examples + /// ``` + /// use la_stack::prelude::*; + /// + /// # fn main() -> Result<(), LaError> { + /// let diagonal = RationalMatrix::<3>::try_from_fn(|row, col| { + /// BigRational::from_integer(u8::from(row == col).into()) + /// })?; + /// assert_eq!(diagonal.det_sign(), DeterminantSign::Positive); + /// # Ok(()) + /// # } + /// ``` + /// /// # Errors /// Returns [`LaError::NonFinite`] at the first generated cell whose raw /// rational denominator is zero. @@ -134,8 +152,8 @@ impl RationalMatrix { self.rows.get(row)?.get(col) } - /// Replace one exact entry while preserving the non-zero-denominator - /// invariant. + /// Replace one exact entry while preserving the canonical non-zero- + /// denominator invariant. /// /// # Errors /// Returns [`LaError::IndexOutOfBounds`] when `(row, col)` lies outside the @@ -148,7 +166,7 @@ impl RationalMatrix { if value.denom().sign() == Sign::NoSign { return Err(LaError::non_finite_input_matrix(row, col)); } - self.rows[row][col] = value; + self.rows[row][col] = canonicalize_rational(value); Ok(()) } @@ -156,9 +174,10 @@ impl RationalMatrix { /// /// This path clears denominators and reads the sign of the resulting /// integer determinant. It does not construct a rational determinant. + /// For D=0, the empty-product determinant has positive sign. pub fn det_sign(&self) -> DeterminantSign { let (integer_rows, _) = self.integer_rows(); - match determinant_big_int(integer_rows).sign() { + match det_big_int(integer_rows).sign() { Sign::Minus => DeterminantSign::Negative, Sign::NoSign => DeterminantSign::Zero, Sign::Plus => DeterminantSign::Positive, @@ -169,11 +188,12 @@ impl RationalMatrix { /// /// Denominators are cleared independently per row. If row `i` uses /// positive scale `sᵢ`, the integer determinant is divided by `∏ᵢ sᵢ`. + /// For D=0, this returns the empty-product determinant `1`. #[must_use] pub fn det(&self) -> BigRational { let (integer_rows, row_scales) = self.integer_rows(); let determinant_denominator = row_scales.iter().product(); - BigRational::new(determinant_big_int(integer_rows), determinant_denominator) + BigRational::new(det_big_int(integer_rows), determinant_denominator) } /// Solve `A x = b` exactly. @@ -181,6 +201,26 @@ impl RationalMatrix { /// Each augmented row is multiplied by one positive common denominator, /// then fraction-free Bareiss forward elimination runs in [`BigInt`]. Only /// the `O(D²)` back-substitution phase constructs [`BigRational`] values. + /// For D=0, the empty matrix and vector have the unique empty solution. + /// + /// # Examples + /// ``` + /// use la_stack::prelude::*; + /// + /// # fn main() -> Result<(), LaError> { + /// let zero = BigRational::from_integer(0.into()); + /// let one = BigRational::from_integer(1.into()); + /// let matrix = RationalMatrix::<2>::try_from_rows([ + /// [BigRational::new(1.into(), 2.into()), zero.clone()], + /// [zero, BigRational::new(1.into(), 3.into())], + /// ])?; + /// let rhs = RationalVector::try_new([one.clone(), one])?; + /// + /// let solution = matrix.solve(&rhs)?.try_to_f64()?.into_array(); + /// assert_eq!(solution, [2.0, 3.0]); + /// # Ok(()) + /// # } + /// ``` /// /// # Errors /// Returns [`LaError::Singular`] with exact-singularity metadata when a @@ -196,7 +236,7 @@ impl RationalMatrix { let integer_rows = from_fn(|row| from_fn(|col| integer_at_scale(&self.rows[row][col], &row_scales[row]))); let integer_rhs = from_fn(|row| integer_at_scale(&rhs.data[row], &row_scales[row])); - solve_big_int(integer_rows, integer_rhs).map(|data| RationalVector { data }) + solve_big_int(integer_rows, integer_rhs).map(RationalVector::from_canonical_array) } /// Clear matrix denominators with one positive common denominator per row. @@ -209,10 +249,30 @@ impl RationalMatrix { } impl RationalVector { + /// Wrap values produced by `BigRational` arithmetic, which preserves the + /// canonical representation established at public input boundaries. + pub(crate) const fn from_canonical_array(data: [BigRational; D]) -> Self { + Self { data } + } + /// Try to create an exact vector from rational storage. /// - /// Raw, non-reduced values and negative denominators are accepted. A raw - /// zero denominator is rejected. + /// Raw, non-reduced values and negative denominators are accepted and + /// stored canonically. A raw zero denominator is rejected. + /// + /// # Examples + /// ``` + /// use la_stack::prelude::*; + /// + /// # fn main() -> Result<(), LaError> { + /// let rhs = RationalVector::<2>::try_new([ + /// BigRational::new(1.into(), 2.into()), + /// BigRational::from_integer(3.into()), + /// ])?; + /// assert_eq!(rhs.try_to_f64()?.into_array(), [0.5, 3.0]); + /// # Ok(()) + /// # } + /// ``` /// /// # Errors /// Returns [`LaError::NonFinite`] at the first vector entry whose raw @@ -223,7 +283,9 @@ impl RationalVector { return Err(LaError::non_finite_input_vector(index)); } } - Ok(Self { data }) + Ok(Self { + data: data.map(canonicalize_rational), + }) } /// Try to create an exact vector by evaluating a function at every index. @@ -273,6 +335,13 @@ impl ExactF64Conversion for RationalVector { } } +/// Reduce one validated rational and make its denominator positive before +/// publishing it through the exact-input storage types. +fn canonicalize_rational(value: BigRational) -> BigRational { + let (numerator, denominator) = value.into_raw(); + BigRational::new(numerator, denominator) +} + /// Return a positive least common multiple of all raw denominator magnitudes. fn common_denominator<'a>(values: impl Iterator) -> BigInt { values.fold(BigInt::from(1), |scale, value| { @@ -320,10 +389,11 @@ fn greatest_common_divisor(mut lhs: BigInt, mut rhs: BigInt) -> BigInt { #[cfg(test)] mod tests { - use super::*; - use crate::{NonFiniteLocation, NonFiniteOrigin, SingularityReason}; use pastey::paste; + use super::*; + use crate::{NonFiniteLocation, NonFiniteOrigin, SingularityReason, UnrepresentableReason}; + fn ratio(numerator: i64, denominator: i64) -> BigRational { BigRational::new(BigInt::from(numerator), BigInt::from(denominator)) } @@ -379,6 +449,40 @@ mod tests { ratio(-1, 2) - BigRational::new(BigInt::from(3), BigInt::from(1_u8) << 81_u32); assert_eq!(matrix.det(), expected); assert_eq!(matrix.det_sign(), DeterminantSign::Negative); + assert_eq!(matrix.as_rows()[0][0], ratio(-3, 4)); + assert_eq!(matrix.as_rows()[1][0], ratio(1, 2)); + assert_eq!(matrix.as_rows()[1][1], ratio(2, 3)); + } + + #[test] + fn construction_and_set_store_equal_quotients_identically() { + let huge_factor = BigInt::from(1_u8) << 256_u32; + let raw = RationalMatrix::<2>::try_from_rows([ + [ + BigRational::new_raw(huge_factor.clone(), &huge_factor * 2_u8), + BigRational::new_raw(BigInt::from(0), -&huge_factor), + ], + [ratio(0, 1), ratio(1, 1)], + ]) + .unwrap(); + let canonical = RationalMatrix::<2>::try_from_rows([ + [ratio(1, 2), ratio(0, 1)], + [ratio(0, 1), ratio(1, 1)], + ]) + .unwrap(); + + assert_eq!(raw, canonical); + assert_eq!(raw.as_rows()[0][1].denom(), &BigInt::from(1)); + + let mut updated = RationalMatrix::<2>::zero(); + updated + .set( + 0, + 0, + BigRational::new_raw(&huge_factor * 3_u8, -&huge_factor * 6_u8), + ) + .unwrap(); + assert_eq!(updated.get(0, 0), Some(&ratio(-1, 2))); } #[test] @@ -402,6 +506,39 @@ mod tests { assert_eq!(matrix.solve(&rhs).unwrap().into_array(), expected); } + #[test] + fn exact_solve_accepts_signed_unreduced_matrix_and_rhs_values() { + let matrix = RationalMatrix::<2>::try_from_rows([ + [ + BigRational::new_raw(BigInt::from(10), BigInt::from(20)), + BigRational::new_raw(BigInt::from(-5), BigInt::from(-15)), + ], + [ + BigRational::new_raw(BigInt::from(-6), BigInt::from(-15)), + BigRational::new_raw(BigInt::from(9), BigInt::from(21)), + ], + ]) + .unwrap(); + let rhs = RationalVector::try_new([ + BigRational::new_raw(BigInt::from(0), BigInt::from(-99)), + BigRational::new_raw(BigInt::from(34), BigInt::from(-70)), + ]) + .unwrap(); + + let solution = matrix.solve(&rhs).unwrap(); + assert_eq!(solution.as_array(), &[ratio(2, 1), ratio(-3, 1)]); + assert_eq!( + &matrix.as_rows()[0][0] * &solution.as_array()[0] + + &matrix.as_rows()[0][1] * &solution.as_array()[1], + rhs.as_array()[0] + ); + assert_eq!( + &matrix.as_rows()[1][0] * &solution.as_array()[0] + + &matrix.as_rows()[1][1] * &solution.as_array()[1], + rhs.as_array()[1] + ); + } + #[test] fn singular_solve_preserves_exact_pivot_metadata() { let matrix = RationalMatrix::<2>::try_from_rows([ @@ -450,6 +587,49 @@ mod tests { )); } + macro_rules! gen_rejected_set_is_failure_atomic_tests { + ($d:literal) => { + paste! { + #[test] + fn []() { + let mut matrix = RationalMatrix::<$d>::try_from_fn(|row, col| { + BigRational::from_integer(BigInt::from(row * $d + col + 1)) + }) + .unwrap(); + let original = matrix.clone(); + + assert!(matches!( + matrix.set($d, 0, ratio(1, 2)), + Err(LaError::IndexOutOfBounds { + row: $d, + col: 0, + dim: $d, + .. + }) + )); + assert_eq!(matrix, original); + + let zero_denominator = + BigRational::new_raw(BigInt::from(1), BigInt::from(0)); + assert!(matches!( + matrix.set(0, 1, zero_denominator), + Err(LaError::NonFinite { + location: NonFiniteLocation::MatrixCell { row: 0, col: 1, .. }, + origin: NonFiniteOrigin::Input, + .. + }) + )); + assert_eq!(matrix, original); + } + } + }; + } + + gen_rejected_set_is_failure_atomic_tests!(2); + gen_rejected_set_is_failure_atomic_tests!(3); + gen_rejected_set_is_failure_atomic_tests!(4); + gen_rejected_set_is_failure_atomic_tests!(5); + #[test] fn zero_dimension_uses_empty_determinant_and_unique_solve() { let matrix = RationalMatrix::<0>::zero(); @@ -466,7 +646,11 @@ mod tests { let vector = RationalVector::<2>::try_new([ratio(1, 2), ratio(1, 3)]).unwrap(); assert!(matches!( vector.try_to_f64(), - Err(LaError::Unrepresentable { index: Some(1), .. }) + Err(LaError::Unrepresentable { + index: Some(1), + reason: UnrepresentableReason::RequiresRounding, + .. + }) )); let rounded = vector.to_rounded_f64().unwrap().into_array(); assert_eq!(rounded[0].to_bits(), 0.5_f64.to_bits()); diff --git a/tests/proptest_exact.rs b/tests/proptest_exact.rs index f56e51c..b4ee7b0 100644 --- a/tests/proptest_exact.rs +++ b/tests/proptest_exact.rs @@ -307,9 +307,9 @@ fn solve_exact_handles_bit_exact_subnormal_row_scales() { let rhs = Vector::<2>::try_new([row_0_scale, row_1_scale]).unwrap(); let solution = matrix.solve_exact(rhs).unwrap(); let one = BigRational::from_integer(BigInt::from(1)); - assert_eq!(solution, [one.clone(), one]); + assert_eq!(solution.as_array(), &[one.clone(), one]); - let residual = big_rational_matvec(&rows, &solution); + let residual = big_rational_matvec(&rows, solution.as_array()); assert_eq!( residual, [ @@ -409,7 +409,7 @@ macro_rules! gen_solve_exact_roundtrip_proptests { BigRational::from_f64(x0[i]).expect("small int fits in BigRational") }); for i in 0..$d { - prop_assert_eq!(&x[i], &expected[i]); + prop_assert_eq!(&x.as_array()[i], &expected[i]); } } } @@ -447,7 +447,7 @@ macro_rules! gen_solve_exact_residual_proptests { let b = Vector::<$d>::try_new(b_arr).unwrap(); let x = a.solve_exact(b).expect("diagonally-dominant A is non-singular"); - let ax = big_rational_matvec::<$d>(&rows, &x); + let ax = big_rational_matvec::<$d>(&rows, x.as_array()); for i in 0..$d { let b_rat = BigRational::from_f64(b_arr[i]) .expect("small int fits in BigRational"); @@ -492,7 +492,7 @@ macro_rules! gen_solve_exact_mixed_exponent_residual_proptests { .solve_exact(b) .expect("strict diagonal dominance guarantees invertibility"); - let ax = big_rational_matvec::<$d>(&rows, &x); + let ax = big_rational_matvec::<$d>(&rows, x.as_array()); for i in 0..$d { let b_rat = BigRational::from_f64(b_arr[i]) .expect("finite f64 converts exactly"); diff --git a/tests/proptest_rational.rs b/tests/proptest_rational.rs index f18e328..daa61d0 100644 --- a/tests/proptest_rational.rs +++ b/tests/proptest_rational.rs @@ -112,15 +112,10 @@ macro_rules! gen_rational_properties { let (numerator, denominator) = solution_entries[index]; rational(numerator, denominator) }); - let rhs_array = rational_matvec(&rows, &expected_solution); - let rhs = RationalVector::try_new(rhs_array.clone()).unwrap(); + let rhs = RationalVector::try_new(rational_matvec(&rows, &expected_solution)).unwrap(); let actual_solution = matrix.solve(&rhs).unwrap(); prop_assert_eq!(actual_solution.as_array(), &expected_solution); - prop_assert_eq!( - rational_matvec(&rows, actual_solution.as_array()), - rhs_array, - ); } } From 67afc8e61345e1993680c315223e544ea1800937 Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Thu, 3 Sep 2026 15:42:35 -0700 Subject: [PATCH 3/4] fix(bench): make rational comparisons fair and backward-compatible - Exclude input cloning from consuming BigRational reference timings. - Support schema-1 artifacts that predate rational-input provenance. - Omit unsupported rational-input rows from legacy coverage checks. --- benches/exact.rs | 39 +++++++++++------ scripts/bench_compare.py | 6 ++- scripts/performance_artifacts.py | 9 +++- scripts/tests/test_archive_performance.py | 1 + scripts/tests/test_bench_compare.py | 14 ++++++ scripts/tests/test_performance_artifacts.py | 48 +++++++++++++++++++++ 6 files changed, 102 insertions(+), 15 deletions(-) diff --git a/benches/exact.rs b/benches/exact.rs index 6efee38..2bc2335 100644 --- a/benches/exact.rs +++ b/benches/exact.rs @@ -33,6 +33,8 @@ 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)))] @@ -255,6 +257,9 @@ fn determinant_sign(value: &BigRational) -> DeterminantSign { /// 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. #[cfg(not(any(la_stack_pre_rational_input_api, la_stack_v0_4_3_api)))] fn bench_rational_input(criterion: &mut Criterion) { let input = rational_input::(); @@ -273,11 +278,14 @@ fn bench_rational_input(criterion: &mut Criterion) { }); }); group.bench_function("det_big_rational_gaussian", |bencher| { - bencher.iter(|| { - let rows = black_box(input.matrix.as_rows()).clone(); - let determinant = rational_determinant_gaussian(rows); - black_box(determinant); - }); + bencher.iter_batched( + || black_box(input.matrix.as_rows()).clone(), + |rows| { + let determinant = rational_determinant_gaussian(rows); + black_box(determinant); + }, + BatchSize::SmallInput, + ); }); group.bench_function("solve_row_cleared_bareiss", |bencher| { bencher.iter(|| { @@ -288,13 +296,20 @@ fn bench_rational_input(criterion: &mut Criterion) { }); }); group.bench_function("solve_big_rational_gaussian", |bencher| { - 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); - }); + 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, + ); }); group.finish(); diff --git a/scripts/bench_compare.py b/scripts/bench_compare.py index f356467..94441f3 100644 --- a/scripts/bench_compare.py +++ b/scripts/bench_compare.py @@ -2095,7 +2095,11 @@ def _parse_args(argv: list[str]) -> argparse.Namespace: def _comparison_policy(scope: str, provenance: HarnessProvenance | None) -> ComparisonPolicy: """Build comparison coverage policy from validated provenance.""" - if provenance is None or provenance.validation is None: + if provenance is None: + return ComparisonPolicy(scope=scope) + if provenance.schema == 1: + return ComparisonPolicy(scope=scope, shared_harness_rational_inputs=False) + if provenance.validation is None: return ComparisonPolicy(scope=scope) compatibility = provenance.validation.get("baseline_api_compatibility") shared_harness_rational_inputs = provenance.validation.get("shared_harness_rational_inputs") diff --git a/scripts/performance_artifacts.py b/scripts/performance_artifacts.py index 1ddd3b2..149a1a4 100644 --- a/scripts/performance_artifacts.py +++ b/scripts/performance_artifacts.py @@ -626,8 +626,9 @@ def _validate_benchmark_provenance(data: Mapping[str, object], *, context: Artif if data.get("baseline") != context.release.baseline: msg = f"benchmark provenance baseline {data.get('baseline')!r} does not match release baseline {context.release.baseline!r}" raise ValueError(msg) - if data.get("current") != context.release.current: - msg = f"benchmark provenance current {data.get('current')!r} does not match release current {context.release.current!r}" + current = _required_provenance_string(data, "current", context="root") + if current != context.release.current: + msg = f"benchmark provenance current {current!r} does not match release current {context.release.current!r}" raise ValueError(msg) criterion = _required_provenance_object(data, "criterion", context="root") @@ -856,6 +857,10 @@ def _parse_provenance(payload: bytes, *, source: str) -> tuple[ArtifactContext, current=_parse_required_string(release_data, "current", source=source), baseline=_parse_required_string(release_data, "baseline", source=source), ) + benchmark_provenance.setdefault("current", release.current) + validation = benchmark_provenance.get("validation") + if isinstance(validation, dict): + validation.setdefault("shared_harness_rational_inputs", False) statistic = _parse_required_string(report_data, "statistic", source=source) if statistic != "median": msg = f"unsupported report statistic in {source}: {statistic!r}" diff --git a/scripts/tests/test_archive_performance.py b/scripts/tests/test_archive_performance.py index 8cd709e..b246248 100644 --- a/scripts/tests/test_archive_performance.py +++ b/scripts/tests/test_archive_performance.py @@ -886,6 +886,7 @@ def test_comparison_benchmark_env_selects_pre_rational_adapter( baseline_tag: str, ) -> None: monkeypatch.delenv("CARGO_ENCODED_RUSTFLAGS", raising=False) + monkeypatch.delenv("RUSTFLAGS", raising=False) monkeypatch.delenv("RUSTUP_TOOLCHAIN", raising=False) (tmp_path / "rust-toolchain.toml").write_text( '[toolchain]\nchannel = "1.98.0"\n', diff --git a/scripts/tests/test_bench_compare.py b/scripts/tests/test_bench_compare.py index 894203a..7e369da 100644 --- a/scripts/tests/test_bench_compare.py +++ b/scripts/tests/test_bench_compare.py @@ -859,6 +859,20 @@ def test_read_harness_provenance_validates_shared_harness_metadata(tmp_path: Pat sha256="a" * 64, baseline="v0.4.3", ) + policy = bench_compare._comparison_policy("release-signal", provenance) + assert policy == bench_compare.ComparisonPolicy( + scope="release-signal", + shared_harness_rational_inputs=False, + ) + + collection = bench_compare._collect_comparisons( + tmp_path, + "v0.4.3", + "median", + suite="exact", + policy=policy, + ) + assert not [gap for gap in collection.gaps if gap.group.startswith("rational_input_d")] def test_read_harness_provenance_is_optional(tmp_path: Path) -> None: diff --git a/scripts/tests/test_performance_artifacts.py b/scripts/tests/test_performance_artifacts.py index 934a629..7ae2da1 100644 --- a/scripts/tests/test_performance_artifacts.py +++ b/scripts/tests/test_performance_artifacts.py @@ -405,6 +405,54 @@ def test_artifact_loader_rejects_incomplete_nested_provenance() -> None: ) +def test_artifact_loader_defaults_fields_absent_from_legacy_schema1_artifacts() -> None: + csv_payload, provenance_payload = serialize_bundle(_bundle()) + provenance = json.loads(provenance_payload) + benchmark_provenance = provenance["benchmark_provenance"] + del benchmark_provenance["current"] + del benchmark_provenance["validation"]["shared_harness_rational_inputs"] + + loaded = load_bundle_bytes( + csv_payload, + (json.dumps(provenance) + "\n").encode(), + source="legacy schema-1 artifact fixture", + ) + + loaded_provenance = loaded.context.benchmark_provenance + assert loaded_provenance["current"] == loaded.context.release.current + validation = loaded_provenance["validation"] + assert isinstance(validation, Mapping) + assert validation["shared_harness_rational_inputs"] is False + + +@pytest.mark.parametrize( + ("field", "value", "message"), + [ + ("current", False, r"root\.current"), + ("shared_harness_rational_inputs", "false", "must be a boolean"), + ], +) +def test_artifact_loader_rejects_invalid_present_legacy_default_fields( + field: str, + value: object, + message: str, +) -> None: + csv_payload, provenance_payload = serialize_bundle(_bundle()) + provenance = json.loads(provenance_payload) + benchmark_provenance = provenance["benchmark_provenance"] + if field == "current": + benchmark_provenance[field] = value + else: + benchmark_provenance["validation"][field] = value + + with pytest.raises((TypeError, ValueError), match=message): + load_bundle_bytes( + csv_payload, + (json.dumps(provenance) + "\n").encode(), + source="invalid legacy-default field fixture", + ) + + def test_artifact_loader_rejects_contradictory_current_revision() -> None: csv_payload, provenance_payload = serialize_bundle(_bundle()) provenance = json.loads(provenance_payload) From 4524f86cbdcb69cdd73e1299520c2ac36a145f02 Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Thu, 3 Sep 2026 15:59:39 -0700 Subject: [PATCH 4/4] test(perf): assert legacy artifact fixture uses schema 2 --- scripts/tests/test_performance_artifacts.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/tests/test_performance_artifacts.py b/scripts/tests/test_performance_artifacts.py index 7ae2da1..a635ccb 100644 --- a/scripts/tests/test_performance_artifacts.py +++ b/scripts/tests/test_performance_artifacts.py @@ -405,17 +405,18 @@ def test_artifact_loader_rejects_incomplete_nested_provenance() -> None: ) -def test_artifact_loader_defaults_fields_absent_from_legacy_schema1_artifacts() -> None: +def test_artifact_loader_defaults_fields_absent_from_legacy_schema2_artifacts() -> None: csv_payload, provenance_payload = serialize_bundle(_bundle()) provenance = json.loads(provenance_payload) benchmark_provenance = provenance["benchmark_provenance"] + assert benchmark_provenance["schema"] == 2 del benchmark_provenance["current"] del benchmark_provenance["validation"]["shared_harness_rational_inputs"] loaded = load_bundle_bytes( csv_payload, (json.dumps(provenance) + "\n").encode(), - source="legacy schema-1 artifact fixture", + source="legacy schema-2 artifact fixture", ) loaded_provenance = loaded.context.benchmark_provenance