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.
Files changed (78) hide show
  1. laprust-1.0.0/.github/constraints/numpy-armv7-build.txt +3 -0
  2. laprust-1.0.0/.github/tests/README.md +73 -0
  3. laprust-1.0.0/.github/tests/check_dist.py +63 -0
  4. laprust-1.0.0/.github/tests/check_wheel.py +20 -0
  5. laprust-1.0.0/.github/tests/conftest.py +15 -0
  6. laprust-1.0.0/.github/tests/install_numpy_compat.py +12 -0
  7. laprust-1.0.0/.github/tests/pytest.ini +5 -0
  8. laprust-1.0.0/.github/tests/requirements.txt +6 -0
  9. laprust-1.0.0/.github/tests/test_benchmarks.py +377 -0
  10. laprust-1.0.0/.github/tests/test_editable_install.py +126 -0
  11. laprust-1.0.0/.github/tests/test_setup.py +215 -0
  12. laprust-1.0.0/.github/workflows/benchmark_batch.yaml +72 -0
  13. laprust-1.0.0/.github/workflows/benchmark_single.yaml +72 -0
  14. laprust-1.0.0/.github/workflows/benchmark_tracking.yaml +72 -0
  15. laprust-1.0.0/.github/workflows/integration.yaml +94 -0
  16. laprust-1.0.0/.github/workflows/native.yaml +56 -0
  17. laprust-1.0.0/.github/workflows/prepublish.yaml +140 -0
  18. laprust-1.0.0/.github/workflows/publish.yaml +45 -0
  19. laprust-1.0.0/.github/workflows/tests.yaml +97 -0
  20. laprust-1.0.0/CHANGELOG.md +50 -0
  21. laprust-1.0.0/CONTRIBUTING.md +110 -0
  22. laprust-1.0.0/LICENSE +21 -0
  23. laprust-1.0.0/MANIFEST.in +12 -0
  24. laprust-1.0.0/NOTICE +12 -0
  25. laprust-1.0.0/PKG-INFO +207 -0
  26. laprust-1.0.0/README.md +140 -0
  27. laprust-1.0.0/benchmarks/README.md +119 -0
  28. laprust-1.0.0/benchmarks/_common.py +441 -0
  29. laprust-1.0.0/benchmarks/benchmark_batch.py +76 -0
  30. laprust-1.0.0/benchmarks/benchmark_single.py +58 -0
  31. laprust-1.0.0/benchmarks/benchmark_tracking.py +108 -0
  32. laprust-1.0.0/benchmarks/ci_run.py +17 -0
  33. laprust-1.0.0/docs/api.md +68 -0
  34. laprust-1.0.0/examples/basic.py +11 -0
  35. laprust-1.0.0/laprust/__init__.py +8 -0
  36. laprust-1.0.0/laprust/__init__.pyi +3 -0
  37. laprust-1.0.0/laprust/_lapjv.pyi +214 -0
  38. laprust-1.0.0/laprust/py.typed +3 -0
  39. laprust-1.0.0/laprust.egg-info/PKG-INFO +207 -0
  40. laprust-1.0.0/laprust.egg-info/SOURCES.txt +76 -0
  41. laprust-1.0.0/laprust.egg-info/dependency_links.txt +1 -0
  42. laprust-1.0.0/laprust.egg-info/requires.txt +19 -0
  43. laprust-1.0.0/laprust.egg-info/top_level.txt +1 -0
  44. laprust-1.0.0/pyproject.toml +39 -0
  45. laprust-1.0.0/setup.cfg +4 -0
  46. laprust-1.0.0/setup.py +297 -0
  47. laprust-1.0.0/src/_lapjv/Cargo.lock +61 -0
  48. laprust-1.0.0/src/_lapjv/Cargo.toml +25 -0
  49. laprust-1.0.0/src/_lapjv/LICENSE +22 -0
  50. laprust-1.0.0/src/_lapjv/THIRD_PARTY_NOTICES +188 -0
  51. laprust-1.0.0/src/_lapjv/_lapjv.cpp +18776 -0
  52. laprust-1.0.0/src/_lapjv/_lapjv.pyx +421 -0
  53. laprust-1.0.0/src/_lapjv/examples/profile.rs +62 -0
  54. laprust-1.0.0/src/_lapjv/lapjv.h +13 -0
  55. laprust-1.0.0/src/_lapjv/src/ffi.rs +620 -0
  56. laprust-1.0.0/src/_lapjv/src/ffi_tests.rs +180 -0
  57. laprust-1.0.0/src/_lapjv/src/lib.rs +466 -0
  58. laprust-1.0.0/src/_lapjv/src/profile.rs +79 -0
  59. laprust-1.0.0/src/_lapjv/src/rectangular.rs +735 -0
  60. laprust-1.0.0/src/_lapjv/src/square.rs +357 -0
  61. laprust-1.0.0/src/_lapjv/src/test_faults.rs +56 -0
  62. laprust-1.0.0/tests/LICENSE +25 -0
  63. laprust-1.0.0/tests/README.md +129 -0
  64. laprust-1.0.0/tests/assignment_checks.py +101 -0
  65. laprust-1.0.0/tests/check_native_errors.py +79 -0
  66. laprust-1.0.0/tests/check_scipy_free.py +21 -0
  67. laprust-1.0.0/tests/conftest.py +27 -0
  68. laprust-1.0.0/tests/cost_eps.csv.gz +0 -0
  69. laprust-1.0.0/tests/lapjv_cases.py +75 -0
  70. laprust-1.0.0/tests/regression_cases.py +194 -0
  71. laprust-1.0.0/tests/test_batch_paths.py +89 -0
  72. laprust-1.0.0/tests/test_core.py +54 -0
  73. laprust-1.0.0/tests/test_cross_package.py +159 -0
  74. laprust-1.0.0/tests/test_edge_cases.py +280 -0
  75. laprust-1.0.0/tests/test_lapjv.py +882 -0
  76. laprust-1.0.0/tests/test_package.py +44 -0
  77. laprust-1.0.0/tests/test_regressions.py +206 -0
  78. laprust-1.0.0/tests/typing/return_types.py +15 -0
@@ -0,0 +1,3 @@
1
+ # Use the NumPy 2.x ABI while retaining compatibility with the GCC 9 build image.
2
+ # This file constrains CI build and test dependencies only; it is not wheel runtime metadata.
3
+ numpy>=2,<2.5
@@ -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,5 @@
1
+ [pytest]
2
+ addopts = -ra
3
+ timeout = 180
4
+ markers =
5
+ editable: builds real editable/wheel installs in a temporary venv (--run-editable)
@@ -0,0 +1,6 @@
1
+ pytest>=7
2
+ pytest-timeout
3
+ setuptools>=77
4
+ wheel
5
+ numpy>=1.21.6
6
+ scipy>=1.9
@@ -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