diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
new file mode 100644
index 0000000..286990d
--- /dev/null
+++ b/.github/workflows/docs.yml
@@ -0,0 +1,69 @@
+name: Build Sphinx docs
+
+on:
+ workflow_dispatch:
+ push:
+ tags:
+ - "[0-9]+.[0-9]+.[0-9]+*"
+ branches:
+ - main
+ - 'stable/**'
+ pull_request:
+ branches:
+ - main
+ - 'stable/**'
+jobs:
+ build:
+ name: Build docs
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ steps:
+ - uses: actions/checkout@v7
+ with:
+ # reno reads the tags and their history to assemble the release notes, and
+ # setup.py needs the vendored upstream headers to compile the extension.
+ fetch-depth: 0
+ submodules: recursive
+ - uses: actions/setup-python@v7
+ with:
+ python-version: '3.10'
+ - name: Install dependencies
+ # autodoc imports the package, so the extension has to compile here -- hence the
+ # same MPI and BLAS packages the test workflow installs, rather than the lighter
+ # set a pure-Python docs build would need.
+ run: |
+ python -m pip install --upgrade pip
+ pip install tox
+ sudo apt-get update
+ sudo apt-get install -y libopenmpi-dev openmpi-bin libopenblas-dev
+ - name: Build docs
+ shell: bash
+ # CPU only: the runner has no GPU toolchain, and the API reference does not
+ # depend on which backends were compiled.
+ env:
+ SBD_BUILD_BACKEND: cpu
+ run: |
+ tox -edocs
+ - name: Upload docs artifact
+ # Uploaded even on failure, which pairs with sphinx-build's --keep-going: the
+ # partial HTML is usually the fastest way to see what went wrong.
+ if: always()
+ uses: actions/upload-pages-artifact@v5
+ with:
+ path: docs/_build/html
+
+ deploy:
+ name: Deploy docs
+ if: ${{ github.ref == 'refs/heads/main' }}
+ needs: build
+ permissions:
+ pages: write
+ id-token: write
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ runs-on: ubuntu-latest
+ steps:
+ - name: Deploy to GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v5
diff --git a/.gitignore b/.gitignore
index 58e68a6..ef20cb7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,6 +10,10 @@ build/
dist/
*.egg-info/
+# Sphinx documentation
+docs/_build/
+docs/stubs/
+
# editor / OS noise
.DS_Store
*.swp
diff --git a/docs/_static/.gitkeep b/docs/_static/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/docs/_static/images/qiskit-dark-logo.svg b/docs/_static/images/qiskit-dark-logo.svg
new file mode 100644
index 0000000..b520890
--- /dev/null
+++ b/docs/_static/images/qiskit-dark-logo.svg
@@ -0,0 +1,178 @@
+
+
diff --git a/docs/_static/images/qiskit-light-logo.svg b/docs/_static/images/qiskit-light-logo.svg
new file mode 100644
index 0000000..25b27dd
--- /dev/null
+++ b/docs/_static/images/qiskit-light-logo.svg
@@ -0,0 +1,178 @@
+
+
diff --git a/docs/_templates/autosummary/class.rst b/docs/_templates/autosummary/class.rst
new file mode 100644
index 0000000..175b08f
--- /dev/null
+++ b/docs/_templates/autosummary/class.rst
@@ -0,0 +1,33 @@
+{#
+ We show all the class's methods and attributes on the same page. By default, we document
+ all methods, including those defined by parent classes.
+-#}
+
+{{ objname | escape | underline }}
+
+.. currentmodule:: {{ module }}
+
+.. autoclass:: {{ objname }}
+ :no-members:
+ :no-inherited-members:
+ :no-special-members:
+ :show-inheritance:
+
+{% block attributes_summary %}
+ {% if attributes %}
+ .. rubric:: Attributes
+ {% for item in attributes %}
+ .. autoattribute:: {{ item }}
+ {%- endfor %}
+ {% endif %}
+{% endblock -%}
+
+{% block methods_summary %}
+ {% set wanted_methods = (methods | reject('==', '__init__') | list) %}
+ {% if wanted_methods %}
+ .. rubric:: Methods
+ {% for item in wanted_methods %}
+ .. automethod:: {{ item }}
+ {%- endfor %}
+ {% endif %}
+{% endblock %}
diff --git a/docs/apidocs/index.rst b/docs/apidocs/index.rst
new file mode 100644
index 0000000..58288ee
--- /dev/null
+++ b/docs/apidocs/index.rst
@@ -0,0 +1,10 @@
+*********************************
+``sbd-eigensolver`` API reference
+*********************************
+
+.. toctree::
+ :maxdepth: 1
+
+ sbd
+ sbd.sbd_solver
+ sbd.device_config
diff --git a/docs/apidocs/sbd.device_config.rst b/docs/apidocs/sbd.device_config.rst
new file mode 100644
index 0000000..7c1a7d1
--- /dev/null
+++ b/docs/apidocs/sbd.device_config.rst
@@ -0,0 +1,8 @@
+================================================
+Device configuration (:mod:`sbd.device_config`)
+================================================
+
+.. automodule:: sbd.device_config
+ :members:
+ :no-inherited-members:
+ :no-special-members:
diff --git a/docs/apidocs/sbd.rst b/docs/apidocs/sbd.rst
new file mode 100644
index 0000000..2e256e8
--- /dev/null
+++ b/docs/apidocs/sbd.rst
@@ -0,0 +1,9 @@
+=============================
+SBD bindings (:mod:`sbd`)
+=============================
+
+.. automodule:: sbd
+ :members:
+ :exclude-members: sbd_solver
+ :no-inherited-members:
+ :no-special-members:
diff --git a/docs/apidocs/sbd.sbd_solver.rst b/docs/apidocs/sbd.sbd_solver.rst
new file mode 100644
index 0000000..65be11d
--- /dev/null
+++ b/docs/apidocs/sbd.sbd_solver.rst
@@ -0,0 +1,8 @@
+=============================================
+SQD-compatible solver (:mod:`sbd.sbd_solver`)
+=============================================
+
+.. automodule:: sbd.sbd_solver
+ :members:
+ :no-inherited-members:
+ :no-special-members:
diff --git a/docs/conf.py b/docs/conf.py
new file mode 100644
index 0000000..47eeaa6
--- /dev/null
+++ b/docs/conf.py
@@ -0,0 +1,180 @@
+# This code is a Qiskit project.
+#
+# (C) Copyright IBM 2026.
+#
+# This code is licensed under the Apache License, Version 2.0. You may
+# obtain a copy of this license in the LICENSE.txt file in the root directory
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""Sphinx configuration for the sbd-eigensolver documentation."""
+
+import inspect
+import os
+import re
+import sys
+from importlib.metadata import version as metadata_version
+
+# Note there is deliberately no `sys.path` manipulation here. The import name of this
+# package (`sbd`) does not match the directory it lives in (`python/`); the mapping is
+# declared as `package-dir = {sbd = "python"}` in pyproject.toml and only setuptools
+# knows about it. Autodoc therefore needs the package genuinely *installed*, which is
+# also what makes the `metadata_version` call below work.
+
+project = "Selected Basis Diagonalization (SBD)"
+project_copyright = "2026, IBM Quantum"
+description = "Python bindings for the SBD eigensolver library"
+author = "IBM Quantum"
+language = "en"
+# The *distribution* name ("sbd-eigensolver"), not the import name ("sbd").
+release = metadata_version("sbd-eigensolver")
+
+html_theme = "qiskit-ecosystem"
+
+html_theme_options = {
+ "dark_logo": "images/qiskit-dark-logo.svg",
+ "light_logo": "images/qiskit-light-logo.svg",
+ "sidebar_qiskit_ecosystem_member": False,
+}
+html_static_path = ["_static"]
+templates_path = ["_templates"]
+
+# Sphinx should ignore these patterns when building.
+exclude_patterns = [
+ "_build",
+]
+
+extensions = [
+ "sphinx.ext.napoleon",
+ "sphinx.ext.autodoc",
+ "sphinx.ext.autosummary",
+ "sphinx.ext.mathjax",
+ "sphinx.ext.linkcode",
+ "sphinx.ext.intersphinx",
+ "sphinx_copybutton",
+ "reno.sphinxext",
+ "qiskit_sphinx_theme",
+]
+
+html_last_updated_fmt = "%Y/%m/%d"
+html_title = f"{project} {release}"
+
+# This allows RST files to put `|version|` in their file and
+# have it updated with the release set in conf.py.
+rst_prolog = f"""
+.. |version| replace:: {release}
+"""
+
+# Options for autodoc. These reflect the values from Qiskit SDK and Runtime.
+autosummary_generate = True
+autosummary_generate_overwrite = False
+autoclass_content = "both"
+autodoc_typehints = "description"
+autodoc_default_options = {
+ "inherited-members": None,
+ "show-inheritance": True,
+}
+napoleon_google_docstring = True
+napoleon_numpy_docstring = False
+
+# This adds numbers to the captions for figures, tables,
+# and code blocks.
+numfig = True
+numfig_format = {"table": "Table %s"}
+
+add_module_names = False
+
+modindex_common_prefix = ["sbd."]
+
+intersphinx_mapping = {
+ "python": ("https://docs.python.org/3", None),
+ "numpy": ("https://numpy.org/doc/stable/", None),
+}
+
+# ----------------------------------------------------------------------------------
+# Source code links
+# ----------------------------------------------------------------------------------
+
+# The package is imported as `sbd` but stored in the repository under `python/`, so the
+# two halves of a source link need different spellings: the installed file path is
+# split on one, and the URL is built with the other.
+_IMPORT_NAME = "sbd"
+_REPO_SUBDIR = "python"
+
+
+def determine_github_branch() -> str:
+ """Determine the GitHub branch name to use for source code links.
+
+ We need to decide whether to use `stable/` vs. `main` for dev builds.
+ Refer to https://docs.github.com/en/actions/learn-github-actions/variables
+ for how we determine this with GitHub Actions.
+ """
+ # If CI env vars not set, default to `main`. This is relevant for local builds.
+ if "GITHUB_REF_NAME" not in os.environ:
+ return "main"
+
+ # PR workflows set the branch they're merging into.
+ if base_ref := os.environ.get("GITHUB_BASE_REF"):
+ return base_ref
+
+ ref_name = os.environ["GITHUB_REF_NAME"]
+
+ # Check if the ref_name is a tag like `1.0.0` or `1.0.0rc1`. If so, we need
+ # to transform it to a Git branch like `stable/1.0`.
+ version_without_patch = re.match(r"(\d+\.\d+)", ref_name)
+ return f"stable/{version_without_patch.group()}" if version_without_patch else ref_name
+
+
+GITHUB_BRANCH = determine_github_branch()
+
+
+def linkcode_resolve(domain, info):
+ """Point the "source" link of each documented object at GitHub."""
+ if domain != "py":
+ return None
+
+ module_name = info["module"]
+ module = sys.modules.get(module_name)
+ if module is None or _IMPORT_NAME not in module_name:
+ return None
+
+ def is_valid_code_object(obj):
+ return inspect.isclass(obj) or inspect.ismethod(obj) or inspect.isfunction(obj)
+
+ obj = module
+ for part in info["fullname"].split("."):
+ try:
+ obj = getattr(obj, part)
+ except AttributeError:
+ return None
+ if not is_valid_code_object(obj):
+ return None
+
+ # Unwrap decorators. This requires they used `functools.wrap()`.
+ while hasattr(obj, "__wrapped__"):
+ obj = obj.__wrapped__
+ if not is_valid_code_object(obj):
+ return None
+
+ try:
+ full_file_name = inspect.getsourcefile(obj)
+ except TypeError:
+ return None
+ if full_file_name is None or f"/{_IMPORT_NAME}/" not in full_file_name:
+ return None
+ file_name = full_file_name.split(f"/{_IMPORT_NAME}/")[-1]
+
+ try:
+ source, lineno = inspect.getsourcelines(obj)
+ except (OSError, TypeError):
+ linespec = ""
+ else:
+ ending_lineno = lineno + len(source) - 1
+ linespec = f"#L{lineno}-L{ending_lineno}"
+ return (
+ "https://github.com/Qiskit/sbd-eigensolver-python/tree/"
+ f"{GITHUB_BRANCH}/{_REPO_SUBDIR}/{file_name}{linespec}"
+ )
diff --git a/docs/index.rst b/docs/index.rst
new file mode 100644
index 0000000..4dcd859
--- /dev/null
+++ b/docs/index.rst
@@ -0,0 +1,56 @@
+#####################################
+Selected Basis Diagonalization (SBD)
+#####################################
+
+``sbd-eigensolver`` provides Python bindings for the SBD (Selected Basis
+Diagonalization) library, which finds eigenvalues and eigenvectors of a
+second-quantized Hamiltonian projected onto a subspace spanned by a selected set of
+determinants. The bindings are MPI-parallel and can run on CPUs or, where a suitable
+toolchain is available, on NVIDIA or AMD GPUs.
+
+The package also exposes a solver compatible with the ``qiskit-addon-sqd`` interface,
+so SBD can be used as the diagonalization step of a sample-based quantum
+diagonalization (SQD) workflow. See :mod:`sbd.sbd_solver`.
+
+Getting started
+---------------
+
+Installation, the environment variables that control which backends are compiled, and
+runnable examples are documented in the `README
+`__ in the root
+of this project's repository. Example scripts and a notebook live in `python/examples
+`__.
+
+A minimal diagonalization looks like this::
+
+ import sbd
+
+ config = sbd.TPB_SBD()
+ results = sbd.tpb_diag_from_files("FCIDUMP", "adets.dat", config)
+
+The backend is initialized automatically on first use; :func:`sbd.init` only needs to
+be called to select a device explicitly.
+
+Contributing
+------------
+
+The source code is available `on GitHub
+`__.
+
+We use `GitHub issues
+`__ for tracking requests and
+bugs.
+
+License
+-------
+
+`Apache License 2.0
+`__
+
+.. toctree::
+ :hidden:
+
+ Documentation home
+ API reference
+ Release notes
+ GitHub
diff --git a/docs/release-notes.rst b/docs/release-notes.rst
new file mode 100644
index 0000000..9523d94
--- /dev/null
+++ b/docs/release-notes.rst
@@ -0,0 +1,3 @@
+.. _release notes:
+
+.. release-notes:: Release Notes
diff --git a/pyproject.toml b/pyproject.toml
index ab324c3..a235da2 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -49,6 +49,16 @@ nbtest = [
"sbd-eigensolver[basetest]",
"nbmake>=1.5.0",
]
+docs = [
+ # The API reference is generated from the docstrings by autodoc, which imports the
+ # package -- so the docs build needs the compiled extension, and `sbd.sbd_solver`
+ # additionally needs qiskit-addon-sqd and pyscf importable. The `test` extra
+ # already pulls both in.
+ "sbd-eigensolver[test]",
+ "qiskit-sphinx-theme~=2.1.0",
+ "sphinx-copybutton",
+ "reno>=4.1",
+]
notebook-dependencies = [
"qiskit-addon-sqd>=0.13.1",
"pyscf>=2.9",
diff --git a/python/__init__.py b/python/__init__.py
index c2a683c..33ac1b5 100644
--- a/python/__init__.py
+++ b/python/__init__.py
@@ -321,11 +321,11 @@ def init(device='cpu', comm_backend='mpi'):
Args:
device: Default compute device — 'cpu', 'gpu', 'gpu-omp', or 'auto'.
- 'gpu' is the NVIDIA-only Thrust backend; 'gpu-omp' is OpenMP
- target offload and serves NVIDIA and AMD alike.
- Aliases: 'gpu-thrust' / 'gpu-nvidia' / 'cuda' (= 'gpu');
- 'gpu-omp-offload' / 'gpu-nvhpc-omp' / 'gpu-nvidia-omp' /
- 'gpu-amd-omp' / 'gpu-rocm-omp' / 'rocm' (= 'gpu-omp').
+ 'gpu' is the NVIDIA-only Thrust backend; 'gpu-omp' is OpenMP
+ target offload and serves NVIDIA and AMD alike.
+ Aliases for 'gpu': 'gpu-thrust', 'gpu-nvidia', 'cuda'.
+ Aliases for 'gpu-omp': 'gpu-omp-offload', 'gpu-nvhpc-omp',
+ 'gpu-nvidia-omp', 'gpu-amd-omp', 'gpu-rocm-omp', 'rocm'.
comm_backend: Communication backend — 'mpi'.
Raises:
diff --git a/python/sbd_solver.py b/python/sbd_solver.py
index e9fb489..d22b13e 100644
--- a/python/sbd_solver.py
+++ b/python/sbd_solver.py
@@ -285,9 +285,11 @@ def assemble_rdms(results: dict, norb: int) -> tuple[np.ndarray | None, np.ndarr
qiskit-addon-sqd (e.g. ``fermion.py``'s own ``solve_fermion``).
SBD's documented layout (sbd-ext docs/user-guide.md, matching the C++
- reference in apps/chemistry_tpb_selected_basis_diagonalization/main.cc):
+ reference in apps/chemistry_tpb_selected_basis_diagonalization/main.cc)::
+
one_p_rdm[s][i + L*j] =
two_p_rdm[s+2t][i + L*j + L^2*k + L^3*l] =
+
A Fortran-order reshape implements those flat-index formulas directly
(arr_F[i, j] / arr_F[i, j, k, l]); rdm1 needs no further transpose
(it is symmetric here regardless), and rdm2's spin-summed block sum
diff --git a/releasenotes/config.yaml b/releasenotes/config.yaml
new file mode 100644
index 0000000..0513169
--- /dev/null
+++ b/releasenotes/config.yaml
@@ -0,0 +1,5 @@
+---
+encoding: utf8
+default_branch: main
+unreleased_version_title: "Upcoming release"
+earliest_version: 1.6.1
diff --git a/releasenotes/notes/.gitkeep b/releasenotes/notes/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/tox.ini b/tox.ini
index dd3ecb9..55f0681 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,6 +1,6 @@
[tox]
minversion = 4.4.3
-envlist = py{310,311,312,313,314}{,-notebook}, mpi
+envlist = py{310,311,312,313,314}{,-notebook}, mpi, docs
isolated_build = True
[testenv]
@@ -71,6 +71,31 @@ extras =
commands =
pytest --nbmake --nbmake-timeout=3000 {posargs} python/examples/
+[testenv:docs]
+# Unlike a pure-Python project, the docs build cannot skip installing the package:
+# autodoc imports `sbd` to read its docstrings, and conf.py reads the version from the
+# installed distribution metadata. `package`/`wheel_build_env` and the MPI/BLAS
+# `passenv` are therefore inherited from [testenv] -- the extension has to compile
+# here just as it does for the tests, and it reuses the same wheel.
+extras =
+ docs
+passenv =
+ {[testenv]passenv}
+ # Consulted by conf.py's determine_github_branch() to aim the source-code links at
+ # the right branch.
+ CI
+ GITHUB_BASE_REF
+ GITHUB_REF_NAME
+commands =
+ sphinx-build -j auto -W -T --keep-going -b html {posargs} {toxinidir}/docs/ {toxinidir}/docs/_build/html
+
+[testenv:docs-clean]
+skip_install = true
+allowlist_externals =
+ rm
+commands =
+ rm -rf {toxinidir}/docs/stubs/ {toxinidir}/docs/_build/
+
[testenv:slow]
# The reference cases grow by roughly an order of magnitude in determinant count per
# row of the upstream tables, and compute time scales worse than linearly in that, so