From 96ea1c0fc2c24fd521571b8d403e258cb2a7aa55 Mon Sep 17 00:00:00 2001 From: Jim Garrison Date: Tue, 29 Sep 2026 15:18:46 -0400 Subject: [PATCH] Add a minimal Sphinx docs build with reno release notes The project had no documentation build at all: no `docs/`, no reno, and no docs CI. Everything user-facing lived in the README, whose hand-written "API Reference" section duplicated the module structure by hand and was free to drift from the code. This adds the smallest thing that is actually real: an API reference generated from the existing docstrings, a working reno setup, and a CI job that fails a pull request when either breaks. Guides and notebooks are deliberately out of scope and can be layered on later without redoing any of this. Two properties of this package shaped the setup. First, autodoc needs the package genuinely installed rather than merely on `sys.path`: the import name `sbd` does not match its directory `python/`, and only setuptools knows the `package-dir` mapping, so the docs environment builds and installs the wheel like the test environments do. Second, importing `sbd` is cheap and GPU-free because backends load lazily, so a CPU-only build on a GPU-less runner is enough to document it. Two details differ from the equivalent setup in qiskit-addon-sqd, where a verbatim copy would have been wrong here: - `linkcode_resolve` needs two separate spellings, since the installed directory (`sbd/`) and the in-repository directory (`python/`) differ. Using one token for both would 404 on every "source" link. - The `sbd` page excludes the `sbd_solver` member. It appears in `sbd.__all__` as a submodule while also having a page of its own, which is a duplicate object description and therefore fatal under `-W`. Enabling `-W` surfaced two pre-existing docstring defects, both fixed here: an unmarked indented block in `assemble_rdms` is now a literal block, which also renders the index formulas as intended, and the over-indented alias list in `init` is reflowed to the indentation napoleon expects. Note that reno only sees notes that git tracks, so a newly added note must be staged before it renders. The release-notes page is also cached across incremental builds; `tox -e docs-clean` forces it to be regenerated. Assisted-by: Claude Opus 5 --- .github/workflows/docs.yml | 69 +++++++++ .gitignore | 4 + docs/_static/.gitkeep | 0 docs/_static/images/qiskit-dark-logo.svg | 178 +++++++++++++++++++++ docs/_static/images/qiskit-light-logo.svg | 178 +++++++++++++++++++++ docs/_templates/autosummary/class.rst | 33 ++++ docs/apidocs/index.rst | 10 ++ docs/apidocs/sbd.device_config.rst | 8 + docs/apidocs/sbd.rst | 9 ++ docs/apidocs/sbd.sbd_solver.rst | 8 + docs/conf.py | 180 ++++++++++++++++++++++ docs/index.rst | 56 +++++++ docs/release-notes.rst | 3 + pyproject.toml | 10 ++ python/__init__.py | 10 +- python/sbd_solver.py | 4 +- releasenotes/config.yaml | 5 + releasenotes/notes/.gitkeep | 0 tox.ini | 27 +++- 19 files changed, 785 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/docs.yml create mode 100644 docs/_static/.gitkeep create mode 100644 docs/_static/images/qiskit-dark-logo.svg create mode 100644 docs/_static/images/qiskit-light-logo.svg create mode 100644 docs/_templates/autosummary/class.rst create mode 100644 docs/apidocs/index.rst create mode 100644 docs/apidocs/sbd.device_config.rst create mode 100644 docs/apidocs/sbd.rst create mode 100644 docs/apidocs/sbd.sbd_solver.rst create mode 100644 docs/conf.py create mode 100644 docs/index.rst create mode 100644 docs/release-notes.rst create mode 100644 releasenotes/config.yaml create mode 100644 releasenotes/notes/.gitkeep 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