Skip to content

Commit 7e845d1

Browse files
committed
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
1 parent 2e71328 commit 7e845d1

27 files changed

Lines changed: 528 additions & 123 deletions

CONTRIBUTING.md

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@ clarity, and the fixed-dimension stack-allocation model.
66

77
## Getting Started
88

9-
Install Rust 1.98.0 through [rustup](https://rustup.rs/), Git, Python 3.14,
10-
[`uv` 0.12.5](https://docs.astral.sh/uv/), and `jq`. Install the repository's
11-
pinned `just` version from its locked dependency graph:
9+
Install Rust 1.98.0 through [rustup](https://rustup.rs/), Git, the
10+
[GitHub CLI](https://cli.github.com/), Python 3.14,
11+
[`uv` 0.12.5](https://docs.astral.sh/uv/), and `jq`. Authenticate the GitHub
12+
CLI for repository operations, then install the repository's pinned `just`
13+
version from its locked dependency graph:
1214

1315
```bash
1416
cargo install --locked just --version 1.58.0
@@ -17,7 +19,7 @@ cargo install --locked just --version 1.58.0
1719
Set up the remaining development tools and validate the checkout:
1820

1921
```bash
20-
just setup # install or verify dev tools and sync Python dependencies
22+
just setup # install or verify dev tools, sync Python dependencies, and build
2123
just check # lint and validate without changing files
2224
just ci # run the comprehensive local CI path
2325
```
@@ -29,9 +31,10 @@ Use `just update` for deliberate dependency and tool maintenance. It composes
2931
`just update-dependencies`, which advances Cargo dependency requirements, exact
3032
Python development-tool pins, and the Cargo/uv locks, with
3133
`just update-cargo-tools`, which upgrades only the Cargo CLI packages owned by
32-
`setup-tools` and atomically reconciles their root `justfile` pins. The tool
33-
updater requires `cargo-install-update` from the `cargo-update` package and does
34-
not touch unrelated Cargo executables or uv's user-global tool environments.
34+
`setup-tools` and atomically reconciles their root `justfile` pins. `just setup`
35+
installs and verifies the pinned `cargo-update` package that provides
36+
`cargo-install-update`; the updater does not touch unrelated Cargo executables
37+
or uv's user-global tool environments.
3538

3639
The repository uses `cargo-nextest` for runnable Rust tests, `cargo-machete`
3740
for unused-dependency checks, and `just cargo-lock-check` to verify that the

README.md

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,12 @@ values were rounded to `f64` before construction.
286286
- **`ExactF64Conversion`** — converts an existing exact determinant or solution
287287
under the strict or rounded contract without repeating exact elimination
288288

289+
Exact determinant value and conversion methods return
290+
`LaError::DeterminantScaleOverflow` if the aggregate power-of-two scaling
291+
exceeds the internal exponent representation. Exact solve methods return
292+
`LaError::Singular` with `SingularityReason::Exact` when the stored matrix is
293+
exactly singular.
294+
289295
For exact-to-f64 output, strict conversions use
290296
`UnrepresentableReason::RequiresRounding` when explicit rounding can produce a
291297
finite value and `UnrepresentableReason::NotFinite` otherwise. Rounded
@@ -379,6 +385,8 @@ the conservative absolute error bound used by the fast filter, computed from
379385
one call that evaluates the determinant once and computes its matching bound.
380386
It returns `None` when a D ≤ 4 computation may be affected by gradual
381387
underflow, as well as for unsupported D ≥ 5 dimensions.
388+
It returns `LaError::NonFinite` if the determinant or bound computation
389+
overflows to NaN or infinity.
382390
This method does NOT require the `exact` feature — it uses pure f64 arithmetic
383391
and is available by default. Use `det_errbound()` when only the bound is needed.
384392
The paired API enables custom adaptive-precision logic for geometric predicates:
@@ -460,8 +468,10 @@ Storage shown above reflects the intentional `f64` scalar model.
460468

461469
For a runtime dimension from 0 through `MAX_STACK_MATRIX_DISPATCH_DIM` (7),
462470
`try_with_stack_matrix!` dispatches to a concrete `Matrix<N>` while preserving
463-
inline stack storage. Larger dimensions return `LaError::UnsupportedDimension`;
464-
the macro does not introduce a dynamically sized matrix representation.
471+
inline stack storage. Larger dimensions produce
472+
`LaError::UnsupportedDimension`, converted through `From<LaError>` into the
473+
closure's declared `Result` error type; the macro does not introduce a
474+
dynamically sized matrix representation.
465475

466476
`Matrix<D>` key methods: `as_rows`, `into_rows`, `lu`, `ldlt`, `det`,
467477
`det_direct`, `det_direct_with_errbound`, `det_errbound`,
@@ -570,13 +580,14 @@ cargo run --features exact --example exact_solve_3x3
570580

571581
A short contributor workflow:
572582

573-
Install Rust 1.98.0 through [rustup](https://rustup.rs/), Git, Python 3.14,
583+
Install Rust 1.98.0 through [rustup](https://rustup.rs/), Git,
584+
[GitHub CLI](https://cli.github.com/), Python 3.14,
574585
[`uv` 0.12.5](https://docs.astral.sh/uv/), and `jq`. Then install the pinned
575586
`just` release from its locked dependency graph:
576587

577588
```bash
578589
cargo install --locked just --version 1.58.0
579-
just setup # install/verify dev tools + sync Python deps
590+
just setup # install/verify dev tools + sync Python deps + build
580591
just check # lint/validate (non-mutating)
581592
just fix # apply auto-fixes (mutating)
582593
just ci # lint + tests + examples + bench compile

docs/BENCHMARKING.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,25 @@ For experimental-design background on controlled repetitions and uncertainty,
369369
see [REFERENCES.md](../REFERENCES.md) \[13\]; these workflows do not claim to
370370
implement every recommendation in that study.
371371

372+
The harness calls native crate APIs where they expose the same operation. Where
373+
a peer crate does not expose a matching convenience method, repository-owned
374+
adapter code computes the agreed mathematical kernel inside the timed closure:
375+
376+
| Metric family | la-stack implementation | nalgebra implementation | faer implementation |
377+
|---------------|-------------------------|-------------------------|---------------------|
378+
| LU factorization and solve rows | Native `Lu` APIs | Native `LU` APIs | Native partial-pivoting LU APIs |
379+
| LDLT/Cholesky factorization and solve rows | Native `Ldlt` APIs | Native `Cholesky` APIs | Native LDLT APIs |
380+
| `det_via_lu`, `det_from_lu` | Native `Lu::det` | Native `LU::determinant` | Harness adapter: product of the U diagonal and permutation sign |
381+
| `det_from_ldlt` / `det_from_cholesky` | Native `Ldlt::det` | Native `Cholesky::determinant` | Harness adapter: product of the D diagonal |
382+
| `dot` | Native `Vector::dot` | Native `dot` | Harness adapter: left-to-right fused multiply-add loop |
383+
| `norm2_sq` | Native `Vector::norm2_sq` | Native `norm_squared` | Native `squared_norm_l2` |
384+
| `inf_norm` | Native `Matrix::inf_norm` | Harness adapter: maximum absolute row sum | Harness adapter: maximum absolute row sum |
385+
386+
These adapter timings are benchmark-kernel comparisons, not claims about the
387+
speed of an identically named public convenience method in every crate. The
388+
adapter implementation is versioned with the benchmark harness, included in the
389+
benchmark-contract digest, and covered by the cross-crate input smoke tests.
390+
372391
All three crates receive equivalent deterministic inputs for a given dimension:
373392

374393
- matrix entries come from the same strictly diagonally-dominant generator

docs/RELEASING.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,14 @@ git switch main
2929
git pull --ff-only
3030
```
3131

32+
Install or verify the pinned development tools before running maintenance
33+
recipes. This includes the `cargo-update` package that provides
34+
`cargo-install-update` for `just update`:
35+
36+
```bash
37+
just setup
38+
```
39+
3240
Refresh Cargo dependency requirements, exact Python development-tool pins,
3341
lockfiles, and repository-owned Cargo tool pins before creating the release
3442
branch:

docs/roadmap.md

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -141,20 +141,33 @@ the small fixed-dimension API model.
141141
primitive and `num-bigint` operations where the new helpers do not simplify
142142
current hot paths or preserve benchmark performance.
143143

144-
The goal is targeted profiling and implementation cleanup for operations where
145-
`vs_linalg` shows a meaningful peer-crate gap. Release scope should stay limited
146-
to changes that preserve numerical behavior, allocation-free fixed-size storage,
147-
and clear const-generic code.
148-
149-
### v0.4.5 Rust 1.98 Numerical Policy
150-
151-
The `v0.4.5` milestone continues stable-Rust maintenance without broadening the
152-
crate's scalar or algorithm scope.
144+
Release outcome:
153145

154-
- [#208](https://github.com/acgetchell/la-stack/issues/208) raises the MSRV and
155-
pinned contributor/CI toolchain to Rust 1.98, audits the final stable release,
156-
and adds a repository guard against the new algebraic floating-point
146+
- `Matrix::inf_norm` moved finiteness checks off the ordinary per-cell success
147+
path while replaying only overflowed rows to preserve exact error locations.
148+
- `Vector::dot` and `Vector::norm2_sq` adopted the same success-path reduction
149+
strategy while retaining left-to-right fused accumulation, `const fn`
150+
evaluation, and typed failure metadata.
151+
- The MSRV moved to Rust 1.97.0 after auditing the new integer bit helpers; the
152+
existing exact-arithmetic operations remained where alternatives did not
153+
improve clarity or preserve measured performance.
154+
- Direct and exact determinant hot paths were restored without weakening the
155+
numerical contracts or the fixed-size allocation model.
156+
157+
### v0.4.5 Rust 1.98 Numerical Policy (released)
158+
159+
This milestone completed stable-Rust maintenance without broadening the crate's
160+
scalar or algorithm scope.
161+
162+
- [#208](https://github.com/acgetchell/la-stack/issues/208) raised the MSRV and
163+
pinned contributor/CI toolchain to Rust 1.98, audited the final stable release,
164+
and added a repository guard against the new algebraic floating-point
157165
operations in correctness-sensitive source, examples, and benchmarks.
166+
- Local and release performance workflows were unified around retained,
167+
schema-versioned CSV/JSON inputs, validated rerendering, and transactional
168+
report promotion.
169+
- Dependency, contributor-tool, and GitHub Action maintenance was refreshed
170+
while preserving explicit repository ownership of update scope.
158171

159172
The existing IEEE 754 operations, deterministic accumulation order, error
160173
bounds, exact fallbacks, and typed non-finite behavior remain authoritative.

justfile

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ cargo_edit_version := "0.13.13"
2121
cargo_llvm_cov_version := "0.9.0"
2222
cargo_machete_version := "0.9.2"
2323
cargo_nextest_version := "0.9.143"
24+
cargo_update_version := "22.1.1"
2425
clippy_sarif_version := "0.8.0"
2526
dprint_version := "0.56.0"
2627
git_cliff_version := "2.13.1"
@@ -799,6 +800,11 @@ setup-tools:
799800
cargo install --locked just --version "$just_version"
800801
fi
801802
803+
cargo_update_version="{{ cargo_update_version }}"
804+
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
805+
cargo install --locked cargo-update --version "$cargo_update_version"
806+
fi
807+
802808
cargo_edit_version="{{ cargo_edit_version }}"
803809
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
804810
cargo install --locked cargo-edit --version "$cargo_edit_version"
@@ -852,6 +858,7 @@ setup-tools:
852858
have jq || { echo "❌ 'jq' is still missing."; exit 1; }
853859
echo " ✓ jq"
854860
verify_tool_version just "$just_version"
861+
verify_tool_version cargo-install-update "$cargo_update_version"
855862
verify_tool_version cargo-upgrade "$cargo_edit_version"
856863
verify_tool_version cargo-llvm-cov "$cargo_llvm_cov_version"
857864
verify_tool_version cargo-machete "$cargo_machete_version"
@@ -1073,6 +1080,7 @@ update-cargo-tools: _ensure-uv
10731080
cargo-llvm-cov
10741081
cargo-machete
10751082
cargo-nextest
1083+
cargo-update
10761084
dprint
10771085
git-cliff
10781086
just

scripts/archive_changelog.py

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import re
2424
import sys
2525
import tempfile
26+
from itertools import pairwise
2627
from pathlib import Path
2728

2829
from postprocess_changelog import normalize_entry_headings_text, postprocess_text
@@ -217,6 +218,16 @@ def group_by_minor(
217218
return groups
218219

219220

221+
def _validate_release_order(version_blocks: list[tuple[str, str]]) -> None:
222+
"""Require release headings to be in strictly descending SemVer order."""
223+
for (previous, _), (current, _) in pairwise(version_blocks):
224+
same_precedence = _version_sort_key(previous) == _version_sort_key(current)
225+
out_of_order = sorted((previous, current), key=_version_sort_key, reverse=True) != [previous, current]
226+
if same_precedence or out_of_order:
227+
msg = f"changelog release headings must be in strictly descending semantic-version order: {previous} appears before {current}"
228+
raise ValueError(msg)
229+
230+
220231
# ---------------------------------------------------------------------------
221232
# Writers
222233
# ---------------------------------------------------------------------------
@@ -496,6 +507,7 @@ def archive_changelog(
496507
text, link_defs = _extract_link_defs(text)
497508

498509
preamble, unreleased, version_blocks = parse_changelog(text)
510+
_validate_release_order(version_blocks)
499511

500512
if not version_blocks:
501513
_postprocess_existing_archives(archive_dir)
@@ -547,7 +559,7 @@ def archive_changelog(
547559
# ---------------------------------------------------------------------------
548560

549561

550-
def main() -> None:
562+
def main(argv: list[str] | None = None) -> int:
551563
"""CLI entry point for ``archive-changelog``."""
552564
parser = argparse.ArgumentParser(
553565
prog="archive-changelog",
@@ -564,16 +576,20 @@ def main() -> None:
564576
default=None,
565577
help=f"Archive output directory (default: {_DEFAULT_ARCHIVE_DIR})",
566578
)
567-
args = parser.parse_args()
579+
args = parser.parse_args(argv)
568580

569581
changelog = Path(args.path)
570-
if not changelog.is_file():
571-
print(f"Error: {changelog} not found", file=sys.stderr)
572-
sys.exit(1)
573-
574-
archive_dir = Path(args.archive_dir) if args.archive_dir else None
575-
archive_changelog(changelog, archive_dir)
582+
try:
583+
if not changelog.is_file():
584+
msg = f"{changelog} not found"
585+
raise FileNotFoundError(msg)
586+
archive_dir = Path(args.archive_dir) if args.archive_dir else None
587+
archive_changelog(changelog, archive_dir)
588+
except (OSError, UnicodeError, ValueError) as error:
589+
print(f"archive-changelog: {error}", file=sys.stderr)
590+
return 1
591+
return 0
576592

577593

578594
if __name__ == "__main__":
579-
main()
595+
raise SystemExit(main())

scripts/archive_performance.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,14 @@
3737
from bench_compare import HOW_TO_UPDATE_SECTION, render_release_artifacts
3838
from benchmark_contract import benchmark_contract_digest
3939
from performance_artifacts import ArtifactPaths, ensure_distinct_paths, load_bundle, publish_bundle
40-
from subprocess_utils import ExecutableNotFoundError, cpu_description, run_git_command, run_git_command_with_input, run_safe_command
40+
from subprocess_utils import (
41+
ExecutableNotFoundError,
42+
cpu_description,
43+
format_exception_diagnostics,
44+
run_git_command,
45+
run_git_command_with_input,
46+
run_safe_command,
47+
)
4148

4249
_VERSION_RE = re.compile(r"^\*\*la-stack\*\* v(?P<version>[^\s`]+)", re.MULTILINE)
4350
_BASELINE_RE = re.compile(r"^Comparison against baseline \*\*(?P<baseline>[^*]+)\*\*:", re.MULTILINE)
@@ -223,15 +230,24 @@ def normalize_tag(tag: str) -> str:
223230

224231
def parse_report_id(text: str) -> ReportId:
225232
"""Parse the current version and baseline tag from a benchmark report."""
226-
version_match = _VERSION_RE.search(text)
227-
if version_match is None:
233+
version_matches = list(_VERSION_RE.finditer(text))
234+
if not version_matches:
228235
msg = "could not find la-stack version line in benchmark report"
229236
raise ValueError(msg)
237+
if len(version_matches) != 1:
238+
msg = f"expected exactly one la-stack version line in benchmark report, found {len(version_matches)}"
239+
raise ValueError(msg)
230240

231-
baseline_match = _BASELINE_RE.search(text)
232-
if baseline_match is None:
241+
baseline_matches = list(_BASELINE_RE.finditer(text))
242+
if not baseline_matches:
233243
msg = "could not find comparison baseline line in benchmark report"
234244
raise ValueError(msg)
245+
if len(baseline_matches) != 1:
246+
msg = f"expected exactly one comparison baseline line in benchmark report, found {len(baseline_matches)}"
247+
raise ValueError(msg)
248+
249+
version_match = version_matches[0]
250+
baseline_match = baseline_matches[0]
235251

236252
return ReportId(
237253
current_tag=normalize_tag(version_match.group("version")),
@@ -2087,7 +2103,7 @@ def main(argv: list[str] | None = None) -> int:
20872103
subprocess.CalledProcessError,
20882104
subprocess.TimeoutExpired,
20892105
) as exc:
2090-
print(f"archive-performance: {exc}", file=sys.stderr)
2106+
print(f"archive-performance: {format_exception_diagnostics(exc)}", file=sys.stderr)
20912107
return 1
20922108

20932109
if result.action == "output":

scripts/bench_compare.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@
5050
load_bundle,
5151
publish_bundle,
5252
)
53-
from subprocess_utils import ExecutableNotFoundError, find_project_root, run_git_command
53+
from subprocess_utils import ExecutableNotFoundError, find_project_root, format_exception_diagnostics, run_git_command
5454

5555
# ---------------------------------------------------------------------------
5656
# Benchmark group / bench discovery
@@ -572,14 +572,18 @@ def _parse_harness_provenance(
572572
msg = f"benchmark harness provenance baseline {baseline!r} does not match requested Criterion baseline {expected_baseline!r} in {path}"
573573
raise ValueError(msg)
574574

575-
if not isinstance(schema, bool) and schema == 1:
575+
if not isinstance(schema, int) or isinstance(schema, bool):
576+
msg = f"unsupported or missing schema in {path}: expected integer 1 or 2, got {schema!r}"
577+
raise TypeError(msg)
578+
579+
if schema == 1:
576580
if mode != "shared-current-harness":
577581
msg = f"unsupported or missing mode in {path}: {mode!r}"
578582
raise ValueError(msg)
579583
sha256 = _required_sha256(data, "sha256", path)
580584
return HarnessProvenance(schema=1, mode=mode, sha256=sha256, baseline=baseline)
581585

582-
if isinstance(schema, bool) or schema != 2:
586+
if schema != 2:
583587
msg = f"unsupported or missing schema in {path}: expected 1 or 2, got {schema!r}"
584588
raise ValueError(msg)
585589
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,
22572261
collection=collection,
22582262
)
22592263
except (ExceptionGroup, OSError, KeyError, TypeError, ValueError) as err:
2260-
print(f"Invalid release-performance artifact data: {err}", file=sys.stderr)
2264+
print(
2265+
f"Invalid release-performance artifact data: {format_exception_diagnostics(err)}",
2266+
file=sys.stderr,
2267+
)
22612268
return 2
22622269
print(f"📊 Wrote {artifact_paths.csv} and {artifact_paths.provenance}")
22632270
print(f"📊 Wrote {output_path}")

scripts/benchmark_contract.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
".config/nextest.toml",
1212
"Cargo.toml",
1313
"Cargo.lock",
14+
"justfile",
1415
"rust-toolchain.toml",
1516
"tests/exact_bench_config.rs",
1617
"tests/vs_linalg_inputs.rs",

0 commit comments

Comments
 (0)