Source code for vibeqc.banner

"""Runtime banner and linked-library version reporter.

Printed at the top of any serious vibe-qc text output so a user can always
identify exactly which build of the program produced a given log.

Programmatic API
----------------

``banner()``
    Return the full multi-line banner as a string.

``print_banner()``
    Convenience: ``print(banner(), **kwargs)``.

``library_versions()``
    Dict of the versions of linked native dependencies (libint, libxc,
    spglib) plus the package version. Useful in tests and bug reports.

``build_info()``
    Dict describing the current source build: git branch, short commit
    SHA, working-tree dirty flag, and exact-tag (if any). Empty when
    running from an installed wheel without a ``.git/`` available.

The ``format_scf_trace()`` helper in :mod:`vibeqc.scf_log` prepends the
banner automatically so a persisted SCF run log carries its provenance —
critical for matching a calculation result back to the exact build that
produced it (see ``docs/release_process.md``).
"""

from __future__ import annotations

import os
import subprocess
import sys
from functools import lru_cache
from pathlib import Path
from typing import Mapping, TextIO

# Package version — reads the installed metadata so it tracks pyproject.toml
# automatically at build/install time. Falls back to an explicit label if the
# package isn't found (e.g. running from a source tree without installation).
try:
    from importlib.metadata import PackageNotFoundError, version as _pkg_version
except ImportError:  # pragma: no cover — Python 3.11+ always ships this
    _pkg_version = None
    PackageNotFoundError = Exception  # type: ignore[misc, assignment]


__all__ = [
    "RELEASE_CODENAMES",
    "VIBEQC_VERSION",
    "banner",
    "build_info",
    "codename_for_version",
    "library_versions",
    "print_banner",
]


# --- Release codename catalog --------------------------------------------
#
# Every major + minor release carries a "Scientist's Animal" codename —
# see docs/roadmap.md § "Release codenames" for the full policy. The
# catalog is keyed by the X.Y.Z release version; minor releases (X.Y.0)
# are always present, and patch releases (X.Y.Z, Z > 0) MAY add their
# own override here if they have a distinct theme worth surfacing
# separately (e.g. v0.7.1 "Pulay's Triangle", v0.7.2 "Boys' Crucible",
# v0.7.3 "Whitten's Bridge" all override v0.7.0 "Löwdin's Compass").
# Patch versions WITHOUT an entry inherit the parent minor's codename
# via the fallback in :func:`codename_for_version`. Dev builds
# (.devN / aN / bN / rcN) inherit the codename of the upcoming release
# after PEP-440 suffix stripping.
#
# When cutting a new MINOR release (X.Y.0), add the entry here in the
# same commit that bumps ``pyproject.toml``. Patches need an entry
# here only if they get a distinct codename; otherwise they inherit.
#
# This catalog is also imported by ``docs/conf.py`` to render the
# landing-page "Current release" tip and the codename in MyST
# substitutions ({{codename}}). One source of truth, two consumers.
RELEASE_CODENAMES: dict[str, str] = {
    "0.1.0": "First Light",          # bootstrap tag; pre-codename era
    "0.4.0": "Schrödinger's Llama",  # retroactively assigned for fun
    "0.5.0": "Wilson's Otter",       # vibrations / Hessian flagship
                                     # (E. B. Wilson 1955)
    "0.6.0": "Pulay's Owl",          # periodic atomic gradients —
                                     # Pulay correction terms (1969)
                                     # are the conceptual core of
                                     # this release; Pulay also gave
                                     # us DIIS (1980) which underpins
                                     # the SCF stack the gradients
                                     # build on. Owl: nocturnal,
                                     # sees what others miss
                                     # (sub-meV displacement-derived
                                     # forces).
    "0.7.0": "Löwdin's Compass",     # periodic SCF correctness
                                     # flagship: linear-dependence
                                     # diagnostic + screening
                                     # optimiser + Lehtola pivoted
                                     # Cholesky + transparent
                                     # primitive filtering. Per-Olov
                                     # Löwdin gave us canonical
                                     # orthogonalisation (Adv. Quantum
                                     # Chem. 5, 185, 1970) and the
                                     # symmetric S^{-½} that anchors
                                     # this release's overlap-matrix
                                     # work. Compass: navigates the
                                     # basis-set / cutoff space and
                                     # finds the loosest PSD-keeping
                                     # configuration without silent
                                     # truncation. Aligns with
                                     # CRYSTAL's "refuse to silently
                                     # truncate; push the problem to
                                     # basis-set design" philosophy.
    "0.7.1": "Pulay's Triangle",     # cross-code parity layer:
                                     # automated regression suite
                                     # (examples/regression/) extended
                                     # to molecules + DF + open-shell
                                     # + MP2 + B3LYP/G; vibe-qc, PySCF
                                     # and ORCA driven from a single
                                     # spec and Δ-compared per case.
                                     # Peter Pulay invented DIIS (1980)
                                     # which makes molecular SCF
                                     # converge tightly enough for
                                     # µHa cross-code parity, the
                                     # Pulay-Vosko RI / density-fitting
                                     # framework (1990) that anchors
                                     # the DF layer, and the analytical-
                                     # gradient theory the suite will
                                     # exercise next. Triangle: the
                                     # three-way reference geometry
                                     # vibe-qc / PySCF / ORCA.
    "0.7.3": "Whitten's Bridge",     # bridge the v0.7.2 DF feature
                                     # into the molecular regression
                                     # runner: `density_fit=True` +
                                     # `aux_basis=...` (with autodetect
                                     # via `default_aux_basis_for`)
                                     # threaded through the SCF and
                                     # post-SCF MP2 paths. John L.
                                     # Whitten introduced
                                     # density-fitting / RI in 1973
                                     # (J. Chem. Phys. 58, 4496),
                                     # giving the field the trick that
                                     # makes def2-style RI auxiliary
                                     # bases tractable. Bridge: the
                                     # (case, code) wiring from spec to
                                     # vibe-qc DF kernel that this
                                     # patch closes — and the bug
                                     # discovered by closing it
                                     # (def2-svp DF SCF SIGSEGVs in
                                     # `run_rhf`, surfaced cleanly via
                                     # the v0.7.2 subprocess-isolation
                                     # layer rather than bricking the
                                     # whole suite).
    "0.8.0": "Grimme's Gecko",       # molecular methods polish:
                                     # B2PLYP + DSD-PBEP86-D4 double
                                     # hybrids, D4 dispersion via dftd4,
                                     # EEQ atomic-charge model
                                     # (Caldeweyher/Grimme D4a-i,
                                     # Apache-2.0 port), direct SCF for
                                     # all four molecular drivers,
                                     # RIJCOSX performance closure
                                     # vs ORCA, Lebedev-Laikov angular
                                     # grids + Stratmann-Scuseria-Frisch
                                     # atomic partition, B3LYP-VWN5
                                     # convention alignment, libecpint
                                     # banner integration, vqfetch
                                     # external-data Phase 1. Stefan
                                     # Grimme's group's lineage runs
                                     # through every major v0.8.0
                                     # methods deliverable: D3(BJ) → D4
                                     # → EEQ; the B2PLYP family (2006);
                                     # DSD-PBEP86 (Kozuch/Martin/Grimme);
                                     # composite "3c" methods (queued
                                     # for v0.9.0). Gecko: small, fast,
                                     # surfaces-stick — the mol-methods
                                     # arc that ships an entire class
                                     # of well-validated post-HF methods
                                     # built on a clean dispersion +
                                     # double-hybrid foundation.
    "0.7.2": "Boys' Crucible",       # density-fitting flagship:
                                     # 2c + 3c integral kernels +
                                     # DensityFitting object + def2 /
                                     # cc-pV*Z / Pople-RIFIT aux
                                     # libraries + DF wired into RHF /
                                     # UHF / RKS / UKS / MP2 / UMP2
                                     # energy and J / K analytic
                                     # gradients. S. F. Boys (1911-1972)
                                     # introduced Gaussian-type orbitals
                                     # in 1950 (Proc. R. Soc. A 200, 542),
                                     # the foundation that makes RI /
                                     # density-fitting numerically
                                     # tractable: the exp(-α r²)
                                     # primitives admit Gaussian-product
                                     # 2- and 3-centre overlap and
                                     # Coulomb integrals in closed form.
                                     # Crucible: per-case subprocess
                                     # isolation in the regression
                                     # suite — every (case, code) pair
                                     # runs in its own contained
                                     # subprocess so a C-level crash
                                     # (PySCF segfault, ORCA non-zero
                                     # exit, libxc abort) is captured
                                     # as a single error row instead of
                                     # bricking the whole dispatcher.
    "0.9.0": "Knowles's Kingfisher", # wavefunction-methods cycle:
                                     # MP2 → MP3 → MP4 → CCD → CCSD
                                     # canonical-correlation
                                     # scaffolding + the full
                                     # semiempirical-DFTB stack
                                     # (GFN2-xTB, PM6, OM1/OM2/OM3) +
                                     # basissetdev integration. Peter
                                     # J. Knowles is a coupled-cluster
                                     # / configuration-interaction
                                     # methods author (the MOLPRO
                                     # MRCI / direct-CI machinery,
                                     # Knowles & Handy 1984 FCI
                                     # algorithm, the Werner-Knowles
                                     # CASSCF). Kingfisher: precise,
                                     # patient, strikes once — the
                                     # post-HF correlation ladder
                                     # that v0.14.0+ will build on.
                                     # Retroactively added to the
                                     # catalog post-v0.10.0; v0.9.0
                                     # cut went to a side branch and
                                     # the entry never landed on main.
    "0.10.0": "Pisani's Penguin",    # periodic-SCF maturity cycle:
                                     # multi-k EDIIS + Python
                                     # accelerator family, SYM6
                                     # crystal symmetry, BIPOLE
                                     # L_max≥2, GPW M2-full iterative
                                     # SCF, CRYSTAL-α / Ewald-ω gauge
                                     # unification (cluster-A1 fix at
                                     # 859efe0a closes +19 Ha
                                     # translation-invariance break
                                     # on LiH rocksalt), full DFTB
                                     # stack + DFT+U Increments 1/2/
                                     # 4a/4b/4d-light, NEB, PWPB95,
                                     # periodic D3-BJ. Cesare Pisani
                                     # founded the CRYSTAL code at
                                     # Turin (1976-) — the periodic-
                                     # SCF lineage vibe-qc inherits.
                                     # Penguin: lives at extremes
                                     # (solid-state, structured,
                                     # low-T). Periodic GDF deferred
                                     # to v0.11.0 (fourth cycle:
                                     # v0.8.0 → v0.9.0 → v0.10.0 →
                                     # v0.11.0).
    "0.11.0": "Sun's Stingray",      # periodic-GDF chain closure:
                                     # compcell + multi-k + AFT —
                                     # fourth-cycle defer finally
                                     # closes (v0.8.0 → v0.9.0 →
                                     # v0.10.0 → v0.11.0). The
                                     # Sun-Berkelbach RSGDF
                                     # (range-separated Gaussian
                                     # density fitting, Sun et al.
                                     # JCP 147, 164119, 2017) is the
                                     # algorithm vibe-qc actually
                                     # implements: short-range Lpq
                                     # via Coulomb-metric GDF +
                                     # long-range via analytic-FT
                                     # auxiliary plane-wave terms.
                                     # Qiming Sun also authored
                                     # PySCF (the µHa-parity
                                     # reference). Stingray: glides
                                     # over the periodic landscape
                                     # via a Fourier transform —
                                     # k-space agile.
}


def _strip_dev_suffix(version: str) -> str:
    """Return the release version a dev build is heading toward, by
    stripping any ``.devN``, ``aN``, ``bN``, ``rcN`` (PEP 440)
    pre-release suffix. ``"0.5.0.dev0"`` → ``"0.5.0"``."""
    for sep in (".dev", "a", "b", "rc"):
        idx = version.find(sep)
        if idx > 0 and version[idx - 1].isdigit():
            return version[:idx]
    return version


def codename_for_version(version: str) -> str | None:
    """Return the codename for a given vibe-qc release version, or
    ``None`` if no codename is assigned.

    Resolution order:

      1. Strip ``.devN`` / ``aN`` / ``bN`` / ``rcN`` pre-release
         suffix (PEP 440 dev builds inherit the upcoming release's
         codename).
      2. Direct lookup of the resulting ``X.Y.Z`` in
         :data:`RELEASE_CODENAMES`.
      3. Fall back to the parent minor's codename: look up
         ``f"{X}.{Y}.0"``. So v0.4.1 / v0.4.2 / ... automatically
         inherit v0.4.0's codename (matches the policy in
         ``docs/roadmap.md § Release codenames``: "Patch releases
         inherit their parent minor's codename").
      4. ``None`` if no codename is registered for the minor.

    Examples
    --------
    >>> codename_for_version("0.5.0")
    "Wilson's Otter"
    >>> codename_for_version("0.5.0.dev0")
    "Wilson's Otter"
    >>> codename_for_version("0.4.1")          # patch inherits v0.4.0
    "Schrödinger's Llama"
    >>> codename_for_version("99.99.99") is None
    True
    """
    stripped = _strip_dev_suffix(version)
    if stripped in RELEASE_CODENAMES:
        return RELEASE_CODENAMES[stripped]
    parts = stripped.split(".")
    if len(parts) >= 2:
        anchor = f"{parts[0]}.{parts[1]}.0"
        if anchor in RELEASE_CODENAMES:
            return RELEASE_CODENAMES[anchor]
    return None


def _compute_version() -> str:
    """Read the installed distribution metadata. The distribution is
    named ``vibe-qc`` in ``pyproject.toml`` since the rename; older
    local installs may still be listed as the legacy ``vibeqc``, so we
    try both."""
    if _pkg_version is None:
        return "0.0.0+unknown"
    for dist_name in ("vibe-qc", "vibeqc"):
        try:
            return _pkg_version(dist_name)
        except PackageNotFoundError:
            continue
    return "0.0.0+unknown"


VIBEQC_VERSION: str = _compute_version()


def _blas_backend_label(libraries: str) -> str:
    """Map the raw BLAS_LIBRARIES string from CMake's FindBLAS to a
    short human-readable backend label for the banner.

    Examples:
        "-framework Accelerate"            -> "Accelerate"
        "/usr/lib/libopenblas.so"          -> "OpenBLAS"
        "/usr/lib/libblas.so.3.12.0"       -> "netlib BLAS"
        "/opt/intel/.../libmkl_rt.so"      -> "MKL"
        ""                                 -> "none"

    The label is informational — it falls back to the raw library
    name when no pattern matches, so a build against an unexpected
    BLAS still gets a meaningful banner line.
    """
    if not libraries:
        return "none"
    haystack = libraries.lower()
    # Order matters: more specific patterns first.
    if "accelerate" in haystack:
        return "Accelerate"
    if "mkl" in haystack:
        return "MKL"
    if "openblas" in haystack:
        return "OpenBLAS"
    if "blis" in haystack:
        return "BLIS"
    if "atlas" in haystack:
        return "ATLAS"
    if "libblas" in haystack or "/blas" in haystack:
        return "netlib BLAS"
    # Unknown backend — return the basename so the user sees *something*
    # concrete in the banner instead of "unknown".
    first = libraries.split(";")[0].strip()
    if first.startswith("-framework "):
        return first[len("-framework "):]
    return os.path.basename(first) or first


[docs] def library_versions() -> Mapping[str, str]: """Return the version strings of vibe-qc itself and its native dependencies. Keys are always present; values are ``"unknown"`` if a probe fails, ``"none"`` when the dependency was not linked at build / install time. Core (always linked at build time): ``libint``, ``libxc``, ``spglib``, ``libecpint``, ``fftw3``, ``blas``. Optional (linked at runtime only when the ``[dispersion]`` extra is installed): ``dftd3``, ``dftd4``. Each is set to ``"none"`` when the corresponding PyPI package isn't installed in the user's environment — distinguishes "not installed" from "probe failed" (the latter would be ``"unknown"``). The ``"vibe-qc"`` key carries the project version. The legacy ``"vibeqc"`` key is kept as an alias so code that predates the rename keeps working without change. The ``"blas"`` key carries a short backend label (Accelerate / OpenBLAS / MKL / netlib BLAS / …) decorated with ``" +LAPACKE"`` when EIGEN_USE_LAPACKE is enabled, so the banner conveys both the linkage and whether dense solvers delegate to LAPACK. """ versions: dict[str, str] = { "vibe-qc": VIBEQC_VERSION, "vibeqc": VIBEQC_VERSION, # alias, retained for back-compat } # Probe the compiled extension. Each of the libraries exposes a # version accessor; every version is known at link time. try: from . import _vibeqc_core as _core except ImportError: versions.update({"libint": "unknown", "libxc": "unknown", "spglib": "unknown", "fftw3": "unknown", "blas": "unknown"}) return versions try: versions["libint"] = _core.libint_version() except Exception: versions["libint"] = "unknown" try: versions["libxc"] = getattr(_core, "libxc_version", lambda: "unknown")() except Exception: versions["libxc"] = "unknown" try: versions["spglib"] = _core.spglib_version() except Exception: versions["spglib"] = "unknown" # libecpint version probe — added with the v0.8.0 banner / libecpint # coupled fix (prep doc § 488-501). libecpint has been bundled since # v0.4.0 (commit add8163) but the banner didn't surface its version # until v0.8.0. Defensive: an older _core lacking libecpint_version # falls back to "unknown" rather than failing the whole library # probe. try: versions["libecpint"] = getattr(_core, "libecpint_version", lambda: "unknown")() except Exception: versions["libecpint"] = "unknown" # FFTW3 version probe — FFTW3 has been a required build dep since # the FFT-Poisson Ewald long-range Hartree solver landed; the GAPW # route (v0.10.x) is the second consumer. Banner coverage added per # CLAUDE.md § 6 alongside the M1 PW-basis infrastructure. Defensive # getattr: an older _core lacking fftw3_version falls back to # "unknown". try: versions["fftw3"] = getattr(_core, "fftw3_version", lambda: "unknown")() except Exception: versions["fftw3"] = "unknown" # BLAS info was added with the BLAS+LAPACK linkage commit; an # older C extension predating that change won't have blas_info. # Treat that as "unknown" so the banner remains parseable across # builds. try: _info = _core.blas_info() if not _info.get("blas_enabled"): versions["blas"] = "none" else: label = _blas_backend_label(_info.get("libraries", "")) if _info.get("lapacke_enabled"): label = f"{label} +LAPACKE" versions["blas"] = label except Exception: versions["blas"] = "unknown" # Optional dispersion backends (dftd3, dftd4). Both ship as PyPI # wheels with bundled binary libraries (libdftd3 / libdftd4) that # vibe-qc links to at runtime via ctypes/cffi when the user calls # vibeqc.compute_d3 / vibeqc.compute_d4. CLAUDE.md § 6 mandates # banner coverage for every linked native dep; these are linked # *optionally* (extra `[dispersion]`) so the value is "none" when # the package is absent rather than "unknown" — distinguishes # "not installed" from "failed to probe". for pkg in ("dftd3", "dftd4"): try: module = __import__(pkg) versions[pkg] = str(getattr(module, "__version__", "unknown")) except ImportError: versions[pkg] = "none" except Exception: versions[pkg] = "unknown" return versions
# --- Build-provenance probe ----------------------------------------------- # # When vibe-qc runs from an editable install or directly from a source # checkout, we want the banner to record exactly which git revision is # in play — branch name, short SHA, dirty-flag, and exact-tag (for # tagged releases). When running from a wheel that has no ``.git/`` # alongside it, all of these probes return None and the banner falls # back to the package version alone. # # This matters because users will run calculations against specific # branches for testing (``release``, ``main``, or topic branches), and # the only way to match a misbehaving SCF log to the build that # produced it is to record the SHA in the log itself. # # Probes are cached at module-import time to avoid repeated subprocess # calls; ``build_info.cache_clear()`` is available for tests. def _find_git_root(start: Path) -> Path | None: """Walk upward from ``start`` looking for a ``.git`` directory or ``.git`` worktree-link file. Returns the parent directory of the first match, or None if we hit the filesystem root.""" p = start.resolve() while p != p.parent: if (p / ".git").exists(): return p p = p.parent return None def _git(repo: Path, *args: str) -> str | None: """Run a git command in ``repo`` and return stripped stdout, or None on any failure (no git installed, broken repo, command rejected). Never raises.""" try: out = subprocess.check_output( ["git", *args], cwd=str(repo), stderr=subprocess.DEVNULL, text=True, timeout=2.0, ) return out.strip() or None except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired, OSError): return None @lru_cache(maxsize=1) def build_info() -> Mapping[str, str | bool | None]: """Return git provenance for the current build, or empty mapping if no ``.git/`` is available (e.g. installed wheel). Keys (always present, may be None / False): - ``branch`` : current branch name, or ``"HEAD"`` for detached. - ``sha`` : 7-character abbreviated commit SHA. - ``sha_full``: full 40-character SHA. - ``dirty`` : True if the working tree has uncommitted changes. - ``tag`` : exact tag at HEAD, e.g. ``"v0.4.1"``; None if not on a tag. - ``is_release``: True if the current commit is exactly tagged AND the working tree is clean. This is the only state where the banner displays as "Release X.Y.Z". Empty dict (``{}``) when no git context exists at all. """ repo = _find_git_root(Path(__file__).parent) if repo is None: return {} # Allow CI / packaging to override these values without spawning git. # Useful when building wheels from a tarball where .git is absent. env_branch = os.environ.get("VIBEQC_BUILD_BRANCH") env_sha = os.environ.get("VIBEQC_BUILD_SHA") env_tag = os.environ.get("VIBEQC_BUILD_TAG") sha_full = env_sha or _git(repo, "rev-parse", "HEAD") sha = sha_full[:7] if sha_full else None branch = env_branch or _git(repo, "rev-parse", "--abbrev-ref", "HEAD") if branch == "HEAD": # Detached HEAD: try to identify by tag or short ref name. branch = _git(repo, "describe", "--all", "--exact-match") or "HEAD" tag = env_tag if tag is None: # ``--exact-match`` returns non-zero when the current commit isn't # tagged; _git swallows that and returns None, which is the # correct "not a release" signal. tag = _git(repo, "describe", "--tags", "--exact-match") dirty_status = _git(repo, "status", "--porcelain") dirty = bool(dirty_status) is_release = bool(tag) and not dirty return { "branch": branch, "sha": sha, "sha_full": sha_full, "dirty": dirty, "tag": tag, "is_release": is_release, } _COPYRIGHT_LINE = "© Michael F. Peintinger · MPL 2.0 · https://vibe-qc.com" def _version_descriptor() -> str: """Compose the line that identifies the current build: * Tagged release, clean tree → "Release v0.5.0 \"Wilson's Otter\"" * Dev build, branch + sha → "dev 0.5.0.dev0 \"Wilson's Otter\" (main @ abc1234)" * Dev build, dirty tree → "dev 0.5.0.dev0 \"Wilson's Otter\" (main @ abc1234, dirty)" * Wheel install (no git info) → "vibe-qc 0.5.0 \"Wilson's Otter\"" * Pre-codename releases → no quoted codename suffix (e.g. "Release v0.4.1", "dev 0.4.0.dev0 (main @ abc1234)") The codename is bound to the release version; dev builds against an upcoming release inherit its codename so the banner is fun even on a working tree. See ``_RELEASE_CODENAMES``. """ info = build_info() codename = codename_for_version(VIBEQC_VERSION) cn_suffix = f' "{codename}"' if codename else "" if not info: return f"vibe-qc {VIBEQC_VERSION}{cn_suffix}" if info.get("is_release"): return f"Release {info['tag']}{cn_suffix}" branch = info.get("branch") or "HEAD" sha = info.get("sha") or "unknown" suffix = ", dirty" if info.get("dirty") else "" return f"dev {VIBEQC_VERSION}{cn_suffix} ({branch} @ {sha}{suffix})"