From 8e12c935fe54e265e8ceb640702267ec0e71b7b1 Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Mon, 8 Jun 2026 21:48:18 -0700 Subject: [PATCH 1/2] refactor: harden Rust release hygiene - Promote missing documentation and dead code lints to deny-level checks. - Forbid unsafe code explicitly across Rust modules and benchmark targets. - Document the LU/LDLT empty-matrix convention for D=0. - Move exact benchmark input generation into typed helpers and consolidate exact benchmark operation dispatch. --- Cargo.toml | 4 +- benches/common/exact.rs | 116 +++++++++ benches/common/vs_linalg.rs | 2 + benches/exact.rs | 237 ++++-------------- benches/vs_linalg.rs | 2 + src/error.rs | 2 + src/exact.rs | 49 +--- src/ldlt.rs | 19 ++ src/lib.rs | 2 +- src/lu.rs | 19 ++ src/matrix.rs | 79 +++--- src/tolerance.rs | 2 + src/vector.rs | 2 + tests/exact_bench_config.rs | 81 ++++++ tests/proptest_exact.rs | 52 ++++ .../src/project_rules/bench_example_usage.rs | 1 - .../src/project_rules/finite_api_contract.rs | 1 - .../project_rules/public_api_panic_paths.rs | 1 - .../src/project_rules/raw_f64_constructors.rs | 1 - .../project_rules/readme_doctest_mirrors.rs | 1 - 20 files changed, 404 insertions(+), 269 deletions(-) create mode 100644 benches/common/exact.rs create mode 100644 tests/exact_bench_config.rs diff --git a/Cargo.toml b/Cargo.toml index f469979..083635a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -62,8 +62,8 @@ features = [ "exact" ] [lints.rust] unsafe_code = "forbid" -missing_docs = "warn" -dead_code = "warn" +missing_docs = "deny" +dead_code = "deny" [lints.rustdoc] broken_intra_doc_links = "deny" diff --git a/benches/common/exact.rs b/benches/common/exact.rs new file mode 100644 index 0000000..2d44f24 --- /dev/null +++ b/benches/common/exact.rs @@ -0,0 +1,116 @@ +#![forbid(unsafe_code)] + +//! Shared helpers for exact-arithmetic benchmark input generation and tests. + +use std::fmt::{self, Display}; +use std::num::NonZeroU64; + +/// Configuration errors for exact-arithmetic benchmark input generation. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ExactBenchConfigError { + /// The random input corpus length was zero. + EmptyCorpus, + /// An ordered inclusive range produced an invalid non-zero sampling width. + InvalidRangeWidth { + /// Inclusive lower bound. + min: i16, + /// Inclusive upper bound. + max: i16, + /// Computed inclusive width before conversion to the cached sampling width. + width: i32, + }, + /// The inclusive lower bound was greater than the inclusive upper bound. + UnorderedRange { + /// Inclusive lower bound. + min: i16, + /// Inclusive upper bound. + max: i16, + }, +} + +impl Display for ExactBenchConfigError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match *self { + Self::EmptyCorpus => f.write_str("random input corpus must be nonempty"), + Self::InvalidRangeWidth { min, max, width } => { + write!( + f, + "random integer range {min}..={max} produced invalid sampling width {width}" + ) + } + Self::UnorderedRange { min, max } => { + write!(f, "random integer range must be ordered: {min}..={max}") + } + } + } +} + +impl std::error::Error for ExactBenchConfigError {} + +/// Inclusive integer range used by the fixed-seed exact benchmark generator. +#[derive(Clone, Copy)] +#[must_use] +pub struct I16Range { + min: i16, + width: NonZeroU64, +} + +impl I16Range { + /// Validate an inclusive `i16` range and cache its sampling width. + /// + /// # Errors + /// + /// Returns [`ExactBenchConfigError::UnorderedRange`] when `min > max`, or + /// [`ExactBenchConfigError::InvalidRangeWidth`] if the inclusive range width + /// cannot be represented as a non-zero sampling width. + pub fn new(min: i16, max: i16) -> Result { + if min > max { + return Err(ExactBenchConfigError::UnorderedRange { min, max }); + } + + let raw_width = i32::from(max) - i32::from(min) + 1; + let width = + u64::try_from(raw_width).map_err(|_| ExactBenchConfigError::InvalidRangeWidth { + min, + max, + width: raw_width, + })?; + let width = NonZeroU64::new(width).ok_or(ExactBenchConfigError::InvalidRangeWidth { + min, + max, + width: raw_width, + })?; + Ok(Self { min, width }) + } +} + +/// Deterministic `SplitMix64` generator for reproducible benchmark corpora. +#[must_use] +pub struct SplitMix64 { + state: u64, +} + +impl SplitMix64 { + /// Initialize the generator with a fixed state. + pub const fn new(state: u64) -> Self { + Self { state } + } + + /// Advance the generator and return the next 64 random bits. + const fn next_u64(&mut self) -> u64 { + self.state = self.state.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = self.state; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + } + + #[allow(clippy::cast_possible_truncation)] + /// Draw a random `i16` inside a validated inclusive range. + #[must_use] + pub fn next_i16(&mut self, range: I16Range) -> i16 { + let offset = (self.next_u64() % range.width.get()) as i32; + let value = i32::from(range.min) + offset; + value as i16 + } +} diff --git a/benches/common/vs_linalg.rs b/benches/common/vs_linalg.rs index da1072f..ccdb443 100644 --- a/benches/common/vs_linalg.rs +++ b/benches/common/vs_linalg.rs @@ -1,3 +1,5 @@ +#![forbid(unsafe_code)] + //! Shared helpers for the `vs_linalg` benchmark and its smoke tests. use faer::linalg::solvers::{Ldlt as FaerLdlt, PartialPivLu}; diff --git a/benches/exact.rs b/benches/exact.rs index 6683333..139f0db 100644 --- a/benches/exact.rs +++ b/benches/exact.rs @@ -1,3 +1,5 @@ +#![forbid(unsafe_code)] + //! Benchmarks for exact arithmetic operations. //! //! These benchmarks measure the performance of the `exact` feature's @@ -26,7 +28,7 @@ use std::array; use std::cell::Cell; -use std::fmt::{self, Display}; +use std::fmt::Display; use std::hint::black_box; use std::num::NonZeroUsize; use std::time::Instant; @@ -36,6 +38,12 @@ use pastey::paste; use la_stack::{Matrix, Vector}; +mod common { + pub mod exact; +} + +use common::exact::{ExactBenchConfigError, I16Range, SplitMix64}; + const RANDOM_INPUTS_PER_DIM: SampleCount = SampleCount::new_unchecked(50); const RANDOM_INPUT_ARRAY_LEN: usize = RANDOM_INPUTS_PER_DIM.get(); const RANDOM_TIMING_PASSES: SampleCount = SampleCount::new_unchecked(5); @@ -54,24 +62,6 @@ fn require_ok(result: Result, operation: &str) -> T { } } -/// Configuration errors for exact-arithmetic benchmark input generation. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum ExactBenchConfigError { - EmptyCorpus, - UnorderedRange { min: i16, max: i16 }, -} - -impl Display for ExactBenchConfigError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match *self { - Self::EmptyCorpus => f.write_str("random input corpus must be nonempty"), - Self::UnorderedRange { min, max } => { - write!(f, "random integer range must be ordered: {min}..={max}") - } - } - } -} - /// Non-zero sample count used when selecting percentile benchmark inputs. #[derive(Clone, Copy)] struct SampleCount { @@ -102,29 +92,6 @@ impl SampleCount { } } -/// Inclusive integer range used by the fixed-seed exact benchmark generator. -#[derive(Clone, Copy)] -struct I16Range { - min: i16, - width: u64, -} - -impl I16Range { - /// Validate an inclusive `i16` range and cache its sampling width. - fn new(min: i16, max: i16) -> Result { - if min > max { - return Err(ExactBenchConfigError::UnorderedRange { min, max }); - } - - let width = i32::from(max) - i32::from(min) + 1; - Ok(Self { - min, - width: u64::try_from(width) - .map_err(|_| ExactBenchConfigError::UnorderedRange { min, max })?, - }) - } -} - /// Percentiles selected from a pre-timed random-input corpus. #[derive(Clone, Copy)] enum RandomPercentile { @@ -205,6 +172,8 @@ struct ExactRandomInput { enum ExactRandomOperation { DetSignExact, DetExact, + DetExactF64Result, + DetExactRoundedF64, SolveExact, SolveExactF64Result, SolveExactRoundedF64, @@ -216,6 +185,8 @@ impl ExactRandomOperation { match self { Self::DetSignExact => "det_sign_exact", Self::DetExact => "det_exact", + Self::DetExactF64Result => "det_exact_f64_result", + Self::DetExactRoundedF64 => "det_exact_rounded_f64", Self::SolveExact => "solve_exact", Self::SolveExactF64Result => "solve_exact_f64_result", Self::SolveExactRoundedF64 => "solve_exact_rounded_f64", @@ -223,35 +194,6 @@ impl ExactRandomOperation { } } -/// Deterministic `SplitMix64` generator for reproducible benchmark corpora. -struct SplitMix64 { - state: u64, -} - -impl SplitMix64 { - /// Initialize the generator with a fixed state. - const fn new(state: u64) -> Self { - Self { state } - } - - /// Advance the generator and return the next 64 random bits. - const fn next_u64(&mut self) -> u64 { - self.state = self.state.wrapping_add(0x9E37_79B9_7F4A_7C15); - let mut z = self.state; - z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); - z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); - z ^ (z >> 31) - } - - #[allow(clippy::cast_possible_truncation)] - /// Draw a random `i16` inside a validated inclusive range. - fn next_i16(&mut self, range: I16Range) -> i16 { - let offset = (self.next_u64() % range.width) as i32; - let value = i32::from(range.min) + offset; - value as i16 - } -} - /// Derive a stable per-dimension seed from the global random benchmark seed. #[allow(clippy::cast_possible_truncation)] fn random_seed_for_dim() -> u64 { @@ -322,6 +264,17 @@ fn run_random_operation( let det = require_ok(black_box(input.matrix).det_exact(), "exact determinant"); black_box(det); } + ExactRandomOperation::DetExactF64Result => { + let det = black_box(input.matrix).det_exact_f64(); + let _ = black_box(det); + } + ExactRandomOperation::DetExactRoundedF64 => { + let det = require_ok( + black_box(input.matrix).det_exact_rounded_f64(), + "exact determinant rounded to f64", + ); + let _ = black_box(det); + } ExactRandomOperation::SolveExact => { let x = require_ok( black_box(input.matrix).solve_exact(black_box(input.rhs)), @@ -343,6 +296,20 @@ fn run_random_operation( } } +/// Add one exact-arithmetic operation benchmark over a fixed input pair. +fn bench_exact_operation( + group: &mut BenchmarkGroup<'_, WallTime>, + operation: ExactRandomOperation, + matrix: Matrix, + rhs: Vector, +) { + group.bench_function(operation.name(), |bencher| { + bencher.iter(|| { + run_random_operation(operation, ExactRandomInput { matrix, rhs }); + }); + }); +} + /// Time one exact operation on one random input in nanoseconds. fn time_random_operation( operation: ExactRandomOperation, @@ -385,13 +352,11 @@ fn percentile_input_indices( RANDOM_PERCENTILES.map(|percentile| { let timing_idx = percentile_index(input_count, percentile); let threshold = timings[timing_idx].0; - let mut indices = Vec::new(); - for &(elapsed, input_idx) in &timings { - if elapsed <= threshold { - indices.push(input_idx); - } - } - indices + let selected_len = timings.partition_point(|&(elapsed, _)| elapsed <= threshold); + timings[..selected_len] + .iter() + .map(|&(_, input_idx)| input_idx) + .collect() }) } @@ -510,46 +475,11 @@ fn bench_extreme_group( m: Matrix, rhs: Vector, ) { - group.bench_function("det_sign_exact", |bencher| { - bencher.iter(|| { - let sign = require_ok(black_box(m).det_sign_exact(), "exact determinant sign"); - black_box(sign); - }); - }); - - group.bench_function("det_exact", |bencher| { - bencher.iter(|| { - let det = require_ok(black_box(m).det_exact(), "exact determinant"); - black_box(det); - }); - }); - - group.bench_function("solve_exact", |bencher| { - bencher.iter(|| { - let x = require_ok( - black_box(m).solve_exact(black_box(rhs)), - "exact linear solve", - ); - let _ = black_box(x); - }); - }); - - group.bench_function("solve_exact_f64_result", |bencher| { - bencher.iter(|| { - let x = black_box(m).solve_exact_f64(black_box(rhs)); - let _ = black_box(x); - }); - }); - - group.bench_function("solve_exact_rounded_f64", |bencher| { - bencher.iter(|| { - let x = require_ok( - black_box(m).solve_exact_rounded_f64(black_box(rhs)), - "exact linear solve rounded to f64", - ); - let _ = black_box(x); - }); - }); + bench_exact_operation(group, ExactRandomOperation::DetSignExact, m, rhs); + bench_exact_operation(group, ExactRandomOperation::DetExact, m, rhs); + bench_exact_operation(group, ExactRandomOperation::SolveExact, m, rhs); + bench_exact_operation(group, ExactRandomOperation::SolveExactF64Result, m, rhs); + bench_exact_operation(group, ExactRandomOperation::SolveExactRoundedF64, m, rhs); } macro_rules! gen_exact_benches_for_dim { @@ -581,70 +511,13 @@ macro_rules! gen_exact_benches_for_dim { }); }); - // === det_exact (BigRational result) === - [].bench_function("det_exact", |bencher| { - bencher.iter(|| { - let det = require_ok(black_box(a).det_exact(), "exact determinant"); - black_box(det); - }); - }); - - // === det_exact_f64 (fallible exact → f64 Result) === - [].bench_function("det_exact_f64_result", |bencher| { - bencher.iter(|| { - let det = black_box(a).det_exact_f64(); - black_box(det); - }); - }); - - // === det_exact_rounded_f64 (lossy exact → f64) === - [].bench_function("det_exact_rounded_f64", |bencher| { - bencher.iter(|| { - let det = require_ok( - black_box(a).det_exact_rounded_f64(), - "exact determinant rounded to f64", - ); - black_box(det); - }); - }); - - // === det_sign_exact (adaptive: fast filter + exact fallback) === - [].bench_function("det_sign_exact", |bencher| { - bencher.iter(|| { - let sign = require_ok(black_box(a).det_sign_exact(), "exact determinant sign"); - black_box(sign); - }); - }); - - // === solve_exact (BigRational result) === - [].bench_function("solve_exact", |bencher| { - bencher.iter(|| { - let x = require_ok( - black_box(a).solve_exact(black_box(rhs)), - "exact linear solve", - ); - black_box(x); - }); - }); - - // === solve_exact_f64 (fallible exact → f64 Result) === - [].bench_function("solve_exact_f64_result", |bencher| { - bencher.iter(|| { - let x = black_box(a).solve_exact_f64(black_box(rhs)); - black_box(x); - }); - }); - - // === solve_exact_rounded_f64 (lossy exact → f64) === - [].bench_function("solve_exact_rounded_f64", |bencher| { - bencher.iter(|| { - let x = require_ok( - black_box(a).solve_exact_rounded_f64(black_box(rhs)), - "exact linear solve rounded to f64", - ); - black_box(x); - }); - }); + bench_exact_operation(&mut [], ExactRandomOperation::DetExact, a, rhs); + bench_exact_operation(&mut [], ExactRandomOperation::DetExactF64Result, a, rhs); + bench_exact_operation(&mut [], ExactRandomOperation::DetExactRoundedF64, a, rhs); + bench_exact_operation(&mut [], ExactRandomOperation::DetSignExact, a, rhs); + bench_exact_operation(&mut [], ExactRandomOperation::SolveExact, a, rhs); + bench_exact_operation(&mut [], ExactRandomOperation::SolveExactF64Result, a, rhs); + bench_exact_operation(&mut [], ExactRandomOperation::SolveExactRoundedF64, a, rhs); [].finish(); }}; diff --git a/benches/vs_linalg.rs b/benches/vs_linalg.rs index 8727c90..9b129a4 100644 --- a/benches/vs_linalg.rs +++ b/benches/vs_linalg.rs @@ -1,3 +1,5 @@ +#![forbid(unsafe_code)] + //! Benchmark comparison between la-stack and other Rust linear algebra crates. //! //! Goal: like-for-like comparisons of the operations la-stack supports across several diff --git a/src/error.rs b/src/error.rs index 41769dd..4d2c370 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,3 +1,5 @@ +#![forbid(unsafe_code)] + //! Error types and helpers for linear algebra operations. use core::fmt; diff --git a/src/exact.rs b/src/exact.rs index 173f26a..76a4bd1 100644 --- a/src/exact.rs +++ b/src/exact.rs @@ -1,3 +1,5 @@ +#![forbid(unsafe_code)] + //! Exact arithmetic operations via arbitrary-precision rational numbers. //! //! This module is only compiled when the `"exact"` Cargo feature is enabled. @@ -1961,12 +1963,6 @@ mod tests { // bareiss_det (wrapper) tests // ----------------------------------------------------------------------- - #[test] - fn bareiss_det_d0_is_one() { - let det = Matrix::<0>::zero().det_exact().unwrap(); - assert_eq!(det, BigRational::from_integer(BigInt::from(1))); - } - #[test] fn bareiss_det_d1_returns_entry() { let det = Matrix::<1>::try_from_rows([[7.0]]) @@ -2736,6 +2732,11 @@ mod tests { }) } + fn hilbert() -> Matrix { + let rows = from_fn(|r| from_fn(|c| 1.0 / f64::from(u32::try_from(r + c + 1).unwrap()))); + Matrix::::try_from_rows(rows).unwrap() + } + /// Near-singular 3×3 solve (matches the `exact_near_singular_3x3` /// bench). With `A = [[1+2^-50, 2, 3], [4, 5, 6], [7, 8, 9]]` and /// `x0 = [1, 1, 1]`, `A · x0 = [6 + 2^-50, 15, 24]`; every component is @@ -2843,15 +2844,8 @@ mod tests { ($d:literal) => { paste! { #[test] - #[allow(clippy::cast_precision_loss)] fn []() { - let mut rows = [[0.0f64; $d]; $d]; - for r in 0..$d { - for c in 0..$d { - rows[r][c] = 1.0 / ((r + c + 1) as f64); - } - } - let h = Matrix::<$d>::try_from_rows(rows).unwrap(); + let h = hilbert::<$d>(); assert_eq!(h.det_sign_exact().unwrap(), 1); } } @@ -2867,15 +2861,8 @@ mod tests { ($d:literal) => { paste! { #[test] - #[allow(clippy::cast_precision_loss)] fn []() { - let mut rows = [[0.0f64; $d]; $d]; - for r in 0..$d { - for c in 0..$d { - rows[r][c] = 1.0 / ((r + c + 1) as f64); - } - } - let h = Matrix::<$d>::try_from_rows(rows).unwrap(); + let h = hilbert::<$d>(); let b = Vector::<$d>::new([1.0; $d]); assert_matches!( @@ -2904,21 +2891,14 @@ mod tests { ($d:literal) => { paste! { #[test] - #[allow(clippy::cast_precision_loss)] fn []() { - let mut rows = [[0.0f64; $d]; $d]; - for r in 0..$d { - for c in 0..$d { - rows[r][c] = 1.0 / ((r + c + 1) as f64); - } - } - let h = Matrix::<$d>::try_from_rows(rows).unwrap(); + let h = hilbert::<$d>(); // Use a non-trivial RHS with both positive and negative // entries to avoid accidental structural cancellation. let mut b_arr = [0.0f64; $d]; for i in 0usize..$d { let sign = if i.is_multiple_of(2) { 1.0 } else { -1.0 }; - b_arr[i] = sign * ((i + 1) as f64); + b_arr[i] = sign * f64::from(u32::try_from(i + 1).unwrap()); } let b = Vector::<$d>::new(b_arr); let x = h.solve_exact(b).unwrap(); @@ -3050,13 +3030,6 @@ mod tests { // exact solve boundary tests // ----------------------------------------------------------------------- - #[test] - fn gauss_solve_d0_returns_empty() { - let a = Matrix::<0>::zero(); - let b = Vector::<0>::zero(); - assert_eq!(a.solve_exact(b).unwrap().len(), 0); - } - #[test] fn gauss_solve_d1() { let a = Matrix::<1>::try_from_rows([[2.0]]).unwrap(); diff --git a/src/ldlt.rs b/src/ldlt.rs index 4f49a49..8b51731 100644 --- a/src/ldlt.rs +++ b/src/ldlt.rs @@ -1,3 +1,5 @@ +#![forbid(unsafe_code)] + //! LDLT factorization and solves. //! //! This module provides a stack-allocated LDLT factorization (`A = L D Lᵀ`) intended for @@ -19,6 +21,9 @@ use crate::{LaError, Tolerance}; /// LDLT factorization (`A = L D Lᵀ`) for symmetric positive (semi)definite matrices. /// +/// `Ldlt<0>` represents the empty factorization. Its determinant is the empty +/// product `1.0`, and solving against [`Vector<0>`] returns [`Vector<0>`]. +/// /// This factorization is **not** a general-purpose symmetric-indefinite LDLT (no pivoting). /// It assumes the input matrix is symmetric and (numerically) SPD/PSD. /// @@ -414,6 +419,20 @@ mod tests { gen_public_api_ldlt_diagonal_tests!(4); gen_public_api_ldlt_diagonal_tests!(5); + #[test] + fn solve_0x0_returns_empty_vector_and_unit_det() { + let a = Matrix::<0>::zero(); + let ldlt = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap(); + + assert_eq!(ldlt.det(), Ok(1.0)); + assert!( + ldlt.solve(Vector::<0>::zero()) + .unwrap() + .into_array() + .is_empty() + ); + } + #[test] fn solve_2x2_known_spd() { let a = Matrix::<2>::try_from_rows(black_box([[4.0, 2.0], [2.0, 3.0]])).unwrap(); diff --git a/src/lib.rs b/src/lib.rs index 20576ab..0ea0a8f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,5 @@ #![forbid(unsafe_code)] -#![warn(missing_docs)] +#![deny(missing_docs)] #![doc = include_str!("../README.md")] #[cfg(doc)] diff --git a/src/lu.rs b/src/lu.rs index 0300a52..8508cd9 100644 --- a/src/lu.rs +++ b/src/lu.rs @@ -1,3 +1,5 @@ +#![forbid(unsafe_code)] + //! LU decomposition and solves. use core::hint::cold_path; @@ -7,6 +9,9 @@ use crate::vector::Vector; use crate::{LaError, Tolerance}; /// LU decomposition (PA = LU) with partial pivoting. +/// +/// `Lu<0>` represents the empty factorization. Its determinant is the empty +/// product `1.0`, and solving against [`Vector<0>`] returns [`Vector<0>`]. #[must_use] #[derive(Clone, Copy, Debug, PartialEq)] pub struct Lu { @@ -436,6 +441,20 @@ mod tests { gen_public_api_tridiagonal_smoke_solve_and_det_tests!(32); gen_public_api_tridiagonal_smoke_solve_and_det_tests!(64); + #[test] + fn solve_0x0_returns_empty_vector_and_unit_det() { + let a = Matrix::<0>::zero(); + let lu = a.lu(DEFAULT_SINGULAR_TOL).unwrap(); + + assert_eq!(lu.det(), Ok(1.0)); + assert!( + lu.solve(Vector::<0>::zero()) + .unwrap() + .into_array() + .is_empty() + ); + } + #[test] fn solve_1x1() { let a = Matrix::<1>::try_from_rows(black_box([[2.0]])).unwrap(); diff --git a/src/matrix.rs b/src/matrix.rs index e7c27e8..61b22c6 100644 --- a/src/matrix.rs +++ b/src/matrix.rs @@ -1,3 +1,5 @@ +#![forbid(unsafe_code)] + //! Fixed-size, stack-allocated square matrices. use core::hint::cold_path; @@ -479,6 +481,10 @@ impl Matrix { /// Compute an LU decomposition with partial pivoting. /// + /// `D = 0` follows the empty-matrix convention: factorization succeeds, + /// [`Lu::det`](crate::Lu::det) returns `1.0`, and solving a length-zero + /// right-hand side returns a length-zero [`Vector`](crate::Vector). + /// /// # Examples /// ``` /// use la_stack::prelude::*; @@ -496,6 +502,20 @@ impl Matrix { /// # } /// ``` /// + /// Empty matrices use the standard empty-product convention: + /// + /// ``` + /// use la_stack::prelude::*; + /// + /// # fn main() -> Result<(), LaError> { + /// let lu = Matrix::<0>::zero().lu(DEFAULT_SINGULAR_TOL)?; + /// + /// assert_eq!(lu.det()?, 1.0); + /// assert!(lu.solve(Vector::<0>::zero())?.into_array().is_empty()); + /// # Ok(()) + /// # } + /// ``` + /// /// The `tol` argument is a [`Tolerance`], so raw caller input must be /// finite and non-negative before it can reach factorization. Use /// [`Tolerance::new`] or [`LaError::validate_tolerance`] when accepting a @@ -514,6 +534,10 @@ impl Matrix { /// Compute an LDLT factorization (`A = L D Lᵀ`) without pivoting. /// + /// `D = 0` follows the empty-matrix convention: factorization succeeds, + /// [`Ldlt::det`](crate::Ldlt::det) returns `1.0`, and solving a length-zero + /// right-hand side returns a length-zero [`Vector`](crate::Vector). + /// /// This is intended for symmetric positive definite (SPD) and positive semi-definite (PSD) /// matrices such as Gram matrices. /// @@ -553,6 +577,20 @@ impl Matrix { /// # } /// ``` /// + /// Empty matrices use the standard empty-product convention: + /// + /// ``` + /// use la_stack::prelude::*; + /// + /// # fn main() -> Result<(), LaError> { + /// let ldlt = Matrix::<0>::zero().ldlt(DEFAULT_SINGULAR_TOL)?; + /// + /// assert_eq!(ldlt.det()?, 1.0); + /// assert!(ldlt.solve(Vector::<0>::zero())?.into_array().is_empty()); + /// # Ok(()) + /// # } + /// ``` + /// /// # Errors /// Returns [`LaError::NotPositiveSemidefinite`] if, for some step `k`, the required /// diagonal entry `d = D[k,k]` is negative. @@ -960,7 +998,6 @@ mod tests { use crate::vector::Vector; use approx::assert_abs_diff_eq; - use core::assert_matches; use pastey::paste; use std::hint::black_box; @@ -1925,44 +1962,4 @@ mod tests { ); assert!(!a.is_symmetric(Tolerance::new(1e-12).unwrap()).unwrap()); } - - #[test] - fn is_symmetric_rejects_invalid_tol() { - assert_eq!( - Tolerance::new(-1.0), - Err(LaError::InvalidTolerance { value: -1.0 }) - ); - assert_matches!( - Tolerance::new(f64::NAN), - Err(LaError::InvalidTolerance { value }) if value.is_nan() - ); - assert_eq!( - Tolerance::new(f64::INFINITY), - Err(LaError::InvalidTolerance { - value: f64::INFINITY, - }) - ); - } - - #[test] - fn first_asymmetry_rejects_negative_tol() { - assert_eq!( - Tolerance::new(-1.0), - Err(LaError::InvalidTolerance { value: -1.0 }) - ); - } - - #[test] - fn first_asymmetry_rejects_nonfinite_tol() { - assert_matches!( - Tolerance::new(f64::NAN), - Err(LaError::InvalidTolerance { value }) if value.is_nan() - ); - assert_eq!( - Tolerance::new(f64::INFINITY), - Err(LaError::InvalidTolerance { - value: f64::INFINITY, - }) - ); - } } diff --git a/src/tolerance.rs b/src/tolerance.rs index cd0a48f..b85dfea 100644 --- a/src/tolerance.rs +++ b/src/tolerance.rs @@ -1,3 +1,5 @@ +#![forbid(unsafe_code)] + //! Finite tolerance values used by numerical predicates and factorizations. use crate::LaError; diff --git a/src/vector.rs b/src/vector.rs index 0f5ad65..6ae6496 100644 --- a/src/vector.rs +++ b/src/vector.rs @@ -1,3 +1,5 @@ +#![forbid(unsafe_code)] + //! Fixed-size, stack-allocated vectors. use core::hint::cold_path; diff --git a/tests/exact_bench_config.rs b/tests/exact_bench_config.rs new file mode 100644 index 0000000..9f94634 --- /dev/null +++ b/tests/exact_bench_config.rs @@ -0,0 +1,81 @@ +//! Tests for exact benchmark input-generation configuration. + +#![cfg(all(feature = "bench", feature = "exact"))] +#![forbid(unsafe_code)] + +#[path = "../benches/common/exact.rs"] +mod exact_bench; + +use core::array::from_fn; + +use exact_bench::{ExactBenchConfigError, I16Range, SplitMix64}; + +#[test] +fn i16_range_rejects_unordered_bounds() { + let Err(err) = I16Range::new(5, 4) else { + panic!("unordered range should be rejected"); + }; + assert_eq!( + err, + ExactBenchConfigError::UnorderedRange { min: 5, max: 4 } + ); +} + +#[test] +fn empty_corpus_error_message_names_requirement() { + assert_eq!( + ExactBenchConfigError::EmptyCorpus.to_string(), + "random input corpus must be nonempty" + ); +} + +#[test] +fn exact_bench_config_error_is_std_error() { + let err = ExactBenchConfigError::UnorderedRange { min: 5, max: 4 }; + let as_error: &dyn std::error::Error = &err; + + assert_eq!( + as_error.to_string(), + "random integer range must be ordered: 5..=4" + ); + assert!(as_error.source().is_none()); +} + +#[test] +fn i16_range_single_value_always_draws_that_value() { + let Ok(range) = I16Range::new(7, 7) else { + panic!("single-value range should be valid"); + }; + let mut rng = SplitMix64::new(0); + + for _ in 0..32 { + assert_eq!(rng.next_i16(range), 7); + } +} + +#[test] +fn i16_range_draws_stay_inside_inclusive_bounds() { + let Ok(range) = I16Range::new(-10, 10) else { + panic!("ordered range should be valid"); + }; + let mut rng = SplitMix64::new(0xCAFE_F00D); + + for _ in 0..1024 { + assert!((-10..=10).contains(&rng.next_i16(range))); + } +} + +#[test] +fn splitmix64_sequence_is_stable_for_benchmark_seed() { + let Ok(range) = I16Range::new(-10, 10) else { + panic!("ordered range should be valid"); + }; + let mut rng = SplitMix64::new(0xCAFE_F00D); + + let draws = from_fn::<_, 16, _>(|_| rng.next_i16(range)); + + assert_eq!( + draws, + [-8, 4, -3, -8, 8, -4, 4, -6, -9, -8, 6, 1, 0, 3, -8, -1] + ); +} diff --git a/tests/proptest_exact.rs b/tests/proptest_exact.rs index f076661..9f7db4d 100644 --- a/tests/proptest_exact.rs +++ b/tests/proptest_exact.rs @@ -450,6 +450,58 @@ gen_det_sign_fast_filter_boundary_proptests!(2); gen_det_sign_fast_filter_boundary_proptests!(3); gen_det_sign_fast_filter_boundary_proptests!(4); +/// Error-bound invariant: for every dense D≤4 matrix in this corpus, +/// `det_errbound()` must bound the absolute error of `det_direct()` against an +/// independent exact Leibniz expansion. The entries include decimal fractions +/// that are not generally exactly representable in binary64, so this exercises +/// non-trivial rounding in the closed-form determinant path while keeping the +/// magnitudes far from overflow. +macro_rules! gen_det_errbound_leibniz_oracle_proptests { + ($d:literal) => { + paste! { + proptest! { + #![proptest_config(ProptestConfig::with_cases(64))] + + #[test] + fn []( + entries in proptest::array::[]( + proptest::array::[]( + (-50i16..=50i16).prop_map(|x| f64::from(x) / 10.0) + ), + ), + ) { + let m = Matrix::<$d>::try_from_rows(entries).unwrap(); + let det_direct = m + .det_direct() + .unwrap() + .expect("D<=4 has closed-form det_direct"); + let bound = m + .det_errbound() + .unwrap() + .expect("D<=4 has a det_errbound"); + + let exact = bigrational_det_leibniz::<$d>(&entries); + let direct_exact = BigRational::from_f64(det_direct) + .expect("det_direct returned finite f64"); + let bound_exact = BigRational::from_f64(bound) + .expect("det_errbound returned finite f64"); + let error = (direct_exact - exact).abs(); + + prop_assert!( + error <= bound_exact, + "det_direct error exceeded det_errbound for D={}: error={error}, bound={bound_exact}", + $d + ); + } + } + } + }; +} + +gen_det_errbound_leibniz_oracle_proptests!(2); +gen_det_errbound_leibniz_oracle_proptests!(3); +gen_det_errbound_leibniz_oracle_proptests!(4); + /// Mixed-scale diagonal matrices stress the shared-exponent conversion path: /// zeros, subnormals, tiny normal values, ordinary values, and very large /// finite values can all appear in the same determinant. The independent diff --git a/tests/semgrep/src/project_rules/bench_example_usage.rs b/tests/semgrep/src/project_rules/bench_example_usage.rs index 2da38de..0909dd6 100644 --- a/tests/semgrep/src/project_rules/bench_example_usage.rs +++ b/tests/semgrep/src/project_rules/bench_example_usage.rs @@ -1,5 +1,4 @@ #![forbid(unsafe_code)] -#![allow(dead_code)] fn benchmark_and_example_fixture(result: Result, value: Option) { // ruleid: la-stack.rust.no-unwrap-expect-in-benches-examples diff --git a/tests/semgrep/src/project_rules/finite_api_contract.rs b/tests/semgrep/src/project_rules/finite_api_contract.rs index acb856f..8ac5fea 100644 --- a/tests/semgrep/src/project_rules/finite_api_contract.rs +++ b/tests/semgrep/src/project_rules/finite_api_contract.rs @@ -1,5 +1,4 @@ #![forbid(unsafe_code)] -#![allow(dead_code)] // ruleid: la-stack.rust.no-public-raw-linear-algebra-modules pub mod matrix; diff --git a/tests/semgrep/src/project_rules/public_api_panic_paths.rs b/tests/semgrep/src/project_rules/public_api_panic_paths.rs index 1e75762..cc398f9 100644 --- a/tests/semgrep/src/project_rules/public_api_panic_paths.rs +++ b/tests/semgrep/src/project_rules/public_api_panic_paths.rs @@ -1,5 +1,4 @@ #![forbid(unsafe_code)] -#![allow(dead_code)] pub enum Error { Invalid, diff --git a/tests/semgrep/src/project_rules/raw_f64_constructors.rs b/tests/semgrep/src/project_rules/raw_f64_constructors.rs index 1710cd0..96d97cc 100644 --- a/tests/semgrep/src/project_rules/raw_f64_constructors.rs +++ b/tests/semgrep/src/project_rules/raw_f64_constructors.rs @@ -1,5 +1,4 @@ #![forbid(unsafe_code)] -#![allow(dead_code)] #[must_use] pub struct Matrix { diff --git a/tests/semgrep/src/project_rules/readme_doctest_mirrors.rs b/tests/semgrep/src/project_rules/readme_doctest_mirrors.rs index 7a5314c..df21b86 100644 --- a/tests/semgrep/src/project_rules/readme_doctest_mirrors.rs +++ b/tests/semgrep/src/project_rules/readme_doctest_mirrors.rs @@ -1,5 +1,4 @@ #![forbid(unsafe_code)] -#![allow(dead_code)] // ruleid: la-stack.rust.no-unwrap-expect-in-readme-doctest-mirrors mod readme_doctests_unwrap { From d7c1487115e1a8e5bb1ec4fcc7592786e300e2ce Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Mon, 8 Jun 2026 22:16:13 -0700 Subject: [PATCH 2/2] feat(bench): add vs_linalg-only performance checks - Add local workflows for comparing current non-exact la-stack kernels against a release baseline without rerunning current nalgebra/faer or exact benchmarks. - Route archive-performance baseline and current benchmark commands by suite, with legacy fallback support for older release worktrees. - Document the faster release-signal workflow and expand Semgrep fixtures for benchmark, example, doctest, and public panic-path rules. --- docs/BENCHMARKING.md | 17 +++ justfile | 23 +++ scripts/README.md | 11 ++ scripts/archive_performance.py | 56 ++++++- scripts/tests/test_archive_performance.py | 137 +++++++++++++++++- tests/semgrep/doctests/unwrap_expect.txt | 9 ++ .../src/project_rules/bench_example_usage.rs | 20 +++ .../project_rules/public_api_panic_paths.rs | 14 ++ .../project_rules/readme_doctest_mirrors.rs | 19 +++ 9 files changed, 298 insertions(+), 8 deletions(-) diff --git a/docs/BENCHMARKING.md b/docs/BENCHMARKING.md index db226d0..4eb3c66 100644 --- a/docs/BENCHMARKING.md +++ b/docs/BENCHMARKING.md @@ -116,6 +116,9 @@ just bench-compare # Run latest measurements and compare against "last" just bench-latest-vs-last + +# Run only non-exact la-stack rows from vs_linalg and compare against "last" +just bench-vs-linalg-latest-vs ``` ## Comparing performance across releases @@ -148,12 +151,26 @@ and faer timings for matching rows, so you can see whether a la-stack change improves or weakens the release signal without rerunning third-party benchmarks on every iteration. +For a faster non-exact check, run: + +```bash +just performance-local-vs-linalg v0.4.3 v0.4.2 +``` + +This generates a local `v0.4.2` baseline for `vs_linalg`, measures only the +current la-stack rows from `vs_linalg`, then compares them using `--suite +vs_linalg`. The report shows saved baseline nalgebra/faer timings as context +without rerunning the peer crates on the current checkout. + ### Workflow ```bash # Current in-tree code vs latest published release, all measured locally just performance-local +# Current in-tree non-exact kernels vs a release baseline +just performance-local-vs-linalg v0.4.3 v0.4.2 + # Stored GitHub Actions release assets, no local cargo runs just performance-github-assets ``` diff --git a/justfile b/justfile index 4bc416d..70935eb 100644 --- a/justfile +++ b/justfile @@ -190,6 +190,10 @@ bench-latest: bench-vs-linalg-la-stack bench-exact bench-latest-vs-last baseline="last": bench-latest python-sync uv run bench-compare {{baseline}} +# Run only la-stack vs_linalg measurements and render a non-exact performance report. +bench-vs-linalg-latest-vs baseline="last": bench-vs-linalg-la-stack python-sync + uv run bench-compare {{baseline}} --suite vs_linalg --scope release-signal + # Save a Criterion baseline. Defaults to all release-signal benchmark suites. bench-save-baseline tag suite="all": #!/usr/bin/env bash @@ -394,8 +398,10 @@ help-workflows: @echo " just bench-compile # Compile benches with warnings-as-errors" @echo " just bench-latest # Run cheap latest measurements" @echo " just bench-latest-vs-last # Run latest and compare against last" + @echo " just bench-vs-linalg-latest-vs # Run non-exact latest and compare against last" @echo " just performance-github-assets # Compare stored GitHub Actions release assets" @echo " just performance-local # Compare current tree against latest release locally" + @echo " just performance-local-vs-linalg # Compare current non-exact kernels locally" @echo " just performance-release # Promote local release performance docs" @echo " just bench-save-last # Save full baseline as 'last'" @echo " just bench-vs-linalg # Run vs_linalg bench (optional filter)" @@ -490,6 +496,23 @@ performance-github-assets current_tag="" baseline_tag="": python-sync performance-local: python-sync uv run archive-performance --current-vs-latest --generate-in-temp-worktree --output-only --output target/bench-reports/performance.md +# Compare current non-exact kernels locally without rerunning current peer crates. +performance-local-vs-linalg current_tag="" baseline_tag="": python-sync + #!/usr/bin/env bash + set -euo pipefail + current_tag="{{current_tag}}" + baseline_tag="{{baseline_tag}}" + if [[ -n "$current_tag" || -n "$baseline_tag" ]]; then + if [[ -z "$current_tag" || -z "$baseline_tag" ]]; then + echo "current_tag and baseline_tag must be provided together" >&2 + exit 2 + fi + uv run archive-performance "$current_tag" "$baseline_tag" --suite vs_linalg --generate-in-temp-worktree --worktree-ref HEAD --output-only --output target/bench-reports/performance.md + else + uv run archive-performance --current-vs-latest --suite vs_linalg --generate-in-temp-worktree --output-only --output target/bench-reports/performance.md + fi + + # Generate local release-signal measurements in a temp worktree, then promote/archive docs. performance-release current_tag="" baseline_tag="": python-sync #!/usr/bin/env bash diff --git a/scripts/README.md b/scripts/README.md index c19e1ea..f916cce 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -26,6 +26,9 @@ just bench-save-last # Run exact benches plus la-stack rows from vs_linalg, then compare to last just bench-latest-vs-last +# Run only non-exact la-stack rows from vs_linalg, then compare to last +just bench-vs-linalg-latest-vs + # Re-render from existing Criterion output just bench-compare ``` @@ -48,6 +51,14 @@ against the latest published release: just performance-local ``` +For a faster non-exact kernel check that skips current nalgebra/faer and exact +benchmark runs, compare current la-stack `vs_linalg` rows against a release +baseline: + +```bash +just performance-local-vs-linalg v0.4.3 v0.4.2 +``` + To compare stored GitHub Actions release benchmark assets without local cargo runs: diff --git a/scripts/archive_performance.py b/scripts/archive_performance.py index ac4dcf4..8e0d8d1 100644 --- a/scripts/archive_performance.py +++ b/scripts/archive_performance.py @@ -47,6 +47,7 @@ _DEFAULT_ARCHIVE_DIR = "docs/archive/performance" _DEFAULT_SUITE = "all" _DEFAULT_SCOPE = "release-signal" +_SUPPORTED_SUITES = ("all", "exact", "vs_linalg") _BENCH_TIMEOUT_SECONDS = 7200 _COMMAND_TIMEOUT_SECONDS = 600 _HOW_TO_UPDATE_RE = re.compile(r"(?ms)^## How to Update\n.*\Z") @@ -467,11 +468,55 @@ def _copy_criterion_sample(*, criterion_dir: Path, source_sample: str, target_sa raise FileNotFoundError(msg) -def _generate_release_baseline(*, baseline_tag: str, repo_root: Path, target_worktree: Path, tmp_dir: Path) -> None: +def _has_suite_aware_baseline_recipe(worktree: Path) -> bool: + justfile = worktree / "justfile" + return justfile.exists() and re.search(r'(?m)^bench-save-baseline\s+tag\s+suite(?:=|"|:|\s)', _read_text(justfile)) is not None + + +def _baseline_tool_args(*, baseline_tag: str, suite: str, baseline_worktree: Path) -> tuple[str, list[str]]: + if suite == _DEFAULT_SUITE: + return ("just", ["bench-save-baseline", baseline_tag]) + if _has_suite_aware_baseline_recipe(baseline_worktree): + return ("just", ["bench-save-baseline", baseline_tag, suite]) + match suite: + case "exact": + return ("cargo", ["bench", "--features", "bench,exact", "--bench", "exact", "--", "--save-baseline", baseline_tag]) + case "vs_linalg": + return ("cargo", ["bench", "--features", "bench", "--bench", "vs_linalg", "--", "--save-baseline", baseline_tag]) + case _: + msg = f"unsupported benchmark suite: {suite}" + raise ValueError(msg) + + +def _latest_recipe_args(*, suite: str) -> list[str]: + match suite: + case "all": + return ["bench-latest"] + case "exact": + return ["bench-exact"] + case "vs_linalg": + return ["bench-vs-linalg-la-stack"] + case _: + msg = f"unsupported benchmark suite: {suite}" + raise ValueError(msg) + + +def _generate_release_baseline(*, baseline_tag: str, suite: str, repo_root: Path, target_worktree: Path, tmp_dir: Path) -> None: baseline_worktree = tmp_dir / "baseline-worktree" _run_git(["worktree", "add", "--detach", str(baseline_worktree), baseline_tag], cwd=repo_root) try: - _run_tool("just", ["bench-save-baseline", baseline_tag], cwd=baseline_worktree, timeout=_BENCH_TIMEOUT_SECONDS, env=_benchmark_env(repo_root)) + baseline_command, baseline_args = _baseline_tool_args( + baseline_tag=baseline_tag, + suite=suite, + baseline_worktree=baseline_worktree, + ) + _run_tool( + baseline_command, + baseline_args, + cwd=baseline_worktree, + timeout=_BENCH_TIMEOUT_SECONDS, + env=_benchmark_env(repo_root), + ) baseline_criterion = baseline_worktree / "target" / "criterion" if not baseline_criterion.is_dir(): msg = f"generated baseline Criterion results were not found: {baseline_criterion}" @@ -486,9 +531,10 @@ def _generate_release_baseline(*, baseline_tag: str, repo_root: Path, target_wor print(f"archive-performance: failed to remove baseline worktree: {exc}", file=sys.stderr) -def _prepare_local_release_baseline(*, baseline_tag: str, repo_root: Path, target_worktree: Path, tmp_dir: Path) -> None: +def _prepare_local_release_baseline(*, baseline_tag: str, suite: str, repo_root: Path, target_worktree: Path, tmp_dir: Path) -> None: _generate_release_baseline( baseline_tag=baseline_tag, + suite=suite, repo_root=repo_root, target_worktree=target_worktree, tmp_dir=tmp_dir, @@ -568,7 +614,7 @@ def _render_report(*, worktree: Path, report: Path, config: GenerationConfig) -> def _run_benchmarks_and_render_report(*, worktree: Path, report: Path, config: GenerationConfig) -> None: benchmark_env = _benchmark_env(config.repo_root) if _has_current_release_signal_tooling(worktree): - _run_tool("just", ["bench-latest"], cwd=worktree, timeout=_BENCH_TIMEOUT_SECONDS, env=benchmark_env) + _run_tool("just", _latest_recipe_args(suite=config.suite), cwd=worktree, timeout=_BENCH_TIMEOUT_SECONDS, env=benchmark_env) else: _run_tool("just", ["bench-exact"], cwd=worktree, timeout=_BENCH_TIMEOUT_SECONDS, env=benchmark_env) _render_report(worktree=worktree, report=report, config=config) @@ -599,6 +645,7 @@ def _generate_report_in_temp_worktree( else: _prepare_local_release_baseline( baseline_tag=config.baseline_tag, + suite=config.suite, repo_root=config.repo_root, target_worktree=worktree, tmp_dir=tmp_dir, @@ -854,6 +901,7 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument( "--suite", default=_DEFAULT_SUITE, + choices=_SUPPORTED_SUITES, help=f"Benchmark suite for --generate-in-temp-worktree (default: {_DEFAULT_SUITE})", ) parser.add_argument( diff --git a/scripts/tests/test_archive_performance.py b/scripts/tests/test_archive_performance.py index 41d5329..78f04fb 100644 --- a/scripts/tests/test_archive_performance.py +++ b/scripts/tests/test_archive_performance.py @@ -12,7 +12,15 @@ import pytest import archive_performance -from archive_performance import GenerationConfig, generate_and_promote_worktree_report, main, normalize_tag, parse_report_id, promote_report +from archive_performance import ( + GenerationConfig, + generate_and_promote_worktree_report, + generate_worktree_report, + main, + normalize_tag, + parse_report_id, + promote_report, +) if TYPE_CHECKING: from collections.abc import Sequence @@ -82,7 +90,10 @@ def _write_unsafe_baseline_archive(path: Path) -> None: def _write_current_benchmark_tooling(worktree: Path) -> None: (worktree / "scripts").mkdir(parents=True, exist_ok=True) - (worktree / "justfile").write_text("bench-latest: bench-vs-linalg-la-stack bench-exact\n", encoding="utf-8") + (worktree / "justfile").write_text( + 'bench-save-baseline tag suite="all":\nbench-latest: bench-vs-linalg-la-stack bench-exact\n', + encoding="utf-8", + ) (worktree / "scripts" / "bench_compare.py").write_text('parser.add_argument("--suite")\nparser.add_argument("--scope")\n', encoding="utf-8") @@ -454,7 +465,7 @@ def fake_run_git_with_input(args: Sequence[str], input_data: str, cwd: Path | No def fake_run_safe(command: str, args: Sequence[str], cwd: Path | None = None, **kwargs: Any) -> SimpleNamespace: calls.append((command, tuple(args), cwd)) - if command == "just" and args == ["bench-save-baseline", "v0.4.2"]: + if command == "just" and args == ["bench-save-baseline", "v0.4.2", "exact"]: assert cwd is not None criterion_dir = cwd / "target" / "criterion" criterion_dir.mkdir(parents=True) @@ -494,6 +505,7 @@ def fake_run_safe(command: str, args: Sequence[str], cwd: Path | None = None, ** assert "Generated benchmark report in a temporary worktree" in captured.out assert "target/bench-reports/performance.md" not in captured.out assert any(kind == "git" and args[:3] == ("worktree", "add", "--detach") and args[4] == "v0.4.3" for kind, args, _ in calls) + assert any(kind == "just" and args == ("bench-exact",) for kind, args, _ in calls) assert any(kind == "uv" and "--suite" in args and args[args.index("--suite") + 1] == "exact" for kind, args, _ in calls) assert not any(kind == "git" and args == ("diff", "--binary", "HEAD") for kind, args, _ in calls) assert not any(kind == "git-stdin" for kind, _, _ in calls) @@ -511,7 +523,10 @@ def fake_run_git(args: Sequence[str], cwd: Path | None = None, **kwargs: Any) -> if args[:3] == ["worktree", "add", "--detach"]: worktree = Path(args[3]) worktree.mkdir(parents=True) - _write_current_benchmark_tooling(worktree) + if worktree.name == "baseline-worktree": + _write_legacy_benchmark_tooling(worktree) + else: + _write_current_benchmark_tooling(worktree) return _result() def fake_run_git_with_input(args: Sequence[str], input_data: str, cwd: Path | None = None, **kwargs: Any) -> SimpleNamespace: @@ -680,6 +695,120 @@ def fake_run_safe(command: str, args: Sequence[str], cwd: Path | None = None, ** assert sum(1 for kind, args, _ in calls if kind == "git" and args[:3] == ("worktree", "remove", "--force")) == 2 +def test_generate_report_vs_linalg_suite_skips_exact_current_benches(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("RUSTUP_TOOLCHAIN", raising=False) + (tmp_path / "rust-toolchain.toml").write_text('[toolchain]\nchannel = "1.96.0"\n', encoding="utf-8") + output = tmp_path / "target" / "bench-reports" / "performance.md" + calls: list[RunnerCall] = [] + + def fake_run_git(args: Sequence[str], cwd: Path | None = None, **kwargs: Any) -> SimpleNamespace: + calls.append(("git", tuple(args), cwd)) + if args[:3] == ["worktree", "add", "--detach"]: + worktree = Path(args[3]) + worktree.mkdir(parents=True) + if worktree.name == "baseline-worktree": + _write_legacy_benchmark_tooling(worktree) + else: + _write_current_benchmark_tooling(worktree) + return _result() + + def fake_run_git_with_input(args: Sequence[str], input_data: str, cwd: Path | None = None, **kwargs: Any) -> SimpleNamespace: + calls.append(("git-stdin", tuple(args), cwd)) + return _result() + + def fake_run_safe(command: str, args: Sequence[str], cwd: Path | None = None, **kwargs: Any) -> SimpleNamespace: + calls.append((command, tuple(args), cwd)) + if command == "cargo" and args == ["bench", "--features", "bench", "--bench", "vs_linalg", "--", "--save-baseline", "v0.4.2"]: + assert kwargs["env"]["RUSTUP_TOOLCHAIN"] == "1.96.0" + assert cwd is not None + criterion_dir = cwd / "target" / "criterion" + criterion_dir.mkdir(parents=True) + (criterion_dir / "baseline.txt").write_text("baseline\n", encoding="utf-8") + if command == "just" and args == ["bench-vs-linalg-la-stack"]: + assert kwargs["env"]["RUSTUP_TOOLCHAIN"] == "1.96.0" + if command == "uv": + report = Path(args[args.index("--output") + 1]) + report.write_text(_report("0.4.3", "v0.4.2"), encoding="utf-8") + return _result() + + monkeypatch.setattr(archive_performance, "run_git_command", fake_run_git) + monkeypatch.setattr(archive_performance, "run_git_command_with_input", fake_run_git_with_input) + monkeypatch.setattr(archive_performance, "run_safe_command", fake_run_safe) + + report_id = generate_worktree_report( + output=output, + config=GenerationConfig( + repo_root=tmp_path, + current_tag="v0.4.3", + baseline_tag="v0.4.2", + worktree_ref="HEAD", + suite="vs_linalg", + apply_current_diff=False, + ), + ) + + assert report_id.archive_name == "v0.4.3-vs-v0.4.2.md" + assert output.read_text(encoding="utf-8") == _normalized_report("0.4.3", "v0.4.2") + assert any( + kind == "cargo" and args == ("bench", "--features", "bench", "--bench", "vs_linalg", "--", "--save-baseline", "v0.4.2") for kind, args, _ in calls + ) + assert any(kind == "just" and args == ("bench-vs-linalg-la-stack",) for kind, args, _ in calls) + assert not any(kind == "just" and args == ("bench-save-baseline", "v0.4.2", "vs_linalg") for kind, args, _ in calls) + assert not any(kind == "just" and args == ("bench-exact",) for kind, args, _ in calls) + assert not any(kind == "just" and args == ("bench-latest",) for kind, args, _ in calls) + + +def test_generate_report_vs_linalg_suite_uses_suite_aware_baseline_recipe(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + output = tmp_path / "target" / "bench-reports" / "performance.md" + calls: list[RunnerCall] = [] + + def fake_run_git(args: Sequence[str], cwd: Path | None = None, **kwargs: Any) -> SimpleNamespace: + calls.append(("git", tuple(args), cwd)) + if args[:3] == ["worktree", "add", "--detach"]: + worktree = Path(args[3]) + worktree.mkdir(parents=True) + _write_current_benchmark_tooling(worktree) + return _result() + + def fake_run_git_with_input(args: Sequence[str], input_data: str, cwd: Path | None = None, **kwargs: Any) -> SimpleNamespace: + calls.append(("git-stdin", tuple(args), cwd)) + return _result() + + def fake_run_safe(command: str, args: Sequence[str], cwd: Path | None = None, **kwargs: Any) -> SimpleNamespace: + calls.append((command, tuple(args), cwd)) + if command == "just" and args == ["bench-save-baseline", "v0.4.2", "vs_linalg"]: + assert cwd is not None + criterion_dir = cwd / "target" / "criterion" + criterion_dir.mkdir(parents=True) + (criterion_dir / "baseline.txt").write_text("baseline\n", encoding="utf-8") + if command == "uv": + report = Path(args[args.index("--output") + 1]) + report.write_text(_report("0.4.3", "v0.4.2"), encoding="utf-8") + return _result() + + monkeypatch.setattr(archive_performance, "run_git_command", fake_run_git) + monkeypatch.setattr(archive_performance, "run_git_command_with_input", fake_run_git_with_input) + monkeypatch.setattr(archive_performance, "run_safe_command", fake_run_safe) + + report_id = generate_worktree_report( + output=output, + config=GenerationConfig( + repo_root=tmp_path, + current_tag="v0.4.3", + baseline_tag="v0.4.2", + worktree_ref="HEAD", + suite="vs_linalg", + apply_current_diff=False, + ), + ) + + assert report_id.archive_name == "v0.4.3-vs-v0.4.2.md" + assert any(kind == "just" and args == ("bench-save-baseline", "v0.4.2", "vs_linalg") for kind, args, _ in calls) + assert any(kind == "just" and args == ("bench-vs-linalg-la-stack",) for kind, args, _ in calls) + assert not any(kind == "cargo" for kind, _, _ in calls) + assert not any(kind == "just" and args == ("bench-exact",) for kind, args, _ in calls) + + def test_main_generates_latest_published_report_from_github_releases(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: current = tmp_path / "docs" / "PERFORMANCE.md" archive_dir = tmp_path / "docs" / "archive" / "performance" diff --git a/tests/semgrep/doctests/unwrap_expect.txt b/tests/semgrep/doctests/unwrap_expect.txt index 7e0958f..4330186 100644 --- a/tests/semgrep/doctests/unwrap_expect.txt +++ b/tests/semgrep/doctests/unwrap_expect.txt @@ -4,9 +4,18 @@ // ruleid: la-stack.rust.no-unwrap-expect-in-doctests //! let value = Ok::(1).expect("doctest should not panic"); +// ruleid: la-stack.rust.no-unwrap-expect-in-doctests +/// # let value = Some(1_u32).unwrap(); + +// ruleid: la-stack.rust.no-unwrap-expect-in-doctests +//! # let value = Ok::(1).expect("hidden doctest setup should not panic"); + // ok: la-stack.rust.no-unwrap-expect-in-doctests /// # fn main() -> Result<(), la_stack::LaError> { Ok(()) } +// ok: la-stack.rust.no-unwrap-expect-in-doctests +/// # let value = Ok::(1)?; + // ok: la-stack.rust.no-unwrap-expect-in-doctests /// Do not use `.unwrap()` in public examples. diff --git a/tests/semgrep/src/project_rules/bench_example_usage.rs b/tests/semgrep/src/project_rules/bench_example_usage.rs index 0909dd6..4801f8b 100644 --- a/tests/semgrep/src/project_rules/bench_example_usage.rs +++ b/tests/semgrep/src/project_rules/bench_example_usage.rs @@ -10,3 +10,23 @@ fn benchmark_and_example_fixture(result: Result, value: Optio // ok: la-stack.rust.no-unwrap-expect-in-benches-examples let _ = result.unwrap_or(0); } + +fn criterion_closure_fixture(result: Result) { + let bench_body = || { + // ruleid: la-stack.rust.no-unwrap-expect-in-benches-examples + result.expect("criterion setup should surface typed errors") + }; + let _ = bench_body; +} + +fn result_returning_example_fixture(result: Result) -> Result<(), &'static str> { + // ruleid: la-stack.rust.no-unwrap-expect-in-benches-examples + let _ = result.unwrap(); + Ok(()) +} + +fn explicit_error_handling_fixture(result: Result) -> Result { + // ok: la-stack.rust.no-unwrap-expect-in-benches-examples + let value = result?; + Ok(value) +} diff --git a/tests/semgrep/src/project_rules/public_api_panic_paths.rs b/tests/semgrep/src/project_rules/public_api_panic_paths.rs index cc398f9..cced7db 100644 --- a/tests/semgrep/src/project_rules/public_api_panic_paths.rs +++ b/tests/semgrep/src/project_rules/public_api_panic_paths.rs @@ -28,6 +28,20 @@ pub async fn expects_on_input(value: Option) -> usize { value.expect("value is required") } +// ruleid: la-stack.rust.no-public-api-panic-paths +pub fn debug_asserts_on_input(value: usize) -> usize { + debug_assert!(value > 0); + value +} + +// ruleid: la-stack.rust.no-public-api-panic-paths +pub fn marks_input_unreachable(value: usize) -> usize { + if value == 0 { + unreachable!("zero is not a valid public API path"); + } + value +} + // ok: la-stack.rust.no-public-api-panic-paths pub fn fallible_result(value: usize) -> Result { if value == 0 { diff --git a/tests/semgrep/src/project_rules/readme_doctest_mirrors.rs b/tests/semgrep/src/project_rules/readme_doctest_mirrors.rs index df21b86..5946b30 100644 --- a/tests/semgrep/src/project_rules/readme_doctest_mirrors.rs +++ b/tests/semgrep/src/project_rules/readme_doctest_mirrors.rs @@ -16,6 +16,15 @@ mod readme_doctests_expect { } } +#[cfg(feature = "exact")] +// ruleid: la-stack.rust.no-unwrap-expect-in-readme-doctest-mirrors +mod readme_doctests_exact_feature { + #[test] + fn exact_readme_mirror_uses_expect() { + let _ = Some(1_u32).expect("feature-gated README mirrors should use ?"); + } +} + // ok: la-stack.rust.no-unwrap-expect-in-readme-doctest-mirrors mod tests { #[test] @@ -32,3 +41,13 @@ mod readme_doctests_result { Ok(()) } } + +#[cfg(feature = "exact")] +// ok: la-stack.rust.no-unwrap-expect-in-readme-doctest-mirrors +mod readme_doctests_exact_result { + #[test] + fn exact_readme_mirror_uses_result() -> Result<(), &'static str> { + let _ = Ok::(1)?; + Ok(()) + } +}