diff --git a/CITATION.cff b/CITATION.cff index 0e79206..457ec19 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -3,13 +3,14 @@ message: "If you use this software, please cite it as below." type: software title: "la-stack: Fast, stack-allocated linear algebra for fixed dimensions in Rust" version: 0.4.4 -date-released: 2026-07-12 +date-released: 2026-07-13 url: "https://github.com/acgetchell/la-stack" repository-code: "https://github.com/acgetchell/la-stack" +doi: "10.5281/zenodo.18158926" identifiers: - - description: "Zenodo concept DOI (all versions)" + - description: "Zenodo DOI for version 0.4.4" type: doi - value: "10.5281/zenodo.18158926" + value: "10.5281/zenodo.21331524" authors: - family-names: "Getchell" given-names: "Adam" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0e52f24..ca4f5f9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,7 +7,7 @@ clarity, and the fixed-dimension stack-allocation model. ## Getting Started Install Rust 1.97.1 through [rustup](https://rustup.rs/), Git, Python 3.14, -[`uv` 0.12.1](https://docs.astral.sh/uv/), and `jq`. Install the repository's +[`uv` 0.12.3](https://docs.astral.sh/uv/), and `jq`. Install the repository's pinned `just` version from its locked dependency graph: ```bash @@ -109,10 +109,12 @@ For final validation of a non-core change, compose each affected surface once: - Examples: `just examples` Run `just ci` for core Rust, public behavior, or GitHub-equivalent validation. -It composes leaf validators directly, runs unit and integration tests together -once through the release-profile `test-rust-ci` bucket, and keeps doctests -separate because nextest does not execute them. `just clippy` remains an -optional all-target sweep outside this CI path. +It composes leaf validators directly and runs `clippy-all-targets` to match the +GitHub Clippy SARIF workflow. Unit and integration tests still run together once +through the release-profile `test-rust-ci` bucket, and doctests remain separate +because nextest does not execute them. The test, example, and benchmark buckets +retain their execution or compile-contract roles because ordinary compilation +does not execute Clippy lints. `la-stack` intentionally has no notebook validation bucket: the repository has no notebooks or supported Python binding surface. Add notebook tooling only diff --git a/Cargo.lock b/Cargo.lock index 8538035..7151266 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1443,9 +1443,9 @@ dependencies = [ [[package]] name = "wide" -version = "1.6.0" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be99e8317aa9f08e7d16e13033ca43faab59ab582b1d0feab7b385424c42f8b1" +checksum = "de2aaf408e58689c2096682331b1f42bb2d9f2ed6b11560407d023cd0a6c634e" dependencies = [ "bytemuck", "safe_arch", diff --git a/Cargo.toml b/Cargo.toml index bcc6830..9505548 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ include = [ "/src/**/*.rs", "/tests/*.proptest-regressions", "/tests/*.rs", + "/tests/common/**/*.rs", ] [dependencies] @@ -82,6 +83,7 @@ codegen-units = 1 [package.metadata.docs.rs] features = [ "exact" ] +rustdoc-args = [ "--cfg", "docsrs" ] [lints.rust] warnings = { level = "deny", priority = -1 } @@ -89,7 +91,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(la_stack_v0_4_3_api)' ] } +unexpected_cfgs = { level = "deny", priority = 0, check-cfg = [ 'cfg(docsrs)', 'cfg(la_stack_v0_4_3_api)' ] } [lints.rustdoc] bare_urls = "deny" diff --git a/README.md b/README.md index 785ec5e..41ef0fb 100644 --- a/README.md +++ b/README.md @@ -515,7 +515,7 @@ across operations. Timings count only when the implementation preserves the documented correctness guarantees and invariants. Performance claims require comparable before-and-after evidence using the same inputs, configuration, and environment. -This snapshot records the measured source state, CPU, operating system, Rust +This snapshot records the measured source state, available CPU model, operating system, Rust toolchain, dependency lock and harness digests, Criterion command, and correctness-gate result in the adjacent JSON sidecar. The publication workflow requires complete canonical-dimension coverage and regenerates the CSV, SVG, @@ -571,7 +571,7 @@ cargo run --features exact --example exact_solve_3x3 A short contributor workflow: Install Rust 1.97.1 through [rustup](https://rustup.rs/), Git, Python 3.14, -[`uv` 0.12.1](https://docs.astral.sh/uv/), and `jq`. Then install the pinned +[`uv` 0.12.3](https://docs.astral.sh/uv/), and `jq`. Then install the pinned `just` release from its locked dependency graph: ```bash diff --git a/REFERENCES.md b/REFERENCES.md index 0733cbe..f2cc252 100644 --- a/REFERENCES.md +++ b/REFERENCES.md @@ -50,9 +50,11 @@ See `src/exact.rs` for the full architecture description. `solve_exact()`, `solve_exact_f64()`, and `solve_exact_rounded_f64()` share the determinant path's exact f64 decomposition and integer scaling. Matrix and RHS entries are decomposed via -IEEE 754 bit extraction [9]. Each collection is scaled independently to its own minimum -exponent, producing a `BigInt` matrix and RHS without inflating one side to accommodate the -other's range. Forward elimination runs in `BigInt` using Bareiss fraction-free updates +IEEE 754 bit extraction [9]. Matrix and RHS scales start from their respective minimum +exponents. When `|e_rhs − e_matrix| ≤ 64`, both sides use `min(e_rhs, e_matrix)` as the shared +scale; when `|e_rhs − e_matrix| > 64`, they retain independent scales so one side is not +inflated excessively. +Forward elimination runs in `BigInt` using Bareiss fraction-free updates [7]—no `BigRational` and no GCD normalisation in the `O(D³)` phase. The upper-triangular result is then lifted into `BigRational` for back-substitution, where fractions are inherent and the cost is only `O(D²)`. Row swaps from first-non-zero pivoting are applied to both the diff --git a/benches/vs_linalg.rs b/benches/vs_linalg.rs index 6274fc3..1e11b84 100644 --- a/benches/vs_linalg.rs +++ b/benches/vs_linalg.rs @@ -13,7 +13,7 @@ use std::hint::black_box; use criterion::measurement::WallTime; -use criterion::{BatchSize, BenchmarkGroup, Criterion}; +use criterion::{BenchmarkGroup, Criterion}; use faer::linalg::solvers::Solve; use faer::mat::AsMatRef; use faer::{Mat, Side}; @@ -73,52 +73,36 @@ where let fa = faer_matrix::(); group.bench_function("la_stack_det_via_lu", |bencher| { - bencher.iter_batched( - || a, - |a| { - let lu = black_box(a) - .lu(DEFAULT_SINGULAR_TOL) - .or_abort("la_stack LU factorization"); - let det = lu.det().or_abort("la_stack LU determinant"); - black_box(det); - }, - BatchSize::SmallInput, - ); + bencher.iter(|| { + let lu = black_box(a) + .lu(DEFAULT_SINGULAR_TOL) + .or_abort("la_stack LU factorization"); + let det = lu.det().or_abort("la_stack LU determinant"); + black_box(det); + }); }); group.bench_function("nalgebra_det_via_lu", |bencher| { - bencher.iter_batched( - || na, - |na| { - let lu = black_box(na).lu(); - let det = lu.determinant(); - black_box(det); - }, - BatchSize::SmallInput, - ); + bencher.iter(|| { + let lu = black_box(na).lu(); + let det = lu.determinant(); + black_box(det); + }); }); group.bench_function("faer_det_via_lu", |bencher| { - bencher.iter_batched( - || &fa, - |fa| { - let lu = black_box(fa).partial_piv_lu(); - let det = PreparedFaerLuDet::new(&lu).det(); - black_box(det); - }, - BatchSize::SmallInput, - ); + bencher.iter(|| { + let lu = black_box(&fa).partial_piv_lu(); + let det = PreparedFaerLuDet::new(&lu).det(); + black_box(det); + }); }); group.bench_function("la_stack_det", |bencher| { - bencher.iter_batched( - || a, - |a| { - let det = black_box(a).det().or_abort("la_stack determinant"); - black_box(det); - }, - BatchSize::SmallInput, - ); + bencher.iter(|| { + let det = black_box(a).det().or_abort("la_stack determinant"); + black_box(det); + }); }); } @@ -132,77 +116,53 @@ where let fa = faer_matrix::(); group.bench_function("la_stack_lu", |bencher| { - bencher.iter_batched( - || a, - |a| { - let lu = black_box(a) - .lu(DEFAULT_SINGULAR_TOL) - .or_abort("la_stack LU factorization"); - let _ = black_box(lu); - }, - BatchSize::SmallInput, - ); + bencher.iter(|| { + let lu = black_box(a) + .lu(DEFAULT_SINGULAR_TOL) + .or_abort("la_stack LU factorization"); + let _ = black_box(lu); + }); }); group.bench_function("nalgebra_lu", |bencher| { - bencher.iter_batched( - || na, - |na| { - let lu = black_box(na).lu(); - let _ = black_box(lu); - }, - BatchSize::SmallInput, - ); + bencher.iter(|| { + let lu = black_box(na).lu(); + let _ = black_box(lu); + }); }); group.bench_function("faer_lu", |bencher| { - bencher.iter_batched( - || &fa, - |fa| { - let lu = black_box(fa).partial_piv_lu(); - let _ = black_box(lu); - }, - BatchSize::SmallInput, - ); + bencher.iter(|| { + let lu = black_box(&fa).partial_piv_lu(); + let _ = black_box(lu); + }); }); group.bench_function("la_stack_ldlt", |bencher| { - bencher.iter_batched( - || a, - |a| { - let ldlt = black_box(a) - .ldlt(DEFAULT_SINGULAR_TOL) - .or_abort("la_stack LDLT factorization"); - let _ = black_box(ldlt); - }, - BatchSize::SmallInput, - ); + bencher.iter(|| { + let ldlt = black_box(a) + .ldlt(DEFAULT_SINGULAR_TOL) + .or_abort("la_stack LDLT factorization"); + let _ = black_box(ldlt); + }); }); group.bench_function("nalgebra_cholesky", |bencher| { - bencher.iter_batched( - || na, - |na| { - let chol = black_box(na) - .cholesky() - .or_abort("nalgebra Cholesky factorization"); - black_box(chol); - }, - BatchSize::SmallInput, - ); + bencher.iter(|| { + let chol = black_box(na) + .cholesky() + .or_abort("nalgebra Cholesky factorization"); + black_box(chol); + }); }); group.bench_function("faer_ldlt", |bencher| { - bencher.iter_batched( - || &fa, - |fa| { - let ldlt = black_box(fa) - .ldlt(Side::Lower) - .or_abort("faer LDLT factorization"); - let _ = black_box(ldlt); - }, - BatchSize::SmallInput, - ); + bencher.iter(|| { + let ldlt = black_box(&fa) + .ldlt(Side::Lower) + .or_abort("faer LDLT factorization"); + let _ = black_box(ldlt); + }); }); } @@ -255,45 +215,33 @@ fn register_ldlt_solve_benchmarks(group: &mut BenchmarkGroup<'_, let frhs = faer_vector::(0.0); group.bench_function("la_stack_ldlt_solve", |bencher| { - bencher.iter_batched( - || (a, rhs), - |(a, rhs)| { - let ldlt = black_box(a) - .ldlt(DEFAULT_SINGULAR_TOL) - .or_abort("la_stack LDLT factorization"); - let x = ldlt.solve(black_box(rhs)).or_abort("la_stack LDLT solve"); - let _ = black_box(x); - }, - BatchSize::SmallInput, - ); + bencher.iter(|| { + let ldlt = black_box(a) + .ldlt(DEFAULT_SINGULAR_TOL) + .or_abort("la_stack LDLT factorization"); + let x = ldlt.solve(black_box(rhs)).or_abort("la_stack LDLT solve"); + let _ = black_box(x); + }); }); group.bench_function("nalgebra_cholesky_solve", |bencher| { - bencher.iter_batched( - || (na, nrhs), - |(na, nrhs)| { - let chol = black_box(na) - .cholesky() - .or_abort("nalgebra Cholesky factorization"); - let x = chol.solve(black_box(&nrhs)); - black_box(x); - }, - BatchSize::SmallInput, - ); + bencher.iter(|| { + let chol = black_box(na) + .cholesky() + .or_abort("nalgebra Cholesky factorization"); + let x = chol.solve(black_box(&nrhs)); + black_box(x); + }); }); group.bench_function("faer_ldlt_solve", |bencher| { - bencher.iter_batched( - || (&fa, &frhs), - |(fa, rhs)| { - let ldlt = black_box(fa) - .ldlt(Side::Lower) - .or_abort("faer LDLT factorization"); - let x = ldlt.solve(black_box(rhs)); - black_box(x); - }, - BatchSize::SmallInput, - ); + bencher.iter(|| { + let ldlt = black_box(&fa) + .ldlt(Side::Lower) + .or_abort("faer LDLT factorization"); + let x = ldlt.solve(black_box(&frhs)); + black_box(x); + }); }); } @@ -568,42 +516,30 @@ fn register_stress_benchmarks(group: &mut BenchmarkGroup<'_, WallTime>) { .or_abort("balanced-range benchmark matrix construction"); group.bench_function("la_stack_lu_pivoting", |bencher| { - bencher.iter_batched( - || pivoting, - |matrix| { - let lu = black_box(matrix) - .lu(zero_tolerance) - .or_abort("pivoting LU factorization"); - let _ = black_box(lu); - }, - BatchSize::SmallInput, - ); + bencher.iter(|| { + let lu = black_box(pivoting) + .lu(zero_tolerance) + .or_abort("pivoting LU factorization"); + let _ = black_box(lu); + }); }); group.bench_function("la_stack_lu_ill_conditioned", |bencher| { - bencher.iter_batched( - || ill_conditioned, - |matrix| { - let lu = black_box(matrix) - .lu(zero_tolerance) - .or_abort("ill-conditioned LU factorization"); - let _ = black_box(lu); - }, - BatchSize::SmallInput, - ); + bencher.iter(|| { + let lu = black_box(ill_conditioned) + .lu(zero_tolerance) + .or_abort("ill-conditioned LU factorization"); + let _ = black_box(lu); + }); }); group.bench_function("la_stack_ldlt_ill_conditioned", |bencher| { - bencher.iter_batched( - || ill_conditioned, - |matrix| { - let ldlt = black_box(matrix) - .ldlt(zero_tolerance) - .or_abort("ill-conditioned LDLT factorization"); - let _ = black_box(ldlt); - }, - BatchSize::SmallInput, - ); + bencher.iter(|| { + let ldlt = black_box(ill_conditioned) + .ldlt(zero_tolerance) + .or_abort("ill-conditioned LDLT factorization"); + let _ = black_box(ldlt); + }); }); #[cfg(not(la_stack_v0_4_3_api))] diff --git a/docs/BENCHMARKING.md b/docs/BENCHMARKING.md index 77e9ef1..d306ece 100644 --- a/docs/BENCHMARKING.md +++ b/docs/BENCHMARKING.md @@ -110,6 +110,17 @@ This command does not depend on existing local `target/criterion/` baselines. It is slower than reusing a saved baseline, but less sensitive to stale local benchmark state. +The workflow streams its correctness-gate, Cargo, and Criterion output as each +phase runs. `[performance]` markers identify baseline validation, baseline +timing, current validation, and current timing, so a long comparison exposes +completed samples and its active phase instead of remaining silent until the +final report is rendered. + +If the checkout's package version is identical to the latest published release, +the command now stops before creating worktrees or running benchmarks because a +release report requires two distinct identifiers. For repeated optimization +within one package version, use the named-baseline loop below instead. + ### Compare Current Code With A Specific Release For a narrower non-exact check against a known release pair, run: @@ -212,6 +223,12 @@ confidence intervals in nanoseconds. The JSON sidecar binds the CSV digest and row count to the release pair, source states, commands, toolchain, Criterion version, harness/configuration digests, host, and schema version. +Before creating worktrees or running either benchmark revision, structured +local and release-report workflows require an identifiable CPU model. Raw +Criterion benchmark recipes may still record measurements when that metadata +is unavailable, but those measurements cannot be promoted as reproducible +release evidence. + The pair is validated and published before the temporary worktree is removed. `docs/PERFORMANCE.md` is then rendered from a validated reload of that retained pair, and the previous committed report is archived under diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md index eb601ee..23a5c33 100644 --- a/docs/PERFORMANCE.md +++ b/docs/PERFORMANCE.md @@ -19,7 +19,7 @@ for the change, so the report makes no statistical-significance or performance-i **Measurement environment**: recorded for both samples under one shared current harness. -- CPU: `arm` +- CPU: unavailable (legacy report recorded architecture only: `arm`) - OS: `Darwin 25.5.0 arm64` - rustc: `rustc 1.97.0 (2d8144b78 2026-07-07)` - Current commit: `e736c5fda155ef23c8712f89ae15bf5369ff3787` @@ -33,7 +33,7 @@ for the change, so the report makes no statistical-significance or performance-i **Publication and validation environment**: -- Publication CPU: `arm` +- Publication CPU: unavailable (legacy report recorded architecture only: `arm`) - Publication OS: `Darwin 25.5.0 arm64` - Publication rustc: `rustc 1.97.0 (2d8144b78 2026-07-07)` - Publication commit: `e736c5fda155ef23c8712f89ae15bf5369ff3787` diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 5d4d670..0568fda 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -74,13 +74,15 @@ Alternative: edit `Cargo.toml` manually and update `version = "..."` under Update release metadata to match the crate version: -- `CITATION.cff`: update `version` and `date-released` +- `CITATION.cff`: update `version`, the version-specific DOI identifier, and + `date-released`. Use the UTC calendar date written into the generated + changelog heading; keep the all-versions concept DOI as the primary `doi`. - `pyproject.toml`: update `[project] version` for the Python utility package Review the citation identity fields at the same time: author name and contact, ORCID, repository URL, and license. Preserve la-stack's Zenodo concept DOI -(`all versions`) unless the archival policy is deliberately changed; do not -replace it with a release-specific DOI. +(`all versions`) as the primary `doi` unless the archival policy is deliberately +changed; retain the release DOI as a version-specific identifier. Refresh both committed lockfiles after those manual metadata edits: @@ -111,9 +113,10 @@ just changelog-unreleased "$TAG" `just changelog-unreleased` runs `GIT_CLIFF_OFFLINE=true git-cliff --tag "$TAG" -o CHANGELOG.md`, then -`postprocess-changelog`, then `archive-changelog`. The root changelog keeps -Unreleased plus the active minor series; older completed minor series live -under `docs/archive/changelog/`. +`postprocess-changelog`, then `archive-changelog`. The generated tagged-release +changelog may begin directly with the active minor series; when git-cliff emits +an Unreleased block, the archiver preserves it. Older completed minor series +live under `docs/archive/changelog/`. 4. Run benchmarks and update the README comparison table diff --git a/docs/assets/bench/vs_linalg_lu_solve_median.provenance.json b/docs/assets/bench/vs_linalg_lu_solve_median.provenance.json index bb74219..01c1749 100644 --- a/docs/assets/bench/vs_linalg_lu_solve_median.provenance.json +++ b/docs/assets/bench/vs_linalg_lu_solve_median.provenance.json @@ -31,7 +31,8 @@ "measurement": { "cargo_lock_sha256": "0c275998d6fe18f8b4def36611598860e96c250303ba459da280ed64e2afd3cd", "commit": "e736c5fda155ef23c8712f89ae15bf5369ff3787", - "cpu": "arm", + "cpu": "unavailable", + "cpu_architecture": "arm", "git_clean": false, "git_status_sha256": "a367ed1608bcdea33eb781794fde643f01a6a280f154ffacbd02e25506cf9f8b", "harness_sha256": "7915a36e12d9895170323aee46910b688492dc6aca704ac7f1daaefae644a9ea", @@ -46,7 +47,8 @@ "cargo_lock_sha256": "0c275998d6fe18f8b4def36611598860e96c250303ba459da280ed64e2afd3cd", "commit": "e736c5fda155ef23c8712f89ae15bf5369ff3787", "correctness_gate": "passed", - "cpu": "arm", + "cpu": "unavailable", + "cpu_architecture": "arm", "git_clean": false, "git_status_sha256": "a367ed1608bcdea33eb781794fde643f01a6a280f154ffacbd02e25506cf9f8b", "harness_sha256": "7915a36e12d9895170323aee46910b688492dc6aca704ac7f1daaefae644a9ea", diff --git a/docs/mathematical_basis.md b/docs/mathematical_basis.md index 4e77fe3..972201e 100644 --- a/docs/mathematical_basis.md +++ b/docs/mathematical_basis.md @@ -150,9 +150,13 @@ represented rational values \[9-10\]. Exact determinants use direct `BigInt` expansions for `D ≤ 4` and fraction-free Bareiss elimination for `D ≥ 5` \[7\]. Exact solves apply Bareiss updates to an integer augmented system, then use `BigRational` for back-substitution. Matrix -and right-hand-side scaling are tracked separately and reconciled with an exact -power-of-two factor. First-nonzero pivoting is sufficient for correctness in -exact arithmetic, although pivot choice can still affect computational cost. +and right-hand-side scales start independently. Writing the selected exponents +as `s_A` and `s_b`, the integer forms satisfy `A = 2ˢᴬ · A_int` and +`b = 2ˢᵇ · b_int` \[9\]. Gaps of at most 64 bits use the lower shared scale, +while larger gaps remain separate; multiplying the integer system's solution by +the exact factor `2^(s_b − s_A)` preserves `A x = b`. First-nonzero pivoting is +sufficient for correctness in exact arithmetic, although pivot choice can still +affect computational cost. `det_sign_exact()` first attempts the certified binary64 filter for `D ≤ 4` and falls back to exact integer arithmetic when the filter is inconclusive. It diff --git a/docs/roadmap.md b/docs/roadmap.md index fb798e2..bfd7bb3 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -27,9 +27,9 @@ computation, large dynamic matrices, sparse matrices, or broad decomposition coverage should use larger linear-algebra ecosystems such as `nalgebra` or `faer`. -## Current Release Sequence +## Release History and Next Sequence -### v0.4.2 Stable Rust Cleanup +### v0.4.2 Stable Rust Cleanup (released) The `v0.4.2` milestone collected work that could be done on stable Rust while keeping the crate useful to downstream geometry crates: @@ -90,7 +90,7 @@ API-invariant cleanup: `ERR_COEFF_4` are documented as dimension-specific roundoff multipliers over the absolute Leibniz sum, not caller-tuned tolerances. -Final release blockers: +Completed release blockers: - [#125](https://github.com/acgetchell/la-stack/issues/125) - Add a Semgrep guardrail against `unwrap` / `expect` in examples, benches, and doctests. @@ -102,11 +102,10 @@ ergonomics, clean up small API contracts, tighten validation, encode reusable invariants behind the public raw-boundary API, lock examples and benchmarks into proper error handling, then finish with broader benchmark work. -### v0.4.3 Benchmark and Tooling Hardening +### v0.4.3 Benchmark and Tooling Hardening (released) -Before the const-generic API revision, tighten the benchmark and tooling story so -performance claims are auditable across releases and Python support scripts have -a modern typed baseline. +This release tightened the benchmark and tooling story so performance claims are +auditable across releases and Python support scripts have a modern typed baseline. - [#137](https://github.com/acgetchell/la-stack/issues/137) - Investigate checked vector kernel performance for v0.4.3. @@ -115,10 +114,9 @@ a modern typed baseline. - [#142](https://github.com/acgetchell/la-stack/issues/142) - Update Python tooling to 3.13 and parse scripts at boundaries. -Release posture: +Release outcome: -- Release `v0.4.3` before starting another performance-focused implementation - branch. The current release-signal comparison against `v0.4.2` shows broad +- The release-signal comparison against `v0.4.2` showed broad improvement across LU, solve, determinant-via-LU, and vector helper rows. - Treat the remaining `D=4` direct determinant regression as a tracked performance note rather than a release blocker because the LU-backed @@ -127,9 +125,9 @@ Release posture: Larger-dimension `vs_linalg` measurements suggest it is the most interesting leaf-kernel target, but it is not required for the release. -### v0.4.4 Focused Leaf-Kernel Performance +### v0.4.4 Focused Leaf-Kernel Performance (released) -After `v0.4.3`, use the improved benchmark workflow to investigate narrow +This release used the improved benchmark workflow to investigate narrow leaf-kernel performance gaps without broadening the crate's scope or weakening the small fixed-dimension API model. diff --git a/justfile b/justfile index 636d49b..1f55b67 100644 --- a/justfile +++ b/justfile @@ -19,16 +19,16 @@ _coverage_base_args := '''--features exact \ --verbose''' cargo_llvm_cov_version := "0.8.7" cargo_machete_version := "0.9.2" -cargo_nextest_version := "0.9.140" +cargo_nextest_version := "0.9.143" clippy_sarif_version := "0.8.0" dprint_version := "0.55.2" git_cliff_version := "2.13.1" just_version := "1.58.0" -rumdl_version := "0.2.50" +rumdl_version := "0.2.52" sarif_fmt_version := "0.8.0" taplo_version := "0.10.0" typos_version := "1.49.0" -uv_version := "0.12.1" +uv_version := "0.12.3" zizmor_version := "1.29.0" # Internal helpers: ensure external tooling is installed @@ -346,9 +346,9 @@ check-fast: cargo check # CI simulation: flat GitHub-equivalent union of leaf validators. -# Keep this dependency list explicit so each target class and validation surface -# is composed once without re-entering broad check/test bundles. -ci: action-lint zizmor markdown-check spell-check docs-version-check toml-parse-check toml-fmt-check toml-lint yaml-fmt-check yaml-lint citation-check validate-json justfile-fmt-check python-format-check python-lint python-typecheck test-python cargo-lock-check fmt-check clippy-core doc-check semgrep semgrep-test unused-deps shell-check test-rust-ci test-doc test-doc-exact bench-compile examples +# Keep this dependency list explicit so each validation surface runs once without +# re-entering broad check/test bundles. All Cargo targets match the SARIF lint scope. +ci: action-lint zizmor markdown-check spell-check docs-version-check toml-parse-check toml-fmt-check toml-lint yaml-fmt-check yaml-lint citation-check validate-json justfile-fmt-check python-format-check python-lint python-typecheck test-python cargo-lock-check fmt-check clippy-all-targets doc-check semgrep semgrep-test unused-deps shell-check test-rust-ci test-doc test-doc-exact bench-compile examples @echo "🎯 CI checks complete!" # Validate CITATION.cff against the Citation File Format schema. @@ -361,7 +361,7 @@ clean: rm -rf target/llvm-cov rm -rf coverage -# Optional broad Clippy sweep across tests, examples, and benches. +# Full Cargo-target Clippy sweep used by `just ci` and the GitHub SARIF workflow. clippy: clippy-all-targets clippy-all-targets: diff --git a/pyproject.toml b/pyproject.toml index 58b56b6..2bd746f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,9 +6,9 @@ build-backend = "setuptools.build_meta" name = "la-stack-scripts" version = "0.4.4" description = "Python utility scripts for the la-stack Rust library" -readme = "README.md" +readme = "scripts/README.md" requires-python = ">=3.14" -license = { text = "BSD-3-Clause" } +license = "BSD-3-Clause" authors = [ { name = "Adam Getchell", email = "adam@adamgetchell.org" }, ] @@ -17,7 +17,6 @@ classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", "Intended Audience :: Science/Research", - "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.14", diff --git a/scripts/README.md b/scripts/README.md index d7591d6..652673d 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -60,6 +60,12 @@ just performance-rerender just performance-github-assets ``` +Local benchmark generation streams Cargo and Criterion progress while retaining +the existing fail-closed report and provenance checks. Lines prefixed with +`[performance]` identify the active validation or timing phase. A +current-vs-latest request whose package and release identifiers match is +rejected before benchmark work starts. + The local release workflows run the independent benchmark-input correctness gate and then measure both library revisions with one hashed current benchmark harness. Reports record source-state, environment, toolchain, dependency, @@ -203,7 +209,7 @@ This repo has been tested with `gnuplot 6.0 patchlevel 3` (Homebrew `gnuplot 6.0 just changelog # Prepend only unreleased changes for a new version -just changelog-unreleased v0.3.0 +just changelog-unreleased vX.Y.Z ``` `just changelog` runs `git-cliff -o CHANGELOG.md`, strips trailing blank @@ -213,13 +219,13 @@ Configuration lives in `cliff.toml` at the repo root. ### Creating a release tag ```bash -just tag v0.3.0 # create annotated tag from CHANGELOG.md section -just tag-force v0.3.0 # recreate tag if it already exists +just tag vX.Y.Z # create an annotated tag matching Cargo.toml +just tag-force vX.Y.Z # replace that tag only when explicitly repairing it ``` The `tag-release` CLI (in `tag_release.py`) extracts the matching version -section from `CHANGELOG.md`, validates semver, and handles GitHub's 125KB -tag-annotation size limit. +section from `CHANGELOG.md`, requires the tag to match the Cargo package version, +validates SemVer, and handles GitHub's 125KB tag-annotation size limit. ### Scripts overview diff --git a/scripts/archive_changelog.py b/scripts/archive_changelog.py index b6fe47d..969dd17 100755 --- a/scripts/archive_changelog.py +++ b/scripts/archive_changelog.py @@ -17,22 +17,28 @@ archive-changelog --archive-dir docs/archive/changelog """ -from __future__ import annotations - import argparse import logging import os import re import sys +import tempfile from pathlib import Path from postprocess_changelog import normalize_entry_headings_text, postprocess_text # Matches ``## [X.Y.Z]`` or ``## [Unreleased]`` _VERSION_HEADING_RE = re.compile(r"^## \[") +_UNRELEASED_HEADING_RE = re.compile(r"^## \[Unreleased\](?:\s|$)") -# Extracts a semver version from a ``## [X.Y.Z]`` heading (linked or plain). -_VERSION_RE = re.compile(r"^## \[(\d+\.\d+\.\d+[^\]]*)\]") +# Extracts a strict SemVer 2.0.0 version from a ``## [X.Y.Z]`` heading. +_SEMVER_ALNUM_ID = r"(?:(?=[0-9A-Za-z-]*[A-Za-z-])[0-9A-Za-z-]+)" +_SEMVER_PATTERN = ( + r"(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)" + rf"(?:-(?:(?:0|[1-9]\d*)|{_SEMVER_ALNUM_ID})(?:\.(?:(?:0|[1-9]\d*)|{_SEMVER_ALNUM_ID}))*)?" + r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?" +) +_VERSION_RE = re.compile(rf"^## \[({_SEMVER_PATTERN})\](?:\s|$)") # Matches a reference-style link definition: ``[label]: URL`` _LINK_DEF_RE = re.compile(r"^\[([^\]]+)\]:\s+\S+") @@ -161,21 +167,32 @@ def parse_changelog(text: str) -> tuple[str, str, list[tuple[str, str]]]: preamble = "\n".join(lines[: headings[0]]) unreleased = "" + unreleased_line: int | None = None version_blocks: list[tuple[str, str]] = [] + version_lines: dict[str, int] = {} for idx, start in enumerate(headings): end = headings[idx + 1] if idx + 1 < len(headings) else len(lines) block = "\n".join(lines[start:end]) heading_line = lines[start] - if heading_line.startswith("## [Unreleased]"): + if _UNRELEASED_HEADING_RE.match(heading_line): + if unreleased_line is not None: + msg = f"Duplicate Unreleased changelog heading at lines {unreleased_line} and {start + 1}" + raise ValueError(msg) unreleased = block + unreleased_line = start + 1 else: m = _VERSION_RE.match(heading_line) if not m: msg = f"Unrecognized changelog version heading at line {start + 1}: {heading_line!r}; expected '## [Unreleased]' or a semantic version" raise ValueError(msg) - version_blocks.append((m.group(1), block)) + version = m.group(1) + if version in version_lines: + msg = f"Duplicate changelog version {version!r} at lines {version_lines[version]} and {start + 1}" + raise ValueError(msg) + version_lines[version] = start + 1 + version_blocks.append((version, block)) return preamble, unreleased, version_blocks @@ -238,9 +255,18 @@ def write_archive( Returns: The path of the written archive file. """ - archive_dir.mkdir(parents=True, exist_ok=True) path = archive_dir / f"{minor}.md" + text = _build_archive_text(minor, blocks, link_defs) + _publish_texts({path: text}) + return path + +def _build_archive_text( + minor: str, + blocks: list[tuple[str, str]], + link_defs: dict[str, str] | None = None, +) -> str: + """Build one normalized archive payload without changing the filesystem.""" parts = [f"# Changelog - {minor}.x\n"] for _ver, block in blocks: parts.append(block) @@ -256,10 +282,83 @@ def write_archive( # Normalize archive output too; archived blocks can preserve historical # commit-body indentation that no longer appears in the trimmed root file. - text = postprocess_text(text) + return postprocess_text(text) + + +def _stage_text(path: Path, text: str) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile( + "w", + encoding="utf-8", + dir=path.parent, + prefix=f".{path.name}.", + suffix=".tmp", + delete=False, + ) as handle: + handle.write(text) + handle.flush() + os.fsync(handle.fileno()) + return Path(handle.name) + + +def _stage_bytes(path: Path, payload: bytes) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile( + "wb", + dir=path.parent, + prefix=f".{path.name}.", + suffix=".tmp", + delete=False, + ) as handle: + handle.write(payload) + handle.flush() + os.fsync(handle.fileno()) + return Path(handle.name) + + +def _replace_path(source: Path, destination: Path) -> None: + source.replace(destination) + + +def _restore_text(path: Path, previous: bytes | None) -> None: + if previous is None: + path.unlink(missing_ok=True) + return + staged = _stage_bytes(path, previous) + try: + _replace_path(staged, path) + finally: + staged.unlink(missing_ok=True) - path.write_text(text, encoding="utf-8") - return path + +def _publish_texts(payloads: dict[Path, str]) -> None: + """Publish a set of text files together and roll all of them back on failure.""" + if not payloads: + return + previous = {path: path.read_bytes() if path.is_file() else None for path in payloads} + staged = {path: _stage_text(path, text) for path, text in payloads.items()} + replaced: list[Path] = [] + try: + for path, staged_path in staged.items(): + _replace_path(staged_path, path) + replaced.append(path) + except BaseException as publication_error: + rollback_errors: list[BaseException] = [] + for path in reversed(replaced): + try: + _restore_text(path, previous[path]) + except BaseException as rollback_error: # noqa: BLE001 + rollback_errors.append(rollback_error) + if rollback_errors: + group_message = "changelog publication and rollback failed" + raise BaseExceptionGroup( + group_message, + [publication_error, *rollback_errors], + ) from None + raise + finally: + for staged_path in staged.values(): + staged_path.unlink(missing_ok=True) def _postprocess_existing_archives(archive_dir: Path) -> None: @@ -267,11 +366,28 @@ def _postprocess_existing_archives(archive_dir: Path) -> None: if not archive_dir.is_dir(): return + payloads: dict[Path, str] = {} + for path in archive_dir.glob("*.md"): + text = path.read_text(encoding="utf-8") + normalized = normalize_entry_headings_text(text) + if normalized != text: + payloads[path] = normalized + _publish_texts(payloads) + + +def _normalized_existing_archive_payloads(archive_dir: Path, *, excluded: set[Path]) -> dict[Path, str]: + """Return changed historical archive payloads not regenerated this run.""" + if not archive_dir.is_dir(): + return {} + payloads: dict[Path, str] = {} for path in archive_dir.glob("*.md"): + if path in excluded: + continue text = path.read_text(encoding="utf-8") normalized = normalize_entry_headings_text(text) if normalized != text: - path.write_text(normalized, encoding="utf-8") + payloads[path] = normalized + return payloads def build_root( @@ -380,8 +496,7 @@ def archive_changelog( # In particular, os.path.relpath() cannot cross Windows volumes. archive_dir_rel = _archive_dir_link_prefix(archive_dir, changelog_path.parent) - for minor in archived_minors: - write_archive(archive_dir, minor, groups[minor], link_defs) + payloads = {archive_dir / f"{minor}.md": _build_archive_text(minor, groups[minor], link_defs) for minor in archived_minors} root_text = build_root( preamble, @@ -400,8 +515,9 @@ def archive_changelog( if defs_text: root_text = root_text.rstrip("\n") + "\n\n" + defs_text + "\n" - changelog_path.write_text(root_text, encoding="utf-8") - _postprocess_existing_archives(archive_dir) + payloads[changelog_path] = root_text + payloads.update(_normalized_existing_archive_payloads(archive_dir, excluded=set(payloads))) + _publish_texts(payloads) # --------------------------------------------------------------------------- diff --git a/scripts/archive_performance.py b/scripts/archive_performance.py index f5f51d2..269283f 100644 --- a/scripts/archive_performance.py +++ b/scripts/archive_performance.py @@ -36,7 +36,7 @@ from bench_compare import HOW_TO_UPDATE_SECTION, render_release_artifacts from performance_artifacts import ArtifactPaths, PerformanceBundle, ensure_distinct_paths, load_bundle, publish_bundle -from subprocess_utils import ExecutableNotFoundError, run_git_command, run_git_command_with_input, run_safe_command +from subprocess_utils import ExecutableNotFoundError, cpu_description, run_git_command, run_git_command_with_input, run_safe_command _VERSION_RE = re.compile(r"^\*\*la-stack\*\* v(?P[^\s`]+)", re.MULTILINE) _BASELINE_RE = re.compile(r"^Comparison against baseline \*\*(?P[^*]+)\*\*:", re.MULTILINE) @@ -128,6 +128,15 @@ def __post_init__(self) -> None: raise ValueError(msg) +@dataclass(frozen=True, slots=True) +class ToolRunOptions: + """Execution controls for one repository support command.""" + + timeout: int = _COMMAND_TIMEOUT_SECONDS + env: dict[str, str] | None = None + stream_output: bool = False + + @dataclass(frozen=True) class ResolvedArchiveRequest: """Release pair and worktree ref resolved from CLI arguments.""" @@ -300,7 +309,6 @@ def _github_release_list(repo_root: Path) -> object: "gh", command, cwd=repo_root, - timeout=_COMMAND_TIMEOUT_SECONDS, ) try: return json.loads(result.stdout) @@ -559,12 +567,19 @@ def _run_tool_output( args: list[str], *, cwd: Path, - timeout: int = _COMMAND_TIMEOUT_SECONDS, - env: dict[str, str] | None = None, + options: ToolRunOptions | None = None, ) -> subprocess.CompletedProcess[str]: """Run a support command and normalize all expected launch failures.""" + resolved_options = options or ToolRunOptions() try: - return run_safe_command(command, args, cwd=cwd, timeout=timeout, env=env) + return run_safe_command( + command, + args, + cwd=cwd, + timeout=resolved_options.timeout, + env=resolved_options.env, + capture_output=not resolved_options.stream_output, + ) except subprocess.CalledProcessError as exc: raise RuntimeError(_format_command_failure([command, *args], exc)) from exc except subprocess.TimeoutExpired as exc: @@ -573,18 +588,34 @@ def _run_tool_output( raise RuntimeError(_format_command_start_failure([command, *args], exc)) from exc -def _run_tool(command: str, args: list[str], *, cwd: Path, timeout: int = _COMMAND_TIMEOUT_SECONDS, env: dict[str, str] | None = None) -> None: - _run_tool_output(command, args, cwd=cwd, timeout=timeout, env=env) +def _run_tool( + command: str, + args: list[str], + *, + cwd: Path, + options: ToolRunOptions | None = None, +) -> None: + _run_tool_output( + command, + args, + cwd=cwd, + options=options, + ) + + +def _progress(message: str) -> None: + """Write one immediately visible workflow progress message.""" + print(f"[performance] {message}", file=sys.stderr, flush=True) def _run_benchmark_input_gate(checkout: Path, *, env: dict[str, str] | None = None) -> None: """Run the shared deterministic benchmark-fixture correctness gate.""" + _progress(f"validating benchmark inputs in {checkout.name}") _run_tool( _BENCHMARK_INPUT_GATE[0], list(_BENCHMARK_INPUT_GATE[1:]), cwd=checkout, - timeout=_COMMAND_TIMEOUT_SECONDS, - env=env, + options=ToolRunOptions(env=env, stream_output=True), ) @@ -641,8 +672,7 @@ def _rustc_version(checkout: Path) -> str: "rustc", ["--version"], cwd=checkout, - timeout=_COMMAND_TIMEOUT_SECONDS, - env=_benchmark_env(checkout), + options=ToolRunOptions(env=_benchmark_env(checkout)), ) version = result.stdout.strip() return version or "unavailable" @@ -650,7 +680,7 @@ def _rustc_version(checkout: Path) -> str: def _environment_metadata(checkout: Path, *, harness_sha256: str) -> dict[str, object]: """Capture deterministic machine, toolchain, revision, and lock provenance.""" - cpu = platform.processor().strip() or platform.machine().strip() or "unavailable" + cpu = cpu_description() os_description = " ".join(part for part in (platform.system(), platform.release(), platform.machine()) if part).strip() return { "cargo_lock_sha256": _sha256_file(checkout / "Cargo.lock"), @@ -665,6 +695,18 @@ def _environment_metadata(checkout: Path, *, harness_sha256: str) -> dict[str, o } +def _require_recorded_measurement_cpu() -> str: + """Return the CPU model required for reproducible release measurements.""" + cpu = cpu_description() + if cpu.casefold() == "unavailable": + msg = ( + "cannot generate release performance measurements because the CPU model is unavailable; " + "raw local benchmarks may still be run with the ordinary Criterion recipes" + ) + raise RuntimeError(msg) + return cpu + + def _criterion_dependency_version(checkout: Path) -> str: """Return the resolved Criterion version, falling back to its manifest requirement.""" lock_data = tomllib.loads(_read_text(checkout / "Cargo.lock")) @@ -1064,13 +1106,18 @@ def _generate_release_baseline(*, baseline_tag: str, suite: str, repo_root: Path api_compatibility = _baseline_api_compatibility(baseline_tag) benchmark_env = _comparison_benchmark_env(repo_root, baseline_tag=baseline_tag) _run_benchmark_input_gate(baseline_worktree, env=benchmark_env) + _progress(f"running {suite} baseline benchmarks for {baseline_tag}") _run_tool( baseline_command, baseline_args, cwd=baseline_worktree, - timeout=_BENCH_TIMEOUT_SECONDS, - env=benchmark_env, + options=ToolRunOptions( + timeout=_BENCH_TIMEOUT_SECONDS, + env=benchmark_env, + stream_output=True, + ), ) + _progress(f"completed {suite} baseline benchmarks for {baseline_tag}") baseline_criterion = baseline_worktree / "target" / "criterion" if not baseline_criterion.is_dir(): msg = f"generated baseline Criterion results were not found: {baseline_criterion}" @@ -1158,21 +1205,14 @@ def _prepare_github_release_assets(*, current_tag: str, baseline_tag: str, repo_ def _apply_current_diff_to_worktree(*, repo_root: Path, worktree: Path) -> None: - # Build the patch through an isolated index so untracked, non-ignored files - # participate without changing the caller's real staging area. Git records - # binary blobs and symlink metadata directly and applies its normal safe-path - # checks when the patch is replayed in the detached worktree. + # Diff HEAD directly so staged and unstaged tracked changes participate, + # while unrelated untracked files stay outside the benchmark worktree. with tempfile.TemporaryDirectory(prefix="la-stack-current-tree-index-") as tmp: temporary_dir = Path(tmp) - env = os.environ.copy() - env["GIT_INDEX_FILE"] = str(temporary_dir / "index") - _run_git_output(["read-tree", "HEAD"], cwd=repo_root, env=env) - _run_git_output(["add", "--all", "--", "."], cwd=repo_root, env=env) patch_path = temporary_dir / "current-tree.patch" _run_git_output( - ["diff", "--cached", "--binary", f"--output={patch_path}", "HEAD"], + ["diff", "--binary", f"--output={patch_path}", "HEAD", "--", "."], cwd=repo_root, - env=env, ) diff = patch_path.read_bytes() if diff.strip(): @@ -1230,7 +1270,6 @@ def _render_report( str(report), ], cwd=config.repo_root, - timeout=_COMMAND_TIMEOUT_SECONDS, ) @@ -1252,13 +1291,18 @@ def _run_benchmarks_and_render_report( current_command = ("just", *_latest_recipe_args(suite=config.suite)) else: current_command = _fallback_current_command(suite=config.suite) + _progress(f"running current {config.suite} benchmarks") _run_tool( current_command[0], list(current_command[1:]), cwd=worktree, - timeout=_BENCH_TIMEOUT_SECONDS, - env=benchmark_env, + options=ToolRunOptions( + timeout=_BENCH_TIMEOUT_SECONDS, + env=benchmark_env, + stream_output=True, + ), ) + _progress(f"completed current {config.suite} benchmarks") _write_local_run_provenance( worktree=worktree, config=config, @@ -1283,6 +1327,8 @@ def _generated_report_in_temp_worktree( published_artifacts: ArtifactPaths, ) -> Iterator[GeneratedReport]: """Generate, publish, and expose a report before its worktree is removed.""" + if config.baseline_source == "local": + _require_recorded_measurement_cpu() with tempfile.TemporaryDirectory(prefix="la-stack-performance-") as tmp: tmp_dir = Path(tmp) worktree = tmp_dir / "worktree" @@ -1689,6 +1735,14 @@ def resolve_archive_request(options: ArchiveRequestOptions) -> ResolvedArchiveRe raise ValueError(msg) inferred_current = _current_package_tag(repo_root) latest = _latest_published_release(repo_root).tag + if inferred_current == latest: + msg = ( + f"current package tag and latest published release are both {latest}; " + "a release-performance report requires distinct identifiers. " + "Use a named local Criterion baseline for same-version worktree comparisons, " + "or rerun after the maintainer updates the package version." + ) + raise ValueError(msg) return ResolvedArchiveRequest( current_tag=inferred_current, baseline_tag=latest, @@ -1897,28 +1951,39 @@ def _run_archive_request(*, args: argparse.Namespace, paths: ArchivePaths, reque ) +def _validate_cli_preflight(args: argparse.Namespace) -> None: + """Reject locally invalid options before release discovery or tag fetching.""" + if args.rerender and any( + ( + args.current_tag, + args.baseline_tag, + args.published_latest, + args.infer_release, + args.current_vs_latest, + args.github_assets, + args.generate_in_temp_worktree, + args.output_only, + ) + ): + msg = "--rerender cannot be combined with release selection, generation, GitHub-asset, or output-only options" + raise ValueError(msg) + if args.output_only and not args.generate_in_temp_worktree: + msg = "--output-only requires --generate-in-temp-worktree" + raise ValueError(msg) + if args.github_assets and not args.generate_in_temp_worktree: + msg = "--github-assets requires --generate-in-temp-worktree" + raise ValueError(msg) + + def main(argv: list[str] | None = None) -> int: """CLI entry point.""" args = build_parser().parse_args(argv) root = Path.cwd() - paths = _resolve_cli_paths(root, args) try: + _validate_cli_preflight(args) + paths = _resolve_cli_paths(root, args) if args.rerender: - if any( - ( - args.current_tag, - args.baseline_tag, - args.published_latest, - args.infer_release, - args.current_vs_latest, - args.github_assets, - args.generate_in_temp_worktree, - args.output_only, - ) - ): - msg = "--rerender cannot be combined with release selection, generation, GitHub-asset, or output-only options" - raise ValueError(msg) result = ArchiveResult( report_id=rerender_and_promote_artifacts( artifacts=paths.artifacts, diff --git a/scripts/bench_compare.py b/scripts/bench_compare.py index 7066643..b5329d7 100644 --- a/scripts/bench_compare.py +++ b/scripts/bench_compare.py @@ -24,10 +24,13 @@ import argparse import json import math +import os import re import subprocess import sys +import tempfile import tomllib +from collections.abc import Mapping from dataclasses import dataclass from datetime import UTC, datetime from pathlib import Path @@ -43,10 +46,11 @@ ReportSource, TimingEstimate, ensure_distinct_paths, + freeze_mapping, load_bundle, - write_bundle, + publish_bundle, ) -from subprocess_utils import ExecutableNotFoundError, run_git_command +from subprocess_utils import ExecutableNotFoundError, find_project_root, run_git_command # --------------------------------------------------------------------------- # Benchmark group / bench discovery @@ -371,10 +375,17 @@ class HarnessProvenance: mode: str sha256: str | None baseline: str - measurement: dict[str, object] | None = None - publication: dict[str, object] | None = None + measurement: Mapping[str, object] | None = None + publication: Mapping[str, object] | None = None criterion: CriterionProvenance | None = None - validation: dict[str, object] | None = None + validation: Mapping[str, object] | None = None + + def __post_init__(self) -> None: + """Detach and freeze nested provenance so the validated model stays valid.""" + for field in ("measurement", "publication", "validation"): + value = getattr(self, field) + if value is not None: + object.__setattr__(self, field, freeze_mapping(value)) @dataclass(frozen=True, slots=True) @@ -407,7 +418,8 @@ class ReportSettings: def _repo_root() -> Path: - return Path(__file__).resolve().parents[1] + """Resolve the checkout from the caller's working tree, including wheel installs.""" + return find_project_root() def _dim_from_vs_linalg_group(name: str) -> int | None: @@ -537,7 +549,7 @@ def _read_harness_provenance( def _parse_harness_provenance( - data: dict[str, object], + data: Mapping[str, object], *, path: Path, expected_baseline: str, @@ -583,6 +595,13 @@ def _parse_harness_provenance( ) _validate_validation_metadata(validation, path=path) _validate_baseline_api_compatibility(validation, baseline=baseline, path=path) + _validate_schema2_consistency( + mode=mode, + measurement=measurement, + publication=publication, + validation=validation, + path=path, + ) sha256: str | None = None if measurement.get("status") == "recorded": @@ -599,16 +618,16 @@ def _parse_harness_provenance( ) -def _required_metadata_object(data: dict[str, object], field: str, path: Path) -> dict[str, object]: +def _required_metadata_object(data: Mapping[str, object], field: str, path: Path) -> Mapping[str, object]: """Return a required provenance object with contextual diagnostics.""" value = data.get(field) - if not isinstance(value, dict) or not all(isinstance(key, str) for key in value): + if not isinstance(value, Mapping) or not all(isinstance(key, str) for key in value): msg = f"invalid or missing {field} object in {path}" raise ValueError(msg) - return cast("dict[str, object]", value) + return cast("Mapping[str, object]", value) -def _required_metadata_string(data: dict[str, object], field: str, path: Path) -> str: +def _required_metadata_string(data: Mapping[str, object], field: str, path: Path) -> str: """Return a required non-empty provenance string.""" value = data.get(field) if not isinstance(value, str) or not value.strip(): @@ -617,7 +636,7 @@ def _required_metadata_string(data: dict[str, object], field: str, path: Path) - return value -def _required_sha256(data: dict[str, object], field: str, path: Path) -> str: +def _required_sha256(data: Mapping[str, object], field: str, path: Path) -> str: """Return a required lowercase SHA-256 digest.""" value = data.get(field) if not isinstance(value, str) or re.fullmatch(r"[0-9a-f]{64}", value) is None: @@ -626,7 +645,7 @@ def _required_sha256(data: dict[str, object], field: str, path: Path) -> str: return value -def _validate_environment_metadata(data: dict[str, object], *, path: Path, context: str) -> None: +def _validate_environment_metadata(data: Mapping[str, object], *, path: Path, context: str) -> None: """Validate deterministic environment fields used to reproduce a run.""" for field in ("cpu", "os", "rustc", "commit"): _required_metadata_string(data, field, path) @@ -642,7 +661,7 @@ def _validate_environment_metadata(data: dict[str, object], *, path: Path, conte raise ValueError(msg) -def _validate_measurement_metadata(data: dict[str, object], *, mode: object, path: Path) -> None: +def _validate_measurement_metadata(data: Mapping[str, object], *, mode: object, path: Path) -> None: """Validate recorded or explicitly unavailable measurement provenance.""" status = _required_metadata_string(data, "status", path) if status == "recorded": @@ -651,6 +670,9 @@ def _validate_measurement_metadata(data: dict[str, object], *, mode: object, pat raise ValueError(msg) for field in ("cpu", "os", "rustc", "current_commit", "baseline_commit"): _required_metadata_string(data, field, path) + if cast("str", data["cpu"]).casefold() == "unavailable": + msg = f"recorded measurement provenance in {path} requires an identified CPU model" + raise ValueError(msg) _required_sha256(data, "cargo_lock_sha256", path) _required_sha256(data, "harness_sha256", path) _required_sha256(data, "current_source_state_sha256", path) @@ -668,7 +690,7 @@ def _validate_measurement_metadata(data: dict[str, object], *, mode: object, pat def _parse_criterion_metadata( - data: dict[str, object], + data: Mapping[str, object], *, path: Path, expected: CriterionSelection, @@ -711,10 +733,10 @@ def _parse_criterion_metadata( commands: dict[str, tuple[str, ...]] = {} for field in ("baseline_command", "current_command"): value = data.get(field) - if not isinstance(value, list) or not value or not all(isinstance(part, str) and part for part in value): + if not isinstance(value, (list, tuple)) or not value or not all(isinstance(part, str) and part for part in value): msg = f"invalid or missing criterion.{field} in {path}" raise ValueError(msg) - commands[field] = tuple(cast("list[str]", value)) + commands[field] = tuple(cast("list[str] | tuple[str, ...]", value)) return CriterionProvenance( suite=cast("BenchmarkSuite", suite), @@ -727,10 +749,10 @@ def _parse_criterion_metadata( ) -def _validate_validation_metadata(data: dict[str, object], *, path: Path) -> None: +def _validate_validation_metadata(data: Mapping[str, object], *, path: Path) -> None: """Require fixture validation for both compared revisions.""" command = data.get("command") - if command != ["just", "test-bench-inputs"]: + if command not in (["just", "test-bench-inputs"], ("just", "test-bench-inputs")): msg = f"invalid validation.command in {path}: expected ['just', 'test-bench-inputs']" raise ValueError(msg) for field in ("current_revision", "baseline_revision"): @@ -746,17 +768,77 @@ def _validate_validation_metadata(data: dict[str, object], *, path: Path) -> Non if not isinstance(data.get(field), bool): msg = f"invalid or missing validation.{field} in {path}" raise TypeError(msg) - compatibility = data.get("baseline_api_compatibility") - if compatibility is not None and (not isinstance(compatibility, str) or not compatibility): - msg = f"invalid validation.baseline_api_compatibility in {path}" - raise TypeError(msg) + _required_metadata_string(data, "baseline_api_compatibility", path) -def _validate_baseline_api_compatibility(data: dict[str, object], *, baseline: str, path: Path) -> None: - """Reject compatibility adapters attached to an unrelated baseline.""" +def _validate_baseline_api_compatibility(data: Mapping[str, object], *, baseline: str, path: Path) -> None: + """Bind the only supported compatibility adapter to its baseline.""" compatibility = data.get("baseline_api_compatibility") - if compatibility == _V0_4_3_API_COMPATIBILITY and baseline != "v0.4.3": - msg = f"validation.baseline_api_compatibility {_V0_4_3_API_COMPATIBILITY!r} in {path} is valid only for baseline 'v0.4.3', got {baseline!r}" + expected = _V0_4_3_API_COMPATIBILITY if baseline == "v0.4.3" else "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) + + +def _require_matching_fields( + first: Mapping[str, object], + second: Mapping[str, object], + pairs: tuple[tuple[str, str], ...], + *, + contexts: tuple[str, str], + path: Path, +) -> None: + first_context, second_context = contexts + for first_field, second_field in pairs: + if first.get(first_field) != second.get(second_field): + msg = f"{first_context}.{first_field} in {path} does not match {second_context}.{second_field}" + raise ValueError(msg) + + +def _validate_schema2_consistency( + *, + mode: str, + measurement: Mapping[str, object], + publication: Mapping[str, object], + validation: Mapping[str, object], + path: Path, +) -> None: + """Reject internally contradictory measurement and validation evidence.""" + status = measurement.get("status") + expected_status = "recorded" if mode == "shared-current-harness" else "unavailable" + if status != expected_status: + msg = f"mode {mode!r} in {path} requires measurement.status {expected_status!r}, got {status!r}" + raise ValueError(msg) + + _require_matching_fields( + publication, + validation, + (("commit", "current_commit"), ("git_clean", "current_git_clean"), ("source_state_sha256", "current_source_state_sha256")), + contexts=("publication", "validation"), + path=path, + ) + + if status != "recorded": + return + identical_fields = tuple((field, field) for field in ("cpu", "os", "rustc", "cargo_lock_sha256", "harness_sha256")) + _require_matching_fields( + measurement, + publication, + (*identical_fields, ("current_commit", "commit"), ("current_git_clean", "git_clean"), ("current_source_state_sha256", "source_state_sha256")), + contexts=("measurement", "publication"), + path=path, + ) + baseline_fields = tuple((field, field) for field in ("baseline_commit", "baseline_git_clean", "baseline_source_state_sha256")) + _require_matching_fields( + measurement, + validation, + baseline_fields, + contexts=("measurement", "validation"), + path=path, + ) + measurement_compatibility = measurement.get("baseline_api_compatibility") + if measurement_compatibility != validation.get("baseline_api_compatibility"): + msg = f"measurement.baseline_api_compatibility in {path} does not match validation.baseline_api_compatibility" raise ValueError(msg) @@ -2012,15 +2094,16 @@ def _resolve_artifact_paths( return paths -def _write_and_render_artifacts( +def _write_and_render_artifacts( # noqa: PLR0913 paths: ArtifactPaths, *, + output_path: Path, root: Path, criterion_dir: Path, settings: ReportSettings, collection: ComparisonCollection | None, -) -> str: - """Write a validated artifact pair and render its reloaded report.""" +) -> None: + """Publish artifacts and Markdown as one rollback-capable operation.""" baseline_name = settings.baseline_name if baseline_name is None or collection is None: msg = "release-performance artifacts require a completed baseline comparison" @@ -2032,8 +2115,32 @@ def _write_and_render_artifacts( settings=settings, collection=collection, ) - write_bundle(paths, bundle) - return render_release_artifacts(paths) + with publish_bundle(paths, bundle): + markdown = render_release_artifacts(paths) + _write_text_atomic(output_path, markdown) + + +def _write_text_atomic(path: Path, text: str) -> None: + """Replace a UTF-8 text file only after its complete payload is durable.""" + path.parent.mkdir(parents=True, exist_ok=True) + staged: Path | None = None + try: + with tempfile.NamedTemporaryFile( + "w", + encoding="utf-8", + dir=path.parent, + prefix=f".{path.name}.", + suffix=".tmp", + delete=False, + ) as handle: + handle.write(text) + handle.flush() + os.fsync(handle.fileno()) + staged = Path(handle.name) + staged.replace(path) + finally: + if staged is not None: + staged.unlink(missing_ok=True) def main(argv: list[str] | None = None) -> int: # noqa: C901, PLR0911, PLR0912, PLR0915 @@ -2144,8 +2251,9 @@ def main(argv: list[str] | None = None) -> int: # noqa: C901, PLR0911, PLR0912, ) if artifact_paths is not None: try: - md = _write_and_render_artifacts( + _write_and_render_artifacts( artifact_paths, + output_path=output_path, root=root, criterion_dir=criterion_dir, settings=settings, @@ -2155,12 +2263,15 @@ def main(argv: list[str] | None = None) -> int: # noqa: C901, PLR0911, PLR0912, print(f"Invalid release-performance artifact data: {err}", file=sys.stderr) return 2 print(f"📊 Wrote {artifact_paths.csv} and {artifact_paths.provenance}") + print(f"📊 Wrote {output_path}") else: md = _generate_markdown(root, table, settings) - - output_path.parent.mkdir(parents=True, exist_ok=True) - output_path.write_text(md, encoding="utf-8") - print(f"📊 Wrote {output_path}") + try: + _write_text_atomic(output_path, md) + except OSError as err: + print(f"Could not write benchmark report: {err}", file=sys.stderr) + return 2 + print(f"📊 Wrote {output_path}") return 0 diff --git a/scripts/check_docs_version_sync.py b/scripts/check_docs_version_sync.py index 3f1b35a..a292378 100644 --- a/scripts/check_docs_version_sync.py +++ b/scripts/check_docs_version_sync.py @@ -1,12 +1,12 @@ """Check release-version references against the Cargo package version.""" -from __future__ import annotations - +import argparse import os import re import sys import tomllib from dataclasses import dataclass +from datetime import date from enum import StrEnum from pathlib import Path from typing import TypeGuard @@ -209,6 +209,7 @@ def _uv_lock_reference(path: Path, project: PythonProjectInfo) -> VersionReferen _CITATION_VERSION_RE = re.compile(r"^version:\s*(?P['\"]?)(?P[0-9A-Za-z][0-9A-Za-z.+-]*)(?P=quote)\s*(?:#.*)?$") +_CITATION_DATE_RE = re.compile(r"^date-released:\s*(?P['\"]?)(?P\d{4}-\d{2}-\d{2})(?P=quote)\s*(?:#.*)?$") def _citation_reference(path: Path) -> VersionReference: @@ -227,6 +228,55 @@ def _citation_reference(path: Path) -> VersionReference: return references[0] +def _release_date(path: Path) -> tuple[int, str]: + matches: list[tuple[int, str]] = [] + for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): + if not line.startswith("date-released:"): + continue + match = _CITATION_DATE_RE.fullmatch(line) + if match is None: + msg = f"{path}:{line_number}: top-level date-released must use YYYY-MM-DD" + raise TypeError(msg) + value = match.group("date") + try: + date.fromisoformat(value) + except ValueError as exc: + msg = f"{path}:{line_number}: invalid date-released {value!r}" + raise TypeError(msg) from exc + matches.append((line_number, value)) + if len(matches) != 1: + msg = f"{path} must contain exactly one top-level date-released; found {len(matches)}" + raise TypeError(msg) + return matches[0] + + +def _validate_release_date_sync(root: Path, package: PackageInfo) -> None: + """Require CFF and generated changelog to use the same UTC release date.""" + changelog = root / "CHANGELOG.md" + if not changelog.is_file(): + return + heading_re = re.compile(rf"^## \[v?{re.escape(package.version)}\] - (?P\d{{4}}-\d{{2}}-\d{{2}})$") + changelog_matches: list[tuple[int, str]] = [] + for line_number, line in enumerate(changelog.read_text(encoding="utf-8").splitlines(), start=1): + match = heading_re.fullmatch(line) + if match is not None: + changelog_matches.append((line_number, match.group("date"))) + if not changelog_matches: + return + if len(changelog_matches) != 1: + msg = f"{changelog} must contain exactly one release heading for {package.version}; found {len(changelog_matches)}" + raise TypeError(msg) + citation = root / "CITATION.cff" + citation_line, citation_date = _release_date(citation) + changelog_line, changelog_date = changelog_matches[0] + if citation_date != changelog_date: + msg = ( + f"release date mismatch: {citation}:{citation_line} has {citation_date}, " + f"but {changelog}:{changelog_line} has {changelog_date}; both must use the generated UTC release date" + ) + raise TypeError(msg) + + def _iter_markdown_files(root: Path) -> list[Path]: markdown_files: list[Path] = [] for dirpath, dirnames, filenames in os.walk(root): @@ -317,12 +367,24 @@ def find_version_mismatches(root: Path) -> list[VersionMismatch]: """Return release-version references that differ from Cargo.toml.""" package = _read_cargo_package_info(root / "Cargo.toml") + _validate_release_date_sync(root, package) return [VersionMismatch(reference=reference, package=package) for reference in _version_references(root, package) if reference.version != package.version] -def main() -> int: +def main(argv: list[str] | None = None) -> int: """Check release-version references against the Cargo package version.""" - root = Path(sys.argv[1]).resolve() if len(sys.argv) > 1 else Path.cwd() + parser = argparse.ArgumentParser( + prog="check-docs-version-sync", + description="Check release-version references against Cargo.toml.", + ) + parser.add_argument( + "root", + nargs="?", + default=Path.cwd(), + type=Path, + help="Repository root to check (default: current directory).", + ) + root = parser.parse_args(argv).root.resolve() try: mismatches = find_version_mismatches(root) except (OSError, TypeError, tomllib.TOMLDecodeError) as error: diff --git a/scripts/check_semgrep_fixtures.py b/scripts/check_semgrep_fixtures.py index 4740e44..b1a1e3b 100644 --- a/scripts/check_semgrep_fixtures.py +++ b/scripts/check_semgrep_fixtures.py @@ -1,7 +1,5 @@ """Validate repository-owned Semgrep fixture annotations.""" -from __future__ import annotations - import collections import json import os @@ -82,26 +80,53 @@ def _semgrep_results() -> SemgrepResults | None: return SemgrepResults(results=tuple(parsed_results)) -def _expected_rule_counts(path: Path) -> collections.Counter[str]: - expected: collections.Counter[str] = collections.Counter() - for line in path.read_text(encoding="utf-8").splitlines(): +type ExpectedFinding = tuple[str, int] +type ActualFinding = tuple[str, int, int] + + +def _expected_findings(path: Path) -> collections.Counter[ExpectedFinding]: + expected: collections.Counter[ExpectedFinding] = collections.Counter() + lines = path.read_text(encoding="utf-8").splitlines() + for line_number, line in enumerate(lines, start=1): for match in RULE_ANNOTATION.finditer(line): - expected.update(rule_id.strip() for rule_id in match.group(1).split(",") if rule_id.strip()) + finding_line = line_number + 1 + while finding_line <= len(lines): + candidate = lines[finding_line - 1].strip() + if candidate and not candidate.startswith("```"): + break + finding_line += 1 + expected.update((rule_id.strip(), finding_line) for rule_id in match.group(1).split(",") if rule_id.strip()) return expected -def _actual_rule_counts(semgrep: SemgrepResults) -> collections.Counter[str] | None: - actual: collections.Counter[str] = collections.Counter() +def _actual_findings(semgrep: SemgrepResults) -> tuple[ActualFinding, ...] | None: + actual: list[ActualFinding] = [] malformed_results: list[str] = [] for index, result in enumerate(semgrep.results): check_id = result.get("check_id") - if isinstance(check_id, str): - actual.update([check_id]) - else: + start = result.get("start") + end = result.get("end") + start_line = start.get("line") if _is_parsed_object(start) else None + end_line = end.get("line") if _is_parsed_object(end) else None + if not isinstance(check_id, str): malformed_results.append(f"result {index} is missing string field 'check_id'") + if not isinstance(start_line, int) or isinstance(start_line, bool) or start_line < 1: + malformed_results.append(f"result {index} is missing positive integer field 'start.line'") + if not isinstance(end_line, int) or isinstance(end_line, bool) or end_line < 1: + malformed_results.append(f"result {index} is missing positive integer field 'end.line'") + if ( + isinstance(check_id, str) + and isinstance(start_line, int) + and not isinstance(start_line, bool) + and start_line >= 1 + and isinstance(end_line, int) + and not isinstance(end_line, bool) + and end_line >= start_line + ): + actual.append((check_id, start_line, end_line)) if not malformed_results: - return actual + return tuple(actual) print("Invalid SEMGREP_JSON shape:", file=sys.stderr) for malformed in malformed_results: @@ -109,29 +134,59 @@ def _actual_rule_counts(semgrep: SemgrepResults) -> collections.Counter[str] | N return None +def _finding_mismatches( + expected: collections.Counter[ExpectedFinding], + actual: tuple[ActualFinding, ...], +) -> tuple[str, ...]: + unmatched_actual = list(actual) + mismatches: list[str] = [] + + for (rule_id, line), expected_count in sorted(expected.items()): + for _ in range(expected_count): + match_index = min( + ( + index + for index, (actual_rule_id, start_line, end_line) in enumerate(unmatched_actual) + if actual_rule_id == rule_id and start_line <= line <= end_line + ), + key=lambda index: unmatched_actual[index][2], + default=None, + ) + if match_index is None: + mismatches.append(f"{rule_id} at line {line}: expected finding not reported") + else: + unmatched_actual.pop(match_index) + + for rule_id, start_line, end_line in sorted(unmatched_actual): + span = str(start_line) if start_line == end_line else f"{start_line}-{end_line}" + mismatches.append(f"{rule_id} at lines {span}: unexpected finding") + + return tuple(mismatches) + + def main() -> int: """Compare expected fixture annotations with the supplied Semgrep results.""" path = _path_argument(sys.argv) if path is None: return 1 - expected = _expected_rule_counts(path) + expected = _expected_findings(path) semgrep = _semgrep_results() if semgrep is None: return 1 - actual = _actual_rule_counts(semgrep) + actual = _actual_findings(semgrep) if actual is None: return 1 - if actual == expected: + mismatches = _finding_mismatches(expected, actual) + if not mismatches: return 0 - print(f"Semgrep fixture mismatch in {path}") - for rule in sorted(expected.keys() | actual.keys()): - if expected[rule] != actual[rule]: - print(f" {rule}: expected {expected[rule]}, got {actual[rule]}") + print(f"Semgrep fixture mismatch in {path}", file=sys.stderr) + for mismatch in mismatches: + print(f" {mismatch}", file=sys.stderr) return 1 diff --git a/scripts/criterion_dim_plot.py b/scripts/criterion_dim_plot.py index 164cb85..488a9b5 100644 --- a/scripts/criterion_dim_plot.py +++ b/scripts/criterion_dim_plot.py @@ -12,8 +12,6 @@ Rust linear algebra crates across dimensions. """ -from __future__ import annotations - import argparse import hashlib import json @@ -29,7 +27,8 @@ from pathlib import Path from typing import Final, Protocol, TypeGuard, cast -from subprocess_utils import ExecutableNotFoundError, run_git_command, run_safe_command +from performance_artifacts import ensure_distinct_paths +from subprocess_utils import ExecutableNotFoundError, cpu_description, find_project_root, run_git_command, run_safe_command @dataclass(frozen=True, slots=True) @@ -272,7 +271,8 @@ def no_plot(self) -> bool: ... def _repo_root() -> Path: - return Path(__file__).resolve().parents[1] + """Resolve the checkout from the caller's working tree, including wheel installs.""" + return find_project_root() def _dim_from_group_dir(name: str) -> int | None: @@ -923,7 +923,7 @@ def _capture_provenance( harness_sha256, missing_harness_files = _provenance_harness_digest(root) cargo_lock = root / "Cargo.lock" cargo_lock_sha256 = hashlib.sha256(cargo_lock.read_bytes()).hexdigest() if cargo_lock.is_file() else "unavailable" - cpu = platform.processor().strip() or platform.machine().strip() or "unavailable" + cpu = cpu_description() os_description = " ".join(part for part in (platform.system(), platform.release(), platform.machine()) if part).strip() or "unavailable" git_clean, git_status_sha256 = _git_status_metadata(root) source_state_sha256, source_missing = _source_state_digest(root) @@ -1026,6 +1026,24 @@ def _validate_readme_target(root: Path, args: PlotCliArgs) -> int: # noqa: C901 return 0 +def _validate_publication_paths(root: Path, args: PlotCliArgs, *, out_svg: Path, out_csv: Path) -> int: + """Reject output aliases before benchmarks or publication can begin.""" + paths = { + "CSV output": out_csv, + "provenance output": out_csv.with_suffix(".provenance.json"), + } + if not args.no_plot: + paths["SVG output"] = out_svg + if args.update_readme: + paths["README output"] = _resolve_under_root(root, args.readme) + try: + ensure_distinct_paths(paths) + except (OSError, ValueError) as exc: + print(f"Invalid benchmark publication paths: {exc}", file=sys.stderr) + return 2 + return 0 + + def _replace_staged_files(pairs: list[tuple[Path, Path]], backup_dir: Path) -> None: """Replace a group of publication files and roll back on any failure.""" backups: dict[Path, Path | None] = {} @@ -1207,6 +1225,10 @@ def main(argv: list[str] | None = None) -> int: # noqa: C901, PLR0911, PLR0912, root = _repo_root() rc = _validate_readme_target(root, args) + if rc != 0: + return rc + out_svg, out_csv = _resolve_output_paths(root, args.metric, args.stat, args.out, args.csv) + rc = _validate_publication_paths(root, args, out_svg=out_svg, out_csv=out_csv) if rc != 0: return rc if args.update_readme: @@ -1238,8 +1260,6 @@ def main(argv: list[str] | None = None) -> int: # noqa: C901, PLR0911, PLR0912, metric = METRICS[args.metric] - out_svg, out_csv = _resolve_output_paths(root, args.metric, args.stat, args.out, args.csv) - try: rows, skipped = _collect_rows(criterion_dir, dims, metric, args.stat, args.sample) except (OSError, KeyError, TypeError, ValueError) as exc: diff --git a/scripts/performance_artifacts.py b/scripts/performance_artifacts.py index cd7b040..dd1887e 100644 --- a/scripts/performance_artifacts.py +++ b/scripts/performance_artifacts.py @@ -7,13 +7,15 @@ import math import os import tempfile +from collections.abc import Mapping from contextlib import contextmanager from dataclasses import dataclass from pathlib import Path +from types import MappingProxyType from typing import TYPE_CHECKING, Literal, cast if TYPE_CHECKING: - from collections.abc import Iterator, Mapping + from collections.abc import Iterator SCHEMA_VERSION = 1 SUITES = ("all", "exact", "vs_linalg") @@ -56,7 +58,7 @@ class TimingEstimate: ci_upper_ns: float def __post_init__(self) -> None: - """Reject non-finite, non-positive, or inconsistent timing intervals.""" + """Reject non-finite, non-positive, or reversed timing intervals.""" for field, value in ( ("median_ns", self.median_ns), ("ci_lower_ns", self.ci_lower_ns), @@ -65,8 +67,8 @@ def __post_init__(self) -> None: if not math.isfinite(value) or value <= 0: msg = f"{field} must be finite and positive: {value!r}" raise ValueError(msg) - if not self.ci_lower_ns <= self.median_ns <= self.ci_upper_ns: - msg = f"confidence interval must contain the median: {self.ci_lower_ns} <= {self.median_ns} <= {self.ci_upper_ns}" + if self.ci_lower_ns > self.ci_upper_ns: + msg = f"confidence interval must be ordered: {self.ci_lower_ns} <= {self.ci_upper_ns}" raise ValueError(msg) @@ -180,7 +182,7 @@ class ArtifactContext: suite: str scope: str source: ReportSource - benchmark_provenance: dict[str, object] + benchmark_provenance: Mapping[str, object] def __post_init__(self) -> None: """Bind report settings and benchmark provenance to the release pair.""" @@ -197,6 +199,7 @@ def __post_init__(self) -> None: self.benchmark_provenance, context=self, ) + object.__setattr__(self, "benchmark_provenance", freeze_mapping(self.benchmark_provenance)) @dataclass(frozen=True, slots=True) @@ -267,6 +270,33 @@ def ensure_distinct_paths(paths: Mapping[str, Path]) -> None: raise ValueError(msg) +def freeze_json(value: object) -> object: + """Recursively detach and freeze JSON-shaped provenance data.""" + if isinstance(value, Mapping): + return MappingProxyType({str(key): freeze_json(item) for key, item in value.items()}) + if isinstance(value, (list, tuple)): + return tuple(freeze_json(item) for item in value) + return value + + +def freeze_mapping(data: Mapping[str, object]) -> Mapping[str, object]: + """Detach and freeze a JSON-shaped mapping while preserving its mapping type contract.""" + frozen = freeze_json(data) + if not isinstance(frozen, Mapping): + msg = "provenance mapping invariant violated" + raise TypeError(msg) + return cast("Mapping[str, object]", frozen) + + +def _thaw_json(value: object) -> object: + """Convert frozen JSON-shaped data back to serializer-native containers.""" + if isinstance(value, Mapping): + return {str(key): _thaw_json(item) for key, item in value.items()} + if isinstance(value, tuple): + return [_thaw_json(item) for item in value] + return value + + def _required_provenance_object(data: Mapping[str, object], field: str, *, context: str) -> dict[str, object]: value = data.get(field) if not isinstance(value, dict) or not all(isinstance(key, str) for key in value): @@ -356,8 +386,11 @@ def _validate_measurement_provenance(measurement: Mapping[str, object], *, mode: if mode != "shared-current-harness": msg = "recorded benchmark measurement provenance requires shared-current-harness mode" raise ValueError(msg) - for field in ("cpu", "os", "rustc", "current_commit", "baseline_commit"): + for field in ("cpu", "os", "rustc", "current_commit", "baseline_commit", "baseline_api_compatibility"): _required_provenance_string(measurement, field, context="measurement") + if cast("str", measurement["cpu"]).casefold() == "unavailable": + msg = "recorded benchmark measurement provenance requires an identified CPU model" + raise ValueError(msg) for field in ( "cargo_lock_sha256", "harness_sha256", @@ -375,7 +408,7 @@ def _validate_measurement_provenance(measurement: Mapping[str, object], *, mode: return measurement_status -def _validate_validation_provenance(validation: Mapping[str, object]) -> str: +def _validate_validation_provenance(validation: Mapping[str, object], *, 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']" @@ -392,6 +425,10 @@ def _validate_validation_provenance(validation: Mapping[str, object]) -> str: 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" + 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) harness = _required_provenance_string(validation, "harness", context="validation") if harness != "shared-current": msg = f"benchmark provenance validation.harness must be 'shared-current', got {harness!r}" @@ -450,8 +487,7 @@ def _validate_recorded_measurement_consistency( first_context="measurement", second_context="validation", ) - measurement_compatibility = measurement.get("baseline_api_compatibility") - if measurement_compatibility is not None and measurement_compatibility != compatibility: + if measurement.get("baseline_api_compatibility") != compatibility: msg = "benchmark provenance measurement.baseline_api_compatibility does not match validation.baseline_api_compatibility" raise ValueError(msg) @@ -481,7 +517,11 @@ def _validate_benchmark_provenance(data: Mapping[str, object], *, context: Artif msg = f"benchmark provenance publication.commit {source_commit!r} does not match report source commit {context.source.commit!r}" raise ValueError(msg) measurement_status = _validate_measurement_provenance(measurement, mode=mode) - compatibility = _validate_validation_provenance(validation) + expected_status = "recorded" if mode == "shared-current-harness" else "unavailable" + 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) _validate_current_revision_consistency(publication, validation) if measurement_status == "recorded": _validate_recorded_measurement_consistency( @@ -537,7 +577,7 @@ def _serialize_csv(bundle: PerformanceBundle) -> bytes: def _serialize_provenance(bundle: PerformanceBundle, csv_payload: bytes) -> bytes: context = bundle.context payload = { - "benchmark_provenance": context.benchmark_provenance, + "benchmark_provenance": _thaw_json(context.benchmark_provenance), "csv": { "columns": list(CSV_COLUMNS), "row_count": len(bundle.rows), diff --git a/scripts/postprocess_changelog.py b/scripts/postprocess_changelog.py index 781cbf2..bea2811 100644 --- a/scripts/postprocess_changelog.py +++ b/scripts/postprocess_changelog.py @@ -18,8 +18,6 @@ postprocess-changelog path/to/CHANGELOG.md """ -from __future__ import annotations - import argparse import re import sys @@ -193,6 +191,12 @@ def _markdown_tokens(text: str) -> list[str]: } +def _is_normalized_squash_heading(line: str) -> bool: + """Return whether *line* is a bold heading emitted for a squash body.""" + stripped = line.strip() + return stripped.endswith("**") and any(stripped.startswith(f"**{label}: ") for label in _SQUASH_HEADING_LABELS.values()) + + def _plain_summary(text: str) -> str: """Return a normalized comparison key for changelog entry text.""" text = _BREAKING_MARKER_RE.sub("", text) @@ -483,6 +487,10 @@ def _deindent_orphan(line: str, lines: list[str], idx: int) -> str: if our_indent > parent_indent and nearest_parent_indent is None: nearest_parent_indent = parent_indent continue # skip cliff-indented content + # A normalized squash heading is a prose parent for its body bullets. + # Preserve that relationship on subsequent post-processing runs. + if _is_normalized_squash_heading(prev): + return " " + stripped # Column-0 non-blank line — determines final result. is_list_parent = prev.startswith(("- ", "* ")) if is_list_parent: diff --git a/scripts/subprocess_utils.py b/scripts/subprocess_utils.py index 0179deb..f2021d8 100644 --- a/scripts/subprocess_utils.py +++ b/scripts/subprocess_utils.py @@ -12,6 +12,8 @@ Ported from the delaunay project's scripts/subprocess_utils.py (minimal subset). """ +import os +import platform import shutil import subprocess import tempfile @@ -20,6 +22,9 @@ type RunKwargs = dict[str, Any] +DEFAULT_COMMAND_TIMEOUT_SECONDS = 300.0 +_GENERIC_CPU_NAMES = frozenset({"amd64", "arm", "arm64", "aarch64", "i386", "i686", "unknown", "x86_64"}) + class ExecutableNotFoundError(Exception): """Raised when a required executable is not found in PATH.""" @@ -74,6 +79,7 @@ def _build_run_kwargs(function_name: str, **kwargs: Any) -> RunKwargs: } # Prefer deterministic UTF-8 unless caller overrides run_kwargs.setdefault("encoding", "utf-8") + run_kwargs.setdefault("timeout", DEFAULT_COMMAND_TIMEOUT_SECONDS) return run_kwargs @@ -166,6 +172,45 @@ def run_safe_command( ) +def _darwin_cpu_model() -> str: + try: + return run_safe_command("sysctl", ["-n", "machdep.cpu.brand_string"]).stdout.strip() + except ExecutableNotFoundError, OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired: + return "" + + +def _linux_cpu_model() -> str: + try: + for line in Path("/proc/cpuinfo").read_text(encoding="utf-8").splitlines(): + field, separator, value = line.partition(":") + if separator and field.strip().casefold() in {"model name", "hardware"} and value.strip(): + return value.strip() + except OSError: + pass + return "" + + +def cpu_description() -> str: + """Return a reproducible processor model plus architecture when available.""" + machine = platform.machine().strip() + model_by_system = { + "Darwin": _darwin_cpu_model, + "Linux": _linux_cpu_model, + "Windows": lambda: os.environ.get("PROCESSOR_IDENTIFIER", "").strip(), + } + model_factory = model_by_system.get(platform.system()) + model = "" if model_factory is None else model_factory() + + processor = platform.processor().strip() + if not model and processor.casefold() not in _GENERIC_CPU_NAMES: + model = processor + if not model: + return "unavailable" + if machine and machine.casefold() not in model.casefold(): + return f"{model} ({machine})" + return model + + def get_git_commit_hash(cwd: Path | None = None) -> str: """Get the current git commit hash.""" result = run_git_command(["rev-parse", "HEAD"], cwd=cwd) @@ -246,12 +291,13 @@ class ProjectRootNotFoundError(Exception): """Raised when project root directory cannot be located.""" -def find_project_root() -> Path: - """Find the nearest project root by walking upward to Cargo.toml.""" - current_dir = Path.cwd() - project_root = current_dir +def find_project_root(start: Path | None = None) -> Path: + """Find the nearest project root by walking upward to ``Cargo.toml``.""" + project_root = (start or Path.cwd()).resolve() + if project_root.is_file(): + project_root = project_root.parent while project_root != project_root.parent: - if (project_root / "Cargo.toml").exists(): + if (project_root / "Cargo.toml").is_file(): return project_root project_root = project_root.parent msg = "Could not locate Cargo.toml to determine project root" diff --git a/scripts/tag_release.py b/scripts/tag_release.py index 5cf9ea3..22cc960 100755 --- a/scripts/tag_release.py +++ b/scripts/tag_release.py @@ -12,13 +12,12 @@ Ported from the delaunay project's changelog_utils.py (tag-creation subset). """ -from __future__ import annotations - import argparse import logging import re import subprocess import sys +import tomllib from pathlib import Path from urllib.parse import urlsplit @@ -95,6 +94,37 @@ def find_changelog(start: Path | None = None) -> Path: raise FileNotFoundError(msg) +def _package_version(changelog: Path) -> str: + """Return the authoritative Cargo package version beside the changelog.""" + cargo_toml = changelog.parent / "Cargo.toml" + try: + data = tomllib.loads(cargo_toml.read_text(encoding="utf-8")) + except tomllib.TOMLDecodeError as exc: + msg = f"Could not parse {cargo_toml}: {exc}" + raise ValueError(msg) from exc + package = data.get("package") + if not isinstance(package, dict): + msg = f"{cargo_toml} does not define a [package] table" + raise TypeError(msg) + version = package.get("version") + if not isinstance(version, str) or not version.strip(): + msg = f"{cargo_toml} does not define a non-empty package version" + raise TypeError(msg) + return version + + +def _validated_release_target(tag_version: str) -> tuple[str, Path]: + """Validate a requested tag and return its version plus changelog path.""" + validate_semver(tag_version) + version = parse_version(tag_version) + changelog = find_changelog() + package_version = _package_version(changelog) + if version != package_version: + msg = f"Tag version {version!r} does not match Cargo package version {package_version!r}" + raise ValueError(msg) + return version, changelog + + def _archive_path_for_version(changelog: Path, version: str) -> Path | None: """Return the archive file path for *version* if it exists.""" parts = version.split(".") @@ -298,8 +328,7 @@ def create_tag(tag_version: str, *, force: bool = False) -> None: If the changelog section exceeds GitHub's 125KB limit, creates the tag with a short reference message instead. """ - validate_semver(tag_version) - version = parse_version(tag_version) + version, changelog = _validated_release_target(tag_version) # Check for existing tag (but don't delete yet — validate first) tag_existed = _tag_exists(tag_version) @@ -309,7 +338,6 @@ def create_tag(tag_version: str, *, force: bool = False) -> None: sys.exit(1) # Extract changelog section (before any mutation) - changelog = find_changelog() section, source = extract_changelog_section(changelog, version) section_bytes = len(section.encode("utf-8")) @@ -390,6 +418,7 @@ def main() -> None: try: create_tag(args.version, force=args.force) except ( + TypeError, ValueError, FileNotFoundError, LookupError, diff --git a/scripts/tests/test_archive_changelog.py b/scripts/tests/test_archive_changelog.py index d0d92b0..eecf95c 100644 --- a/scripts/tests/test_archive_changelog.py +++ b/scripts/tests/test_archive_changelog.py @@ -1,7 +1,5 @@ """Tests for archive_changelog.py — parsing, grouping, split/archive, and idempotency.""" -from __future__ import annotations - import logging from typing import TYPE_CHECKING @@ -166,6 +164,27 @@ def test_rejects_non_semver_headings(self) -> None: with pytest.raises(ValueError, match="Unrecognized changelog version heading"): parse_changelog(text) + def test_rejects_unreleased_heading_without_closing_bracket_boundary(self) -> None: + text = _PREAMBLE + "## [Unreleased]invalid\n\n- Something\n\n" + _V072 + + with pytest.raises(ValueError, match="Unrecognized changelog version heading"): + parse_changelog(text) + + @pytest.mark.parametrize("version", ["01.2.3", "1.02.3", "1.2.03", "1.2.3garbage", "1.2.3-01"]) + def test_rejects_malformed_semver_headings(self, version: str) -> None: + text = _PREAMBLE + f"## [{version}] - 2026-01-01\n" + + with pytest.raises(ValueError, match="semantic version"): + parse_changelog(text) + + def test_rejects_duplicate_unreleased_headings(self) -> None: + with pytest.raises(ValueError, match="Duplicate Unreleased"): + parse_changelog(_PREAMBLE + _UNRELEASED + _UNRELEASED + _V072) + + def test_rejects_duplicate_release_headings(self) -> None: + with pytest.raises(ValueError, match="Duplicate changelog version"): + parse_changelog(_PREAMBLE + _V072 + _V072) + class TestGroupByMinor: def test_groups_correctly(self) -> None: @@ -335,6 +354,31 @@ def test_splits_and_archives(self, tmp_path: Path) -> None: assert "## [0.6.2]" in a06 assert "## [0.6.1]" in a06 + def test_multi_file_publication_rolls_back_on_failure(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + changelog = tmp_path / "CHANGELOG.md" + original_root = _full_changelog() + changelog.write_text(original_root, encoding="utf-8") + archive_dir = tmp_path / "docs" / "archive" / "changelog" + archive_dir.mkdir(parents=True) + existing_archive = archive_dir / "0.6.md" + original_archive = b"# Existing 0.6 archive\r\n" + existing_archive.write_bytes(original_archive) + + def fail_root_publication(source: Path, destination: Path) -> None: + if destination == changelog: + msg = "simulated root publication failure" + raise OSError(msg) + source.replace(destination) + + monkeypatch.setattr("archive_changelog._replace_path", fail_root_publication) + + with pytest.raises(OSError, match="simulated root publication failure"): + archive_changelog(changelog, archive_dir) + + assert changelog.read_text(encoding="utf-8") == original_root + assert existing_archive.read_bytes() == original_archive + assert not (archive_dir / "0.2.md").exists() + def test_idempotent(self, tmp_path: Path) -> None: """Running archive twice produces the same output.""" changelog = tmp_path / "CHANGELOG.md" diff --git a/scripts/tests/test_archive_performance.py b/scripts/tests/test_archive_performance.py index 96226ab..6416f8a 100644 --- a/scripts/tests/test_archive_performance.py +++ b/scripts/tests/test_archive_performance.py @@ -37,6 +37,12 @@ type RunnerCall = tuple[str, tuple[str, ...], Path | None] +@pytest.fixture(autouse=True) +def _stable_cpu_description(monkeypatch: pytest.MonkeyPatch) -> None: + """Keep release-generation tests independent of the test host.""" + monkeypatch.setattr(archive_performance, "cpu_description", lambda: "Test CPU (x86_64)") + + def _result(stdout: str = "") -> SimpleNamespace: return SimpleNamespace(stdout=stdout) @@ -278,7 +284,7 @@ def test_purge_selected_new_samples_preserves_named_baselines_and_other_suites(t assert linalg_new.is_dir() -def test_apply_current_diff_includes_complete_current_tree_without_mutating_index(tmp_path: Path) -> None: +def test_apply_current_diff_includes_only_tracked_changes_without_mutating_index(tmp_path: Path) -> None: repo_root = tmp_path / "repo" worktree = tmp_path / "worktree" repo_root.mkdir() @@ -311,10 +317,8 @@ def test_apply_current_diff_includes_complete_current_tree_without_mutating_inde archive_performance._apply_current_diff_to_worktree(repo_root=repo_root, worktree=worktree) assert (worktree / "tracked.txt").read_text(encoding="utf-8") == "working tree\n" - assert (worktree / binary.name).read_bytes() == binary_payload - applied_link = worktree / link.name - assert applied_link.is_symlink() - assert applied_link.readlink() == Path(binary.name) + assert not (worktree / binary.name).exists() + assert not (worktree / link.name).exists() assert not (worktree / "ignored.bin").exists() assert _git(repo_root, "show", ":tracked.txt") == "staged\n" assert _git(repo_root, "status", "--porcelain=v1", "--untracked-files=all") == status_before @@ -348,7 +352,7 @@ def test_apply_current_diff_preserves_crlf_patch_bytes(tmp_path: Path) -> None: assert _git(repo_root, "rev-parse", f":{tracked.name}") == index_before -def test_apply_current_diff_fails_loudly_and_cleans_temporary_index( +def test_apply_current_diff_fails_loudly_and_cleans_temporary_directory( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -356,20 +360,20 @@ def test_apply_current_diff_fails_loudly_and_cleans_temporary_index( worktree = tmp_path / "worktree" repo_root.mkdir() worktree.mkdir() - temporary_index: Path | None = None + patch_path: Path | None = None def fake_run_git(args: Sequence[str], cwd: Path | None = None, **kwargs: Any) -> SimpleNamespace: - nonlocal temporary_index + nonlocal patch_path assert cwd == repo_root - env = kwargs["env"] - temporary_index = Path(env["GIT_INDEX_FILE"]) - assert temporary_index.parent.is_dir() - if args == ["add", "--all", "--", "."]: + assert kwargs.get("env") is None + if args[:2] == ["diff", "--binary"]: + patch_path = Path(next(part.removeprefix("--output=") for part in args if part.startswith("--output="))) + assert patch_path.parent.is_dir() raise subprocess.CalledProcessError( 128, ["git", *args], output="snapshot stdout", - stderr="cannot snapshot current tree", + stderr="cannot diff current tree", ) return _result() @@ -379,11 +383,11 @@ def fake_run_git(args: Sequence[str], cwd: Path | None = None, **kwargs: Any) -> archive_performance._apply_current_diff_to_worktree(repo_root=repo_root, worktree=worktree) error = str(exc_info.value) - assert "command failed (128): git add --all -- ." in error + assert "command failed (128): git diff --binary" in error assert "snapshot stdout" in error - assert "cannot snapshot current tree" in error - assert temporary_index is not None - assert not temporary_index.parent.exists() + assert "cannot diff current tree" in error + assert patch_path is not None + assert not patch_path.parent.exists() @pytest.mark.parametrize( @@ -412,6 +416,53 @@ def fail_run(*_args: object, **_kwargs: object) -> SimpleNamespace: assert exc_info.value.__cause__ is failure +def test_run_tool_can_stream_long_running_command_output( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + observed_kwargs: dict[str, object] = {} + + def fake_run(*_args: object, **kwargs: object) -> SimpleNamespace: + observed_kwargs.update(kwargs) + return _result() + + monkeypatch.setattr(archive_performance, "run_safe_command", fake_run) + + archive_performance._run_tool( + "tool", + ["--flag"], + cwd=tmp_path, + options=archive_performance.ToolRunOptions(stream_output=True), + ) + + assert observed_kwargs["capture_output"] is False + + +def test_benchmark_input_gate_streams_progress( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + calls: list[tuple[str, tuple[str, ...], bool]] = [] + + def fake_run_tool( + command: str, + args: list[str], + *, + cwd: Path, + options: archive_performance.ToolRunOptions, + ) -> None: + del cwd + calls.append((command, tuple(args), options.stream_output)) + + monkeypatch.setattr(archive_performance, "_run_tool", fake_run_tool) + + archive_performance._run_benchmark_input_gate(tmp_path) + + assert calls == [("just", ("test-bench-inputs",), True)] + assert "[performance] validating benchmark inputs" in capsys.readouterr().err + + def test_temporary_worktree_cleanup_failure_fails_successful_operation( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -653,6 +704,34 @@ def fake_run_safe(command: str, args: Sequence[str], cwd: Path | None = None, ** assert request.tags_to_fetch == ("v0.4.2",) +def test_resolve_archive_request_current_vs_latest_rejects_equal_release_tags_before_benchmarking( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + (tmp_path / "Cargo.toml").write_text('[package]\nversion = "0.4.3"\n', encoding="utf-8") + + def fake_run_safe(command: str, args: Sequence[str], cwd: Path | None = None, **kwargs: Any) -> SimpleNamespace: + assert command == "gh" + assert args[:2] == ["release", "list"] + assert cwd == tmp_path + return _result('[{"tagName":"v0.4.3","isDraft":false,"isPrerelease":false,"publishedAt":"2026-03-01T00:00:00Z"}]') + + monkeypatch.setattr(archive_performance, "run_safe_command", fake_run_safe) + + with pytest.raises(ValueError, match=r"both v0\.4\.3"): + archive_performance.resolve_archive_request( + archive_performance.ArchiveRequestOptions( + current_tag=None, + baseline_tag=None, + published_latest=False, + infer_release=False, + current_vs_latest=True, + worktree_ref="HEAD", + repo_root=tmp_path, + ) + ) + + def test_benchmark_env_uses_current_repo_toolchain(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("RUSTUP_TOOLCHAIN", raising=False) (tmp_path / "rust-toolchain.toml").write_text('[toolchain]\nchannel = "1.97.0"\n', encoding="utf-8") @@ -1028,6 +1107,53 @@ def test_main_reports_release_pair_mismatch_to_stderr( assert not current.exists() +def test_main_rejects_local_option_conflict_before_release_discovery( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.chdir(tmp_path) + + def fail_discovery(_options: object) -> None: + msg = "release discovery must not run" + raise AssertionError(msg) + + monkeypatch.setattr(archive_performance, "resolve_archive_request", fail_discovery) + + rc = main(["--published-latest", "--output-only"]) + + assert rc == 1 + assert "--output-only requires --generate-in-temp-worktree" in capsys.readouterr().err + + +def test_local_release_generation_rejects_unavailable_cpu_before_external_work( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + output = tmp_path / "performance.md" + + def fail_external_work(*_args: object, **_kwargs: object) -> None: + msg = "external work must not start" + raise AssertionError(msg) + + monkeypatch.setattr(archive_performance, "cpu_description", lambda: "unavailable") + monkeypatch.setattr(archive_performance, "run_git_command", fail_external_work) + monkeypatch.setattr(archive_performance, "run_safe_command", fail_external_work) + + with pytest.raises(RuntimeError, match="CPU model is unavailable"): + generate_worktree_report( + output=output, + config=GenerationConfig( + repo_root=tmp_path, + current_tag="v0.4.4", + baseline_tag="v0.4.3", + worktree_ref="HEAD", + ), + ) + + assert not output.exists() + + @pytest.mark.parametrize("alias", ["artifact-csv", "current-report"]) def test_main_rerender_rejects_output_alias_without_mutation( tmp_path: Path, @@ -1376,7 +1502,14 @@ def fake_run_safe(command: str, args: Sequence[str], cwd: Path | None = None, ** captured = capsys.readouterr() assert rc == 0 - assert captured.err == "" + assert captured.err.splitlines() == [ + "[performance] validating benchmark inputs in baseline-worktree", + "[performance] running all baseline benchmarks for v0.4.2", + "[performance] completed all baseline benchmarks for v0.4.2", + "[performance] validating benchmark inputs in worktree", + "[performance] running current all benchmarks", + "[performance] completed current all benchmarks", + ] assert current.read_text(encoding="utf-8") == _retained_report(tmp_path) assert not any(kind == "gh" for kind, _, _ in calls) assert any(kind == "just" and args == ("bench-save-baseline", "v0.4.2") for kind, args, _ in calls) @@ -1880,7 +2013,7 @@ def fake_run_git(args: Sequence[str], cwd: Path | None = None, **kwargs: Any) -> worktree = Path(args[3]) worktree.mkdir(parents=True) _write_current_benchmark_tooling(worktree) - if args[:3] == ["diff", "--cached", "--binary"]: + if args[:2] == ["diff", "--binary"]: output_arg = next(arg for arg in args if arg.startswith("--output=")) Path(output_arg.removeprefix("--output=")).write_bytes(b"diff --git a/README.md b/README.md\n") return _result() diff --git a/scripts/tests/test_bench_compare.py b/scripts/tests/test_bench_compare.py index 8929096..7bf84be 100644 --- a/scripts/tests/test_bench_compare.py +++ b/scripts/tests/test_bench_compare.py @@ -1,7 +1,5 @@ """Tests for exact-arithmetic benchmark comparison reports.""" -from __future__ import annotations - import json import re import subprocess @@ -87,6 +85,7 @@ def _schema2_provenance_data() -> dict[str, object]: "suite": "all", }, "measurement": { + "baseline_api_compatibility": "la_stack_v0_4_3_api", "baseline_commit": "baseline-commit", "baseline_git_clean": False, "baseline_source_state_sha256": "d" * 64, @@ -828,6 +827,9 @@ def test_read_schema2_provenance_records_versions_dirty_source_and_both_gates(tm scope="release-signal", baseline_api_compatibility="la_stack_v0_4_3_api", ) + assert provenance.measurement is not None + with pytest.raises(TypeError): + cast("dict[str, object]", provenance.measurement)["status"] = "unavailable" def test_historical_asset_provenance_uses_mode_appropriate_gate_wording(tmp_path: Path) -> None: @@ -861,6 +863,20 @@ def test_read_schema2_provenance_requires_criterion_version(tmp_path: Path) -> N _read_harness_provenance(tmp_path) +def test_read_schema2_provenance_rejects_recorded_measurement_without_cpu_model(tmp_path: Path) -> None: + data = _schema2_provenance_data() + measurement = data["measurement"] + publication = data["publication"] + assert isinstance(measurement, dict) + assert isinstance(publication, dict) + cast("dict[str, object]", measurement)["cpu"] = "unavailable" + cast("dict[str, object]", publication)["cpu"] = "unavailable" + (tmp_path / ".la-stack-benchmark-harness.json").write_text(json.dumps(data), encoding="utf-8") + + with pytest.raises(ValueError, match="requires an identified CPU model"): + _read_harness_provenance(tmp_path) + + @pytest.mark.parametrize( ("field", "value"), [ @@ -890,10 +906,30 @@ 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"valid only for baseline 'v0\.4\.3'"): + with pytest.raises(ValueError, match=r"must be 'none' for baseline 'v0\.4\.4'"): _read_harness_provenance(tmp_path, baseline="v0.4.4") +def test_read_schema2_provenance_rejects_mode_status_contradiction(tmp_path: Path) -> None: + data = _schema2_provenance_data() + data["mode"] = "historical-assets" + (tmp_path / ".la-stack-benchmark-harness.json").write_text(json.dumps(data), encoding="utf-8") + + with pytest.raises(ValueError, match="requires shared-current-harness mode"): + _read_harness_provenance(tmp_path) + + +def test_read_schema2_provenance_rejects_revision_contradiction(tmp_path: Path) -> None: + data = _schema2_provenance_data() + publication = data["publication"] + assert isinstance(publication, dict) + cast("dict[str, object]", publication)["commit"] = "different-commit" + (tmp_path / ".la-stack-benchmark-harness.json").write_text(json.dumps(data), encoding="utf-8") + + with pytest.raises(ValueError, match=r"publication\.commit.*validation\.current_commit"): + _read_harness_provenance(tmp_path) + + def test_read_harness_provenance_rejects_different_requested_baseline(tmp_path: Path) -> None: _write_harness_provenance(tmp_path, baseline="v0.4.3") @@ -975,6 +1011,15 @@ def test_main_no_criterion_dir(tmp_path: Path, capsys: pytest.CaptureFixture[str assert "No Criterion results" in capsys.readouterr().err +def test_repo_root_resolution_uses_working_checkout_for_installed_entrypoint(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + nested = tmp_path / "nested" + nested.mkdir() + (tmp_path / "Cargo.toml").write_text("[package]\n", encoding="utf-8") + monkeypatch.chdir(nested) + + assert bench_compare._repo_root() == tmp_path + + def test_main_comparison_no_baseline(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: criterion_dir = tmp_path / "criterion" _build_criterion_tree(criterion_dir) @@ -1112,6 +1157,73 @@ def test_main_rejects_invalid_artifact_option_combinations( assert not provenance_output.exists() +def test_markdown_failure_rolls_back_release_artifact_pair(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + paths = bench_compare.ArtifactPaths( + csv=tmp_path / "performance.csv", + provenance=tmp_path / "performance.provenance.json", + ) + output = tmp_path / "performance.md" + paths.csv.write_bytes(b"old csv\n") + paths.provenance.write_bytes(b"old provenance\n") + output.write_text("old markdown\n", encoding="utf-8") + timing = bench_compare.TimingEstimate(median_ns=10.0, ci_lower_ns=9.0, ci_upper_ns=11.0) + bundle = bench_compare.PerformanceBundle( + context=bench_compare.ArtifactContext( + release=bench_compare.ReleasePair(current="v0.4.4", baseline="v0.4.3"), + statistic="median", + suite="all", + scope="release-signal", + source=bench_compare.ReportSource( + version="0.4.4", + commit="current-commit", + ref="HEAD", + revision_timestamp="2026-08-04 12:00:00 UTC", + ), + benchmark_provenance=_schema2_provenance_data(), + ), + rows=( + bench_compare.PerformanceRow( + suite="exact", + scope="release-signal", + benchmark_id="exact_d2/det_exact", + group="exact_d2", + benchmark="det_exact", + baseline_benchmark="det_exact", + coverage_status="comparable", + coverage_note="", + baseline=timing, + current=timing, + ), + ), + ) + monkeypatch.setattr(bench_compare, "_release_artifact_bundle", lambda **_kwargs: bundle) + + def fail_markdown(_path: Path, _text: str) -> None: + msg = "simulated Markdown publication failure" + raise OSError(msg) + + monkeypatch.setattr(bench_compare, "_write_text_atomic", fail_markdown) + + with pytest.raises(OSError, match="simulated Markdown publication failure"): + bench_compare._write_and_render_artifacts( + paths, + output_path=output, + root=tmp_path, + criterion_dir=tmp_path, + settings=bench_compare.ReportSettings( + baseline_name="v0.4.3", + stat="median", + suite="all", + scope="release-signal", + ), + collection=bench_compare.ComparisonCollection(comparisons=[], gaps=[]), + ) + + assert paths.csv.read_bytes() == b"old csv\n" + assert paths.provenance.read_bytes() == b"old provenance\n" + assert output.read_text(encoding="utf-8") == "old markdown\n" + + def test_main_v043_comparison_allows_only_unavailable_balanced_baselines( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, diff --git a/scripts/tests/test_check_docs_version_sync.py b/scripts/tests/test_check_docs_version_sync.py index d3d9724..efb9137 100644 --- a/scripts/tests/test_check_docs_version_sync.py +++ b/scripts/tests/test_check_docs_version_sync.py @@ -1,7 +1,5 @@ """Tests for documentation and package-version synchronization checks.""" -from __future__ import annotations - from typing import TYPE_CHECKING import pytest @@ -213,3 +211,25 @@ def test_find_version_mismatches_rejects_malformed_citation_version(tmp_path: Pa with pytest.raises(TypeError, match=r"CITATION\.cff:2: top-level version"): check_docs_version_sync.find_version_mismatches(tmp_path) + + +def test_main_supports_help(capsys: pytest.CaptureFixture[str]) -> None: + with pytest.raises(SystemExit, match="0"): + check_docs_version_sync.main(["--help"]) + + assert "Repository root to check" in capsys.readouterr().out + + +def test_main_rejects_extra_positional_arguments() -> None: + with pytest.raises(SystemExit, match="2"): + check_docs_version_sync.main(["one", "two"]) + + +def test_release_date_must_match_generated_changelog(tmp_path: Path) -> None: + _write_project(tmp_path) + citation = tmp_path / "CITATION.cff" + citation.write_text(citation.read_text(encoding="utf-8") + "date-released: 2026-07-12\n", encoding="utf-8") + (tmp_path / "CHANGELOG.md").write_text("# Changelog\n\n## [1.2.3] - 2026-07-13\n", encoding="utf-8") + + with pytest.raises(TypeError, match="release date mismatch"): + check_docs_version_sync.find_version_mismatches(tmp_path) diff --git a/scripts/tests/test_check_semgrep_fixtures.py b/scripts/tests/test_check_semgrep_fixtures.py index 211aaca..823c2b4 100644 --- a/scripts/tests/test_check_semgrep_fixtures.py +++ b/scripts/tests/test_check_semgrep_fixtures.py @@ -1,7 +1,5 @@ """Tests for Semgrep fixture-annotation validation.""" -from __future__ import annotations - import json from typing import TYPE_CHECKING @@ -13,10 +11,14 @@ import pytest +def _result(check_id: str, line: int, end_line: int | None = None) -> dict[str, object]: + return {"check_id": check_id, "start": {"line": line}, "end": {"line": line if end_line is None else end_line}} + + def test_semgrep_results_parses_valid_result_objects(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv( "SEMGREP_JSON", - json.dumps({"results": [{"check_id": "rust.foo"}, {"check_id": "rust.bar"}]}), + json.dumps({"results": [_result("rust.foo", 2), _result("rust.bar", 4)]}), ) results = check_semgrep_fixtures._semgrep_results() @@ -26,7 +28,7 @@ def test_semgrep_results_parses_valid_result_objects(monkeypatch: pytest.MonkeyP def test_semgrep_results_rejects_malformed_result_objects(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: - monkeypatch.setenv("SEMGREP_JSON", json.dumps({"results": [{"check_id": "rust.foo"}, "bad"]})) + monkeypatch.setenv("SEMGREP_JSON", json.dumps({"results": [_result("rust.foo", 2), "bad"]})) results = check_semgrep_fixtures._semgrep_results() @@ -42,7 +44,7 @@ def test_main_accepts_matching_annotations(monkeypatch: pytest.MonkeyPatch, tmp_ ) monkeypatch.setenv( "SEMGREP_JSON", - json.dumps({"results": [{"check_id": "rust.foo"}, {"check_id": "rust.bar"}, {"check_id": "rust.foo"}]}), + json.dumps({"results": [_result("rust.foo", 2), _result("rust.bar", 2), _result("rust.foo", 3)]}), ) monkeypatch.setattr(check_semgrep_fixtures.sys, "argv", ["check_semgrep_fixtures.py", str(fixture)]) @@ -63,4 +65,56 @@ def test_main_reports_missing_check_id(monkeypatch: pytest.MonkeyPatch, tmp_path rc = check_semgrep_fixtures.main() assert rc == 1 - assert "missing string field 'check_id'" in capsys.readouterr().err + captured = capsys.readouterr() + assert "missing string field 'check_id'" in captured.err + assert "missing positive integer field 'start.line'" in captured.err + assert "missing positive integer field 'end.line'" in captured.err + + +def test_main_rejects_findings_at_wrong_lines_even_when_rule_counts_match( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + fixture = tmp_path / "fixture.rs" + fixture.write_text("// ruleid: rust.foo\nbad_one();\n// ruleid: rust.foo\nbad_two();\n", encoding="utf-8") + monkeypatch.setenv( + "SEMGREP_JSON", + json.dumps({"results": [_result("rust.foo", 2), _result("rust.foo", 5)]}), + ) + monkeypatch.setattr(check_semgrep_fixtures.sys, "argv", ["check_semgrep_fixtures.py", str(fixture)]) + + rc = check_semgrep_fixtures.main() + + assert rc == 1 + captured = capsys.readouterr() + assert captured.out == "" + assert "rust.foo at line 4: expected finding not reported" in captured.err + assert "rust.foo at lines 5: unexpected finding" in captured.err + + +def test_main_matches_overlapping_spans_by_earliest_end_line( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + fixture = tmp_path / "fixture.rs" + fixture.write_text("// ruleid: rust.foo\nbad_one();\n// ruleid: rust.foo\nbad_two();\n", encoding="utf-8") + monkeypatch.setenv( + "SEMGREP_JSON", + json.dumps({"results": [_result("rust.foo", 2, 4), _result("rust.foo", 2)]}), + ) + monkeypatch.setattr(check_semgrep_fixtures.sys, "argv", ["check_semgrep_fixtures.py", str(fixture)]) + + assert check_semgrep_fixtures.main() == 0 + + +def test_main_matches_markdown_finding_after_blank_line_and_code_fence( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + fixture = tmp_path / "fixture.md" + fixture.write_text("\n\n```bash\nbad-command\n```\n", encoding="utf-8") + monkeypatch.setenv("SEMGREP_JSON", json.dumps({"results": [_result("docs.foo", 4)]})) + monkeypatch.setattr(check_semgrep_fixtures.sys, "argv", ["check_semgrep_fixtures.py", str(fixture)]) + + assert check_semgrep_fixtures.main() == 0 diff --git a/scripts/tests/test_criterion_dim_plot.py b/scripts/tests/test_criterion_dim_plot.py index 6ac5758..fbfc8a5 100644 --- a/scripts/tests/test_criterion_dim_plot.py +++ b/scripts/tests/test_criterion_dim_plot.py @@ -1,7 +1,5 @@ """Tests for Criterion dimension-report generation and README updates.""" -from __future__ import annotations - import argparse import json import re @@ -1335,3 +1333,34 @@ def fail_replacement_and_rollback(source: Path, destination: Path) -> Path: assert (backup_dir / "backup-0").read_text(encoding="utf-8") == "old one\n" assert destination_one.read_text(encoding="utf-8") == "new one\n" assert destination_two.read_text(encoding="utf-8") == "old two\n" + + +def test_repo_root_resolution_uses_working_checkout_for_installed_entrypoint(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + nested = tmp_path / "nested" + nested.mkdir() + (tmp_path / "Cargo.toml").write_text("[package]\n", encoding="utf-8") + monkeypatch.chdir(nested) + + assert criterion_dim_plot._repo_root() == tmp_path + + +def test_publication_paths_reject_output_aliases(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + output = tmp_path / "benchmark.csv" + args = criterion_dim_plot.PlotCliArgs( + metric="lu_solve", + stat="median", + sample="new", + criterion_dir="target/criterion", + out=str(output), + csv=str(output), + log_y=False, + no_plot=False, + update_readme=False, + readme="README.md", + allow_partial=False, + ) + + rc = criterion_dim_plot._validate_publication_paths(tmp_path, args, out_svg=output, out_csv=output) + + assert rc == 2 + assert "must use distinct paths" in capsys.readouterr().err diff --git a/scripts/tests/test_performance_artifacts.py b/scripts/tests/test_performance_artifacts.py index 9113ca6..158c108 100644 --- a/scripts/tests/test_performance_artifacts.py +++ b/scripts/tests/test_performance_artifacts.py @@ -5,6 +5,8 @@ import io import json from pathlib import Path +from types import MappingProxyType +from typing import cast import pytest @@ -34,6 +36,7 @@ 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" return ArtifactContext( release=ReleasePair(current=current, baseline=baseline), statistic="median", @@ -57,7 +60,7 @@ def _context(*, current: str = "v0.4.4", baseline: str = "v0.4.3") -> ArtifactCo "suite": "exact", }, "measurement": { - "baseline_api_compatibility": "none", + "baseline_api_compatibility": compatibility, "baseline_commit": "def5678", "baseline_git_clean": True, "baseline_source_state_sha256": "d" * 64, @@ -85,7 +88,7 @@ def _context(*, current: str = "v0.4.4", baseline: str = "v0.4.3") -> ArtifactCo }, "schema": 2, "validation": { - "baseline_api_compatibility": "none", + "baseline_api_compatibility": compatibility, "baseline_commit": "def5678", "baseline_git_clean": True, "baseline_revision": "passed", @@ -290,6 +293,20 @@ def test_artifact_loader_rejects_contradictory_current_revision() -> None: ) +def test_artifact_loader_rejects_recorded_measurement_without_cpu_model() -> None: + csv_payload, provenance_payload = serialize_bundle(_bundle()) + provenance = json.loads(provenance_payload) + provenance["benchmark_provenance"]["measurement"]["cpu"] = "unavailable" + provenance["benchmark_provenance"]["publication"]["cpu"] = "unavailable" + + with pytest.raises(ValueError, match="requires an identified CPU model"): + load_bundle_bytes( + csv_payload, + (json.dumps(provenance) + "\n").encode(), + source="unidentified CPU fixture", + ) + + def test_bundle_rejects_duplicate_benchmark_keys() -> None: bundle = _bundle() @@ -303,6 +320,54 @@ def test_timing_rejects_non_positive_or_non_finite_values(value: float) -> None: TimingEstimate(median_ns=value, ci_lower_ns=1.0, ci_upper_ns=2.0) +def test_timing_accepts_ordered_bootstrap_interval_that_excludes_point_estimate() -> None: + estimate = TimingEstimate(median_ns=12.0, ci_lower_ns=9.0, ci_upper_ns=11.0) + + assert estimate.median_ns == 12.0 + + +def test_timing_rejects_reversed_interval() -> None: + with pytest.raises(ValueError, match="confidence interval must be ordered"): + TimingEstimate(median_ns=10.0, ci_lower_ns=11.0, ci_upper_ns=9.0) + + +def test_artifact_context_freezes_nested_provenance() -> None: + context = _context() + + with pytest.raises(TypeError): + cast("dict[str, object]", context.benchmark_provenance)["schema"] = 3 + + criterion = context.benchmark_provenance["criterion"] + assert isinstance(criterion, MappingProxyType) + + +def test_artifact_loader_rejects_mode_measurement_contradiction() -> None: + csv_payload, provenance_payload = serialize_bundle(_bundle()) + provenance = json.loads(provenance_payload) + provenance["benchmark_provenance"]["mode"] = "historical-assets" + + with pytest.raises(ValueError, match="requires shared-current-harness mode"): + load_bundle_bytes(csv_payload, (json.dumps(provenance) + "\n").encode(), source="contradictory mode fixture") + + +def test_artifact_loader_rejects_arbitrary_compatibility_adapter() -> None: + csv_payload, provenance_payload = serialize_bundle(_bundle()) + provenance = json.loads(provenance_payload) + provenance["benchmark_provenance"]["validation"]["baseline_api_compatibility"] = "custom-adapter" + + with pytest.raises(ValueError, match="baseline_api_compatibility must be"): + load_bundle_bytes(csv_payload, (json.dumps(provenance) + "\n").encode(), source="invalid compatibility fixture") + + +def test_artifact_loader_requires_recorded_measurement_compatibility() -> None: + csv_payload, provenance_payload = serialize_bundle(_bundle()) + provenance = json.loads(provenance_payload) + del provenance["benchmark_provenance"]["measurement"]["baseline_api_compatibility"] + + with pytest.raises(ValueError, match=r"measurement\.baseline_api_compatibility"): + load_bundle_bytes(csv_payload, (json.dumps(provenance) + "\n").encode(), source="missing compatibility fixture") + + def test_artifact_loader_fails_closed_on_partial_pair(tmp_path: Path) -> None: paths = ArtifactPaths( csv=tmp_path / "performance.csv", diff --git a/scripts/tests/test_postprocess_changelog.py b/scripts/tests/test_postprocess_changelog.py index 8761aff..cce593a 100644 --- a/scripts/tests/test_postprocess_changelog.py +++ b/scripts/tests/test_postprocess_changelog.py @@ -1,7 +1,5 @@ """Tests for postprocess_changelog.py — trailing blanks, reflow, code blocks, summaries.""" -from __future__ import annotations - from typing import TYPE_CHECKING from postprocess_changelog import ( @@ -918,6 +916,14 @@ def test_adds_blank_after_code_block_before_prose(self, tmp_path: Path) -> None: class TestIntegration: + def test_full_pipeline_is_idempotent(self) -> None: + content = "# Changelog\n\n## [1.0.0] - 2026-01-01\n\n### Fixed\n\n- fixed: preserve the generated body\n\n - Historical detail.\n" + + once = postprocess_text(content) + + assert "\n - Historical detail.\n" in once + assert postprocess_text(once) == once + def test_full_changelog_reflow(self, tmp_path: Path) -> None: """Simulate a realistic changelog snippet with long lines.""" long_entry = ( diff --git a/scripts/tests/test_subprocess_utils.py b/scripts/tests/test_subprocess_utils.py index 5c7d501..8b41ef8 100644 --- a/scripts/tests/test_subprocess_utils.py +++ b/scripts/tests/test_subprocess_utils.py @@ -1,18 +1,18 @@ """Tests for subprocess_utils.py — secure subprocess wrappers.""" -from __future__ import annotations - -from typing import BinaryIO, cast +from typing import TYPE_CHECKING, BinaryIO, cast from unittest.mock import MagicMock, patch import pytest import subprocess_utils from subprocess_utils import ( + DEFAULT_COMMAND_TIMEOUT_SECONDS, ExecutableNotFoundError, _build_run_kwargs, check_git_history, check_git_repo, + cpu_description, find_project_root, get_git_commit_hash, get_git_remote_url, @@ -23,6 +23,9 @@ run_safe_command, ) +if TYPE_CHECKING: + from pathlib import Path + # --------------------------------------------------------------------------- # get_safe_executable # --------------------------------------------------------------------------- @@ -50,6 +53,7 @@ def test_defaults(self) -> None: assert kwargs["text"] is True assert kwargs["check"] is True assert kwargs["encoding"] == "utf-8" + assert kwargs["timeout"] == DEFAULT_COMMAND_TIMEOUT_SECONDS def test_rejects_shell_true(self) -> None: with pytest.raises(ValueError, match="shell=True is not allowed"): @@ -72,6 +76,10 @@ def test_respects_custom_encoding(self) -> None: kwargs = _build_run_kwargs("test_func", encoding="latin-1") assert kwargs["encoding"] == "latin-1" + def test_respects_custom_timeout(self) -> None: + kwargs = _build_run_kwargs("test_func", timeout=12.5) + assert kwargs["timeout"] == 12.5 + # --------------------------------------------------------------------------- # run_git_command @@ -147,6 +155,21 @@ def capture_run(*_args: object, **kwargs: object) -> subprocess_utils.subprocess class TestAdditionalHelpers: + def test_cpu_description_uses_macos_brand_and_architecture(self) -> None: + result = subprocess_utils.subprocess.CompletedProcess( + args=["sysctl"], + returncode=0, + stdout="Apple M4 Pro\n", + stderr="", + ) + with ( + patch("subprocess_utils.platform.system", return_value="Darwin"), + patch("subprocess_utils.platform.machine", return_value="arm64"), + patch("subprocess_utils.platform.processor", return_value="arm"), + patch("subprocess_utils.run_safe_command", return_value=result), + ): + assert cpu_description() == "Apple M4 Pro (arm64)" + def test_run_cargo_command_uses_safe_executable(self) -> None: with ( patch("subprocess_utils.get_safe_executable", return_value="/usr/bin/cargo") as mock_executable, @@ -195,3 +218,11 @@ def fake_run_git(args: list[str], **_kwargs: object) -> subprocess_utils.subproc def test_find_project_root(self) -> None: assert (find_project_root() / "Cargo.toml").is_file() + + def test_find_project_root_accepts_a_nested_start(self, tmp_path: Path) -> None: + root = tmp_path / "checkout" + nested = root / "target" / "wheel" + nested.mkdir(parents=True) + (root / "Cargo.toml").write_text("[package]\n", encoding="utf-8") + + assert find_project_root(nested) == root diff --git a/scripts/tests/test_tag_release.py b/scripts/tests/test_tag_release.py index fef6224..f672058 100644 --- a/scripts/tests/test_tag_release.py +++ b/scripts/tests/test_tag_release.py @@ -1,7 +1,5 @@ """Tests for tag_release.py — annotated tag creation with size-limit handling.""" -from __future__ import annotations - from typing import TYPE_CHECKING from unittest.mock import MagicMock, patch @@ -227,6 +225,10 @@ def test_oversized_section_detected(self, tmp_path: Path) -> None: class TestCreateTag: + @pytest.fixture(autouse=True) + def _matching_package_version(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(tag_release, "_package_version", lambda _changelog: "1.0.0") + def test_next_step_sets_release_title( self, tmp_path: Path, @@ -385,6 +387,19 @@ def test_invalid_remote_does_not_replace_existing_tag( assert marker not in output.out assert marker not in output.err + def test_rejects_tag_that_differs_from_cargo_before_git(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + changelog = tmp_path / "CHANGELOG.md" + changelog.write_text(_SAMPLE_CHANGELOG, encoding="utf-8") + mock_exists = MagicMock() + monkeypatch.setattr(tag_release, "find_changelog", lambda: changelog) + monkeypatch.setattr(tag_release, "_package_version", lambda _changelog: "1.0.1") + monkeypatch.setattr(tag_release, "_tag_exists", mock_exists) + + with pytest.raises(ValueError, match="does not match Cargo package version"): + tag_release.create_tag("v1.0.0") + + mock_exists.assert_not_called() + class TestRepoUrl: @pytest.mark.parametrize( diff --git a/src/lib.rs b/src/lib.rs index ba5ab44..713f55d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,6 @@ #![forbid(unsafe_code)] #![deny(missing_docs)] +#![cfg_attr(docsrs, feature(doc_cfg))] #![doc = include_str!("../README.md")] #[cfg(doc)] @@ -205,12 +206,16 @@ mod tolerance; mod vector; #[cfg(feature = "exact")] +#[cfg_attr(docsrs, doc(cfg(feature = "exact")))] pub use exact::{DeterminantSign, ExactF64Conversion}; #[cfg(feature = "exact")] +#[cfg_attr(docsrs, doc(cfg(feature = "exact")))] pub use num_bigint::BigInt; #[cfg(feature = "exact")] +#[cfg_attr(docsrs, doc(cfg(feature = "exact")))] pub use num_rational::BigRational; #[cfg(feature = "exact")] +#[cfg_attr(docsrs, doc(cfg(feature = "exact")))] pub use num_traits::{FromPrimitive, Signed, ToPrimitive}; // --------------------------------------------------------------------------- @@ -485,6 +490,7 @@ pub mod prelude { }; #[cfg(feature = "exact")] + #[cfg_attr(docsrs, doc(cfg(feature = "exact")))] pub use crate::{ BigInt, BigRational, DeterminantSign, ExactF64Conversion, FromPrimitive, Signed, ToPrimitive, diff --git a/src/matrix.rs b/src/matrix.rs index a6665ac..de97171 100644 --- a/src/matrix.rs +++ b/src/matrix.rs @@ -1335,7 +1335,15 @@ impl Matrix { ) -> Result, LaError> { let bound = match self.det_errbound_from_arithmetic(det) { Ok(Some(bound)) => bound, - Ok(None) => return Ok(None), + Ok(None) => { + if !det.value.is_finite() { + cold_path(); + return Err(LaError::non_finite_computation_scalar( + ArithmeticOperation::Determinant, + )); + } + return Ok(None); + } Err(error) => return Err(error), }; if !det.value.is_finite() { diff --git a/tests/common/proptest_config.rs b/tests/common/proptest_config.rs new file mode 100644 index 0000000..94822eb --- /dev/null +++ b/tests/common/proptest_config.rs @@ -0,0 +1,16 @@ +#![forbid(unsafe_code)] + +use proptest::test_runner::Config as ProptestConfig; + +/// Preserve a suite-specific local default while honoring `PROPTEST_CASES`. +pub(crate) fn with_default_cases(default_cases: u32) -> ProptestConfig { + let config = ProptestConfig::default(); + if std::env::var_os("PROPTEST_CASES").is_some() { + config + } else { + ProptestConfig { + cases: default_cases, + ..config + } + } +} diff --git a/tests/prelude_exports.rs b/tests/prelude_exports.rs index e9c233c..b936443 100644 --- a/tests/prelude_exports.rs +++ b/tests/prelude_exports.rs @@ -16,11 +16,12 @@ fn common_prelude_supports_downstream_composition() -> Result<(), LaError> { let matrix = Matrix::<2>::identity(); let vector = Vector::<2>::try_new([1.0, 2.0])?; let tolerance = Tolerance::try_new(0.0)?; - let estimate: Option = matrix.det_direct_with_errbound()?; - if let Some(estimate) = estimate { - assert_abs_diff_eq!(estimate.determinant(), 1.0, epsilon = 0.0); - assert!(estimate.absolute_error_bound() >= 0.0); - } + let Some(estimate): Option = matrix.det_direct_with_errbound()? + else { + panic!("the 2x2 identity must have a certified direct determinant bound"); + }; + assert_abs_diff_eq!(estimate.determinant(), 1.0, epsilon = 0.0); + assert!(estimate.absolute_error_bound() >= 0.0); let lu: Lu<2> = matrix.lu(tolerance)?; let ldlt: Ldlt<2> = matrix.ldlt(tolerance)?; diff --git a/tests/proptest_exact.rs b/tests/proptest_exact.rs index 1cd72e8..f56e51c 100644 --- a/tests/proptest_exact.rs +++ b/tests/proptest_exact.rs @@ -23,6 +23,10 @@ use proptest::{array, prelude::*}; use la_stack::prelude::*; +#[path = "common/proptest_config.rs"] +mod proptest_config; +use proptest_config::with_default_cases; + fn small_nonzero_f64() -> impl Strategy { prop_oneof![(-1000i16..=-1i16), (1i16..=1000i16)].prop_map(|x| f64::from(x) / 10.0) } @@ -319,7 +323,7 @@ macro_rules! gen_det_sign_exact_proptests { ($d:literal) => { paste! { proptest! { - #![proptest_config(ProptestConfig::with_cases(64))] + #![proptest_config(with_default_cases(64))] #[test] fn []( @@ -375,7 +379,7 @@ macro_rules! gen_solve_exact_roundtrip_proptests { ($d:literal) => { paste! { proptest! { - #![proptest_config(ProptestConfig::with_cases(64))] + #![proptest_config(with_default_cases(64))] #[test] fn []( @@ -428,7 +432,7 @@ macro_rules! gen_solve_exact_residual_proptests { ($d:literal) => { paste! { proptest! { - #![proptest_config(ProptestConfig::with_cases(32))] + #![proptest_config(with_default_cases(32))] #[test] fn []( @@ -471,7 +475,7 @@ macro_rules! gen_solve_exact_mixed_exponent_residual_proptests { ($d:literal) => { paste! { proptest! { - #![proptest_config(ProptestConfig::with_cases(24))] + #![proptest_config(with_default_cases(24))] #[test] fn []( @@ -511,7 +515,7 @@ macro_rules! gen_det_exact_and_sign_leibniz_oracle_proptests { ($d:literal) => { paste! { proptest! { - #![proptest_config(ProptestConfig::with_cases(64))] + #![proptest_config(with_default_cases(64))] #[test] fn []( @@ -545,7 +549,7 @@ macro_rules! gen_det_sign_fast_filter_boundary_proptests { ($d:literal) => { paste! { proptest! { - #![proptest_config(ProptestConfig::with_cases(64))] + #![proptest_config(with_default_cases(64))] #[test] fn []( @@ -597,7 +601,7 @@ macro_rules! gen_det_errbound_leibniz_oracle_proptests { ($d:literal) => { paste! { proptest! { - #![proptest_config(ProptestConfig::with_cases(64))] + #![proptest_config(with_default_cases(64))] #[test] fn []( @@ -613,19 +617,24 @@ macro_rules! gen_det_errbound_leibniz_oracle_proptests { .unwrap() .expect("D<=4 has closed-form det_direct"); let exact = big_rational_det_leibniz::<$d>(&entries); - if let Some(bound) = m.det_errbound().unwrap() { - 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 - ); - } + let bound = m.det_errbound().unwrap(); + prop_assert!( + bound.is_some(), + "bounded dense corpus unexpectedly produced no determinant bound for D={}", + $d, + ); + let bound = bound.expect("the preceding property assertion rejects None"); + 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 + ); } } } @@ -646,7 +655,7 @@ macro_rules! gen_extreme_exponent_det_filter_proptests { ($d:literal) => { paste! { proptest! { - #![proptest_config(ProptestConfig::with_cases(32))] + #![proptest_config(with_default_cases(32))] #[test] fn []( @@ -695,7 +704,7 @@ macro_rules! gen_mixed_scale_diagonal_exact_det_proptests { ($d:literal) => { paste! { proptest! { - #![proptest_config(ProptestConfig::with_cases(32))] + #![proptest_config(with_default_cases(32))] #[test] fn []( diff --git a/tests/proptest_factorizations.rs b/tests/proptest_factorizations.rs index 955f8f6..aac6c5f 100644 --- a/tests/proptest_factorizations.rs +++ b/tests/proptest_factorizations.rs @@ -11,6 +11,10 @@ use proptest::{array, prelude::*}; use la_stack::prelude::*; +#[path = "common/proptest_config.rs"] +mod proptest_config; +use proptest_config::with_default_cases; + fn small_f64() -> impl Strategy { (-1000i16..=1000i16).prop_map(|x| f64::from(x) / 10.0) } @@ -34,7 +38,7 @@ macro_rules! gen_factorization_proptests { ($d:literal) => { paste! { proptest! { - #![proptest_config(ProptestConfig::with_cases(64))] + #![proptest_config(with_default_cases(64))] #[test] fn []( diff --git a/tests/proptest_matrix.rs b/tests/proptest_matrix.rs index f14b72a..7f4aa03 100644 --- a/tests/proptest_matrix.rs +++ b/tests/proptest_matrix.rs @@ -8,6 +8,10 @@ use proptest::{array, prelude::*}; use la_stack::prelude::*; +#[path = "common/proptest_config.rs"] +mod proptest_config; +use proptest_config::with_default_cases; + fn small_f64() -> impl Strategy { (-1000i16..=1000i16).prop_map(|x| f64::from(x) / 10.0) } @@ -20,7 +24,7 @@ macro_rules! gen_matrix_proptests { ($d:literal) => { paste! { proptest! { - #![proptest_config(ProptestConfig::with_cases(64))] + #![proptest_config(with_default_cases(64))] #[test] fn []( diff --git a/tests/proptest_vector.rs b/tests/proptest_vector.rs index 29db94a..2bb84b2 100644 --- a/tests/proptest_vector.rs +++ b/tests/proptest_vector.rs @@ -8,6 +8,10 @@ use proptest::{array, prelude::*}; use la_stack::prelude::*; +#[path = "common/proptest_config.rs"] +mod proptest_config; +use proptest_config::with_default_cases; + fn small_f64() -> impl Strategy { (-1000i16..=1000i16).prop_map(|x| f64::from(x) / 10.0) } @@ -16,7 +20,7 @@ macro_rules! gen_vector_proptests { ($d:literal) => { paste! { proptest! { - #![proptest_config(ProptestConfig::with_cases(64))] + #![proptest_config(with_default_cases(64))] #[test] fn []( diff --git a/tests/regressions.rs b/tests/regressions.rs index f4d4714..f4ea74c 100644 --- a/tests/regressions.rs +++ b/tests/regressions.rs @@ -138,3 +138,15 @@ fn det_errbound_skips_zero_coefficient_terms_that_would_overflow() -> Result<(), Ok(()) } + +#[test] +fn det_errbound_reports_overflow_even_when_another_term_underflows() -> Result<(), LaError> { + let matrix = Matrix::<2>::try_from_rows([[1.0e308, 1.0e-308], [1.0e-308, 2.0]])?; + let expected = LaError::non_finite_computation_scalar(ArithmeticOperation::Determinant); + + assert_eq!(matrix.det_direct(), Err(expected)); + assert_eq!(matrix.det_direct_with_errbound(), Err(expected)); + assert_eq!(matrix.det_errbound(), Err(expected)); + + Ok(()) +}