From 7e845d12c7ad4a9d4dffb01740b5016098aa09ef Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Fri, 21 Aug 2026 06:41:03 -0700 Subject: [PATCH] chore(tooling): tighten dependency-update tooling and diagnostics - add `cargo-update` to pinned tool setup and include it in `just update` maintenance flow - make archive, benchmark, and tag release scripts emit safer error handling with preserved sub-exception diagnostics - strengthen SemVer and ordering validation paths in tool scripts to fail fast with clearer messages - refresh contributor/release docs and exact-api docs for updated setup/update expectations - normalize and simplify exact-module test structure without changing runtime behavior --- CONTRIBUTING.md | 17 +++-- README.md | 19 +++-- docs/BENCHMARKING.md | 19 +++++ docs/RELEASING.md | 8 +++ docs/roadmap.md | 37 ++++++---- justfile | 8 +++ scripts/archive_changelog.py | 34 ++++++--- scripts/archive_performance.py | 28 ++++++-- scripts/bench_compare.py | 15 ++-- scripts/benchmark_contract.py | 1 + scripts/check_semgrep_fixtures.py | 10 +++ scripts/postprocess_changelog.py | 30 +++++++- scripts/subprocess_utils.py | 49 +++++++++++++ scripts/tag_release.py | 12 ++-- scripts/tests/test_archive_changelog.py | 36 ++++++++++ scripts/tests/test_archive_performance.py | 34 ++++++++- scripts/tests/test_bench_compare.py | 57 ++++++++++++++- scripts/tests/test_check_semgrep_fixtures.py | 14 ++++ scripts/tests/test_criterion_dim_plot.py | 30 +++++--- .../tests/test_justfile_discoverability.py | 8 +++ scripts/tests/test_postprocess_changelog.py | 44 +++++++++++- scripts/tests/test_subprocess_utils.py | 20 ++++++ scripts/tests/test_tag_release.py | 24 +++++++ scripts/tests/test_update_cargo_tool_pins.py | 9 +++ scripts/update_cargo_tool_pins.py | 8 ++- src/exact.rs | 70 +++++-------------- tests/proptest_vector.rs | 10 --- 27 files changed, 528 insertions(+), 123 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d3d5732..dd6f360 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,9 +6,11 @@ clarity, and the fixed-dimension stack-allocation model. ## Getting Started -Install Rust 1.98.0 through [rustup](https://rustup.rs/), Git, Python 3.14, -[`uv` 0.12.5](https://docs.astral.sh/uv/), and `jq`. Install the repository's -pinned `just` version from its locked dependency graph: +Install Rust 1.98.0 through [rustup](https://rustup.rs/), Git, the +[GitHub CLI](https://cli.github.com/), Python 3.14, +[`uv` 0.12.5](https://docs.astral.sh/uv/), and `jq`. Authenticate the GitHub +CLI for repository operations, then install the repository's pinned `just` +version from its locked dependency graph: ```bash cargo install --locked just --version 1.58.0 @@ -17,7 +19,7 @@ cargo install --locked just --version 1.58.0 Set up the remaining development tools and validate the checkout: ```bash -just setup # install or verify dev tools and sync Python dependencies +just setup # install or verify dev tools, sync Python dependencies, and build just check # lint and validate without changing files just ci # run the comprehensive local CI path ``` @@ -29,9 +31,10 @@ Use `just update` for deliberate dependency and tool maintenance. It composes `just update-dependencies`, which advances Cargo dependency requirements, exact Python development-tool pins, and the Cargo/uv locks, with `just update-cargo-tools`, which upgrades only the Cargo CLI packages owned by -`setup-tools` and atomically reconciles their root `justfile` pins. The tool -updater requires `cargo-install-update` from the `cargo-update` package and does -not touch unrelated Cargo executables or uv's user-global tool environments. +`setup-tools` and atomically reconciles their root `justfile` pins. `just setup` +installs and verifies the pinned `cargo-update` package that provides +`cargo-install-update`; the updater does not touch unrelated Cargo executables +or uv's user-global tool environments. The repository uses `cargo-nextest` for runnable Rust tests, `cargo-machete` for unused-dependency checks, and `just cargo-lock-check` to verify that the diff --git a/README.md b/README.md index dd7f90a..7cc77a7 100644 --- a/README.md +++ b/README.md @@ -286,6 +286,12 @@ values were rounded to `f64` before construction. - **`ExactF64Conversion`** — converts an existing exact determinant or solution under the strict or rounded contract without repeating exact elimination +Exact determinant value and conversion methods return +`LaError::DeterminantScaleOverflow` if the aggregate power-of-two scaling +exceeds the internal exponent representation. Exact solve methods return +`LaError::Singular` with `SingularityReason::Exact` when the stored matrix is +exactly singular. + For exact-to-f64 output, strict conversions use `UnrepresentableReason::RequiresRounding` when explicit rounding can produce a finite value and `UnrepresentableReason::NotFinite` otherwise. Rounded @@ -379,6 +385,8 @@ the conservative absolute error bound used by the fast filter, computed from one call that evaluates the determinant once and computes its matching bound. It returns `None` when a D ≤ 4 computation may be affected by gradual underflow, as well as for unsupported D ≥ 5 dimensions. +It returns `LaError::NonFinite` if the determinant or bound computation +overflows to NaN or infinity. This method does NOT require the `exact` feature — it uses pure f64 arithmetic and is available by default. Use `det_errbound()` when only the bound is needed. The paired API enables custom adaptive-precision logic for geometric predicates: @@ -460,8 +468,10 @@ Storage shown above reflects the intentional `f64` scalar model. For a runtime dimension from 0 through `MAX_STACK_MATRIX_DISPATCH_DIM` (7), `try_with_stack_matrix!` dispatches to a concrete `Matrix` while preserving -inline stack storage. Larger dimensions return `LaError::UnsupportedDimension`; -the macro does not introduce a dynamically sized matrix representation. +inline stack storage. Larger dimensions produce +`LaError::UnsupportedDimension`, converted through `From` into the +closure's declared `Result` error type; the macro does not introduce a +dynamically sized matrix representation. `Matrix` key methods: `as_rows`, `into_rows`, `lu`, `ldlt`, `det`, `det_direct`, `det_direct_with_errbound`, `det_errbound`, @@ -570,13 +580,14 @@ cargo run --features exact --example exact_solve_3x3 A short contributor workflow: -Install Rust 1.98.0 through [rustup](https://rustup.rs/), Git, Python 3.14, +Install Rust 1.98.0 through [rustup](https://rustup.rs/), Git, +[GitHub CLI](https://cli.github.com/), Python 3.14, [`uv` 0.12.5](https://docs.astral.sh/uv/), and `jq`. Then install the pinned `just` release from its locked dependency graph: ```bash cargo install --locked just --version 1.58.0 -just setup # install/verify dev tools + sync Python deps +just setup # install/verify dev tools + sync Python deps + build just check # lint/validate (non-mutating) just fix # apply auto-fixes (mutating) just ci # lint + tests + examples + bench compile diff --git a/docs/BENCHMARKING.md b/docs/BENCHMARKING.md index 08be44c..40ecf40 100644 --- a/docs/BENCHMARKING.md +++ b/docs/BENCHMARKING.md @@ -369,6 +369,25 @@ For experimental-design background on controlled repetitions and uncertainty, see [REFERENCES.md](../REFERENCES.md) \[13\]; these workflows do not claim to implement every recommendation in that study. +The harness calls native crate APIs where they expose the same operation. Where +a peer crate does not expose a matching convenience method, repository-owned +adapter code computes the agreed mathematical kernel inside the timed closure: + +| Metric family | la-stack implementation | nalgebra implementation | faer implementation | +|---------------|-------------------------|-------------------------|---------------------| +| LU factorization and solve rows | Native `Lu` APIs | Native `LU` APIs | Native partial-pivoting LU APIs | +| LDLT/Cholesky factorization and solve rows | Native `Ldlt` APIs | Native `Cholesky` APIs | Native LDLT APIs | +| `det_via_lu`, `det_from_lu` | Native `Lu::det` | Native `LU::determinant` | Harness adapter: product of the U diagonal and permutation sign | +| `det_from_ldlt` / `det_from_cholesky` | Native `Ldlt::det` | Native `Cholesky::determinant` | Harness adapter: product of the D diagonal | +| `dot` | Native `Vector::dot` | Native `dot` | Harness adapter: left-to-right fused multiply-add loop | +| `norm2_sq` | Native `Vector::norm2_sq` | Native `norm_squared` | Native `squared_norm_l2` | +| `inf_norm` | Native `Matrix::inf_norm` | Harness adapter: maximum absolute row sum | Harness adapter: maximum absolute row sum | + +These adapter timings are benchmark-kernel comparisons, not claims about the +speed of an identically named public convenience method in every crate. The +adapter implementation is versioned with the benchmark harness, included in the +benchmark-contract digest, and covered by the cross-crate input smoke tests. + All three crates receive equivalent deterministic inputs for a given dimension: - matrix entries come from the same strictly diagonally-dominant generator diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 80aed3f..e02d67c 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -29,6 +29,14 @@ git switch main git pull --ff-only ``` +Install or verify the pinned development tools before running maintenance +recipes. This includes the `cargo-update` package that provides +`cargo-install-update` for `just update`: + +```bash +just setup +``` + Refresh Cargo dependency requirements, exact Python development-tool pins, lockfiles, and repository-owned Cargo tool pins before creating the release branch: diff --git a/docs/roadmap.md b/docs/roadmap.md index 6bc3c7f..0e8260c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -141,20 +141,33 @@ the small fixed-dimension API model. primitive and `num-bigint` operations where the new helpers do not simplify current hot paths or preserve benchmark performance. -The goal is targeted profiling and implementation cleanup for operations where -`vs_linalg` shows a meaningful peer-crate gap. Release scope should stay limited -to changes that preserve numerical behavior, allocation-free fixed-size storage, -and clear const-generic code. - -### v0.4.5 Rust 1.98 Numerical Policy - -The `v0.4.5` milestone continues stable-Rust maintenance without broadening the -crate's scalar or algorithm scope. +Release outcome: -- [#208](https://github.com/acgetchell/la-stack/issues/208) raises the MSRV and - pinned contributor/CI toolchain to Rust 1.98, audits the final stable release, - and adds a repository guard against the new algebraic floating-point +- `Matrix::inf_norm` moved finiteness checks off the ordinary per-cell success + path while replaying only overflowed rows to preserve exact error locations. +- `Vector::dot` and `Vector::norm2_sq` adopted the same success-path reduction + strategy while retaining left-to-right fused accumulation, `const fn` + evaluation, and typed failure metadata. +- The MSRV moved to Rust 1.97.0 after auditing the new integer bit helpers; the + existing exact-arithmetic operations remained where alternatives did not + improve clarity or preserve measured performance. +- Direct and exact determinant hot paths were restored without weakening the + numerical contracts or the fixed-size allocation model. + +### v0.4.5 Rust 1.98 Numerical Policy (released) + +This milestone completed stable-Rust maintenance without broadening the crate's +scalar or algorithm scope. + +- [#208](https://github.com/acgetchell/la-stack/issues/208) raised the MSRV and + pinned contributor/CI toolchain to Rust 1.98, audited the final stable release, + and added a repository guard against the new algebraic floating-point operations in correctness-sensitive source, examples, and benchmarks. +- Local and release performance workflows were unified around retained, + schema-versioned CSV/JSON inputs, validated rerendering, and transactional + report promotion. +- Dependency, contributor-tool, and GitHub Action maintenance was refreshed + while preserving explicit repository ownership of update scope. The existing IEEE 754 operations, deterministic accumulation order, error bounds, exact fallbacks, and typed non-finite behavior remain authoritative. diff --git a/justfile b/justfile index d0ba556..3cfa34b 100644 --- a/justfile +++ b/justfile @@ -21,6 +21,7 @@ cargo_edit_version := "0.13.13" cargo_llvm_cov_version := "0.9.0" cargo_machete_version := "0.9.2" cargo_nextest_version := "0.9.143" +cargo_update_version := "22.1.1" clippy_sarif_version := "0.8.0" dprint_version := "0.56.0" git_cliff_version := "2.13.1" @@ -799,6 +800,11 @@ setup-tools: cargo install --locked just --version "$just_version" fi + cargo_update_version="{{ cargo_update_version }}" + if ! have cargo-install-update || [[ "$(cargo-install-update --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)" != "$cargo_update_version" ]]; then + cargo install --locked cargo-update --version "$cargo_update_version" + fi + cargo_edit_version="{{ cargo_edit_version }}" if ! cargo upgrade --version >/dev/null 2>&1 || [[ "$(cargo upgrade --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)" != "$cargo_edit_version" ]]; then cargo install --locked cargo-edit --version "$cargo_edit_version" @@ -852,6 +858,7 @@ setup-tools: have jq || { echo "❌ 'jq' is still missing."; exit 1; } echo " ✓ jq" verify_tool_version just "$just_version" + verify_tool_version cargo-install-update "$cargo_update_version" verify_tool_version cargo-upgrade "$cargo_edit_version" verify_tool_version cargo-llvm-cov "$cargo_llvm_cov_version" verify_tool_version cargo-machete "$cargo_machete_version" @@ -1073,6 +1080,7 @@ update-cargo-tools: _ensure-uv cargo-llvm-cov cargo-machete cargo-nextest + cargo-update dprint git-cliff just diff --git a/scripts/archive_changelog.py b/scripts/archive_changelog.py index a3476ff..77479f9 100755 --- a/scripts/archive_changelog.py +++ b/scripts/archive_changelog.py @@ -23,6 +23,7 @@ import re import sys import tempfile +from itertools import pairwise from pathlib import Path from postprocess_changelog import normalize_entry_headings_text, postprocess_text @@ -217,6 +218,16 @@ def group_by_minor( return groups +def _validate_release_order(version_blocks: list[tuple[str, str]]) -> None: + """Require release headings to be in strictly descending SemVer order.""" + for (previous, _), (current, _) in pairwise(version_blocks): + same_precedence = _version_sort_key(previous) == _version_sort_key(current) + out_of_order = sorted((previous, current), key=_version_sort_key, reverse=True) != [previous, current] + if same_precedence or out_of_order: + msg = f"changelog release headings must be in strictly descending semantic-version order: {previous} appears before {current}" + raise ValueError(msg) + + # --------------------------------------------------------------------------- # Writers # --------------------------------------------------------------------------- @@ -496,6 +507,7 @@ def archive_changelog( text, link_defs = _extract_link_defs(text) preamble, unreleased, version_blocks = parse_changelog(text) + _validate_release_order(version_blocks) if not version_blocks: _postprocess_existing_archives(archive_dir) @@ -547,7 +559,7 @@ def archive_changelog( # --------------------------------------------------------------------------- -def main() -> None: +def main(argv: list[str] | None = None) -> int: """CLI entry point for ``archive-changelog``.""" parser = argparse.ArgumentParser( prog="archive-changelog", @@ -564,16 +576,20 @@ def main() -> None: default=None, help=f"Archive output directory (default: {_DEFAULT_ARCHIVE_DIR})", ) - args = parser.parse_args() + args = parser.parse_args(argv) changelog = Path(args.path) - if not changelog.is_file(): - print(f"Error: {changelog} not found", file=sys.stderr) - sys.exit(1) - - archive_dir = Path(args.archive_dir) if args.archive_dir else None - archive_changelog(changelog, archive_dir) + try: + if not changelog.is_file(): + msg = f"{changelog} not found" + raise FileNotFoundError(msg) + archive_dir = Path(args.archive_dir) if args.archive_dir else None + archive_changelog(changelog, archive_dir) + except (OSError, UnicodeError, ValueError) as error: + print(f"archive-changelog: {error}", file=sys.stderr) + return 1 + return 0 if __name__ == "__main__": - main() + raise SystemExit(main()) diff --git a/scripts/archive_performance.py b/scripts/archive_performance.py index c613148..ef8f0fb 100644 --- a/scripts/archive_performance.py +++ b/scripts/archive_performance.py @@ -37,7 +37,14 @@ from bench_compare import HOW_TO_UPDATE_SECTION, render_release_artifacts from benchmark_contract import benchmark_contract_digest from performance_artifacts import ArtifactPaths, ensure_distinct_paths, load_bundle, publish_bundle -from subprocess_utils import ExecutableNotFoundError, cpu_description, run_git_command, run_git_command_with_input, run_safe_command +from subprocess_utils import ( + ExecutableNotFoundError, + cpu_description, + format_exception_diagnostics, + 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) @@ -223,15 +230,24 @@ def normalize_tag(tag: str) -> str: def parse_report_id(text: str) -> ReportId: """Parse the current version and baseline tag from a benchmark report.""" - version_match = _VERSION_RE.search(text) - if version_match is None: + version_matches = list(_VERSION_RE.finditer(text)) + if not version_matches: msg = "could not find la-stack version line in benchmark report" raise ValueError(msg) + if len(version_matches) != 1: + msg = f"expected exactly one la-stack version line in benchmark report, found {len(version_matches)}" + raise ValueError(msg) - baseline_match = _BASELINE_RE.search(text) - if baseline_match is None: + baseline_matches = list(_BASELINE_RE.finditer(text)) + if not baseline_matches: msg = "could not find comparison baseline line in benchmark report" raise ValueError(msg) + if len(baseline_matches) != 1: + msg = f"expected exactly one comparison baseline line in benchmark report, found {len(baseline_matches)}" + raise ValueError(msg) + + version_match = version_matches[0] + baseline_match = baseline_matches[0] return ReportId( current_tag=normalize_tag(version_match.group("version")), @@ -2087,7 +2103,7 @@ def main(argv: list[str] | None = None) -> int: subprocess.CalledProcessError, subprocess.TimeoutExpired, ) as exc: - print(f"archive-performance: {exc}", file=sys.stderr) + print(f"archive-performance: {format_exception_diagnostics(exc)}", file=sys.stderr) return 1 if result.action == "output": diff --git a/scripts/bench_compare.py b/scripts/bench_compare.py index d399807..2bd4e21 100644 --- a/scripts/bench_compare.py +++ b/scripts/bench_compare.py @@ -50,7 +50,7 @@ load_bundle, publish_bundle, ) -from subprocess_utils import ExecutableNotFoundError, find_project_root, run_git_command +from subprocess_utils import ExecutableNotFoundError, find_project_root, format_exception_diagnostics, run_git_command # --------------------------------------------------------------------------- # Benchmark group / bench discovery @@ -572,14 +572,18 @@ def _parse_harness_provenance( msg = f"benchmark harness provenance baseline {baseline!r} does not match requested Criterion baseline {expected_baseline!r} in {path}" raise ValueError(msg) - if not isinstance(schema, bool) and schema == 1: + if not isinstance(schema, int) or isinstance(schema, bool): + msg = f"unsupported or missing schema in {path}: expected integer 1 or 2, got {schema!r}" + raise TypeError(msg) + + if schema == 1: if mode != "shared-current-harness": msg = f"unsupported or missing mode in {path}: {mode!r}" raise ValueError(msg) sha256 = _required_sha256(data, "sha256", path) return HarnessProvenance(schema=1, mode=mode, sha256=sha256, baseline=baseline) - if isinstance(schema, bool) or schema != 2: + if schema != 2: msg = f"unsupported or missing schema in {path}: expected 1 or 2, got {schema!r}" raise ValueError(msg) if mode not in {"shared-current-harness", "historical-assets"}: @@ -2257,7 +2261,10 @@ def main(argv: list[str] | None = None) -> int: # noqa: C901, PLR0911, PLR0912, collection=collection, ) except (ExceptionGroup, OSError, KeyError, TypeError, ValueError) as err: - print(f"Invalid release-performance artifact data: {err}", file=sys.stderr) + print( + f"Invalid release-performance artifact data: {format_exception_diagnostics(err)}", + file=sys.stderr, + ) return 2 print(f"📊 Wrote {artifact_paths.csv} and {artifact_paths.provenance}") print(f"📊 Wrote {output_path}") diff --git a/scripts/benchmark_contract.py b/scripts/benchmark_contract.py index de93ac4..571531a 100644 --- a/scripts/benchmark_contract.py +++ b/scripts/benchmark_contract.py @@ -11,6 +11,7 @@ ".config/nextest.toml", "Cargo.toml", "Cargo.lock", + "justfile", "rust-toolchain.toml", "tests/exact_bench_config.rs", "tests/vs_linalg_inputs.rs", diff --git a/scripts/check_semgrep_fixtures.py b/scripts/check_semgrep_fixtures.py index b1a1e3b..082befe 100644 --- a/scripts/check_semgrep_fixtures.py +++ b/scripts/check_semgrep_fixtures.py @@ -114,6 +114,16 @@ def _actual_findings(semgrep: SemgrepResults) -> tuple[ActualFinding, ...] | Non 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(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 >= 1 + and end_line < start_line + ): + malformed_results.append(f"result {index} has end.line {end_line} before start.line {start_line}") if ( isinstance(check_id, str) and isinstance(start_line, int) diff --git a/scripts/postprocess_changelog.py b/scripts/postprocess_changelog.py index 662f58c..119a18d 100644 --- a/scripts/postprocess_changelog.py +++ b/scripts/postprocess_changelog.py @@ -19,8 +19,11 @@ """ import argparse +import os import re +import stat import sys +import tempfile from dataclasses import dataclass from pathlib import Path from typing import cast @@ -893,12 +896,37 @@ def postprocess_text(text: str) -> str: return text.rstrip("\n") + "\n" +def _write_text_atomic(path: Path, text: str) -> None: + """Replace an existing UTF-8 file atomically while preserving its mode.""" + mode = stat.S_IMODE(path.stat().st_mode) + 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: + staged = Path(handle.name) + handle.write(text) + handle.flush() + os.fsync(handle.fileno()) + staged.chmod(mode) + staged.replace(path) + staged = None + finally: + if staged is not None: + staged.unlink(missing_ok=True) + + def postprocess(path: Path) -> None: """Read *path*, apply hygiene fixes, and write it back.""" text = path.read_text(encoding="utf-8") text = postprocess_text(text) - path.write_text(text, encoding="utf-8") + _write_text_atomic(path, text) def main() -> None: diff --git a/scripts/subprocess_utils.py b/scripts/subprocess_utils.py index 6b66f09..0045afd 100644 --- a/scripts/subprocess_utils.py +++ b/scripts/subprocess_utils.py @@ -30,6 +30,55 @@ class ExecutableNotFoundError(Exception): """Raised when a required executable is not found in PATH.""" +def _diagnostic_stream(value: str | bytes | None) -> str: + """Return captured subprocess output as stripped, readable text.""" + if value is None: + return "" + if isinstance(value, bytes): + return value.decode("utf-8", errors="replace").strip() + return value.strip() + + +def _diagnostic_command(value: object) -> str: + """Render a subprocess command without Python container syntax.""" + if isinstance(value, (list, tuple)): + return " ".join(str(part) for part in value) + return str(value) + + +def format_exception_diagnostics(error: BaseException) -> str: + """Render expected CLI failures without discarding nested diagnostics.""" + if isinstance(error, BaseExceptionGroup): + lines = [f"{error.message} ({len(error.exceptions)} sub-exceptions):"] + for index, child in enumerate(error.exceptions, start=1): + detail_lines = format_exception_diagnostics(child).splitlines() or [child.__class__.__name__] + lines.append(f" {index}. {detail_lines[0]}") + lines.extend(f" {line}" for line in detail_lines[1:]) + return "\n".join(lines) + + if isinstance(error, subprocess.CalledProcessError): + parts = [f"command failed with exit status {error.returncode}: {_diagnostic_command(error.cmd)}"] + stdout = _diagnostic_stream(error.stdout) + stderr = _diagnostic_stream(error.stderr) + if stdout: + parts.append(f"stdout:\n{stdout}") + if stderr: + parts.append(f"stderr:\n{stderr}") + return "\n".join(parts) + + if isinstance(error, subprocess.TimeoutExpired): + parts = [f"command timed out after {error.timeout} seconds: {_diagnostic_command(error.cmd)}"] + stdout = _diagnostic_stream(error.stdout) + stderr = _diagnostic_stream(error.stderr) + if stdout: + parts.append(f"stdout:\n{stdout}") + if stderr: + parts.append(f"stderr:\n{stderr}") + return "\n".join(parts) + + return str(error) + + def get_safe_executable(command: str) -> str: """Get the full path to an executable, validating it exists. diff --git a/scripts/tag_release.py b/scripts/tag_release.py index 22cc960..bc33164 100755 --- a/scripts/tag_release.py +++ b/scripts/tag_release.py @@ -23,6 +23,7 @@ from subprocess_utils import ( ExecutableNotFoundError, + format_exception_diagnostics, run_git_command, run_git_command_with_input, ) @@ -399,7 +400,7 @@ def create_tag(tag_version: str, *, force: bool = False) -> None: # --------------------------------------------------------------------------- -def main() -> None: +def main(argv: list[str] | None = None) -> int: """CLI entry point for ``tag-release``.""" parser = argparse.ArgumentParser( prog="tag-release", @@ -408,7 +409,7 @@ def main() -> None: parser.add_argument("version", help="Tag version (e.g. v1.2.3)") parser.add_argument("--force", action="store_true", help="Recreate tag if it already exists") parser.add_argument("--debug", action="store_true", help="Enable debug logging") - args = parser.parse_args() + args = parser.parse_args(argv) if args.debug: logging.basicConfig(level=logging.DEBUG, format="%(levelname)s: %(message)s") @@ -426,9 +427,10 @@ def main() -> None: subprocess.CalledProcessError, subprocess.TimeoutExpired, ) as exc: - print(f"Error: {exc}", file=sys.stderr) - sys.exit(1) + print(f"Error: {format_exception_diagnostics(exc)}", file=sys.stderr) + return 1 + return 0 if __name__ == "__main__": - main() + raise SystemExit(main()) diff --git a/scripts/tests/test_archive_changelog.py b/scripts/tests/test_archive_changelog.py index b8e8ef4..08efed7 100644 --- a/scripts/tests/test_archive_changelog.py +++ b/scripts/tests/test_archive_changelog.py @@ -314,6 +314,42 @@ def test_no_link_defs_by_default(self) -> None: class TestArchiveChangelog: + def test_out_of_order_releases_preserve_root_and_archives(self, tmp_path: Path) -> None: + changelog = tmp_path / "CHANGELOG.md" + original = _PREAMBLE + _V071 + _V072 + _V062 + changelog.write_text(original, encoding="utf-8") + archive_dir = tmp_path / "docs" / "archive" / "changelog" + archive_dir.mkdir(parents=True) + existing_archive = archive_dir / "0.6.md" + existing = b"# Existing archive\r\n" + existing_archive.write_bytes(existing) + + with pytest.raises(ValueError, match="strictly descending semantic-version order"): + archive_changelog(changelog, archive_dir) + + assert changelog.read_text(encoding="utf-8") == original + assert existing_archive.read_bytes() == existing + assert sorted(path.name for path in archive_dir.iterdir()) == ["0.6.md"] + + def test_cli_reports_order_error_without_traceback( + self, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + ) -> None: + changelog = tmp_path / "CHANGELOG.md" + original = _PREAMBLE + _V071 + _V072 + _V062 + changelog.write_text(original, encoding="utf-8") + archive_dir = tmp_path / "archive" + + status = archive_changelog_module.main([str(changelog), "--archive-dir", str(archive_dir)]) + + captured = capsys.readouterr() + assert status == 1 + assert "strictly descending semantic-version order" in captured.err + assert "Traceback" not in captured.err + assert changelog.read_text(encoding="utf-8") == original + assert not archive_dir.exists() + def test_unknown_heading_preserves_root_and_archives(self, tmp_path: Path) -> None: """An unknown version-like heading fails before any output is rewritten.""" changelog = tmp_path / "CHANGELOG.md" diff --git a/scripts/tests/test_archive_performance.py b/scripts/tests/test_archive_performance.py index 692fc72..6d296bc 100644 --- a/scripts/tests/test_archive_performance.py +++ b/scripts/tests/test_archive_performance.py @@ -6,7 +6,7 @@ import tarfile from pathlib import Path from types import SimpleNamespace -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Never import pytest @@ -540,6 +540,38 @@ def test_parse_report_id_reads_current_and_baseline_tags() -> None: assert report_id.archive_name == "v0.4.2-vs-v0.4.1.md" +@pytest.mark.parametrize( + ("extra", "message"), + [ + ("\n**la-stack** v0.4.3 · `def5678` (release/test) · 2026-06-09 12:00:00 UTC\n", "la-stack version line"), + ("\nComparison against baseline **v0.4.0**:\n", "comparison baseline line"), + ], +) +def test_parse_report_id_rejects_duplicate_identity_lines(extra: str, message: str) -> None: + with pytest.raises(ValueError, match=rf"exactly one {message}.*found 2"): + parse_report_id(_report("0.4.2", "v0.4.1") + extra) + + +def test_main_preserves_exception_group_diagnostics( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + def fail_request(_options: object) -> Never: + message = "publication and rollback failed" + raise ExceptionGroup( + message, + [OSError("could not publish docs/PERFORMANCE.md"), OSError("could not restore prior report")], + ) + + monkeypatch.setattr(archive_performance, "resolve_archive_request", fail_request) + + assert main(["v0.4.3", "v0.4.2"]) == 1 + captured = capsys.readouterr() + assert "publication and rollback failed (2 sub-exceptions)" in captured.err + assert "could not publish docs/PERFORMANCE.md" in captured.err + assert "could not restore prior report" in captured.err + + def test_published_release_pair_discovers_latest_stable_semver_pair(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: def fake_run_safe(command: str, args: Sequence[str], cwd: Path | None = None, **kwargs: Any) -> SimpleNamespace: assert command == "gh" diff --git a/scripts/tests/test_bench_compare.py b/scripts/tests/test_bench_compare.py index ee169db..d4931e2 100644 --- a/scripts/tests/test_bench_compare.py +++ b/scripts/tests/test_bench_compare.py @@ -3,6 +3,7 @@ import json import re import subprocess +from types import SimpleNamespace from typing import TYPE_CHECKING, cast import pytest @@ -940,6 +941,9 @@ def test_read_harness_provenance_rejects_different_requested_baseline(tmp_path: @pytest.mark.parametrize( ("field", "value", "message"), [ + ("schema", True, "unsupported or missing schema"), + ("schema", 1.0, "unsupported or missing schema"), + ("schema", 2.0, "unsupported or missing schema"), ("schema", 3, "unsupported or missing schema"), ("mode", "independent-harnesses", "unsupported or missing mode"), ("sha256", "not-a-digest", "invalid or missing sha256"), @@ -962,7 +966,7 @@ def test_read_harness_provenance_rejects_malformed_fields( tmp_path.mkdir(parents=True, exist_ok=True) (tmp_path / ".la-stack-benchmark-harness.json").write_text(json.dumps(data), encoding="utf-8") - with pytest.raises(ValueError, match=message): + with pytest.raises((TypeError, ValueError), match=message): _read_harness_provenance(tmp_path) @@ -1162,6 +1166,57 @@ def test_main_rejects_invalid_artifact_option_combinations( assert not provenance_output.exists() +def test_main_preserves_artifact_exception_group_diagnostics( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + criterion_dir = tmp_path / "criterion" + criterion_dir.mkdir() + csv_output = tmp_path / "performance.csv" + provenance_output = tmp_path / "performance.provenance.json" + output = tmp_path / "performance.md" + + monkeypatch.setattr(bench_compare, "_read_harness_provenance", lambda *_args, **_kwargs: None) + monkeypatch.setattr( + bench_compare, + "_collect_comparisons", + lambda *_args, **_kwargs: SimpleNamespace(comparisons=[object()], gaps=[]), + ) + monkeypatch.setattr(bench_compare, "_comparison_tables", lambda *_args, **_kwargs: "table") + + def fail_publication(*_args: object, **_kwargs: object) -> None: + message = "artifact publication and rollback failed" + raise ExceptionGroup( + message, + [OSError("could not publish performance.csv"), OSError("could not restore provenance")], + ) + + monkeypatch.setattr(bench_compare, "_write_and_render_artifacts", fail_publication) + + status = bench_compare.main( + [ + "last", + "--repo-root", + str(tmp_path), + "--criterion-dir", + str(criterion_dir), + "--output", + str(output), + "--csv-output", + str(csv_output), + "--provenance-output", + str(provenance_output), + ] + ) + + assert status == 2 + captured = capsys.readouterr() + assert "artifact publication and rollback failed (2 sub-exceptions)" in captured.err + assert "could not publish performance.csv" in captured.err + assert "could not restore provenance" in captured.err + + 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", diff --git a/scripts/tests/test_check_semgrep_fixtures.py b/scripts/tests/test_check_semgrep_fixtures.py index 823c2b4..8416fe0 100644 --- a/scripts/tests/test_check_semgrep_fixtures.py +++ b/scripts/tests/test_check_semgrep_fixtures.py @@ -71,6 +71,20 @@ def test_main_reports_missing_check_id(monkeypatch: pytest.MonkeyPatch, tmp_path assert "missing positive integer field 'end.line'" in captured.err +def test_main_rejects_reversed_result_span( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + fixture = tmp_path / "fixture.rs" + fixture.write_text("// ruleid: rust.foo\nbad();\n", encoding="utf-8") + monkeypatch.setenv("SEMGREP_JSON", json.dumps({"results": [_result("rust.foo", 4, 2)]})) + monkeypatch.setattr(check_semgrep_fixtures.sys, "argv", ["check_semgrep_fixtures.py", str(fixture)]) + + assert check_semgrep_fixtures.main() == 1 + assert "result 0 has end.line 2 before start.line 4" in capsys.readouterr().err + + def test_main_rejects_findings_at_wrong_lines_even_when_rule_counts_match( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, diff --git a/scripts/tests/test_criterion_dim_plot.py b/scripts/tests/test_criterion_dim_plot.py index fb6a28e..b6c2b63 100644 --- a/scripts/tests/test_criterion_dim_plot.py +++ b/scripts/tests/test_criterion_dim_plot.py @@ -1196,19 +1196,29 @@ def test_main_publication_rejects_stale_harness_without_writing( assert "old table" in readme.read_text(encoding="utf-8") -@pytest.mark.parametrize( - ("include_contract", "expected_status"), - [(True, "matched"), (False, "legacy-retained-artifact")], -) -def test_main_publication_ignores_justfile_only_changes( +def test_main_publication_rejects_benchmark_recipe_changes( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + _write_benchmark_checkout(tmp_path) + _write_performance_bundle(tmp_path) + (tmp_path / "justfile").write_text("test-bench-inputs:\n cargo test --test changed-input-gate\n", encoding="utf-8") + readme = tmp_path / "README.md" + readme.write_text(_canonical_benchmark_readme("0.0.8"), encoding="utf-8") + _mock_publication_environment(tmp_path, monkeypatch) + + assert criterion_dim_plot.main(["--update-readme"]) == 2 + assert "benchmark_contract_sha256" in capsys.readouterr().err + assert "old table" in readme.read_text(encoding="utf-8") + + +def test_main_publication_labels_legacy_artifact_without_contract( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, - include_contract: bool, - expected_status: str, ) -> None: _write_benchmark_checkout(tmp_path) - _write_performance_bundle(tmp_path, include_contract=include_contract) - (tmp_path / "justfile").write_text("test-bench-inputs:\n\n# publication help changed\n", encoding="utf-8") + _write_performance_bundle(tmp_path, include_contract=False) readme = tmp_path / "README.md" readme.write_text(_canonical_benchmark_readme("0.0.8"), encoding="utf-8") _mock_publication_environment(tmp_path, monkeypatch) @@ -1220,7 +1230,7 @@ def test_main_publication_ignores_justfile_only_changes( assert criterion_dim_plot.main(["--update-readme"]) == 0 provenance = json.loads((tmp_path / "docs/assets/bench/vs_linalg_lu_solve_median.provenance.json").read_text(encoding="utf-8")) - assert provenance["publication"]["benchmark_contract"] == expected_status + assert provenance["publication"]["benchmark_contract"] == "legacy-retained-artifact" def test_main_publication_rejects_tampered_performance_csv( diff --git a/scripts/tests/test_justfile_discoverability.py b/scripts/tests/test_justfile_discoverability.py index 8e4db34..bab8217 100644 --- a/scripts/tests/test_justfile_discoverability.py +++ b/scripts/tests/test_justfile_discoverability.py @@ -105,6 +105,14 @@ def test_update_workflow_composes_scoped_dependency_and_tool_updates() -> None: assert updated_packages == set(update_cargo_tool_pins.PIN_TO_PACKAGE.values()) +def test_setup_tools_installs_and_verifies_cargo_update_provider() -> None: + """A clean setup must provide the updater used by the update workflow.""" + body = json.dumps(just_recipes()["setup-tools"]["body"]) + + assert "cargo install --locked cargo-update --version" in body + assert "verify_tool_version cargo-install-update" in body + + def test_managed_cargo_tool_pins_exist_once_in_root_justfile() -> None: """Every managed Cargo package should map to one real root Just pin.""" justfile_text = (REPO_ROOT / "justfile").read_text(encoding="utf-8") diff --git a/scripts/tests/test_postprocess_changelog.py b/scripts/tests/test_postprocess_changelog.py index cce593a..77a770b 100644 --- a/scripts/tests/test_postprocess_changelog.py +++ b/scripts/tests/test_postprocess_changelog.py @@ -1,7 +1,11 @@ """Tests for postprocess_changelog.py — trailing blanks, reflow, code blocks, summaries.""" -from typing import TYPE_CHECKING +import os +from typing import TYPE_CHECKING, Never +import pytest + +import postprocess_changelog from postprocess_changelog import ( _CodeFence, _compact_entry, @@ -37,6 +41,44 @@ def test_strips_trailing_blank_lines(self, tmp_path: Path) -> None: assert f.read_text(encoding="utf-8") == "# Changelog\n\n- Item\n" + @pytest.mark.skipif(os.name == "nt", reason="POSIX mode preservation is not meaningful on Windows") + def test_atomic_write_preserves_file_mode(self, tmp_path: Path) -> None: + f = tmp_path / "CHANGELOG.md" + f.write_text("# Changelog\n\n- Item\n\n\n", encoding="utf-8") + f.chmod(0o640) + + postprocess(f) + + assert f.stat().st_mode & 0o777 == 0o640 + + @pytest.mark.parametrize("failure_point", ["stage", "fsync", "replace"]) + def test_atomic_write_failure_preserves_original( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + failure_point: str, + ) -> None: + f = tmp_path / "CHANGELOG.md" + original = b"# Changelog\n\n- Item\n\n\n" + f.write_bytes(original) + + def fail(*_args: object, **_kwargs: object) -> Never: + msg = f"simulated {failure_point} failure" + raise OSError(msg) + + if failure_point == "stage": + monkeypatch.setattr(postprocess_changelog.tempfile, "NamedTemporaryFile", fail) + elif failure_point == "fsync": + monkeypatch.setattr(postprocess_changelog.os, "fsync", fail) + else: + monkeypatch.setattr(postprocess_changelog.Path, "replace", fail) + + with pytest.raises(OSError, match=f"simulated {failure_point} failure"): + postprocess(f) + + assert f.read_bytes() == original + assert not list(tmp_path.glob(".CHANGELOG.md.*.tmp")) + def test_preserves_single_trailing_newline(self, tmp_path: Path) -> None: f = tmp_path / "CHANGELOG.md" f.write_text("# Changelog\n\n- Item\n", encoding="utf-8") diff --git a/scripts/tests/test_subprocess_utils.py b/scripts/tests/test_subprocess_utils.py index 8b41ef8..eef0c87 100644 --- a/scripts/tests/test_subprocess_utils.py +++ b/scripts/tests/test_subprocess_utils.py @@ -14,6 +14,7 @@ check_git_repo, cpu_description, find_project_root, + format_exception_diagnostics, get_git_commit_hash, get_git_remote_url, get_safe_executable, @@ -41,6 +42,25 @@ def test_raises_for_nonexistent_command(self) -> None: get_safe_executable("definitely_not_a_real_command_12345") +class TestFormatExceptionDiagnostics: + def test_preserves_nested_subprocess_output(self) -> None: + failure = subprocess_utils.subprocess.CalledProcessError( + 128, + ["git", "tag", "v1.2.3"], + output="tag stdout", + stderr="tag rejected by hook", + ) + error = ExceptionGroup("publication and rollback failed", [failure, OSError("rollback target unavailable")]) + + rendered = format_exception_diagnostics(error) + + assert "publication and rollback failed (2 sub-exceptions)" in rendered + assert "git tag v1.2.3" in rendered + assert "tag stdout" in rendered + assert "tag rejected by hook" in rendered + assert "rollback target unavailable" in rendered + + # --------------------------------------------------------------------------- # _build_run_kwargs # --------------------------------------------------------------------------- diff --git a/scripts/tests/test_tag_release.py b/scripts/tests/test_tag_release.py index f672058..b58122d 100644 --- a/scripts/tests/test_tag_release.py +++ b/scripts/tests/test_tag_release.py @@ -1,5 +1,6 @@ """Tests for tag_release.py — annotated tag creation with size-limit handling.""" +import subprocess from typing import TYPE_CHECKING from unittest.mock import MagicMock, patch @@ -229,6 +230,29 @@ class TestCreateTag: def _matching_package_version(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(tag_release, "_package_version", lambda _changelog: "1.0.0") + def test_main_preserves_captured_git_diagnostics( + self, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + ) -> None: + def fail_create_tag(_version: str, *, force: bool = False) -> None: + del force + raise subprocess.CalledProcessError( + 128, + ["git", "tag", "v1.0.0"], + output="tag stdout", + stderr="tag rejected by hook", + ) + + monkeypatch.setattr(tag_release, "create_tag", fail_create_tag) + + assert tag_release.main(["v1.0.0"]) == 1 + captured = capsys.readouterr() + assert "git tag v1.0.0" in captured.err + assert "tag stdout" in captured.err + assert "tag rejected by hook" in captured.err + assert "Traceback" not in captured.err + def test_next_step_sets_release_title( self, tmp_path: Path, diff --git a/scripts/tests/test_update_cargo_tool_pins.py b/scripts/tests/test_update_cargo_tool_pins.py index 9e3546c..b2b49c2 100644 --- a/scripts/tests/test_update_cargo_tool_pins.py +++ b/scripts/tests/test_update_cargo_tool_pins.py @@ -62,6 +62,15 @@ def test_parse_installed_packages_accepts_prerelease_with_build_metadata() -> No assert installed["rumdl"] == version +@pytest.mark.parametrize( + "version", + ["01.2.3", "1.02.3", "1.2.03", "1.2.3-01", "1.2.3-alpha..beta", "1.2.3+", "1.2.3+build..1"], +) +def test_parse_installed_packages_rejects_noncanonical_semver(version: str) -> None: + with pytest.raises(ValueError, match="invalid installed version for rumdl"): + update_cargo_tool_pins.parse_installed_packages(installed_output(override=("rumdl", version))) + + def test_reconcile_pins_preserves_prerelease_with_build_metadata(tmp_path: Path) -> None: version = "1.2.3-rc.1+build.5" justfile = tmp_path / "justfile" diff --git a/scripts/update_cargo_tool_pins.py b/scripts/update_cargo_tool_pins.py index cc1774b..8593fe6 100644 --- a/scripts/update_cargo_tool_pins.py +++ b/scripts/update_cargo_tool_pins.py @@ -15,6 +15,7 @@ "cargo_llvm_cov_version": "cargo-llvm-cov", "cargo_machete_version": "cargo-machete", "cargo_nextest_version": "cargo-nextest", + "cargo_update_version": "cargo-update", "dprint_version": "dprint", "git_cliff_version": "git-cliff", "just_version": "just", @@ -24,7 +25,12 @@ "zizmor_version": "zizmor", } PACKAGE_HEADER = re.compile(r"^(?P[A-Za-z0-9_-]+) v(?P[^\s:]+):$", re.MULTILINE) -VERSION = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$") +_SEMVER_IDENTIFIER = r"(?:0|[1-9][0-9]*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)" +VERSION = re.compile( + rf"^(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)" + rf"(?:-{_SEMVER_IDENTIFIER}(?:\.{_SEMVER_IDENTIFIER})*)?" + r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$" +) def parse_installed_packages(output: str) -> dict[str, str]: diff --git a/src/exact.rs b/src/exact.rs index 9becc9b..caaf404 100644 --- a/src/exact.rs +++ b/src/exact.rs @@ -3534,58 +3534,26 @@ mod tests { // ----------------------------------------------------------------------- #[test] - fn f64_to_big_rational_positive_zero() { - let r = f64_to_big_rational(0.0); - assert_eq!(r, BigRational::from_integer(BigInt::from(0))); - } - - #[test] - fn f64_to_big_rational_negative_zero() { - let r = f64_to_big_rational(-0.0); - assert_eq!(r, BigRational::from_integer(BigInt::from(0))); - } - - #[test] - fn f64_to_big_rational_one() { - let r = f64_to_big_rational(1.0); - assert_eq!(r, BigRational::from_integer(BigInt::from(1))); - } - - #[test] - fn f64_to_big_rational_negative_one() { - let r = f64_to_big_rational(-1.0); - assert_eq!(r, BigRational::from_integer(BigInt::from(-1))); - } - - #[test] - fn f64_to_big_rational_half() { - let r = f64_to_big_rational(0.5); - assert_eq!(r, BigRational::new(BigInt::from(1), BigInt::from(2))); - } - - #[test] - fn f64_to_big_rational_quarter() { - let r = f64_to_big_rational(0.25); - assert_eq!(r, BigRational::new(BigInt::from(1), BigInt::from(4))); - } - - #[test] - fn f64_to_big_rational_negative_three_and_a_half() { - // -3.5 = -7/2 - let r = f64_to_big_rational(-3.5); - assert_eq!(r, BigRational::new(BigInt::from(-7), BigInt::from(2))); - } - - #[test] - fn f64_to_big_rational_integer() { - let r = f64_to_big_rational(42.0); - assert_eq!(r, BigRational::from_integer(BigInt::from(42))); - } + fn f64_to_big_rational_scalar_cases() { + let cases = [ + ("positive zero", 0.0, 0, 1), + ("negative zero", -0.0, 0, 1), + ("one", 1.0, 1, 1), + ("negative one", -1.0, -1, 1), + ("half", 0.5, 1, 2), + ("quarter", 0.25, 1, 4), + ("negative three and a half", -3.5, -7, 2), + ("integer", 42.0, 42, 1), + ("power of two", 1024.0, 1024, 1), + ]; - #[test] - fn f64_to_big_rational_power_of_two() { - let r = f64_to_big_rational(1024.0); - assert_eq!(r, BigRational::from_integer(BigInt::from(1024))); + for (label, value, numerator, denominator) in cases { + assert_eq!( + f64_to_big_rational(value), + BigRational::new(BigInt::from(numerator), BigInt::from(denominator)), + "{label}" + ); + } } #[test] diff --git a/tests/proptest_vector.rs b/tests/proptest_vector.rs index 2bb84b2..2a10689 100644 --- a/tests/proptest_vector.rs +++ b/tests/proptest_vector.rs @@ -72,13 +72,3 @@ gen_vector_proptests!(2); gen_vector_proptests!(3); gen_vector_proptests!(4); gen_vector_proptests!(5); - -#[test] -fn zero_dimension_vector_obeys_empty_sum_contracts() { - let vector = Vector::<0>::try_new([]).unwrap(); - - assert!(vector.as_array().is_empty()); - assert!(vector.into_array().is_empty()); - assert_eq!(vector.dot(&Vector::zero()), Ok(0.0)); - assert_eq!(vector.norm2_sq(), Ok(0.0)); -}