diff --git a/.codacy.yml b/.codacy.yml index 82d90dc..a3fde82 100644 --- a/.codacy.yml +++ b/.codacy.yml @@ -3,6 +3,7 @@ # # This repository is primarily Rust. # Note: clippy and rustfmt are not supported by Codacy and are handled by GitHub Actions CI. +# cspell:ignore pyproject engines: # === DOCUMENTATION / SCRIPTS === @@ -22,6 +23,29 @@ engines: severity: warning include_code: true + # === PYTHON / SECURITY === + # Ruff for Python linting and formatting (reads from pyproject.toml) + ruff: + enabled: true + include_paths: + - "scripts/**/*.py" + - "**/*.py" + config: + file: "pyproject.toml" + + # Bandit for Python security analysis + bandit: + enabled: true + include_paths: + - "scripts/**/*.py" + - "**/*.py" + config: + severity: high + confidence: high + skips: ["B101", "B102", "B103", "B108", "B110", "B404", "B603", "B607"] + exclude_info: true + exclude_dirs: ["tests"] + # === RUST / SECURITY === lizard: enabled: true @@ -30,8 +54,9 @@ engines: - "tests/**/*.rs" - "examples/**/*.rs" - "benches/**/*.rs" + - "scripts/**/*.py" config: - languages: ["rust"] + languages: ["rust", "python"] threshold: cyclomatic_complexity: 15 token_count: 300 @@ -46,6 +71,7 @@ engines: - "tests/**/*.rs" - "examples/**/*.rs" - "benches/**/*.rs" + - "scripts/**/*.py" trivy: enabled: true @@ -75,6 +101,15 @@ exclude_paths: - ".git/**" - ".cspellcache" - ".DS_Store" + # Python artifacts + - "__pycache__/**" + - "*.pyc" + - ".pytest_cache/**" + - ".ruff_cache/**" + - ".mypy_cache/**" + - "venv/**" + - ".venv/**" + - "uv.lock" # Focus analysis on source, docs, and CI configuration include_paths: @@ -82,7 +117,10 @@ include_paths: - "benches/**" - "examples/**" - "tests/**" + - "scripts/**" + - "*.py" - "Cargo.toml" + - "pyproject.toml" - "rust-toolchain.toml" - "rustfmt.toml" - "justfile" @@ -100,6 +138,9 @@ languages: rust: extensions: - ".rs" + python: + extensions: + - ".py" markdown: extensions: - ".md" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 67776f2..cbd2e8e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,6 +55,12 @@ jobs: with: tool: just + - name: Install uv (for Python scripts and pytest) + if: matrix.os != 'windows-latest' + uses: astral-sh/setup-uv@ed21f2f24f8dd64503750218de024bcf64c7250a # v7.1.5 + with: + version: "0.9.17" # Pinned for reproducible CI + - name: Install Node.js (for markdownlint and cspell) if: matrix.os != 'windows-latest' uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 diff --git a/.github/workflows/codacy.yml b/.github/workflows/codacy.yml index 0a6f41b..274942d 100644 --- a/.github/workflows/codacy.yml +++ b/.github/workflows/codacy.yml @@ -61,6 +61,19 @@ jobs: mkdir -p "$CODACY_WORKDIR" rsync -a --delete --exclude '.git' ./ "$CODACY_WORKDIR/" + - name: Verify Codacy config includes Python security tooling + run: | + set -euo pipefail + config="$CODACY_WORKDIR/.codacy.yml" + if [ ! -f "$config" ]; then + echo "::error::.codacy.yml not found in workspace copy ($config)" + exit 1 + fi + if ! grep -qE '^[[:space:]]*bandit:' "$config"; then + echo "::error::Bandit engine not configured in .codacy.yml; Python security scanning will be skipped." + exit 1 + fi + # Execute Codacy Analysis CLI and generate a SARIF output with # the security issues identified during the analysis - name: Run Codacy Analysis CLI diff --git a/.gitignore b/.gitignore index fdf592a..43fa25c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,13 @@ /build* /cobertura.xml .DS_Store + +# Python / uv +**/__pycache__/ +*.egg-info/ +.venv/ +venv/ +.ruff_cache/ +.pytest_cache/ +.mypy_cache/ +uv.lock diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..2c07333 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.11 diff --git a/Cargo.lock b/Cargo.lock index ed0fe09..1714f4a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -79,6 +79,26 @@ name = "bytemuck" version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "cast" @@ -220,12 +240,86 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "defer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "930c7171c8df9fb1782bdf9b918ed9ed2d33d1d22300abb754f9085bc48bf8e8" + +[[package]] +name = "dyn-stack" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c4713e43e2886ba72b8271aa66c93d722116acf7a75555cce11dcde84388fe8" +dependencies = [ + "bytemuck", + "dyn-stack-macros", +] + +[[package]] +name = "dyn-stack-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d926b4d407d372f141f93bb444696142c29d32962ccbd3531117cf3aa0bfa9" + [[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "equator" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c35da53b5a021d2484a7cc49b2ac7f2d840f8236a286f84202369bd338d761ea" +dependencies = [ + "equator-macro 0.2.1", +] + +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro 0.4.2", +] + +[[package]] +name = "equator-macro" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bf679796c0322556351f287a51b49e48f7c4986e727b5dd78c972d30e2e16cc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "errno" version = "0.3.14" @@ -236,6 +330,57 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "faer" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cb922206162d9405f9fc059052b3f997bdc92745da7bfd620645f5092df20d1" +dependencies = [ + "bytemuck", + "dyn-stack", + "equator 0.4.2", + "faer-macros", + "faer-traits", + "gemm", + "generativity", + "libm", + "nano-gemm", + "num-complex", + "num-traits", + "private-gemm-x86", + "pulp", + "reborrow", +] + +[[package]] +name = "faer-macros" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cc4b8cd876795d3b19ddfd59b03faa303c0b8adb9af6e188e81fc647c485bb9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "faer-traits" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24b69235b5f54416286c485fb047f2f499fc935a4eee2caadf4757f3c94c7b62" +dependencies = [ + "bytemuck", + "dyn-stack", + "faer-macros", + "generativity", + "libm", + "num-complex", + "num-traits", + "pulp", + "qd", + "reborrow", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -254,6 +399,129 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "gemm" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab96b703d31950f1aeddded248bc95543c9efc7ac9c4a21fda8703a83ee35451" +dependencies = [ + "dyn-stack", + "gemm-c32", + "gemm-c64", + "gemm-common", + "gemm-f16", + "gemm-f32", + "gemm-f64", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + +[[package]] +name = "gemm-c32" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6db9fd9f40421d00eea9dd0770045a5603b8d684654816637732463f4073847" +dependencies = [ + "dyn-stack", + "gemm-common", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + +[[package]] +name = "gemm-c64" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfcad8a3d35a43758330b635d02edad980c1e143dc2f21e6fd25f9e4eada8edf" +dependencies = [ + "dyn-stack", + "gemm-common", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + +[[package]] +name = "gemm-common" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a352d4a69cbe938b9e2a9cb7a3a63b7e72f9349174a2752a558a8a563510d0f3" +dependencies = [ + "bytemuck", + "dyn-stack", + "half", + "libm", + "num-complex", + "num-traits", + "once_cell", + "paste", + "pulp", + "raw-cpuid", + "seq-macro", + "sysctl", +] + +[[package]] +name = "gemm-f16" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cff95ae3259432f3c3410eaa919033cd03791d81cebd18018393dc147952e109" +dependencies = [ + "dyn-stack", + "gemm-common", + "gemm-f32", + "half", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + +[[package]] +name = "gemm-f32" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc8d3d4385393304f407392f754cd2dc4b315d05063f62cf09f47b58de276864" +dependencies = [ + "dyn-stack", + "gemm-common", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + +[[package]] +name = "gemm-f64" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35b2a4f76ce4b8b16eadc11ccf2e083252d8237c1b589558a49b0183545015bd" +dependencies = [ + "dyn-stack", + "gemm-common", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + +[[package]] +name = "generativity" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5881e4c3c2433fe4905bb19cfd2b5d49d4248274862b68c27c33d9ba4e13f9ec" + [[package]] name = "getrandom" version = "0.3.4" @@ -368,11 +636,36 @@ version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ + "bytemuck", "cfg-if", "crunchy", + "num-traits", "zerocopy", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "interpol" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb58032ba748f4010d15912a1855a8a0b1ba9eaad3395b0c171c09b3b356ae50" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "itertools" version = "0.13.0" @@ -404,6 +697,7 @@ version = "0.1.0" dependencies = [ "approx", "criterion", + "faer", "nalgebra", "pastey", "proptest", @@ -415,6 +709,12 @@ version = "0.2.178" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" +[[package]] +name = "libm" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -477,7 +777,77 @@ checksum = "973e7178a678cfd059ccec50887658d482ce16b0aa9da3888ddeab5cd5eb4889" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", +] + +[[package]] +name = "nano-gemm" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb5ba2bea1c00e53de11f6ab5bd0761ba87dc0045d63b0c87ee471d2d3061376" +dependencies = [ + "equator 0.2.2", + "nano-gemm-c32", + "nano-gemm-c64", + "nano-gemm-codegen", + "nano-gemm-core", + "nano-gemm-f32", + "nano-gemm-f64", + "num-complex", +] + +[[package]] +name = "nano-gemm-c32" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a40449e57a5713464c3a1208c4c3301c8d29ee1344711822cf022bc91373a91b" +dependencies = [ + "nano-gemm-codegen", + "nano-gemm-core", + "num-complex", +] + +[[package]] +name = "nano-gemm-c64" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743a6e6211358fba85d1009616751e4107da86f4c95b24e684ce85f25c25b3bf" +dependencies = [ + "nano-gemm-codegen", + "nano-gemm-core", + "num-complex", +] + +[[package]] +name = "nano-gemm-codegen" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "963bf7c7110d55430169dc74c67096375491ed580cd2ef84842550ac72e781fa" + +[[package]] +name = "nano-gemm-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe3fc4f83ae8861bad79dc3c016bd6b0220da5f9de302e07d3112d16efc24aa6" + +[[package]] +name = "nano-gemm-f32" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e3681b7ce35658f79da94b7f62c60a005e29c373c7111ed070e3bf64546a8bb" +dependencies = [ + "nano-gemm-codegen", + "nano-gemm-core", +] + +[[package]] +name = "nano-gemm-f64" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc1e619ed04d801809e1f63e61b669d380c4119e8b0cdd6ed184c6b111f046d8" +dependencies = [ + "nano-gemm-codegen", + "nano-gemm-core", ] [[package]] @@ -496,6 +866,7 @@ version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" dependencies = [ + "bytemuck", "num-traits", ] @@ -526,6 +897,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", ] [[package]] @@ -599,6 +981,18 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "private-gemm-x86" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0af8c3e5087969c323f667ccb4b789fa0954f5aa650550e38e81cf9108be21b5" +dependencies = [ + "defer", + "interpol", + "num_cpus", + "raw-cpuid", +] + [[package]] name = "proc-macro2" version = "1.0.103" @@ -627,6 +1021,32 @@ dependencies = [ "unarray", ] +[[package]] +name = "pulp" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b86df24f0a7ddd5e4b95c94fc9ed8a98f1ca94d3b01bdce2824097e7835907" +dependencies = [ + "bytemuck", + "cfg-if", + "libm", + "num-complex", + "reborrow", + "version_check", +] + +[[package]] +name = "qd" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff8bb755b6008c3b41bf8a0866c8dd4e1245a2f011ceaa22a13ee55c538493e2" +dependencies = [ + "bytemuck", + "libm", + "num-traits", + "pulp", +] + [[package]] name = "quick-error" version = "1.2.3" @@ -686,6 +1106,15 @@ dependencies = [ "rand_core", ] +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags", +] + [[package]] name = "rawpointer" version = "0.2.1" @@ -712,6 +1141,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "reborrow" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430" + [[package]] name = "regex" version = "1.12.2" @@ -796,6 +1231,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "seq-macro" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" + [[package]] name = "serde" version = "1.0.228" @@ -823,7 +1264,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -858,6 +1299,17 @@ dependencies = [ "wide", ] +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.111" @@ -869,6 +1321,20 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sysctl" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01198a2debb237c62b6826ec7081082d951f46dbb64b0e8c7649a452230d1dfc" +dependencies = [ + "bitflags", + "byteorder", + "enum-as-inner", + "libc", + "thiserror", + "walkdir", +] + [[package]] name = "tempfile" version = "3.23.0" @@ -882,6 +1348,26 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "tinytemplate" version = "1.2.1" @@ -910,6 +1396,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wait-timeout" version = "0.2.1" @@ -970,7 +1462,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.111", "wasm-bindgen-shared", ] @@ -1072,5 +1564,5 @@ checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] diff --git a/Cargo.toml b/Cargo.toml index 6daaf94..9e99e90 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ rust-version = "1.92" license = "BSD-3-Clause" description = "Small, stack-allocated linear algebra for fixed dimensions" readme = "README.md" +documentation = "https://docs.rs/la-stack" repository = "https://github.com/acgetchell/la-stack" categories = ["mathematics", "science"] keywords = ["linear-algebra", "geometry", "const-generics"] @@ -16,12 +17,13 @@ keywords = ["linear-algebra", "geometry", "const-generics"] [dev-dependencies] approx = "0.5.1" criterion = { version = "0.8.1", features = ["html_reports"] } +faer = { version = "0.23.2", default-features = false, features = ["std", "linalg"] } nalgebra = "0.34.1" pastey = "0.2.0" proptest = "1.9.0" [[bench]] -name = "vs_nalgebra" +name = "vs_linalg" harness = false [lints.rust] diff --git a/README.md b/README.md index bf9f796..9bd607e 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,12 @@ while keeping the API intentionally small and explicit. - ✅ `unsafe` forbidden - ✅ No runtime dependencies (dev-dependencies are for contributors only) +## 🚫 Anti-goals + +- Bare-metal performance: see [`blas-src`](https://crates.io/crates/blas-src), [`lapack-src`](https://crates.io/crates/lapack-src), [`openblas-src`](https://crates.io/crates/openblas-src) +- Comprehensive: use [`nalgebra`](https://crates.io/crates/nalgebra) if you need a full-featured library +- Large matrices/dimensions with parallelism: use [`faer`](https://crates.io/crates/faer) if you need this + ## 🔢 Scalar types Today, the core types are implemented for `f64`. The intent is to support `f32` and `f64` @@ -107,6 +113,27 @@ just commit-check # lint + all tests + examples For the full set of developer commands, see `just --list` and `WARP.md`. +## 📊 Benchmarks (vs nalgebra/faer) + +![LU solve (factor + solve): median time vs dimension](docs/assets/bench/vs_linalg_lu_solve_median.svg) + +Raw data: [docs/assets/bench/vs_linalg_lu_solve_median.csv](docs/assets/bench/vs_linalg_lu_solve_median.csv) + +Summary (median time; lower is better). The “la-stack vs nalgebra/faer” columns show the % time reduction relative to each baseline (positive = la-stack faster): + + +| D | la-stack median (ns) | nalgebra median (ns) | faer median (ns) | la-stack vs nalgebra | la-stack vs faer | +|---:|--------------------:|--------------------:|----------------:|---------------------:|----------------:| +| 2 | 2.065 | 18.375 | 160.418 | +88.8% | +98.7% | +| 3 | 13.457 | 23.377 | 198.440 | +42.4% | +93.2% | +| 4 | 27.750 | 54.267 | 228.744 | +48.9% | +87.9% | +| 5 | 46.317 | 73.840 | 291.623 | +37.3% | +84.1% | +| 8 | 138.183 | 177.982 | 389.006 | +22.4% | +64.5% | +| 16 | 629.427 | 591.505 | 893.672 | -6.4% | +29.6% | +| 32 | 2,688.216 | 2,503.157 | 2,908.436 | -7.4% | +7.6% | +| 64 | 16,771.962 | 14,860.016 | 12,485.424 | -12.9% | -34.3% | + + ## 📄 License BSD 3-Clause License. See [LICENSE](./LICENSE). diff --git a/WARP.md b/WARP.md index 334430f..06c5920 100644 --- a/WARP.md +++ b/WARP.md @@ -35,7 +35,7 @@ When making changes in this repo, prioritize (in order): - `src/lu.rs`: `Lu` factorization with partial pivoting (`solve_vec`, `det`) - A minimal `justfile` exists for common workflows (see `just --list`). - The public API re-exports these items from `src/lib.rs`. -- Dev-only benchmarks live in `benches/vs_nalgebra.rs` (Criterion + nalgebra comparison). +- Dev-only benchmarks live in `benches/vs_linalg.rs` (Criterion + nalgebra/faer comparison). ## Publishing note diff --git a/benches/vs_linalg.rs b/benches/vs_linalg.rs new file mode 100644 index 0000000..4493c91 --- /dev/null +++ b/benches/vs_linalg.rs @@ -0,0 +1,393 @@ +//! Benchmark comparison between la-stack and other Rust linear algebra crates. +//! +//! Goal: like-for-like comparisons of the operations la-stack supports across several +//! fixed dimensions. +//! +//! Notes: +//! - Determinant is benchmarked via LU on all sides (nalgebra uses closed-forms for 1×1/2×2/3×3). +//! - Matrix infinity norm is the maximum absolute row sum on all sides. + +use criterion::Criterion; +use faer::linalg::solvers::Solve; +use faer::perm::PermRef; +use pastey::paste; +use std::hint::black_box; + +fn faer_perm_sign(p: PermRef<'_, usize>) -> f64 { + // Sign(det(P)) for a permutation matrix P is +1 for even permutations, -1 for odd. + // Parity can be computed from the number of cycles: + // sign = (-1)^(n - cycles) + let (forward, _inverse) = p.arrays(); + let n = forward.len(); + + let mut seen = vec![false; n]; + let mut cycles = 0usize; + + for start in 0..n { + if seen[start] { + continue; + } + cycles += 1; + + let mut i = start; + while !seen[i] { + seen[i] = true; + i = forward[i]; + } + } + + if (n - cycles).is_multiple_of(2) { + 1.0 + } else { + -1.0 + } +} + +fn faer_det_from_partial_piv_lu(lu: &faer::linalg::solvers::PartialPivLu) -> f64 { + // For PA = LU with unit-lower L, det(A) = det(P) * det(U). + let u = lu.U(); + let mut det = 1.0; + for i in 0..u.nrows() { + det *= u[(i, i)]; + } + det * faer_perm_sign(lu.P()) +} + +#[inline] +#[allow(clippy::cast_precision_loss)] // D, r, c are small integers, precision loss is not an issue. +fn matrix_entry(r: usize, c: usize) -> f64 { + if r == c { + // Strict diagonal dominance for stability. + (r as f64).mul_add(1.0e-3, (D as f64) + 1.0) + } else { + // Small, varying off-diagonals. + 0.1 / ((r + c + 1) as f64) + } +} + +#[inline] +fn make_matrix_rows() -> [[f64; D]; D] { + let mut rows = [[0.0; D]; D]; + + let mut r = 0; + while r < D { + let mut c = 0; + while c < D { + rows[r][c] = matrix_entry::(r, c); + c += 1; + } + r += 1; + } + + rows +} + +#[inline] +#[allow(clippy::cast_precision_loss)] // i is a small integer, precision loss is not an issue. +fn vector_entry(i: usize, offset: f64) -> f64 { + (i as f64) + 1.0 + offset +} + +#[inline] +fn make_vector_array(offset: f64) -> [f64; D] { + let mut data = [0.0; D]; + + let mut i = 0; + while i < D { + data[i] = vector_entry(i, offset); + i += 1; + } + + data +} + +#[inline] +fn nalgebra_inf_norm(m: &nalgebra::SMatrix) -> f64 { + // Infinity norm = max absolute row sum. + let mut max_row_sum = 0.0; + + let mut r = 0; + while r < D { + let mut row_sum = 0.0; + let mut c = 0; + while c < D { + row_sum += m[(r, c)].abs(); + c += 1; + } + if row_sum > max_row_sum { + max_row_sum = row_sum; + } + r += 1; + } + + max_row_sum +} + +macro_rules! gen_vs_linalg_benches_for_dim { + ($c:expr, $d:literal) => { + paste! {{ + // Isolate each dimension's inputs to keep types and captures clean. + { + let a = la_stack::Matrix::<$d>::from_rows(make_matrix_rows::<$d>()); + let rhs = la_stack::Vector::<$d>::new(make_vector_array::<$d>(0.0)); + let v1 = la_stack::Vector::<$d>::new(make_vector_array::<$d>(0.0)); + let v2 = la_stack::Vector::<$d>::new(make_vector_array::<$d>(1.0)); + + let na = nalgebra::SMatrix::::from_fn(|r, c| matrix_entry::<$d>(r, c)); + let nrhs = nalgebra::SVector::::from_fn(|i, _| vector_entry(i, 0.0)); + let nv1 = nalgebra::SVector::::from_fn(|i, _| vector_entry(i, 0.0)); + let nv2 = nalgebra::SVector::::from_fn(|i, _| vector_entry(i, 1.0)); + + let fa = faer::Mat::::from_fn($d, $d, |r, c| matrix_entry::<$d>(r, c)); + let frhs = faer::Mat::::from_fn($d, 1, |i, _| vector_entry(i, 0.0)); + let fv1 = faer::Mat::::from_fn($d, 1, |i, _| vector_entry(i, 0.0)); + let fv2 = faer::Mat::::from_fn($d, 1, |i, _| vector_entry(i, 1.0)); + + // Precompute LU once for solve-only / det-only benchmarks. + let a_lu = a + .lu(la_stack::DEFAULT_PIVOT_TOL) + .expect("matrix should be non-singular"); + let na_lu = na.clone().lu(); + let fa_lu = fa.partial_piv_lu(); + + let mut [] = ($c).benchmark_group(concat!("d", stringify!($d))); + + // === Determinant via LU (factor + det) === + [].bench_function("la_stack_det_via_lu", |bencher| { + bencher.iter(|| { + let lu = black_box(a) + .lu(la_stack::DEFAULT_PIVOT_TOL) + .expect("matrix should be non-singular"); + let det = lu.det(); + black_box(det); + }); + }); + + [].bench_function("nalgebra_det_via_lu", |bencher| { + bencher.iter(|| { + let lu = black_box(na.clone()).lu(); + let det = lu.determinant(); + black_box(det); + }); + }); + + [].bench_function("faer_det_via_lu", |bencher| { + bencher.iter(|| { + let lu = black_box(&fa).partial_piv_lu(); + let det = faer_det_from_partial_piv_lu(&lu); + black_box(det); + }); + }); + + // === LU factorization === + [].bench_function("la_stack_lu", |bencher| { + bencher.iter(|| { + let lu = black_box(a) + .lu(la_stack::DEFAULT_PIVOT_TOL) + .expect("matrix should be non-singular"); + let _ = black_box(lu); + }); + }); + + [].bench_function("nalgebra_lu", |bencher| { + bencher.iter(|| { + let lu = black_box(na.clone()).lu(); + black_box(lu); + }); + }); + + [].bench_function("faer_lu", |bencher| { + bencher.iter(|| { + let lu = black_box(&fa).partial_piv_lu(); + black_box(lu); + }); + }); + + // === LU solve (factor + solve) === + [].bench_function("la_stack_lu_solve", |bencher| { + bencher.iter(|| { + let lu = black_box(a) + .lu(la_stack::DEFAULT_PIVOT_TOL) + .expect("matrix should be non-singular"); + let x = lu + .solve_vec(black_box(rhs)) + .expect("solve should succeed"); + let _ = black_box(x); + }); + }); + + [].bench_function("nalgebra_lu_solve", |bencher| { + bencher.iter(|| { + let lu = black_box(na.clone()).lu(); + let x = lu + .solve(black_box(&nrhs)) + .expect("solve should succeed"); + black_box(x); + }); + }); + + [].bench_function("faer_lu_solve", |bencher| { + bencher.iter(|| { + let lu = black_box(&fa).partial_piv_lu(); + let x = lu.solve(black_box(&frhs)); + black_box(x); + }); + }); + + // === Solve using a precomputed LU === + [].bench_function("la_stack_solve_from_lu", |bencher| { + bencher.iter(|| { + let x = a_lu + .solve_vec(black_box(rhs)) + .expect("solve should succeed"); + let _ = black_box(x); + }); + }); + + [].bench_function("nalgebra_solve_from_lu", |bencher| { + bencher.iter(|| { + let x = na_lu + .solve(black_box(&nrhs)) + .expect("solve should succeed"); + black_box(x); + }); + }); + + [].bench_function("faer_solve_from_lu", |bencher| { + bencher.iter(|| { + let x = fa_lu.solve(black_box(&frhs)); + black_box(x); + }); + }); + + // === Determinant from a precomputed LU === + [].bench_function("la_stack_det_from_lu", |bencher| { + bencher.iter(|| { + let det = a_lu.det(); + black_box(det); + }); + }); + + [].bench_function("nalgebra_det_from_lu", |bencher| { + bencher.iter(|| { + let det = na_lu.determinant(); + black_box(det); + }); + }); + + [].bench_function("faer_det_from_lu", |bencher| { + bencher.iter(|| { + let det = faer_det_from_partial_piv_lu(&fa_lu); + black_box(det); + }); + }); + + // === Vector dot product === + [].bench_function("la_stack_dot", |bencher| { + bencher.iter(|| { + let result = black_box(v1).dot(black_box(v2)); + black_box(result); + }); + }); + + [].bench_function("nalgebra_dot", |bencher| { + bencher.iter(|| { + let result = black_box(&nv1).dot(black_box(&nv2)); + black_box(result); + }); + }); + + [].bench_function("faer_dot", |bencher| { + bencher.iter(|| { + let mut sum = 0.0; + let a = black_box(&fv1); + let b = black_box(&fv2); + for i in 0..$d { + sum += a[(i, 0)] * b[(i, 0)]; + } + black_box(sum); + }); + }); + + // === Vector norm squared === + [].bench_function("la_stack_norm2_sq", |bencher| { + bencher.iter(|| { + let result = black_box(v1).norm2_sq(); + black_box(result); + }); + }); + + [].bench_function("nalgebra_norm_squared", |bencher| { + bencher.iter(|| { + let result = black_box(&nv1).norm_squared(); + black_box(result); + }); + }); + + [].bench_function("faer_norm2_sq", |bencher| { + bencher.iter(|| { + let mut sum = 0.0; + let v = black_box(&fv1); + for i in 0..$d { + let x = v[(i, 0)]; + sum += x * x; + } + black_box(sum); + }); + }); + + // === Matrix infinity norm (max absolute row sum) === + [].bench_function("la_stack_inf_norm", |bencher| { + bencher.iter(|| { + let result = black_box(a).inf_norm(); + black_box(result); + }); + }); + + [].bench_function("nalgebra_inf_norm", |bencher| { + bencher.iter(|| { + let result = nalgebra_inf_norm::<$d>(black_box(&na)); + black_box(result); + }); + }); + + [].bench_function("faer_inf_norm", |bencher| { + bencher.iter(|| { + let m = black_box(&fa); + let mut max_row_sum = 0.0; + + for r in 0..$d { + let mut row_sum = 0.0; + for c in 0..$d { + row_sum += m[(r, c)].abs(); + } + if row_sum > max_row_sum { + max_row_sum = row_sum; + } + } + + black_box(max_row_sum); + }); + }); + + [].finish(); + } + }} + }; +} + +fn main() { + let mut c = Criterion::default().configure_from_args(); + + gen_vs_linalg_benches_for_dim!(&mut c, 2); + gen_vs_linalg_benches_for_dim!(&mut c, 3); + gen_vs_linalg_benches_for_dim!(&mut c, 4); + gen_vs_linalg_benches_for_dim!(&mut c, 5); + + gen_vs_linalg_benches_for_dim!(&mut c, 8); + gen_vs_linalg_benches_for_dim!(&mut c, 16); + gen_vs_linalg_benches_for_dim!(&mut c, 32); + gen_vs_linalg_benches_for_dim!(&mut c, 64); + + c.final_summary(); +} diff --git a/benches/vs_nalgebra.rs b/benches/vs_nalgebra.rs deleted file mode 100644 index f96de7b..0000000 --- a/benches/vs_nalgebra.rs +++ /dev/null @@ -1,39 +0,0 @@ -//! Minimal benchmark harness. -//! -//! This exists so the `[[bench]]` target in `Cargo.toml` is valid and `cargo test` -//! can parse the manifest. It also provides a small comparison point against -//! `nalgebra` for determinant computation. - -use criterion::Criterion; -use std::hint::black_box; - -fn main() { - let mut c = Criterion::default().configure_from_args(); - - // Small, fixed-size system representative of the geometric use cases. - let a = la_stack::Matrix::<3>::from_rows([[1.0, 2.0, 3.0], [0.5, -4.0, 0.25], [7.0, 0.0, 1.0]]); - - let na = nalgebra::Matrix3::new( - 1.0, 2.0, 3.0, // - 0.5, -4.0, 0.25, // - 7.0, 0.0, 1.0, - ); - - c.bench_function("la_stack_det_3x3", |b| { - b.iter(|| { - let det = black_box(a) - .det(la_stack::DEFAULT_PIVOT_TOL) - .expect("matrix should be non-singular"); - black_box(det); - }); - }); - - c.bench_function("nalgebra_det_3x3", |b| { - b.iter(|| { - let det = black_box(na).determinant(); - black_box(det); - }); - }); - - c.final_summary(); -} diff --git a/cspell.json b/cspell.json index 079bbde..c8d20b0 100644 --- a/cspell.json +++ b/cspell.json @@ -4,46 +4,74 @@ "useGitignore": true, "words": [ "acgetchell", + "blas", + "capsys", "Clippy", "clippy", "codacy", "const", + "datafile", "doctests", "elif", "endgroup", "f128", "f32", "f64", + "faer", + "frhs", "generics", "Getchell", + "gnuplot", "Justfile", "justfile", + "keepends", "laerror", + "lapack", + "linalg", + "linespoints", + "logscale", "lu", "markdownlint", "MSRV", "msvc", "mult", + "mypy", "nalgebra", + "noenhanced", + "nomirror", "nonfinite", + "noplot", + "nrhs", + "nrows", + "openblas", "pastey", + "patchlevel", "pipefail", "pivoting", "println", "proptest", "proptests", + "pycache", + "pytest", "rug", "RUSTDOCFLAGS", "sarif", "semgrep", + "setuptools", "shellcheck", "taiki", "tridiagonal", "unittests", - "usize" + "usize", + "venv", + "xlabel", + "xtics", + "ylabel", + "yerrorlines" ], "ignorePaths": [ "**/.git/**", + "**/*.svg", "target", "Cargo.lock" ] diff --git a/docs/assets/bench/.gitkeep b/docs/assets/bench/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/docs/assets/bench/.gitkeep @@ -0,0 +1 @@ + diff --git a/docs/assets/bench/vs_linalg_lu_solve_median.csv b/docs/assets/bench/vs_linalg_lu_solve_median.csv new file mode 100644 index 0000000..cd2d751 --- /dev/null +++ b/docs/assets/bench/vs_linalg_lu_solve_median.csv @@ -0,0 +1,9 @@ +D,la_stack,la_lo,la_hi,nalgebra,na_lo,na_hi,faer,fa_lo,fa_hi +2,2.064783337073873,2.057689704502174,2.068888221867889,18.37497049638361,18.331137225709913,18.44905068359342,160.41828167015206,159.771806584937,161.23095742945685 +3,13.456732290936305,13.447517145467188,13.49236442840063,23.37662094350666,23.266710970814643,23.477406862396062,198.4402652520574,197.38808795625323,199.0284125157543 +4,27.75044852748178,27.599588184050052,27.785226320734317,54.266619121715074,54.15885008672231,54.36197439817226,228.74410991553628,227.56318439122288,230.055154703485 +5,46.31708492242545,46.21936017779927,46.502000712392494,73.83977618264294,73.57429654893484,74.07010889514254,291.6230709464669,290.6976886943525,292.8926041338858 +8,138.18343752426807,137.71388876876108,138.5654231809486,177.9820086523135,177.7122781945933,178.24448790485158,389.0063341187339,387.97176861931916,389.80891746278604 +16,629.4267672715603,626.9209242618742,638.6291503313327,591.5050977290701,590.798294714918,592.3790737673589,893.6719342969343,891.1105343477684,898.4784384384384 +32,2688.2164628623186,2684.227603602204,2691.7876890359166,2503.1570307509674,2500.668872475772,2506.5409929308526,2908.435890151515,2905.222288277013,2916.704963235294 +64,16771.9616225278,16754.37058346066,16817.485164835165,14860.015805946792,14773.547535211268,14956.477776495036,12485.423971036585,12471.988315217392,12501.272443181819 diff --git a/docs/assets/bench/vs_linalg_lu_solve_median.svg b/docs/assets/bench/vs_linalg_lu_solve_median.svg new file mode 100644 index 0000000..21124e5 --- /dev/null +++ b/docs/assets/bench/vs_linalg_lu_solve_median.svg @@ -0,0 +1,431 @@ + + + +Gnuplot +Produced by GNUPLOT 6.0 patchlevel 3 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + + + + + + + + + + + 10 + + + + + + + + + + + + + 100 + + + + + + + + + + + + + 1000 + + + + + + + + + + + + + 10000 + + + + + + + + + + + + + 100000 + + + + + + + + + + + + + 2 + + + + + + + + + + + + + 3 + + + + + + + + + + + + + 4 + + + + + + + + + + + + + 5 + + + + + + + + + + + + + 8 + + + + + + + + + + + + + 16 + + + + + + + + + + + + + 32 + + + + + + + + + + + + + 64 + + + + + + + + + gnuplot_plot_1 + + + + + la-stack + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + gnuplot_plot_2 + + + nalgebra + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + gnuplot_plot_3 + + + faer + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + median time (ns) + + + + + Dimension D + + + + + + + LU solve (factor + solve): median time vs dimension + + + + + + + diff --git a/justfile b/justfile index 24348e4..90de2c5 100644 --- a/justfile +++ b/justfile @@ -6,6 +6,24 @@ # Use bash with strict error handling for all recipes set shell := ["bash", "-euo", "pipefail", "-c"] +# Internal helper: ensure uv is installed +_ensure-uv: + #!/usr/bin/env bash + set -euo pipefail + command -v uv >/dev/null || { echo "❌ 'uv' not found. Install: https://github.com/astral-sh/uv (macOS: brew install uv)"; exit 1; } + +# Python tooling (uv) +python-sync: _ensure-uv + uv sync --group dev + +python-lint: python-sync + uv run ruff check scripts/ --fix + uv run ruff format scripts/ + uv run mypy scripts/criterion_dim_plot.py + +test-python: python-sync + uv run pytest -q + # GitHub Actions workflow validation (optional) action-lint: #!/usr/bin/env bash @@ -28,6 +46,48 @@ action-lint: bench: cargo bench +# Bench the la-stack vs nalgebra/faer comparison suite. +bench-vs-linalg filter="": + #!/usr/bin/env bash + set -euo pipefail + filter="{{filter}}" + if [ -n "$filter" ]; then + cargo bench --bench vs_linalg -- "$filter" + else + cargo bench --bench vs_linalg + fi + +# Quick iteration (reduced runtime, no Criterion HTML). +bench-vs-linalg-quick filter="": + #!/usr/bin/env bash + set -euo pipefail + filter="{{filter}}" + if [ -n "$filter" ]; then + cargo bench --bench vs_linalg -- "$filter" --quick --noplot + else + cargo bench --bench vs_linalg -- --quick --noplot + fi + +# Plot: generate a single time-vs-dimension SVG from Criterion results. +plot-vs-linalg metric="lu_solve" stat="median" sample="new" log_y="false": python-sync + #!/usr/bin/env bash + set -euo pipefail + args=(--metric "{{metric}}" --stat "{{stat}}" --sample "{{sample}}") + if [ "{{log_y}}" = "true" ]; then + args+=(--log-y) + fi + uv run criterion-dim-plot "${args[@]}" + +# Plot + update the README benchmark table between BENCH_TABLE markers. +plot-vs-linalg-readme metric="lu_solve" stat="median" sample="new" log_y="false": python-sync + #!/usr/bin/env bash + set -euo pipefail + args=(--metric "{{metric}}" --stat "{{stat}}" --sample "{{sample}}" --update-readme) + if [ "{{log_y}}" = "true" ]; then + args+=(--log-y) + fi + uv run criterion-dim-plot "${args[@]}" + bench-compile: cargo bench --no-run @@ -113,7 +173,7 @@ fmt-check: # Lint groups (delaunay-style) lint: lint-code lint-docs lint-config -lint-code: fmt-check clippy doc-check +lint-code: fmt-check clippy doc-check python-lint lint-config: validate-json action-lint @@ -152,14 +212,14 @@ spell-check: # Testing (delaunay-style split) # - test: lib + doc tests (fast) -# - test-all: everything in Rust +# - test-all: all tests (Rust + Python) # - test-integration: tests/ (if present) test: cargo test --lib --verbose cargo test --doc --verbose -test-all: test test-integration +test-all: test test-integration test-python @echo "✅ All tests passed" test-integration: diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ded8543 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,108 @@ +[build-system] +requires = ["setuptools>=65.0.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "la-stack-scripts" +version = "0.1.0" +description = "Python utility scripts for the la-stack Rust library" +readme = "README.md" +requires-python = ">=3.11" +license = {text = "BSD-3-Clause"} +authors = [ + {name = "Adam Getchell", email = "adam@adamgetchell.org"}, +] +keywords = ["linear-algebra", "benchmarking", "utilities", "la-stack"] +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.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Scientific/Engineering :: Mathematics", + "Topic :: System :: Benchmarking", +] + +# No runtime dependencies currently; scripts rely on the standard library. +dependencies = [] + +[project.urls] +"Homepage" = "https://github.com/acgetchell/la-stack" +"Documentation" = "https://docs.rs/la-stack" +"Repository" = "https://github.com/acgetchell/la-stack" +"Bug Tracker" = "https://github.com/acgetchell/la-stack/issues" + +[project.scripts] +criterion-dim-plot = "criterion_dim_plot:main" + +# Configure setuptools to find modules in scripts/ directory. +[tool.setuptools] +package-dir = {"" = "scripts"} +py-modules = ["criterion_dim_plot"] + +[tool.ruff] +line-length = 150 +target-version = "py311" +src = ["scripts"] + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "N", "UP", "YTT", "S", "BLE", "FBT", "B", "A", "COM", "C4", "DTZ", "T10", "EM", "EXE", "ISC", "ICN", "G", "INP", "PIE", "T20", "PYI", "PT", "Q", "RSE", "RET", "SLF", "SIM", "TID", "TCH", "ARG", "PTH", "ERA", "PD", "PGH", "PL", "TRY", "NPY", "RUF"] +fixable = ["ALL"] +unfixable = [] +ignore = [ + # Formatter conflicts + "COM812", # Trailing comma missing (conflicts with formatter) + "ISC001", # Implicitly concatenated string literals (conflicts with formatter) + + # CLI script patterns + "PLR2004", # Magic value used in comparison - OK for CLI constants and thresholds + "FBT001", # Boolean-typed positional argument - OK for CLI flags + "FBT002", # Boolean default positional argument - OK for CLI flags + "BLE001", # Do not catch blind exception - OK for CLI robustness + "T201", # print found - OK for CLI output + "TRY300", # Consider moving statement to else block - OK for CLI control flow + "TRY301", # Abstract raise to inner function - OK for straightforward CLI error handling + "ARG001", # Unused function argument - common in callbacks + "ERA001", # Found commented-out code - OK for explanatory comments + "EXE001", # Shebang present but file not executable - handled by packaging + "PTH123", # open() should be replaced by Path.open() - OK for some call sites + "EM102", # Exception must not use f-string - OK for CLI error messages + "TRY003", # Avoid specifying long messages outside exception class - OK for CLI reporting +] + +[tool.ruff.lint.per-file-ignores] +"scripts/tests/**/*.py" = [ + "S101", # asserts are fine in tests + "SLF001", # tests may call internal helpers +] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +skip-magic-trailing-comma = false +line-ending = "auto" + +[tool.mypy] +python_version = "3.11" +mypy_path = "scripts" +warn_unused_configs = true +no_implicit_optional = true +strict_equality = true +warn_redundant_casts = true +warn_no_return = true +show_error_codes = true +show_column_numbers = true +pretty = true +allow_untyped_calls = true +allow_incomplete_defs = true + +[dependency-groups] +dev = [ + "mypy>=1.19.0", + "pytest>=8.0.0", + "ruff>=0.12.11", +] diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..4a76971 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,120 @@ +# scripts/ + +This directory contains Python utilities used during development of the `la-stack` crate. + +## Setup + +The Python tooling in this repo is managed with [`uv`](https://github.com/astral-sh/uv). + +```bash +just python-sync +# or: +uv sync --group dev +``` + +## How to use it + +### Plotting Criterion benchmarks (la-stack vs nalgebra/faer) + +The plotter reads Criterion output under: + +- `target/criterion/d{D}/{benchmark}/{new|base}/estimates.json` + +And writes: + +- `docs/assets/bench/vs_linalg_{metric}_{stat}.csv` +- `docs/assets/bench/vs_linalg_{metric}_{stat}.svg` + +To generate the single “time vs dimension” chart: + +By default, the benchmark suite runs for dimensions 2–5, 8, 16, 32, and 64. + +1. Run the benchmarks you want to plot (this produces `target/criterion/...`): + +```bash +# full run (takes longer, better for README plots) +just bench-vs-linalg lu_solve + +# or quick run (fast sanity check; still produces estimates.json) +just bench-vs-linalg-quick lu_solve +``` + +2. Generate the chart (median or mean): + +```bash +# median (recommended) +just plot-vs-linalg lu_solve median new true + +# median + update README's benchmark table (between BENCH_TABLE markers) +just plot-vs-linalg-readme lu_solve median new true + +# or mean +just plot-vs-linalg lu_solve mean new true +``` + +This writes: + +- `docs/assets/bench/vs_linalg_lu_solve_median.csv` +- `docs/assets/bench/vs_linalg_lu_solve_median.svg` (requires `gnuplot`) + +(For `stat=mean`, the filenames end in `_mean` instead of `_median`.) + +### More examples + +Plot a different metric: + +```bash +uv run criterion-dim-plot --metric dot --stat median --sample new +uv run criterion-dim-plot --metric inf_norm --stat median --sample new +``` + +Plot a different statistic: + +```bash +uv run criterion-dim-plot --metric lu_solve --stat mean --sample new +``` + +Plot the previous (baseline) sample instead of the newest run: + +```bash +uv run criterion-dim-plot --metric lu_solve --stat median --sample base +``` + +Use a log-scale y-axis: + +```bash +uv run criterion-dim-plot --metric lu_solve --stat median --sample new --log-y +``` + +Write to custom output paths: + +```bash +uv run criterion-dim-plot \ + --metric lu_solve --stat median --sample new \ + --csv docs/assets/bench/custom.csv \ + --out docs/assets/bench/custom.svg +``` + +CSV only (skip SVG/gnuplot): + +```bash +uv run criterion-dim-plot --no-plot --metric lu_solve --stat median --sample new +``` + +### gnuplot + +SVG rendering requires `gnuplot` to be installed and available on `PATH`. + +Install (macOS/Homebrew): + +```bash +brew install gnuplot +``` + +Verify the installed version: + +```bash +gnuplot --version +``` + +This repo has been tested with `gnuplot 6.0 patchlevel 3` (Homebrew `gnuplot 6.0.3`). diff --git a/scripts/criterion_dim_plot.py b/scripts/criterion_dim_plot.py new file mode 100644 index 0000000..2a63d42 --- /dev/null +++ b/scripts/criterion_dim_plot.py @@ -0,0 +1,487 @@ +#!/usr/bin/env python3 +"""Aggregate Criterion benchmark results into a time-vs-dimension chart. + +Reads Criterion output under: + target/criterion/d{D}/{benchmark}/{new|base}/estimates.json + +And writes: + docs/assets/bench/vs_linalg_{metric}_{stat}.csv + docs/assets/bench/vs_linalg_{metric}_{stat}.svg + +This is intended to create a single, README-friendly plot comparing la-stack to other +Rust linear algebra crates across dimensions. +""" + +from __future__ import annotations + +import argparse +import json +import re +import shutil +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Final + + +@dataclass(frozen=True, slots=True) +class Metric: + la_bench: str + na_bench: str + fa_bench: str + title: str + + +@dataclass(frozen=True, slots=True) +class PlotRequest: + csv_path: Path + out_svg: Path + title: str + stat: str + dims: tuple[int, ...] + log_y: bool + + +@dataclass(frozen=True, slots=True) +class Row: + dim: int + la_time: float + la_lo: float + la_hi: float + na_time: float + na_lo: float + na_hi: float + fa_time: float + fa_lo: float + fa_hi: float + + +class ReadmeMarkerError(ValueError): + """Base error for invalid README BENCH_TABLE markers.""" + + +class MarkerNotFoundError(ReadmeMarkerError): + """Raised when README markers are missing or not unique.""" + + +class MarkerOrderError(ReadmeMarkerError): + """Raised when README markers are out of order.""" + + +METRICS: Final[dict[str, Metric]] = { + "det_via_lu": Metric( + la_bench="la_stack_det_via_lu", + na_bench="nalgebra_det_via_lu", + fa_bench="faer_det_via_lu", + title="Determinant via LU (factor + det)", + ), + "lu": Metric( + la_bench="la_stack_lu", + na_bench="nalgebra_lu", + fa_bench="faer_lu", + title="LU factorization", + ), + "lu_solve": Metric( + la_bench="la_stack_lu_solve", + na_bench="nalgebra_lu_solve", + fa_bench="faer_lu_solve", + title="LU solve (factor + solve)", + ), + "solve_from_lu": Metric( + la_bench="la_stack_solve_from_lu", + na_bench="nalgebra_solve_from_lu", + fa_bench="faer_solve_from_lu", + title="Solve from precomputed LU", + ), + "det_from_lu": Metric( + la_bench="la_stack_det_from_lu", + na_bench="nalgebra_det_from_lu", + fa_bench="faer_det_from_lu", + title="Determinant from precomputed LU", + ), + "dot": Metric( + la_bench="la_stack_dot", + na_bench="nalgebra_dot", + fa_bench="faer_dot", + title="Vector dot product", + ), + # Different names between crates. + "norm2_sq": Metric( + la_bench="la_stack_norm2_sq", + na_bench="nalgebra_norm_squared", + fa_bench="faer_norm2_sq", + title="Vector squared 2-norm", + ), + "inf_norm": Metric( + la_bench="la_stack_inf_norm", + na_bench="nalgebra_inf_norm", + fa_bench="faer_inf_norm", + title="Matrix infinity norm (max abs row sum)", + ), +} + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[1] + + +def _dim_from_group_dir(name: str) -> int | None: + match = re.fullmatch(r"d(\d+)", name) + if match is None: + return None + return int(match.group(1)) + + +def _discover_dims(criterion_dir: Path) -> list[int]: + dims: list[int] = [] + for child in criterion_dir.iterdir(): + if not child.is_dir(): + continue + d = _dim_from_group_dir(child.name) + if d is None: + continue + dims.append(d) + return sorted(dims) + + +def _read_estimate(estimates_json: Path, stat: str) -> tuple[float, float, float]: + data = json.loads(estimates_json.read_text(encoding="utf-8")) + + stat_obj = data.get(stat) + if not isinstance(stat_obj, dict): + raise KeyError(f"stat '{stat}' not found in {estimates_json}") + + point = float(stat_obj["point_estimate"]) + ci = stat_obj.get("confidence_interval") + if not isinstance(ci, dict): + return (point, point, point) + + lo = float(ci.get("lower_bound", point)) + hi = float(ci.get("upper_bound", point)) + return (point, lo, hi) + + +def _write_csv(out_csv: Path, rows: list[Row]) -> None: + out_csv.parent.mkdir(parents=True, exist_ok=True) + with out_csv.open("w", encoding="utf-8") as f: + f.write("D,la_stack,la_lo,la_hi,nalgebra,na_lo,na_hi,faer,fa_lo,fa_hi\n") + for row in rows: + f.write(f"{row.dim},{row.la_time},{row.la_lo},{row.la_hi},{row.na_time},{row.na_lo},{row.na_hi},{row.fa_time},{row.fa_lo},{row.fa_hi}\n") + + +def _pct_reduction(baseline: float, value: float) -> str: + """Percent time reduction relative to baseline (positive = value is faster).""" + if baseline == 0.0: + return "n/a" + pct = ((baseline - value) / baseline) * 100.0 + return f"{pct:+.1f}%" + + +def _markdown_table(rows: list[Row], stat: str) -> str: + lines = [ + f"| D | la-stack {stat} (ns) | nalgebra {stat} (ns) | faer {stat} (ns) | la-stack vs nalgebra | la-stack vs faer |", + "|---:|--------------------:|--------------------:|----------------:|---------------------:|----------------:|", + ] + + for row in rows: + pct_vs_na = _pct_reduction(row.na_time, row.la_time) + pct_vs_fa = _pct_reduction(row.fa_time, row.la_time) + lines.append(f"| {row.dim} | {row.la_time:,.3f} | {row.na_time:,.3f} | {row.fa_time:,.3f} | {pct_vs_na} | {pct_vs_fa} |") + + return "\n".join(lines) + + +def _readme_table_markers(metric: str, stat: str, sample: str) -> tuple[str, str]: + tag = f"BENCH_TABLE:{metric}:{stat}:{sample}" + return (f"", f"") + + +def _update_readme_table(readme_path: Path, marker_begin: str, marker_end: str, table_md: str) -> bool: + lines = readme_path.read_text(encoding="utf-8").splitlines(keepends=True) + + begin_indices = [i for i, line in enumerate(lines) if line.strip() == marker_begin] + end_indices = [i for i, line in enumerate(lines) if line.strip() == marker_end] + + if len(begin_indices) != 1 or len(end_indices) != 1: + msg = f"README markers not found or not unique (begin={len(begin_indices)}, end={len(end_indices)})." + raise MarkerNotFoundError(msg) + + begin_idx = begin_indices[0] + end_idx = end_indices[0] + if begin_idx >= end_idx: + msg = "README markers are out of order." + raise MarkerOrderError(msg) + + table_lines = [line + "\n" for line in table_md.strip("\n").splitlines()] + new_lines = [ + *lines[: begin_idx + 1], + *table_lines, + *lines[end_idx:], + ] + + if new_lines == lines: + return False + + readme_path.write_text("".join(new_lines), encoding="utf-8") + return True + + +def _gp_quote(s: str) -> str: + # gnuplot supports single-quoted strings; escape backslashes and single quotes. + return "'" + s.replace("\\", "\\\\").replace("'", "\\'") + "'" + + +def _render_svg_with_gnuplot(req: PlotRequest) -> None: + gnuplot_path = shutil.which("gnuplot") + if gnuplot_path is None: + msg = "gnuplot not found. Install it (macOS: `brew install gnuplot`) or re-run with --no-plot." + raise FileNotFoundError(msg) + + req.out_svg.parent.mkdir(parents=True, exist_ok=True) + + xtics = ", ".join(str(d) for d in req.dims) + + gp_lines = [ + "set terminal svg size 960,540 noenhanced", + f"set output {_gp_quote(str(req.out_svg))}", + "set datafile separator comma", + "set grid", + "set key left top", + f"set title {_gp_quote(req.title)}", + "set xlabel 'Dimension D'", + f"set ylabel {_gp_quote(f'{req.stat} time (ns)')}", + f"set xtics ({xtics})", + "set style line 1 lc rgb '#1f77b4' lt 1 lw 2 pt 7 ps 1", + "set style line 2 lc rgb '#ff7f0e' lt 1 lw 2 pt 5 ps 1", + "set style line 3 lc rgb '#2ca02c' lt 1 lw 2 pt 9 ps 1", + "set style data linespoints", + "set tics nomirror", + "set border linewidth 1", + ] + + if req.log_y: + gp_lines.append("set logscale y 10") + + gp_lines.extend( + [ + "plot \\", + f" {_gp_quote(str(req.csv_path))} using 1:2:3:4 with yerrorlines ls 1 title 'la-stack', \\", + f" {_gp_quote(str(req.csv_path))} using 1:5:6:7 with yerrorlines ls 2 title 'nalgebra', \\", + f" {_gp_quote(str(req.csv_path))} using 1:8:9:10 with yerrorlines ls 3 title 'faer'", + ] + ) + + # Safe: gnuplot executable is resolved via PATH; input is a generated script with fully + # quoted file paths. + subprocess.run([gnuplot_path], input="\n".join(gp_lines), text=True, check=True) # noqa: S603 + + +def _parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Plot Criterion time vs dimension for la-stack vs nalgebra/faer.") + + parser.add_argument( + "--metric", + default="lu_solve", + choices=sorted(METRICS.keys()), + help="Which vs_linalg metric to plot.", + ) + parser.add_argument( + "--stat", + default="median", + choices=["mean", "median"], + help="Statistic to plot from estimates.json.", + ) + parser.add_argument( + "--sample", + default="new", + choices=["new", "base"], + help="Which Criterion run directory to read (new = most recent).", + ) + parser.add_argument( + "--criterion-dir", + default="target/criterion", + help="Criterion output directory (default: target/criterion).", + ) + parser.add_argument( + "--out", + default=None, + help="Output SVG path (default: docs/assets/bench/vs_linalg_{metric}_{stat}.svg).", + ) + parser.add_argument( + "--csv", + default=None, + help="Output CSV path (default: docs/assets/bench/vs_linalg_{metric}_{stat}.csv).", + ) + parser.add_argument( + "--log-y", + action="store_true", + help="Use a log-scale y-axis.", + ) + parser.add_argument( + "--no-plot", + action="store_true", + help="Only write CSV (skip gnuplot/SVG).", + ) + parser.add_argument( + "--update-readme", + action="store_true", + help="Update a Markdown table in README.md between BENCH_TABLE markers.", + ) + parser.add_argument( + "--readme", + default="README.md", + help="Path to README file to update (default: README.md at repo root).", + ) + + return parser.parse_args(argv) + + +def _resolve_under_root(root: Path, arg: str) -> Path: + path = Path(arg) + return path if path.is_absolute() else root / path + + +def _resolve_output_paths(root: Path, metric: str, stat: str, out_svg: str | None, out_csv: str | None) -> tuple[Path, Path]: + svg = Path(out_svg) if out_svg is not None else Path(f"docs/assets/bench/vs_linalg_{metric}_{stat}.svg") + csv = Path(out_csv) if out_csv is not None else Path(f"docs/assets/bench/vs_linalg_{metric}_{stat}.csv") + + if not svg.is_absolute(): + svg = root / svg + if not csv.is_absolute(): + csv = root / csv + + return (svg, csv) + + +def _collect_rows(criterion_dir: Path, dims: list[int], metric: Metric, stat: str, sample: str) -> tuple[list[Row], list[str]]: + rows: list[Row] = [] + skipped: list[str] = [] + + for d in dims: + group_dir = criterion_dir / f"d{d}" + la_est = group_dir / metric.la_bench / sample / "estimates.json" + na_est = group_dir / metric.na_bench / sample / "estimates.json" + fa_est = group_dir / metric.fa_bench / sample / "estimates.json" + + if not la_est.exists() or not na_est.exists() or not fa_est.exists(): + skipped.append(f"d{d} (missing {metric.la_bench}, {metric.na_bench}, or {metric.fa_bench})") + continue + + la, la_lo, la_hi = _read_estimate(la_est, stat) + na, na_lo, na_hi = _read_estimate(na_est, stat) + fa, fa_lo, fa_hi = _read_estimate(fa_est, stat) + rows.append( + Row( + dim=d, + la_time=la, + la_lo=la_lo, + la_hi=la_hi, + na_time=na, + na_lo=na_lo, + na_hi=na_hi, + fa_time=fa, + fa_lo=fa_lo, + fa_hi=fa_hi, + ) + ) + + return (rows, skipped) + + +def _maybe_update_readme(root: Path, args: argparse.Namespace, rows: list[Row]) -> int: + if not args.update_readme: + return 0 + + readme_path = _resolve_under_root(root, args.readme) + + marker_begin, marker_end = _readme_table_markers(args.metric, args.stat, args.sample) + table_md = _markdown_table(rows, args.stat) + + try: + changed = _update_readme_table(readme_path, marker_begin, marker_end, table_md) + except (OSError, ValueError) as e: + print(str(e), file=sys.stderr) + return 2 + + if changed: + print(f"Updated README table: {readme_path}") + + return 0 + + +def _maybe_render_plot(args: argparse.Namespace, req: PlotRequest, skipped: list[str]) -> int: + if args.no_plot: + print(f"Wrote CSV: {req.csv_path}") + return 0 + + try: + _render_svg_with_gnuplot(req) + except (FileNotFoundError, subprocess.CalledProcessError) as e: + print(str(e), file=sys.stderr) + print(f"Wrote CSV instead: {req.csv_path}", file=sys.stderr) + return 1 + + if skipped: + print("Warning: some dimension groups were skipped:") + for s in skipped: + print(f" - {s}") + + print(f"Wrote CSV: {req.csv_path}") + print(f"Wrote SVG: {req.out_svg}") + return 0 + + +def main(argv: list[str] | None = None) -> int: + args = _parse_args(sys.argv[1:] if argv is None else argv) + + root = _repo_root() + + criterion_dir = _resolve_under_root(root, args.criterion_dir) + + dims = _discover_dims(criterion_dir) if criterion_dir.exists() else [] + if not dims: + print( + f"No Criterion results found under {criterion_dir}.\n\nRun benchmarks first, e.g.:\n cargo bench --bench vs_linalg\n", + file=sys.stderr, + ) + return 2 + + metric = METRICS[args.metric] + + out_svg, out_csv = _resolve_output_paths(root, args.metric, args.stat, args.out, args.csv) + + rows, skipped = _collect_rows(criterion_dir, dims, metric, args.stat, args.sample) + if not rows: + print( + "No benchmark results found to plot for the selected metric/stat.\n" + f"Expected files like:\n {criterion_dir}/d32/{metric.la_bench}/{args.sample}/estimates.json\n", + file=sys.stderr, + ) + if skipped: + print("Skipped groups:", *skipped, sep="\n - ", file=sys.stderr) + return 2 + + _write_csv(out_csv, rows) + + rc = _maybe_update_readme(root, args, rows) + if rc != 0: + return rc + + dims_present = [row.dim for row in rows] + + title = f"{metric.title}: {args.stat} time vs dimension" + req = PlotRequest( + csv_path=out_csv, + out_svg=out_svg, + title=title, + stat=args.stat, + dims=tuple(dims_present), + log_y=args.log_y, + ) + + return _maybe_render_plot(args, req, skipped) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/tests/__init__.py b/scripts/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/scripts/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/scripts/tests/test_criterion_dim_plot.py b/scripts/tests/test_criterion_dim_plot.py new file mode 100644 index 0000000..f29c79b --- /dev/null +++ b/scripts/tests/test_criterion_dim_plot.py @@ -0,0 +1,248 @@ +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +import pytest + +import criterion_dim_plot + +if TYPE_CHECKING: + from pathlib import Path + + +def test_readme_table_markers_are_stable() -> None: + begin, end = criterion_dim_plot._readme_table_markers("lu_solve", "median", "new") + assert begin == "" + assert end == "" + + +def test_markdown_table_formats_values_and_pct() -> None: + rows = [ + criterion_dim_plot.Row( + dim=2, + la_time=50.0, + la_lo=0.0, + la_hi=0.0, + na_time=100.0, + na_lo=0.0, + na_hi=0.0, + fa_time=200.0, + fa_lo=0.0, + fa_hi=0.0, + ), # +50.0% vs na, +75.0% vs fa + criterion_dim_plot.Row( + dim=64, + la_time=1_000.0, + la_lo=0.0, + la_hi=0.0, + na_time=900.0, + na_lo=0.0, + na_hi=0.0, + fa_time=800.0, + fa_lo=0.0, + fa_hi=0.0, + ), # -11.1% vs na, -25.0% vs fa + ] + + table = criterion_dim_plot._markdown_table(rows, stat="median") + + assert "| D | la-stack median (ns) | nalgebra median (ns) | faer median (ns) | la-stack vs nalgebra | la-stack vs faer |" in table + assert "| 2 | 50.000 | 100.000 | 200.000 | +50.0% | +75.0% |" in table + # thousand separator and sign + assert "| 64 | 1,000.000 | 900.000 | 800.000 | -11.1% | -25.0% |" in table + + +def test_markdown_table_handles_zero_nalgebra_time() -> None: + rows = [ + # nalgebra time of 0 indicates missing/corrupt data; ensure we don't crash. + criterion_dim_plot.Row( + dim=2, + la_time=10.0, + la_lo=0.0, + la_hi=0.0, + na_time=0.0, + na_lo=0.0, + na_hi=0.0, + fa_time=100.0, + fa_lo=0.0, + fa_hi=0.0, + ), + ] + + table = criterion_dim_plot._markdown_table(rows, stat="median") + assert "| 2 | 10.000 | 0.000 | 100.000 | n/a | +90.0% |" in table + + +def test_gp_quote_escapes_backslashes_and_quotes() -> None: + assert criterion_dim_plot._gp_quote("plain") == "'plain'" + assert criterion_dim_plot._gp_quote("a'b") == "'a\\'b'" + assert criterion_dim_plot._gp_quote("a\\b") == "'a\\\\b'" + assert criterion_dim_plot._gp_quote("a\\'b") == "'a\\\\\\'b'" + + +def test_maybe_render_plot_handles_gnuplot_failure(capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch) -> None: + # Simulate gnuplot existing but failing to run (CalledProcessError). + def boom(_req: object) -> None: + raise criterion_dim_plot.subprocess.CalledProcessError(1, ["gnuplot"]) # type: ignore[arg-type] + + monkeypatch.setattr(criterion_dim_plot, "_render_svg_with_gnuplot", boom) + + args = type("Args", (), {"no_plot": False})() + req = criterion_dim_plot.PlotRequest( + csv_path=criterion_dim_plot.Path("out.csv"), + out_svg=criterion_dim_plot.Path("out.svg"), + title="t", + stat="median", + dims=(2,), + log_y=False, + ) + + rc = criterion_dim_plot._maybe_render_plot(args, req, skipped=[]) + assert rc == 1 + + captured = capsys.readouterr() + assert "Wrote CSV instead" in captured.err + + +def test_update_readme_table_replaces_only_between_markers(tmp_path: Path) -> None: + marker_begin, marker_end = criterion_dim_plot._readme_table_markers("lu_solve", "median", "new") + + readme = tmp_path / "README.md" + readme.write_text( + "\n".join( + [ + "# Title", + "before", + marker_begin, + "old line 1", + "old line 2", + marker_end, + "after", + "", + ] + ), + encoding="utf-8", + ) + + table_md = "| a |\n|---|\n| 1 |" + + changed = criterion_dim_plot._update_readme_table(readme, marker_begin, marker_end, table_md) + assert changed is True + + text = readme.read_text(encoding="utf-8") + assert "old line 1" not in text + assert "old line 2" not in text + assert marker_begin in text + assert marker_end in text + assert "| a |" in text + + # Re-running with the same content should be a no-op. + changed_again = criterion_dim_plot._update_readme_table(readme, marker_begin, marker_end, table_md) + assert changed_again is False + + +def test_update_readme_table_errors_on_missing_markers(tmp_path: Path) -> None: + readme = tmp_path / "README.md" + readme.write_text("# Title\n", encoding="utf-8") + + with pytest.raises(criterion_dim_plot.ReadmeMarkerError, match=r"README markers not found"): + criterion_dim_plot._update_readme_table( + readme, + "", + "", + "| x |", + ) + + +def test_update_readme_table_errors_on_out_of_order_markers(tmp_path: Path) -> None: + marker_begin, marker_end = criterion_dim_plot._readme_table_markers("lu_solve", "median", "new") + + readme = tmp_path / "README.md" + readme.write_text("\n".join([marker_end, marker_begin, ""]), encoding="utf-8") + + with pytest.raises(criterion_dim_plot.ReadmeMarkerError, match=r"out of order"): + criterion_dim_plot._update_readme_table(readme, marker_begin, marker_end, "| x |") + + +def test_update_readme_table_errors_on_non_unique_markers(tmp_path: Path) -> None: + marker_begin, marker_end = criterion_dim_plot._readme_table_markers("lu_solve", "median", "new") + + readme = tmp_path / "README.md" + readme.write_text( + "\n".join( + [ + marker_begin, + marker_begin, + marker_end, + "", + ] + ), + encoding="utf-8", + ) + + with pytest.raises(criterion_dim_plot.ReadmeMarkerError, match=r"not found or not unique"): + criterion_dim_plot._update_readme_table(readme, marker_begin, marker_end, "| x |") + + +def test_main_update_readme_no_plot_happy_path(tmp_path: Path) -> None: + # Create a minimal Criterion directory structure for lu_solve. + criterion_dir = tmp_path / "criterion" + + def write_estimates(path: Path, median: float) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps( + { + "median": { + "point_estimate": median, + "confidence_interval": {"lower_bound": median * 0.9, "upper_bound": median * 1.1}, + } + } + ), + encoding="utf-8", + ) + + for d, la, na, fa in [(2, 10.0, 20.0, 40.0), (8, 100.0, 50.0, 200.0)]: + base = criterion_dir / f"d{d}" + write_estimates(base / "la_stack_lu_solve" / "new" / "estimates.json", la) + write_estimates(base / "nalgebra_lu_solve" / "new" / "estimates.json", na) + write_estimates(base / "faer_lu_solve" / "new" / "estimates.json", fa) + + readme = tmp_path / "README.md" + marker_begin, marker_end = criterion_dim_plot._readme_table_markers("lu_solve", "median", "new") + readme.write_text("\n".join(["# Bench", marker_begin, "placeholder", marker_end, ""]), encoding="utf-8") + + out_csv = tmp_path / "out.csv" + + rc = criterion_dim_plot.main( + [ + "--metric", + "lu_solve", + "--stat", + "median", + "--sample", + "new", + "--criterion-dir", + str(criterion_dir), + "--csv", + str(out_csv), + "--no-plot", + "--update-readme", + "--readme", + str(readme), + ] + ) + assert rc == 0 + + # CSV written + csv_text = out_csv.read_text(encoding="utf-8") + assert csv_text.startswith("D,la_stack,la_lo,la_hi,nalgebra,na_lo,na_hi,faer,fa_lo,fa_hi\n") + assert "2,10.0" in csv_text + assert "8,100.0" in csv_text + + # README updated with computed table + readme_text = readme.read_text(encoding="utf-8") + assert "placeholder" not in readme_text + assert "| 2 | 10.000 | 20.000 | 40.000 | +50.0% | +75.0% |" in readme_text + assert "| 8 | 100.000 | 50.000 | 200.000 | -100.0% | +50.0% |" in readme_text diff --git a/src/lu.rs b/src/lu.rs index 7c04bfe..92ab801 100644 --- a/src/lu.rs +++ b/src/lu.rs @@ -86,7 +86,6 @@ impl Lu { /// /// # Examples /// ``` - /// #![allow(unused_imports)] /// use la_stack::prelude::*; /// /// # fn main() -> Result<(), LaError> { @@ -152,7 +151,6 @@ impl Lu { /// /// # Examples /// ``` - /// #![allow(unused_imports)] /// use la_stack::prelude::*; /// /// # fn main() -> Result<(), LaError> { diff --git a/src/matrix.rs b/src/matrix.rs index 3eff55a..6d7a5bb 100644 --- a/src/matrix.rs +++ b/src/matrix.rs @@ -15,7 +15,6 @@ impl Matrix { /// /// # Examples /// ``` - /// #![allow(unused_imports)] /// use la_stack::prelude::*; /// /// let m = Matrix::<2>::from_rows([[1.0, 2.0], [3.0, 4.0]]); @@ -30,7 +29,6 @@ impl Matrix { /// /// # Examples /// ``` - /// #![allow(unused_imports)] /// use la_stack::prelude::*; /// /// let z = Matrix::<2>::zero(); @@ -47,7 +45,6 @@ impl Matrix { /// /// # Examples /// ``` - /// #![allow(unused_imports)] /// use la_stack::prelude::*; /// /// let i = Matrix::<3>::identity(); @@ -72,7 +69,6 @@ impl Matrix { /// /// # Examples /// ``` - /// #![allow(unused_imports)] /// use la_stack::prelude::*; /// /// let m = Matrix::<2>::from_rows([[1.0, 2.0], [3.0, 4.0]]); @@ -95,7 +91,6 @@ impl Matrix { /// /// # Examples /// ``` - /// #![allow(unused_imports)] /// use la_stack::prelude::*; /// /// let mut m = Matrix::<2>::zero(); @@ -117,7 +112,6 @@ impl Matrix { /// /// # Examples /// ``` - /// #![allow(unused_imports)] /// use la_stack::prelude::*; /// /// let m = Matrix::<2>::from_rows([[1.0, -2.0], [3.0, 4.0]]); @@ -142,7 +136,6 @@ impl Matrix { /// /// # Examples /// ``` - /// #![allow(unused_imports)] /// use la_stack::prelude::*; /// /// # fn main() -> Result<(), LaError> { @@ -170,7 +163,6 @@ impl Matrix { /// /// # Examples /// ``` - /// #![allow(unused_imports)] /// use la_stack::prelude::*; /// /// # fn main() -> Result<(), LaError> { diff --git a/src/vector.rs b/src/vector.rs index e63c3c2..6660587 100644 --- a/src/vector.rs +++ b/src/vector.rs @@ -12,7 +12,6 @@ impl Vector { /// /// # Examples /// ``` - /// #![allow(unused_imports)] /// use la_stack::prelude::*; /// /// let v = Vector::<3>::new([1.0, 2.0, 3.0]); @@ -27,7 +26,6 @@ impl Vector { /// /// # Examples /// ``` - /// #![allow(unused_imports)] /// use la_stack::prelude::*; /// /// let z = Vector::<2>::zero(); @@ -42,7 +40,6 @@ impl Vector { /// /// # Examples /// ``` - /// #![allow(unused_imports)] /// use la_stack::prelude::*; /// /// let v = Vector::<2>::new([1.0, -2.0]); @@ -58,7 +55,6 @@ impl Vector { /// /// # Examples /// ``` - /// #![allow(unused_imports)] /// use la_stack::prelude::*; /// /// let v = Vector::<2>::new([1.0, 2.0]); @@ -75,7 +71,6 @@ impl Vector { /// /// # Examples /// ``` - /// #![allow(unused_imports)] /// use la_stack::prelude::*; /// /// let a = Vector::<3>::new([1.0, 2.0, 3.0]); @@ -98,7 +93,6 @@ impl Vector { /// /// # Examples /// ``` - /// #![allow(unused_imports)] /// use la_stack::prelude::*; /// /// let v = Vector::<3>::new([1.0, 2.0, 3.0]);