Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
```
Expand All @@ -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
Expand Down
19 changes: 15 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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<N>` 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<LaError>` into the
closure's declared `Result` error type; the macro does not introduce a
dynamically sized matrix representation.

`Matrix<D>` key methods: `as_rows`, `into_rows`, `lu`, `ldlt`, `det`,
`det_direct`, `det_direct_with_errbound`, `det_errbound`,
Expand Down Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions docs/BENCHMARKING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions docs/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
37 changes: 25 additions & 12 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -1073,6 +1080,7 @@ update-cargo-tools: _ensure-uv
cargo-llvm-cov
cargo-machete
cargo-nextest
cargo-update
dprint
git-cliff
just
Expand Down
34 changes: 25 additions & 9 deletions scripts/archive_changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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",
Expand All @@ -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())
28 changes: 22 additions & 6 deletions scripts/archive_performance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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<version>[^\s`]+)", re.MULTILINE)
_BASELINE_RE = re.compile(r"^Comparison against baseline \*\*(?P<baseline>[^*]+)\*\*:", re.MULTILINE)
Expand Down Expand Up @@ -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")),
Expand Down Expand Up @@ -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":
Expand Down
15 changes: 11 additions & 4 deletions scripts/bench_compare.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"}:
Expand Down Expand Up @@ -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}")
Expand Down
1 change: 1 addition & 0 deletions scripts/benchmark_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading