laprust 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- laprust-1.0.0/.github/constraints/numpy-armv7-build.txt +3 -0
- laprust-1.0.0/.github/tests/README.md +73 -0
- laprust-1.0.0/.github/tests/check_dist.py +63 -0
- laprust-1.0.0/.github/tests/check_wheel.py +20 -0
- laprust-1.0.0/.github/tests/conftest.py +15 -0
- laprust-1.0.0/.github/tests/install_numpy_compat.py +12 -0
- laprust-1.0.0/.github/tests/pytest.ini +5 -0
- laprust-1.0.0/.github/tests/requirements.txt +6 -0
- laprust-1.0.0/.github/tests/test_benchmarks.py +377 -0
- laprust-1.0.0/.github/tests/test_editable_install.py +126 -0
- laprust-1.0.0/.github/tests/test_setup.py +215 -0
- laprust-1.0.0/.github/workflows/benchmark_batch.yaml +72 -0
- laprust-1.0.0/.github/workflows/benchmark_single.yaml +72 -0
- laprust-1.0.0/.github/workflows/benchmark_tracking.yaml +72 -0
- laprust-1.0.0/.github/workflows/integration.yaml +94 -0
- laprust-1.0.0/.github/workflows/native.yaml +56 -0
- laprust-1.0.0/.github/workflows/prepublish.yaml +140 -0
- laprust-1.0.0/.github/workflows/publish.yaml +45 -0
- laprust-1.0.0/.github/workflows/tests.yaml +97 -0
- laprust-1.0.0/CHANGELOG.md +50 -0
- laprust-1.0.0/CONTRIBUTING.md +110 -0
- laprust-1.0.0/LICENSE +21 -0
- laprust-1.0.0/MANIFEST.in +12 -0
- laprust-1.0.0/NOTICE +12 -0
- laprust-1.0.0/PKG-INFO +207 -0
- laprust-1.0.0/README.md +140 -0
- laprust-1.0.0/benchmarks/README.md +119 -0
- laprust-1.0.0/benchmarks/_common.py +441 -0
- laprust-1.0.0/benchmarks/benchmark_batch.py +76 -0
- laprust-1.0.0/benchmarks/benchmark_single.py +58 -0
- laprust-1.0.0/benchmarks/benchmark_tracking.py +108 -0
- laprust-1.0.0/benchmarks/ci_run.py +17 -0
- laprust-1.0.0/docs/api.md +68 -0
- laprust-1.0.0/examples/basic.py +11 -0
- laprust-1.0.0/laprust/__init__.py +8 -0
- laprust-1.0.0/laprust/__init__.pyi +3 -0
- laprust-1.0.0/laprust/_lapjv.pyi +214 -0
- laprust-1.0.0/laprust/py.typed +3 -0
- laprust-1.0.0/laprust.egg-info/PKG-INFO +207 -0
- laprust-1.0.0/laprust.egg-info/SOURCES.txt +76 -0
- laprust-1.0.0/laprust.egg-info/dependency_links.txt +1 -0
- laprust-1.0.0/laprust.egg-info/requires.txt +19 -0
- laprust-1.0.0/laprust.egg-info/top_level.txt +1 -0
- laprust-1.0.0/pyproject.toml +39 -0
- laprust-1.0.0/setup.cfg +4 -0
- laprust-1.0.0/setup.py +297 -0
- laprust-1.0.0/src/_lapjv/Cargo.lock +61 -0
- laprust-1.0.0/src/_lapjv/Cargo.toml +25 -0
- laprust-1.0.0/src/_lapjv/LICENSE +22 -0
- laprust-1.0.0/src/_lapjv/THIRD_PARTY_NOTICES +188 -0
- laprust-1.0.0/src/_lapjv/_lapjv.cpp +18776 -0
- laprust-1.0.0/src/_lapjv/_lapjv.pyx +421 -0
- laprust-1.0.0/src/_lapjv/examples/profile.rs +62 -0
- laprust-1.0.0/src/_lapjv/lapjv.h +13 -0
- laprust-1.0.0/src/_lapjv/src/ffi.rs +620 -0
- laprust-1.0.0/src/_lapjv/src/ffi_tests.rs +180 -0
- laprust-1.0.0/src/_lapjv/src/lib.rs +466 -0
- laprust-1.0.0/src/_lapjv/src/profile.rs +79 -0
- laprust-1.0.0/src/_lapjv/src/rectangular.rs +735 -0
- laprust-1.0.0/src/_lapjv/src/square.rs +357 -0
- laprust-1.0.0/src/_lapjv/src/test_faults.rs +56 -0
- laprust-1.0.0/tests/LICENSE +25 -0
- laprust-1.0.0/tests/README.md +129 -0
- laprust-1.0.0/tests/assignment_checks.py +101 -0
- laprust-1.0.0/tests/check_native_errors.py +79 -0
- laprust-1.0.0/tests/check_scipy_free.py +21 -0
- laprust-1.0.0/tests/conftest.py +27 -0
- laprust-1.0.0/tests/cost_eps.csv.gz +0 -0
- laprust-1.0.0/tests/lapjv_cases.py +75 -0
- laprust-1.0.0/tests/regression_cases.py +194 -0
- laprust-1.0.0/tests/test_batch_paths.py +89 -0
- laprust-1.0.0/tests/test_core.py +54 -0
- laprust-1.0.0/tests/test_cross_package.py +159 -0
- laprust-1.0.0/tests/test_edge_cases.py +280 -0
- laprust-1.0.0/tests/test_lapjv.py +882 -0
- laprust-1.0.0/tests/test_package.py +44 -0
- laprust-1.0.0/tests/test_regressions.py +206 -0
- laprust-1.0.0/tests/typing/return_types.py +15 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Build, packaging, and benchmark-tool checks
|
|
2
|
+
|
|
3
|
+
Solver behavior, native error handling, cross-package solver comparisons, and
|
|
4
|
+
public API typing checks live in [tests/](../../tests/README.md).
|
|
5
|
+
This directory holds build configuration, installation, distribution, and
|
|
6
|
+
benchmark-tool checks, with its own pytest configuration and dependencies.
|
|
7
|
+
|
|
8
|
+
## Routine checks
|
|
9
|
+
|
|
10
|
+
Run from the repository root:
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
python -m pip install -r .github/tests/requirements.txt
|
|
14
|
+
python -m pytest -q .github/tests -m "not editable"
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
- `test_setup.py` checks compiler flags and build configuration without a native compiler.
|
|
18
|
+
- `test_benchmarks.py` checks benchmark inputs, API calls, reference results,
|
|
19
|
+
timing boundaries, and ranking output using stand-ins for the installed solvers.
|
|
20
|
+
It needs NumPy and SciPy, but no installed solver package.
|
|
21
|
+
|
|
22
|
+
For platforms without SciPy wheels, run only the compiler-option tests here:
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
python -m pytest -q .github/tests/test_setup.py
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The portable solver suite runs separately with `python tests/check_scipy_free.py`.
|
|
29
|
+
|
|
30
|
+
## Distributions and runtime compatibility
|
|
31
|
+
|
|
32
|
+
After building the source archive and wheel, and installing the wheel:
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
python .github/tests/check_dist.py
|
|
36
|
+
python .github/tests/check_wheel.py
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`check_dist.py` verifies metadata, namespaces, licenses, and source-archive
|
|
40
|
+
contents. Both the solver suite and these CI checks are included in source
|
|
41
|
+
archives so full CI runs can validate wheels built from them.
|
|
42
|
+
`check_wheel.py` checks the installed package and rejects imports from the source
|
|
43
|
+
checkout. Invoke it by path as shown, with NumPy and the wheel installed.
|
|
44
|
+
`install_numpy_compat.py` installs the NumPy/SciPy versions selected by CI and
|
|
45
|
+
checks the built wheel against that runtime.
|
|
46
|
+
|
|
47
|
+
## Isolated editable installs
|
|
48
|
+
|
|
49
|
+
These checks require access to the package index and a native C++ compiler;
|
|
50
|
+
`laprust` also requires Rust and Cargo. Run from the repository root:
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
python -m pytest -q .github/tests/test_editable_install.py --run-editable
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
They copy the current source into a temporary checkout and create a fresh
|
|
57
|
+
virtual environment without system site packages. They check default, strict,
|
|
58
|
+
and compatibility modes, visible source edits, native imports, standalone
|
|
59
|
+
dependencies, type stubs, wheel replacement, and editable reinstallation.
|
|
60
|
+
They do not modify the working checkout or the invoking environment.
|
|
61
|
+
Without `--run-editable`, pytest skips this group.
|
|
62
|
+
|
|
63
|
+
The [tests workflow](../workflows/tests.yaml) runs solver and build/tool checks
|
|
64
|
+
in separate steps, including the older-NumPy jobs. The
|
|
65
|
+
[integration workflow](../workflows/integration.yaml) runs the editable group
|
|
66
|
+
separately from cross-package solver checks. Publish and prepublish run only
|
|
67
|
+
the NumPy-only installed-wheel smoke check for each cibuildwheel target.
|
|
68
|
+
Manual prepublish runs default to Python 3.9 and 3.15, matching LAPX; select
|
|
69
|
+
`full` to build every supported Python version. Publishing always requests
|
|
70
|
+
the full Python 3.9-3.15 wheel set. Release builds run in two Python groups,
|
|
71
|
+
with separate jobs for each architecture and Linux wheel type.
|
|
72
|
+
Integration and native/sanitizer workflows run on pull requests or manual
|
|
73
|
+
dispatch, separately from publishing, matching LAPX's release workflow.
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Check built distributions without importing the native package."""
|
|
2
|
+
import ast
|
|
3
|
+
from email.parser import BytesParser
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import tarfile
|
|
6
|
+
import zipfile
|
|
7
|
+
|
|
8
|
+
ROOT = Path(__file__).resolve().parents[2]
|
|
9
|
+
PACKAGE = "laprust"
|
|
10
|
+
tree = ast.parse((ROOT / PACKAGE / "__init__.py").read_text(encoding="utf-8"))
|
|
11
|
+
version = next(ast.literal_eval(node.value) for node in tree.body
|
|
12
|
+
if isinstance(node, ast.Assign)
|
|
13
|
+
and any(isinstance(t, ast.Name) and t.id == "__version__" for t in node.targets))
|
|
14
|
+
wheels = list((ROOT / "dist").glob(f"{PACKAGE}-{version}-*.whl"))
|
|
15
|
+
assert wheels, "Build the source archive and wheel first: python -m build"
|
|
16
|
+
for wheel in wheels:
|
|
17
|
+
with zipfile.ZipFile(wheel) as archive:
|
|
18
|
+
names = archive.namelist()
|
|
19
|
+
metadata = BytesParser().parsebytes(archive.read(next(n for n in names if n.endswith("/METADATA"))))
|
|
20
|
+
assert metadata["Name"] == PACKAGE and metadata["Version"] == version
|
|
21
|
+
assert metadata["Requires-Python"] == ">=3.9"
|
|
22
|
+
runtime = [r for r in metadata.get_all("Requires-Dist", []) if "extra ==" not in r]
|
|
23
|
+
assert runtime == ["numpy>=1.21.6"], runtime
|
|
24
|
+
assert all(n.startswith((PACKAGE + "/", PACKAGE + ".libs/",
|
|
25
|
+
f"{PACKAGE}-{version}.dist-info/")) for n in names)
|
|
26
|
+
extensions = [n for n in names if n.startswith(PACKAGE + "/_lapjv.")
|
|
27
|
+
and n.endswith((".pyd", ".so"))]
|
|
28
|
+
assert len(extensions) == 1, extensions
|
|
29
|
+
for suffix in ("/licenses/LICENSE", "/licenses/NOTICE", "/licenses/src/_lapjv/LICENSE", "/licenses/tests/LICENSE"):
|
|
30
|
+
assert any(n.endswith(suffix) for n in names), suffix
|
|
31
|
+
for filename in ("__init__.pyi", "_lapjv.pyi", "py.typed"):
|
|
32
|
+
assert PACKAGE + "/" + filename in names
|
|
33
|
+
|
|
34
|
+
with tarfile.open(ROOT / "dist" / f"{PACKAGE}-{version}.tar.gz") as archive:
|
|
35
|
+
prefix = f"{PACKAGE}-{version}/"
|
|
36
|
+
names = {n[len(prefix):] for n in archive.getnames() if n.startswith(prefix)}
|
|
37
|
+
assert not any("/target/" in n or n.endswith((".pyd", ".so", ".pyc")) for n in names)
|
|
38
|
+
assert not any(n.startswith(("build/", "dist/", ".git/")) for n in names)
|
|
39
|
+
required = {"setup.py", "pyproject.toml", "src/_lapjv/_lapjv.pyx", "src/_lapjv/lapjv.h",
|
|
40
|
+
"tests/test_lapjv.py", ".github/tests/check_wheel.py", ".github/tests/check_dist.py",
|
|
41
|
+
"tests/typing/return_types.py", "benchmarks/_common.py",
|
|
42
|
+
"tests/cost_eps.csv.gz", "tests/LICENSE", "tests/regression_cases.py",
|
|
43
|
+
"tests/README.md", "tests/conftest.py", "tests/assignment_checks.py",
|
|
44
|
+
".github/tests/test_benchmarks.py", ".github/tests/install_numpy_compat.py",
|
|
45
|
+
"tests/test_regressions.py", "tests/test_edge_cases.py", "tests/test_batch_paths.py",
|
|
46
|
+
"tests/test_core.py", "tests/check_scipy_free.py", "tests/check_native_errors.py",
|
|
47
|
+
".github/tests/test_setup.py", ".github/tests/conftest.py",
|
|
48
|
+
".github/tests/pytest.ini", ".github/tests/requirements.txt",
|
|
49
|
+
".github/tests/README.md", ".github/workflows/native.yaml",
|
|
50
|
+
"tests/test_cross_package.py", ".github/tests/test_editable_install.py",
|
|
51
|
+
".github/workflows/integration.yaml",
|
|
52
|
+
".github/workflows/tests.yaml",
|
|
53
|
+
".github/constraints/numpy-armv7-build.txt"}
|
|
54
|
+
required |= ({"src/_lapjv/solver.cpp", "src/_lapjv/completion.cpp", "tests/native/CMakeLists.txt",
|
|
55
|
+
"tests/native/test_solver.cpp", "tests/native/faults.h"} if PACKAGE == "lapcpp" else
|
|
56
|
+
{"src/_lapjv/Cargo.lock", "src/_lapjv/Cargo.toml", "src/_lapjv/src/ffi.rs",
|
|
57
|
+
"src/_lapjv/THIRD_PARTY_NOTICES", "src/_lapjv/src/ffi_tests.rs",
|
|
58
|
+
"src/_lapjv/src/test_faults.rs"})
|
|
59
|
+
assert required <= names, required - names
|
|
60
|
+
moved = ("test_setup.py", "test_editable_install.py", "test_benchmarks.py",
|
|
61
|
+
"check_dist.py", "check_wheel.py", "install_numpy_compat.py")
|
|
62
|
+
assert not any("tests/" + name in names for name in moved)
|
|
63
|
+
print(PACKAGE, version, "distribution metadata, namespaces, licenses and source contents: PASS")
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Run from outside the repository package directory after installing a wheel."""
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import importlib.metadata
|
|
4
|
+
import numpy as np
|
|
5
|
+
import laprust
|
|
6
|
+
|
|
7
|
+
root = Path(__file__).resolve().parents[2]
|
|
8
|
+
installed = Path(laprust.__file__).resolve()
|
|
9
|
+
assert root / "laprust" not in installed.parents, installed
|
|
10
|
+
assert importlib.metadata.version("laprust") == laprust.__version__
|
|
11
|
+
assert laprust.__all__ == ["lapjv", "lapjv_batch"]
|
|
12
|
+
a = np.array([[.1, 5., 2.], [3., .2, 4.]], dtype=np.float32)
|
|
13
|
+
total, rows, cols = laprust.lapjv(a, cost_limit=.5)
|
|
14
|
+
assert rows.tolist() == cols.tolist() == [0, 1]
|
|
15
|
+
np.testing.assert_allclose(total, .3)
|
|
16
|
+
totals, batch_rows, batch_cols = laprust.lapjv_batch(np.stack([a, a]), cost_limit=.5, n_threads=2)
|
|
17
|
+
np.testing.assert_allclose(totals, [.3, .3])
|
|
18
|
+
assert all(r.tolist() == c.tolist() == [0, 1] for r, c in zip(batch_rows, batch_cols))
|
|
19
|
+
assert (installed.parent / "py.typed").is_file()
|
|
20
|
+
print("Verified", installed, laprust.__version__)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Explicit opt-in for checks that build and install into temporary environments."""
|
|
2
|
+
import pytest
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def pytest_addoption(parser):
|
|
6
|
+
parser.addoption("--run-editable", action="store_true",
|
|
7
|
+
help="build editable/wheel installs in a fresh temporary virtualenv")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def pytest_collection_modifyitems(config, items):
|
|
11
|
+
if not config.getoption("--run-editable"):
|
|
12
|
+
skip = pytest.mark.skip(reason="enable --run-editable for this integration check")
|
|
13
|
+
for item in items:
|
|
14
|
+
if item.get_closest_marker("editable"):
|
|
15
|
+
item.add_marker(skip)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Install the CI-selected NumPy runtime without rebuilding the NumPy-2 wheel."""
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
root = Path(__file__).resolve().parents[2]
|
|
8
|
+
subprocess.run([sys.executable, "-m", "pip", "install", "numpy==" + os.environ["NUMPY_VERSION"],
|
|
9
|
+
"scipy==" + os.environ["SCIPY_VERSION"], "-r", str(root / ".github/tests/requirements.txt")], check=True)
|
|
10
|
+
subprocess.run([sys.executable, "-m", "pip", "install", "--no-deps", "--no-index",
|
|
11
|
+
"--find-links=" + str(root / "dist"), "laprust"], check=True)
|
|
12
|
+
subprocess.run([sys.executable, str(root / ".github/tests/check_wheel.py")], check=True)
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
"""Guard the public fast-path calls, timing boundaries and benchmark checks.
|
|
2
|
+
|
|
3
|
+
Native API stand-ins keep these tests runnable in either package's standalone
|
|
4
|
+
CI. The benchmark workflows also execute all five real installed candidates.
|
|
5
|
+
"""
|
|
6
|
+
from functools import partial
|
|
7
|
+
import importlib.util
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import sys
|
|
10
|
+
from types import SimpleNamespace
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
import pytest
|
|
14
|
+
from scipy.optimize import linear_sum_assignment
|
|
15
|
+
|
|
16
|
+
BENCHMARKS = Path(__file__).resolve().parents[2] / "benchmarks"
|
|
17
|
+
spec = importlib.util.spec_from_file_location("benchmark_common", BENCHMARKS / "_common.py")
|
|
18
|
+
bench = importlib.util.module_from_spec(spec)
|
|
19
|
+
spec.loader.exec_module(bench)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@pytest.fixture
|
|
23
|
+
def scripts(monkeypatch):
|
|
24
|
+
calls = []
|
|
25
|
+
|
|
26
|
+
def native_api(name, batch=False):
|
|
27
|
+
def api(cost, **options):
|
|
28
|
+
calls.append((name, cost, options))
|
|
29
|
+
matrices = cost if batch else cost[None]
|
|
30
|
+
pairs = [np.column_stack(linear_sum_assignment(a))[::-1] for a in matrices]
|
|
31
|
+
# Deliberately return a different order to detect normalization in wrappers.
|
|
32
|
+
if not options["return_cost"]:
|
|
33
|
+
return pairs if batch else pairs[0]
|
|
34
|
+
totals = np.array([a[tuple(p.T)].sum(dtype=np.float64) for a, p in zip(matrices, pairs)])
|
|
35
|
+
return (totals, pairs) if batch else (totals[0], pairs[0])
|
|
36
|
+
return api
|
|
37
|
+
|
|
38
|
+
for name in ("lapcpp", "laprust"):
|
|
39
|
+
monkeypatch.setitem(sys.modules, name, SimpleNamespace(
|
|
40
|
+
lapjv=native_api(name), lapjv_batch=native_api(name, batch=True)))
|
|
41
|
+
monkeypatch.setitem(sys.modules, "lap", SimpleNamespace(
|
|
42
|
+
lapjvxa=native_api("lapjvxa"), lapjvsa=native_api("lapjvsa"),
|
|
43
|
+
lapjvxa_batch=native_api("lapjvxa", batch=True), lapjvsa_batch=native_api("lapjvsa", batch=True)))
|
|
44
|
+
monkeypatch.setitem(sys.modules, "_common", bench)
|
|
45
|
+
loaded = {}
|
|
46
|
+
for kind in ("single", "batch", "tracking"):
|
|
47
|
+
spec = importlib.util.spec_from_file_location("benchmark_" + kind, BENCHMARKS / ("benchmark_" + kind + ".py"))
|
|
48
|
+
module = importlib.util.module_from_spec(spec)
|
|
49
|
+
spec.loader.exec_module(module)
|
|
50
|
+
loaded[kind] = module
|
|
51
|
+
return loaded, calls
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@pytest.mark.parametrize("candidate", bench.CANDIDATES)
|
|
55
|
+
@pytest.mark.parametrize("shape", [(4, 4), (4, 6), (6, 4)])
|
|
56
|
+
@pytest.mark.parametrize("dtype", [np.float32, np.float64])
|
|
57
|
+
def test_single_uses_unmodified_input_and_default_fast_path(scripts, candidate, shape, dtype):
|
|
58
|
+
modules, calls = scripts
|
|
59
|
+
cost = bench.make_costs(shape, 2026, 0, dtype)
|
|
60
|
+
result = getattr(modules["single"], "solve_" + candidate)(cost)
|
|
61
|
+
baseline = np.column_stack(linear_sum_assignment(cost))
|
|
62
|
+
bench.check_single(cost, result, baseline)
|
|
63
|
+
if candidate == "scipy":
|
|
64
|
+
assert not calls
|
|
65
|
+
return
|
|
66
|
+
name, received, options = calls.pop()
|
|
67
|
+
assert name == candidate and received is cost and not calls
|
|
68
|
+
expected = dict(extend_cost=shape[0] != shape[1], return_cost=False)
|
|
69
|
+
if candidate in ("lapcpp", "laprust"):
|
|
70
|
+
expected.update(precision="native", output="pairs")
|
|
71
|
+
# In particular, no finite cost_limit and no prefer_float32 override for JVS.
|
|
72
|
+
assert options == expected
|
|
73
|
+
np.testing.assert_array_equal(result, baseline[::-1])
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@pytest.mark.parametrize("candidate", bench.CANDIDATES)
|
|
77
|
+
@pytest.mark.parametrize("shape", [(3, 4, 4), (3, 4, 6), (3, 6, 4)])
|
|
78
|
+
@pytest.mark.parametrize("return_cost", [False, True])
|
|
79
|
+
def test_batch_calls_public_batch_api_once(scripts, candidate, shape, return_cost):
|
|
80
|
+
modules, calls = scripts
|
|
81
|
+
costs = bench.make_costs(shape, 2026, 1, np.float64)
|
|
82
|
+
options = dict(return_cost=return_cost)
|
|
83
|
+
if candidate != "scipy":
|
|
84
|
+
options["n_threads"] = 3
|
|
85
|
+
result = getattr(modules["batch"], "solve_" + candidate)(costs, **options)
|
|
86
|
+
baseline = modules["batch"].solve_scipy(costs, return_cost=return_cost)
|
|
87
|
+
bench.check_batch(costs, result, baseline, return_cost=return_cost)
|
|
88
|
+
if candidate == "scipy":
|
|
89
|
+
assert not calls
|
|
90
|
+
return
|
|
91
|
+
name, received, options = calls.pop()
|
|
92
|
+
assert name == candidate and received is costs and not calls
|
|
93
|
+
expected = dict(extend_cost=shape[1] != shape[2], return_cost=return_cost, n_threads=3)
|
|
94
|
+
if candidate in ("lapcpp", "laprust"):
|
|
95
|
+
expected.update(precision="native", output="pairs")
|
|
96
|
+
assert options == expected
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@pytest.mark.parametrize("candidate", bench.CANDIDATES)
|
|
100
|
+
@pytest.mark.parametrize("threshold", [0., .4, 1.])
|
|
101
|
+
@pytest.mark.parametrize("transpose", [False, True])
|
|
102
|
+
def test_tracking_postfilters_full_assignment_and_keeps_unmatched_lists(scripts, candidate, threshold, transpose):
|
|
103
|
+
modules, calls = scripts
|
|
104
|
+
# A joint rejection solve at .4 could retain .1; post-filtering retains the
|
|
105
|
+
# two .4 edges of the full optimum. This distinguishes the two objectives.
|
|
106
|
+
cost = np.array([[.1, .4, 9.], [.4, .9, 9.], [9., 9., .8], [9., 9., 9.]])
|
|
107
|
+
if transpose:
|
|
108
|
+
cost = cost.T.copy()
|
|
109
|
+
result = getattr(modules["tracking"], "solve_" + candidate)(cost, threshold)
|
|
110
|
+
full = np.array([[0, 1], [1, 0], [2, 2]])
|
|
111
|
+
expected = full[cost[tuple(full.T)] <= threshold]
|
|
112
|
+
unmatched_rows = sorted(set(range(cost.shape[0])) - set(expected[:, 0]))
|
|
113
|
+
unmatched_cols = sorted(set(range(cost.shape[1])) - set(expected[:, 1]))
|
|
114
|
+
modules["tracking"].check_tracking(cost, result, (expected, unmatched_rows, unmatched_cols), threshold=threshold)
|
|
115
|
+
assert result[0].dtype == np.dtype(int)
|
|
116
|
+
if candidate == "scipy":
|
|
117
|
+
assert not calls
|
|
118
|
+
return
|
|
119
|
+
name, received, options = calls.pop()
|
|
120
|
+
assert name == candidate and received is cost and not calls
|
|
121
|
+
expected_options = dict(extend_cost=True, return_cost=False)
|
|
122
|
+
if candidate in ("lapcpp", "laprust"):
|
|
123
|
+
expected_options.update(precision="native", output="pairs")
|
|
124
|
+
assert options == expected_options
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_checks_detect_invalid_suboptimal_and_wrong_total_results(scripts):
|
|
128
|
+
cost = np.array([[0., 1.], [1., 0.]])
|
|
129
|
+
optimum = np.array([[0, 0], [1, 1]])
|
|
130
|
+
wrong = np.array([[0, 1], [1, 0]])
|
|
131
|
+
for pairs in (wrong, optimum[:1], np.array([[0, 0], [1, 0]]), np.array([[0, -1], [1, 1]])):
|
|
132
|
+
with pytest.raises(AssertionError):
|
|
133
|
+
bench.check_single(cost, pairs, optimum)
|
|
134
|
+
with pytest.raises(AssertionError):
|
|
135
|
+
bench.check_batch(cost[None], [pairs], [optimum])
|
|
136
|
+
with pytest.raises(AssertionError):
|
|
137
|
+
bench.check_batch(cost[None], (np.array([3.]), [optimum]), (np.array([0.]), [optimum]), return_cost=True)
|
|
138
|
+
# Batch checks the original objective, so equivalent tied optima are valid.
|
|
139
|
+
bench.check_batch(np.zeros((1, 2, 2)), [wrong], [optimum])
|
|
140
|
+
tracking = scripts[0]["tracking"]
|
|
141
|
+
with pytest.raises(AssertionError):
|
|
142
|
+
tracking.check_tracking(cost, (optimum, [0], []), (optimum, [], []), threshold=.5)
|
|
143
|
+
with pytest.raises(AssertionError):
|
|
144
|
+
tracking.check_tracking(cost, (wrong, [], []), (wrong, [], []), threshold=.5)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_inputs_and_default_precision_match_pinned_reference():
|
|
148
|
+
for shape in ((4, 6), (8, 4, 6)):
|
|
149
|
+
for round_index in (0, 1):
|
|
150
|
+
expected = np.random.default_rng([2026 + round_index, *shape]).random(shape)
|
|
151
|
+
np.testing.assert_array_equal(bench.make_costs(shape, 2026, round_index, "float64"), expected)
|
|
152
|
+
args = bench.common_parser("test", repeats=3).parse_args([])
|
|
153
|
+
assert args.dtype == "float64" and args.seed == 2026 and args.repeats == 3
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_timing_excludes_generation_warmup_checks_and_dispatch(monkeypatch):
|
|
157
|
+
clock = [0.]
|
|
158
|
+
in_timer = [False]
|
|
159
|
+
counts = dict.fromkeys(bench.CANDIDATES, 0)
|
|
160
|
+
|
|
161
|
+
def now():
|
|
162
|
+
in_timer[0] = not in_timer[0]
|
|
163
|
+
return clock[0]
|
|
164
|
+
|
|
165
|
+
def make_costs(*args):
|
|
166
|
+
assert not in_timer[0]
|
|
167
|
+
clock[0] += 1000
|
|
168
|
+
return np.zeros((4, 4))
|
|
169
|
+
|
|
170
|
+
def check(*args):
|
|
171
|
+
assert not in_timer[0]
|
|
172
|
+
clock[0] += 100
|
|
173
|
+
|
|
174
|
+
def solve(name, costs):
|
|
175
|
+
counts[name] += 1
|
|
176
|
+
clock[0] += .125
|
|
177
|
+
return np.column_stack((np.arange(4), np.arange(4)))
|
|
178
|
+
|
|
179
|
+
class Solvers(dict):
|
|
180
|
+
def __getitem__(self, key):
|
|
181
|
+
assert not in_timer[0]
|
|
182
|
+
return super().__getitem__(key)
|
|
183
|
+
|
|
184
|
+
monkeypatch.setattr(bench.time, "perf_counter", now)
|
|
185
|
+
monkeypatch.setattr(bench, "make_costs", make_costs)
|
|
186
|
+
solvers = Solvers((name, partial(solve, name)) for name in bench.CANDIDATES)
|
|
187
|
+
args = SimpleNamespace(seed=2026, repeats=2, calls=3)
|
|
188
|
+
records = bench.measure_case(solvers, (1, 4, 4), "float64", args, check)
|
|
189
|
+
assert counts == dict.fromkeys(bench.CANDIDATES, 8) # One warmup + three timed calls per round.
|
|
190
|
+
assert all(record["samples_ms"] == [125., 125.] for record in records)
|
|
191
|
+
assert all(record["median_ms"] == 125. for record in records)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@pytest.mark.parametrize("seconds, calls, rounds", [(.001, 10, 2), (.0005, 20, 5)])
|
|
195
|
+
def test_calibrated_timing_warms_each_solver_and_repeats_calls(monkeypatch, seconds, calls, rounds):
|
|
196
|
+
clock = [0.]
|
|
197
|
+
events = []
|
|
198
|
+
|
|
199
|
+
def solve(name, costs):
|
|
200
|
+
events.append(name)
|
|
201
|
+
clock[0] += seconds
|
|
202
|
+
return np.column_stack((np.arange(4), np.arange(4)))
|
|
203
|
+
|
|
204
|
+
monkeypatch.setattr(bench.time, "perf_counter", lambda: clock[0])
|
|
205
|
+
solvers = {name: partial(solve, name) for name in bench.CANDIDATES}
|
|
206
|
+
args = SimpleNamespace(seed=2026, repeats=2, calls=0, min_time=.01, fast_rounds=5, reference_timing=False)
|
|
207
|
+
records = bench.measure_case(solvers, (1, 4, 4), "float64", args, lambda *args: None)
|
|
208
|
+
# Round one: warm-up, calibration call, timed calls. Later rounds: warm-up, timed calls.
|
|
209
|
+
# Sub-millisecond cases get the fast-round count.
|
|
210
|
+
per_solver = 2 + calls + (rounds - 1) * (1 + calls)
|
|
211
|
+
assert all(events.count(name) == per_solver for name in bench.CANDIDATES)
|
|
212
|
+
# Each sample's calls run back to back, right after that solver's warm-up.
|
|
213
|
+
first = events[:calls + 2]
|
|
214
|
+
assert first == [first[0]] * (calls + 2)
|
|
215
|
+
for record in records:
|
|
216
|
+
assert record["calls_per_round"] == calls
|
|
217
|
+
assert record["samples_ms"] == pytest.approx([seconds * 1000] * rounds)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def test_workload_sizes_preserve_requested_small_and_tapered_batches():
|
|
221
|
+
for kind in ("single", "tracking", "batch"):
|
|
222
|
+
for quick in (False, True):
|
|
223
|
+
shapes = bench.cases(kind, quick)
|
|
224
|
+
assert len(shapes) == len(set(shapes))
|
|
225
|
+
for size in ((4, 4), (8, 8), (24, 32), (32, 24)):
|
|
226
|
+
assert any(shape[1:] == size for shape in shapes)
|
|
227
|
+
batch = bench.cases("batch", False)
|
|
228
|
+
assert len(batch) == 65 and max(b for b, _, _ in batch) == 128
|
|
229
|
+
assert (4, 1000, 1000) in batch and (128, 1000, 1000) not in batch
|
|
230
|
+
assert max(b * n * m for b, n, m in batch) <= 4_000_000
|
|
231
|
+
assert max(max(n, m) for _, n, m in batch) <= 1000
|
|
232
|
+
assert all((b, 32, 32) in batch for b in (1, 2, 4))
|
|
233
|
+
for kind in ("single", "tracking", "batch"):
|
|
234
|
+
assert set(bench.cases(kind, True)) <= set(bench.cases(kind, False))
|
|
235
|
+
shapes = {(n, m) for _, n, m in bench.cases(kind, False)}
|
|
236
|
+
for shape in ((32, 48), (64, 96), (128, 192), (16, 128)):
|
|
237
|
+
assert shape in shapes and shape[::-1] in shapes
|
|
238
|
+
single = bench.cases("single", False)
|
|
239
|
+
assert single == bench.cases("tracking", False) and len(single) == 39
|
|
240
|
+
for n, m in ((2000, 2000), (3000, 3000), (5000, 5000),
|
|
241
|
+
(500, 1000), (2000, 3000), (3000, 4000), (4000, 5000)):
|
|
242
|
+
assert (1, n, m) in single and (1, m, n) in single
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def test_verdicts_and_summary_rank_actual_total_times(capsys):
|
|
246
|
+
assert bench.verdict("scipy", 100, 100) == "⭐"
|
|
247
|
+
for candidate in bench.CANDIDATES[1:]:
|
|
248
|
+
assert bench.verdict(candidate, 98, 100) == "🏆"
|
|
249
|
+
assert bench.verdict(candidate, 102, 100) == "🐌"
|
|
250
|
+
for tied in (99, 100, 101):
|
|
251
|
+
assert bench.verdict(candidate, tied, 100) == "🤝"
|
|
252
|
+
# SciPy wins more individual cases, but totals must determine the rank.
|
|
253
|
+
records = [dict(candidate=name, median_ms=ms) for name, times in
|
|
254
|
+
(("scipy", [1, 1, 100]), ("lapcpp", [2, 2, 4]), ("laprust", [3, 3, 5]))
|
|
255
|
+
for ms in times]
|
|
256
|
+
summary = bench.summarize(records)
|
|
257
|
+
assert summary == [
|
|
258
|
+
dict(candidate="lapcpp", tests=3, total_ms=8, position=1, verdict="🥇"),
|
|
259
|
+
dict(candidate="laprust", tests=3, total_ms=11, position=2, verdict="🥈"),
|
|
260
|
+
dict(candidate="scipy", tests=3, total_ms=102, position=3, verdict="🥉"),
|
|
261
|
+
]
|
|
262
|
+
bench.print_summary(summary)
|
|
263
|
+
lines = capsys.readouterr().out.splitlines()
|
|
264
|
+
header = next(i for i, line in enumerate(lines) if line.startswith("Solver"))
|
|
265
|
+
assert lines[header - 1] == ""
|
|
266
|
+
assert lines[header].endswith("Verdict")
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def test_five_candidate_rankings_and_partial_summary():
|
|
270
|
+
records = [dict(candidate=name, median_ms=index + 1) for index, name in enumerate(bench.CANDIDATES)]
|
|
271
|
+
assert [row["verdict"] for row in bench.summarize(records)] == ["🥇", "🥈", "🥉", "🚩", "🏳️"]
|
|
272
|
+
assert [row["candidate"] for row in bench.summarize(records[:2])] == ["scipy", "lapcpp"]
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _reported_case(times, *, shape=(1, 24, 32), dtype="float64", threshold=None):
|
|
276
|
+
baseline = times[0]
|
|
277
|
+
records = []
|
|
278
|
+
for name, ms in zip(bench.CANDIDATES, times):
|
|
279
|
+
record = dict(candidate=name, median_ms=ms, shape=list(shape), dtype=dtype,
|
|
280
|
+
samples_ms=[ms, ms, ms], calls_per_round=25, check="PASS",
|
|
281
|
+
verdict=bench.verdict(name, ms, baseline))
|
|
282
|
+
if threshold is not None:
|
|
283
|
+
record["threshold"] = threshold
|
|
284
|
+
records.append(record)
|
|
285
|
+
return records
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def test_ranked_table_labels_baseline_ratios_ties_and_keeps_raw_results(capsys):
|
|
289
|
+
from copy import deepcopy
|
|
290
|
+
records = _reported_case([10, 2, 9.95, 20, 5])
|
|
291
|
+
saved = deepcopy(records)
|
|
292
|
+
bench.print_records(records)
|
|
293
|
+
output = capsys.readouterr().out
|
|
294
|
+
lines = output.splitlines()
|
|
295
|
+
rows = [line for line in lines if line.split() and line.split()[0] in bench.CANDIDATES]
|
|
296
|
+
assert [line.split()[0] for line in rows] == ["lapcpp", "lapjvsa", "laprust", "scipy", "lapjvxa"]
|
|
297
|
+
assert "🥇 5.00x faster" in rows[0]
|
|
298
|
+
assert "🥉 within 1%" in rows[2] and rows[2].endswith("🤝")
|
|
299
|
+
assert "⭐ BASE" in rows[3] and "🚩 baseline" in rows[3] and rows[3].endswith("⭐")
|
|
300
|
+
assert "🏳️ 2.00x slower" in rows[4] and rows[4].endswith("🐌")
|
|
301
|
+
header = next(i for i, line in enumerate(lines) if line.startswith("Solver"))
|
|
302
|
+
assert lines[header - 1] == "" and lines[header].endswith("Verdict")
|
|
303
|
+
assert "Matrix 24 x 32 | float64" in output
|
|
304
|
+
assert records == saved
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def test_batch_table_shows_positions_for_baseline_and_slower_results(capsys):
|
|
308
|
+
records = _reported_case([1, 2, 3, 4, 5], shape=(128, 24, 32))
|
|
309
|
+
records[-1]["check"] = "FAIL"
|
|
310
|
+
bench.print_records(records, batch=True)
|
|
311
|
+
output = capsys.readouterr().out
|
|
312
|
+
assert "Batch 128 x 24 x 32" in output
|
|
313
|
+
for relative in ("🥇 baseline", "🥈 2.00x slower", "🥉 3.00x slower", "🚩 4.00x slower", "🏳️ -"):
|
|
314
|
+
assert relative in output
|
|
315
|
+
failed = next(line for line in output.splitlines() if line.startswith("lapjvsa"))
|
|
316
|
+
assert "❌ FAIL" in failed and "PASS" not in failed and failed.endswith("-")
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def test_tracking_tables_group_thresholds_and_dtypes_and_align_columns(capsys):
|
|
320
|
+
records = (_reported_case([1, 2, 3, 4, 5], threshold=.05) +
|
|
321
|
+
_reported_case([20, 3, 2, 4, 5], shape=(1, 1000, 1000), threshold=.05) +
|
|
322
|
+
_reported_case([2, 1, 3, 4, 5], threshold=.5) +
|
|
323
|
+
_reported_case([2, 1, 3, 4, 5], threshold=.05, dtype="float32"))
|
|
324
|
+
bench.print_tracking_records(records)
|
|
325
|
+
output = capsys.readouterr().out
|
|
326
|
+
assert output.count("Tracking |") == 3
|
|
327
|
+
assert "Tracking | float64 | threshold 0.05" in output
|
|
328
|
+
assert "Tracking | float64 | threshold 0.5" in output
|
|
329
|
+
assert "Tracking | float32 | threshold 0.05" in output
|
|
330
|
+
first = output.split("Tracking |")[1]
|
|
331
|
+
data_lines = [line for line in first.splitlines() if line.startswith(("24x32", "1000x1000"))]
|
|
332
|
+
assert len(data_lines) == 2 and "1st ⭐" in data_lines[0] and "1st 🏆" in data_lines[1]
|
|
333
|
+
# Column boundaries stay aligned despite emoji and different time magnitudes.
|
|
334
|
+
boundaries = [[bench.display_width(line[:i]) for i, char in enumerate(line) if char == "|"] for line in data_lines]
|
|
335
|
+
assert boundaries[0] == boundaries[1]
|
|
336
|
+
lines = output.splitlines()
|
|
337
|
+
for i, line in enumerate(lines):
|
|
338
|
+
if line.startswith("Size"):
|
|
339
|
+
assert lines[i - 1] == ""
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def test_overall_ranking_counts_case_positions_but_ranks_by_total(capsys):
|
|
343
|
+
records = (_reported_case([1, 2, 3, 4, 5], shape=(1, 4, 4)) +
|
|
344
|
+
_reported_case([100, 4, 5, 6, 7], shape=(1, 8, 8)))
|
|
345
|
+
bench.print_summary(bench.summarize(records), records)
|
|
346
|
+
output = capsys.readouterr().out
|
|
347
|
+
rows = [line for line in output.splitlines() if line.split() and line.split()[0] in bench.CANDIDATES]
|
|
348
|
+
assert rows[0].startswith("lapcpp") and rows[0].endswith(" 🥇")
|
|
349
|
+
assert "1x🥇 1x🥈 0x🥉 0x🚩 0x🏳️" in rows[0] and "6.000000" in rows[0]
|
|
350
|
+
assert rows[-1].startswith("scipy") and "1x🥇" in rows[-1] and "1x🏳️" in rows[-1]
|
|
351
|
+
for position in bench.POSITION_VERDICTS.values():
|
|
352
|
+
token = "x" + position
|
|
353
|
+
columns = {bench.display_width(line[:line.index(token)]) for line in rows if token in line}
|
|
354
|
+
assert len(columns) <= 1 # A placing stays in the same column even when other ranks are absent.
|
|
355
|
+
assert "OVERALL RANKING" in output and "Case positions" in output
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def test_terminal_width_accounts_for_rank_emoji_and_variation_selectors():
|
|
359
|
+
for emoji in bench.POSITION_VERDICTS.values():
|
|
360
|
+
assert bench.display_width(emoji) == 2
|
|
361
|
+
assert bench.display_width("✅ PASS") == 7
|
|
362
|
+
assert bench.display_width("12x🏳️") == 5
|
|
363
|
+
assert bench.display_width("✓") == 1
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def test_header_keeps_full_metadata_available_without_default_dump(capsys):
|
|
367
|
+
metadata = dict(kind="single", python="3.11.0", numpy="2.0.0", versions={"lapcpp": "1.0.0"},
|
|
368
|
+
input_model="uniform [0, 1)", reference_release=bench.REFERENCE_RELEASE,
|
|
369
|
+
settings=dict(dtype="float64", seed=2026, repeats=3, calls=0, verbose=False),
|
|
370
|
+
modules={"lapcpp": "example/loaded/package"})
|
|
371
|
+
bench.print_header(metadata)
|
|
372
|
+
output = capsys.readouterr().out
|
|
373
|
+
assert "Single assignment benchmark" in output and "float64" in output
|
|
374
|
+
assert "example/loaded/package" not in output
|
|
375
|
+
metadata["settings"]["verbose"] = True
|
|
376
|
+
bench.print_header(metadata)
|
|
377
|
+
assert '"lapcpp": "example/loaded/package"' in capsys.readouterr().out
|