Skip to content

Commit 0f7e7f1

Browse files
garrisonclaude
andauthored
Add a test suite based on the upstream reference energies, and run it in CI (#5)
* Add a test suite based on the upstream reference energies Each molecule directory in the vendored upstream data carries a README with a table of determinant-selection thresholds and the electronic energy a diagonalization over those determinants should produce. Those tables make a good first test: the determinants are fixed, so the answer is deterministic and independent of sampling, and the energies came from filtering a full CI calculation. Eight cases are transcribed, from the h2o and n2 tables. The two cheapest run by default in about 16 seconds; the rest are marked slow, following the --run-slow pattern used in qiskit-addon-cutting so that they are reported as skipped with a reason rather than silently deselected. Two of the slow cases were run to confirm the transcription: h2o-1em4 (2.38e6 determinants) and n2-3em4 (4.12e5) reach their published energies in about 16 minutes together. One test is marked `mpi` and checks that the energy does not depend on how many processes the determinants were spread across. `tox -e mpi` runs it under mpirun with two processes, overridable with SBD_TEST_NPROCS. The tests pin bit_length to 64. SBD packs determinants into words of that many bits and freezes the resulting word count for the process -- det_vector::_elem_size is an inline static that throws "elem_size mismatch" on a later, different value -- so h2o (24 orbitals) and n2 (18) could not otherwise share a process. A bit_length of 64 gives both a single word. It does not affect the result: verified across 8, 20, 32, 48 and 64, spanning word counts 6 down to 1, with the h2o energy identical to ten digits each time. `sbd` is imported at module scope rather than through importorskip, so a build where it is missing fails collection instead of reporting a suite that passed. The vendored data is a submodule, so its absence does skip, and a backend named in SBD_TEST_DEVICE that was not compiled skips too, since both are facts about the checkout rather than failures. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Run the test suite in CI Adapted from the qiskit-addon-utils workflow, with three changes this repo needs. The checkout recurses submodules: the reference energies the tests check against come from data in the vendored upstream checkout, and setup.py needs its headers to compile at all. MPI and BLAS are installed with apt, since the extension links against both. setup.py defaults to looking for openblas on the system path and falls through to mpicc detection, so libopenmpi-dev and libopenblas-dev are enough and no environment variables are needed. That is also why the matrix is Linux-only for now: those package names are Debian/Ubuntu specific, and macOS would additionally need libomp because Apple clang ships without OpenMP. Both tox environments run in one invocation rather than two. With `package = wheel` and a shared wheel_build_env, one `tox -e py,mpi` compiles the extension once and installs the resulting wheel into both environments; two separate commands would compile it twice. Verified from a clean .tox: one build_wheel, two install_package, 36 seconds for the pair. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Report why tests were skipped Most of this suite skips by default, so a bare count of skips said very little: it did not distinguish a case being slow from a backend not being built or the reference data being absent. Adding -rs to addopts, as qiskit-addon-cutting does, lists each skip with its reason: SKIPPED [6] skipping slow test, as --run-slow was not provided SKIPPED [1] need --with-mpi option to run --durations=10 comes along with it, since the cases differ by orders of magnitude in cost and it is useful to see where the time went. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Report which backends were built and which is under test setup.py builds the GPU backends only when nvc++ is present, so the same command tests CPU alone on one machine and CPU plus GPU on another. Nothing in the output said which, meaning a green run left the reader to deduce what had actually been exercised -- and on CI, where the build messages are suppressed, to deduce it from the absence of NVHPC on the runner. A pytest header line now states it: sbd 1.6.1: backends built ['cpu'], testing default Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 7fa76aa commit 0f7e7f1

5 files changed

Lines changed: 365 additions & 1 deletion

File tree

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
name: Tests
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
- 'stable/**'
8+
pull_request:
9+
branches:
10+
- main
11+
- 'stable/**'
12+
schedule:
13+
- cron: '0 1 * * *'
14+
15+
jobs:
16+
tests:
17+
name: latest version tests (${{ matrix.os }}, ${{ matrix.python-version }})
18+
runs-on: ${{ matrix.os }}
19+
timeout-minutes: 30
20+
strategy:
21+
max-parallel: 4
22+
matrix:
23+
# Only Linux for now: the extension modules must compile against an MPI
24+
# implementation and a BLAS, and the apt packages installed below are
25+
# Debian/Ubuntu specific. macOS would additionally need libomp, since Apple
26+
# clang ships without OpenMP support.
27+
os: [ubuntu-latest]
28+
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
29+
steps:
30+
- uses: actions/checkout@v7
31+
with:
32+
# The reference energies the tests check against come from data in the
33+
# vendored upstream checkout, and setup.py needs its headers to compile.
34+
submodules: recursive
35+
- name: Set up Python ${{ matrix.python-version }}
36+
uses: actions/setup-python@v7
37+
with:
38+
python-version: ${{ matrix.python-version }}
39+
- name: Install MPI and BLAS
40+
run: |
41+
sudo apt-get update
42+
sudo apt-get install -y libopenmpi-dev openmpi-bin libopenblas-dev
43+
- name: Install dependencies
44+
run: |
45+
python -m pip install --upgrade pip
46+
pip install tox
47+
- name: Test using tox environments
48+
shell: bash
49+
# One invocation rather than two, so that the extension is compiled once and
50+
# the resulting wheel is installed into both environments. Running `tox -e py`
51+
# and `tox -e mpi` as separate commands would compile it twice.
52+
#
53+
# Not --parallel: the two environments would then race to install the same
54+
# wheel, and the MPI tests want the runner's cores to themselves.
55+
run: |
56+
tox -e py,mpi

pyproject.toml

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ dependencies = [
3030
"numpy>=1.19.0",
3131
]
3232

33+
[project.optional-dependencies]
34+
test = [
35+
"pytest>=8.0",
36+
"pytest-mpi>=0.6",
37+
]
38+
3339
[project.urls]
3440
Homepage = "https://github.com/Qiskit/sbd-eigensolver-python"
3541
Documentation = "https://github.com/Qiskit/sbd-eigensolver-python"
@@ -41,4 +47,12 @@ Issues = "https://github.com/Qiskit/sbd-eigensolver-python/issues"
4147
# imperatively in setup.py; everything else lives in [project] above.
4248
packages = ["sbd"]
4349
package-dir = {sbd = "python"}
44-
zip-safe = false
50+
zip-safe = false
51+
52+
[tool.pytest.ini_options]
53+
testpaths = ["test"]
54+
# -rs lists the skipped tests and why, which matters here because most of the suite
55+
# skips by default: a bare count of skips would not distinguish "this case is slow" or
56+
# "this backend was not built" from "the reference data is missing". --durations shows
57+
# where the time goes, the cases differing by orders of magnitude in cost.
58+
addopts = "-rs --durations=10"

test/conftest.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
# This code is a Qiskit project.
2+
#
3+
# (C) Copyright IBM 2026.
4+
#
5+
# This code is licensed under the Apache License, Version 2.0. You may
6+
# obtain a copy of this license in the LICENSE.txt file in the root directory
7+
# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
8+
#
9+
# Any modifications or derivative works of this code must retain this
10+
# copyright notice, and modified files need to carry a notice indicating
11+
# that they have been altered from the originals.
12+
13+
"""Shared fixtures and helpers for the test suite."""
14+
15+
from __future__ import annotations
16+
17+
import os
18+
from pathlib import Path
19+
20+
import pytest
21+
22+
# The reference molecules live in the vendored upstream SBD checkout, which is a git
23+
# submodule. It is not present in an sdist or a fresh clone until the submodule is
24+
# initialized, so tests that need it are skipped rather than failing.
25+
DATA_DIR = Path(__file__).resolve().parents[1] / "vendor" / "sbd-upstream" / "data"
26+
27+
28+
# Slow tests are opt-in through a command-line flag rather than excluded by default,
29+
# so that they are reported as skipped with a reason instead of silently deselected.
30+
# https://docs.pytest.org/en/latest/example/simple.html#control-skipping-of-tests-according-to-command-line-option
31+
32+
33+
# pylint: disable=missing-function-docstring
34+
def pytest_addoption(parser):
35+
parser.addoption(
36+
"--run-slow",
37+
action="store_true",
38+
default=False,
39+
help="run slow tests",
40+
)
41+
42+
43+
def pytest_configure(config):
44+
config.addinivalue_line("markers", "slow: mark test as slow to run")
45+
46+
47+
def pytest_collection_modifyitems(config, items):
48+
if not config.getoption("--run-slow"):
49+
marker = pytest.mark.skip(reason="skipping slow test, as --run-slow was not provided")
50+
for item in items:
51+
if "slow" in item.keywords:
52+
item.add_marker(marker)
53+
54+
55+
@pytest.fixture(scope="session")
56+
def data_dir() -> Path:
57+
"""Path to the vendored reference data, skipping the test if it is absent."""
58+
if not DATA_DIR.is_dir():
59+
pytest.skip(
60+
f"reference data not found at {DATA_DIR}; "
61+
"run 'git submodule update --init --recursive'"
62+
)
63+
return DATA_DIR
64+
65+
66+
def pytest_report_header():
67+
"""Record which backends were compiled, and which of them is under test.
68+
69+
Without this, a run gives no indication of what it actually exercised: setup.py
70+
builds the GPU backends only when nvc++ is present, so the same command tests CPU
71+
only on one machine and CPU plus GPU on another. A passing run should say which.
72+
"""
73+
import sbd
74+
75+
available = sbd.available_backends()
76+
requested = os.environ.get("SBD_TEST_DEVICE") or "default"
77+
return f"sbd {sbd.__version__}: backends built {available}, testing {requested}"
78+
79+
80+
@pytest.fixture(scope="session")
81+
def backend():
82+
"""The SBD backend to test, honoring SBD_TEST_DEVICE if it is set.
83+
84+
A backend named in ``SBD_TEST_DEVICE`` that was not compiled into this build is
85+
reported as a skip, since which backends exist depends on how the package was
86+
built. That a backend is missing is a fact about the build; that ``sbd`` itself is
87+
missing is a failure, and is left to raise.
88+
"""
89+
import sbd
90+
91+
device = os.environ.get("SBD_TEST_DEVICE")
92+
available = sbd.available_backends()
93+
if device is not None and device not in available:
94+
pytest.skip(f"backend {device!r} was not built; available: {available}")
95+
return sbd.get_backend(device)

test/test_reference_energies.py

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
# This code is a Qiskit project.
2+
#
3+
# (C) Copyright IBM 2026.
4+
#
5+
# This code is licensed under the Apache License, Version 2.0. You may
6+
# obtain a copy of this license in the LICENSE.txt file in the root directory
7+
# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
8+
#
9+
# Any modifications or derivative works of this code must retain this
10+
# copyright notice, and modified files need to carry a notice indicating
11+
# that they have been altered from the originals.
12+
13+
"""Check SBD against the reference energies published with the upstream test data.
14+
15+
Each molecule directory under ``vendor/sbd-upstream/data`` carries a README with a
16+
table of determinant-selection thresholds and the electronic energy that a
17+
diagonalization over the corresponding determinants should produce. Those tables are
18+
the closest thing to ground truth available here: the determinants are fixed, so the
19+
answer is deterministic and independent of sampling, and the energies were obtained
20+
by filtering a full CI calculation.
21+
22+
The cases are the rows of those tables. Only the cheapest of them run by default;
23+
the rest are marked ``slow`` because they take minutes to hours and, at the far end,
24+
more memory than a workstation has.
25+
"""
26+
27+
from __future__ import annotations
28+
29+
import pytest
30+
31+
import sbd
32+
33+
# (molecule, alpha determinant file, expected electronic energy, is_slow)
34+
#
35+
# Transcribed from the tables in vendor/sbd-upstream/data/<molecule>/README.md. The
36+
# rows beyond the first of each molecule are marked slow: the determinant count grows
37+
# by roughly an order of magnitude per row, and compute time is reported upstream to
38+
# scale as (determinants)**1.23. The largest rows of each table are omitted entirely,
39+
# needing hundreds of gigabytes.
40+
REFERENCE_ENERGIES = [
41+
# H2O, cc-pvdz, 24 orbitals, 10 electrons. FCI: -76.24377680
42+
("h2o", "h2o-1em3-alpha.txt", -76.23594663, False),
43+
("h2o", "h2o-1em4-alpha.txt", -76.24295848, True),
44+
("h2o", "h2o-1em5-alpha.txt", -76.24373504, True),
45+
# N2, 6-31g, 18 orbitals, 14 electrons. FCI: -109.04874199
46+
("n2", "1em3-alpha.txt", -109.04162110, False),
47+
("n2", "3em4-alpha.txt", -109.04697304, True),
48+
("n2", "1em4-alpha.txt", -109.04835269, True),
49+
("n2", "3em5-alpha.txt", -109.04864315, True),
50+
("n2", "1em5-alpha.txt", -109.04871934, True),
51+
]
52+
53+
54+
def _case_id(case) -> str:
55+
molecule, det_file, _, _ = case
56+
# e.g. "h2o-1em3": the threshold is the informative part of the file name.
57+
threshold = det_file.replace(f"{molecule}-", "").replace("-alpha.txt", "")
58+
return f"{molecule}-{threshold}"
59+
60+
61+
# SBD packs a determinant into words of ``bit_length`` bits, and the resulting word
62+
# count is a process-wide constant: ``det_vector::_elem_size`` is an inline static that
63+
# throws "det_vector: elem_size mismatch" if a later diagonalization needs a different
64+
# one. Two molecules can therefore share a process only if they agree on it.
65+
#
66+
# The word count is ``ceil(2 * norb / bit_length)``, so a ``bit_length`` of 64 keeps it
67+
# at 1 for every reference molecule up to 32 orbitals: h2o (24), n2 (18) and nh3 (29).
68+
# The two larger ones, c2h2 (38) and c4h4 (44), would need 2 words and so cannot share
69+
# a process with these; adding them means a separate module, or forking per test.
70+
#
71+
# ``bit_length`` does not affect the result. Verified across 8, 20, 32, 48 and 64,
72+
# which span word counts 6 down to 1: the h2o energy was identical to ten digits.
73+
BIT_LENGTH = 64
74+
75+
76+
def _diagonalize(backend, fcidump, det_file, **overrides):
77+
"""Diagonalize over the determinants in ``det_file`` and return the energy."""
78+
sbd_data = backend.TPB_SBD()
79+
# A tolerance well below the precision the reference energies are quoted to, so
80+
# that a disagreement means a wrong answer rather than an unconverged one.
81+
sbd_data.eps = 1e-10
82+
sbd_data.max_it = 200
83+
sbd_data.bit_length = BIT_LENGTH
84+
for name, value in overrides.items():
85+
setattr(sbd_data, name, value)
86+
results = sbd.tpb_diag_from_files(str(fcidump), str(det_file), sbd_data)
87+
return results["energy"]
88+
89+
90+
@pytest.mark.parametrize(
91+
"molecule,det_file,expected",
92+
[
93+
pytest.param(
94+
molecule,
95+
det_file,
96+
expected,
97+
marks=pytest.mark.slow if is_slow else (),
98+
id=_case_id((molecule, det_file, expected, is_slow)),
99+
)
100+
for molecule, det_file, expected, is_slow in REFERENCE_ENERGIES
101+
],
102+
)
103+
def test_reference_energy(data_dir, backend, molecule, det_file, expected):
104+
"""The energy matches the value published for these determinants.
105+
106+
The reference energies are quoted to eight decimal places, so they are compared to
107+
that precision rather than to the solver's own convergence tolerance.
108+
"""
109+
molecule_dir = data_dir / molecule
110+
energy = _diagonalize(
111+
backend, molecule_dir / "fcidump.txt", molecule_dir / det_file
112+
)
113+
assert energy == pytest.approx(expected, abs=1e-8)
114+
115+
116+
@pytest.mark.mpi
117+
def test_energy_does_not_depend_on_process_count(data_dir, backend):
118+
"""Splitting the determinants across processes does not change the answer.
119+
120+
Run under ``mpirun``, this diagonalizes the same subspace over however many
121+
processes were launched and compares against the published energy. A result that
122+
depends on the process count would mean the distribution itself is wrong, which is
123+
the failure this guards against; it is also why the comparison is against the
124+
reference rather than against another run.
125+
"""
126+
from mpi4py import MPI
127+
128+
comm = MPI.COMM_WORLD
129+
molecule_dir = data_dir / "h2o"
130+
energy = _diagonalize(
131+
backend,
132+
molecule_dir / "fcidump.txt",
133+
molecule_dir / "h2o-1em3-alpha.txt",
134+
adet_comm_size=comm.Get_size(),
135+
)
136+
137+
# Only rank 0 receives the energy; the others are given a placeholder.
138+
if comm.Get_rank() == 0:
139+
assert energy == pytest.approx(-76.23594663, abs=1e-8)

tox.ini

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
[tox]
2+
minversion = 4.4.3
3+
envlist = py{310,311,312,313,314}, mpi
4+
isolated_build = True
5+
6+
[testenv]
7+
# Build a wheel once and install it into every environment, rather than the default
8+
# sdist which each environment would compile for itself. Compiling the extension is
9+
# the slowest part of a run, so doing it once matters.
10+
package = wheel
11+
wheel_build_env = .pkg
12+
# The extension modules are compiled against whatever MPI and BLAS the environment
13+
# provides, so the build cannot be isolated from it: pass the variables setup.py
14+
# consults through to the build and to the tests.
15+
passenv =
16+
MPI_HOME
17+
BLAS_LIB_PATH
18+
BLAS_LIBS
19+
NVHPC_HOME
20+
SBD_GPU_ARCH
21+
SBD_BUILD_BACKEND
22+
SBD_TEST_DEVICE
23+
SBD_TEST_NPROCS
24+
CC
25+
CXX
26+
OMP_NUM_THREADS
27+
PATH
28+
LD_LIBRARY_PATH
29+
extras =
30+
test
31+
commands =
32+
pytest {posargs}
33+
34+
[testenv:mpi]
35+
# Run the tests marked `mpi` under a launcher, and only those: the rest of the suite
36+
# is single-process, and running it under mpirun would just repeat each test once per
37+
# rank. `--only-mpi` selects them; note that it must be used *without* `--with-mpi`,
38+
# since pytest-mpi checks the two in an if/elif and the latter takes precedence,
39+
# which would quietly run everything.
40+
#
41+
# Two processes is enough to tell a genuinely distributed code path from one that only
42+
# works on a single rank, so that is the default. Override it to use more:
43+
#
44+
# SBD_TEST_NPROCS=8 tox -e mpi
45+
#
46+
# Note that `mpirun` is not installed by tox, so the environment must already provide
47+
# it -- on a cluster that usually means loading a module first.
48+
allowlist_externals =
49+
mpirun
50+
setenv =
51+
SBD_TEST_NPROCS = {env:SBD_TEST_NPROCS:2}
52+
commands =
53+
mpirun -n {env:SBD_TEST_NPROCS} pytest --only-mpi {posargs}
54+
55+
[testenv:slow]
56+
# The reference cases grow by roughly an order of magnitude in determinant count per
57+
# row of the upstream tables, and compute time scales worse than linearly in that, so
58+
# these are opt-in rather than part of the default run.
59+
commands =
60+
pytest --run-slow {posargs}

0 commit comments

Comments
 (0)