Source code for vibeqc.runner

"""High-level "run a job" convenience -- classic QC-program workflow.

A single call writes the output text file, the molden orbital file, and
(for geometry optimization) a trajectory animation -- the kind of shape
users expect from Gaussian / ORCA / NWChem:

    from vibeqc import Atom, Molecule
    from vibeqc.runner import run_job

    mol = Molecule.from_xyz("h2o.xyz")
    run_job(mol, basis="6-31g*", method="rhf", output="h2o")
    # -> h2o.out, h2o.molden

    run_job(mol, basis="6-31g*", method="rks", functional="PBE",
            optimize=True, output="h2o_opt")
    # -> h2o_opt.out, h2o_opt.molden, h2o_opt.traj

All text output goes to ``{output}.out``. The molden file is written iff
the method produces molecular orbitals (RHF / UHF / RKS / UKS) and
``write_molden=True``. Geometry optimization uses ASE's BFGS and writes
one frame per step to ``{output}.traj``.
"""

from __future__ import annotations

from dataclasses import replace
import os
import time
from pathlib import Path
from typing import TYPE_CHECKING, Any, List, Literal, Optional, Union

import numpy as np

from ._vibeqc_core import (
    Atom,
    BasisSet,
    D3BJParams,
    Functional,
    GridOptions,
    InitialGuess,
    MP2Options,
    Molecule,
    RHFOptions,
    RKSOptions,
    UHFOptions,
    UMP2Options,
    UKSOptions,
    XCKind,
    get_num_threads,
    run_rhf,
    run_rks,
    run_uhf,
    run_uks,
    set_num_threads,
)
from .banner import (
    VIBEQC_VERSION,
    banner,
    enforce_runtime_pin_from_env,
    library_versions,
)
from .composites import (
    Availability,
    CompositeRecipe,
    CompositeUnavailable,
    resolve_composite,
)
from .crash_dump import crash_dump_context, dump_on_failure
from .dispersion import compute_d3bj, d3bj_params_for
from .dispersion_d4 import compute_d4, dftd4_available
from .dispersion_d4_parameters import normalize_d4_key
from .ecp_metadata import attach_inline_ecp_options_from_basis_sidecar
from .gcp import GCPDataMissing, compute_gcp
from .memory import (
    check_memory,
    estimate_memory,
    estimate_semiempirical_memory,
    format_memory_report,
)
from .naming.report import write_iupac_name
from .output import (
    Column,
    Level,
    OutputChannel,
    OutputDocument,
    OutputPlan,
    OutputWriter,
    Quantity,
    Table,
    active_policy,
    dry_run_manifest,
    flush,
    is_dry_run_estimate_requested,
    is_dry_run_requested,
    parse_write_cube_kwarg,
    record,
    render_duration,
    render_energy_labeled,
    render_frequency,
    render_temperature,
    requested_mo_indices,
    section_header,
    warn,
    write,
)
from .output._errors import (
    OutputFailureKind,
    warn_output_failure,
    warn_writer_failure,
)
from .output.citations import (
    format_references_block,
    load_default_database,
    write_references_block,
)
from .perf import PerfScope
from .perf import perf_log as _perf_log_ctx
from .progress import ProgressLogger, resolve_progress
from .rohf import ROHFOptions, run_rohf
from .roks import ROKSOptions, run_roks
from .scf_log import write_scf_trace
from .semiempirical.runner import (
    MOLECULAR_SEMIEMPIRICAL_METHODS,
    SEMIEMPIRICAL_METHOD_ALIASES,
    SEMIEMPIRICAL_METHODS,
    SemiempiricalResult as _SEMPR,
    normalise_semiempirical_method,
    run_ccm as _run_ccm,
    run_semiempirical as _run_semiempirical,
)
from .solvers import (
    CASCIOptions,
    CASPT2Options,
    CASSCFOptions,
    CC3Options,
    CC3Result,
    CCSDTOptions,
    CCSDTResult,
    CISDOptions,
    DMRGOptions,
    Hamiltonian,
    NEVPT2Options,
    SelectedCIOptions,
    SolverResult,
    TranscorrelatedOptions,
    V2RDMOptions,
    build_hamiltonian_mo,
    build_transcorrelated_hamiltonian,
    get_hf_orbital_provider,
    solve_dmrg,
    solve_selected_ci,
    solve_v2rdm,
)

_BOHR_TO_ANGSTROM = 0.529177210903
_EV_PER_HARTREE = 27.211386245988
_EV_PER_ANGSTROM_TO_HA_PER_BOHR = _BOHR_TO_ANGSTROM / _EV_PER_HARTREE
from .structured_log import (
    StructuredLog,
    run_fingerprint,
)
from .structured_log import (
    structured_log as _structured_log_ctx,
)

DispersionSpec = Optional[object]  # str functional name | D3BJParams | None


def _resolve_dispersion(
    dispersion: DispersionSpec,
    functional: Optional[str],
) -> Optional[D3BJParams]:
    """Normalize the ``dispersion=`` argument into a D3BJParams object,
    or None if no dispersion correction is requested.

    * ``None`` or empty string -> no dispersion.
    * ``True`` / ``"d3bj"`` -> use D3-BJ params for the current functional.
    * ``"pbe" / "b3lyp" / ...`` -> use D3-BJ params for that functional
      explicitly (useful for HF + D3-BJ).
    * :class:`D3BJParams` instance -> used as-is.
    """
    if dispersion is None or dispersion is False:
        return None
    if isinstance(dispersion, D3BJParams):
        return dispersion
    if isinstance(dispersion, str):
        key = dispersion.strip().lower()
        if key in ("", "none", "false"):
            return None
        if key in ("d3bj", "true", "yes", "on"):
            if not functional:
                raise ValueError(
                    "dispersion='d3bj' requires a DFT functional so we can "
                    "look up damping parameters; pass functional='pbe', "
                    "etc., or give a functional name directly as the "
                    "dispersion argument."
                )
            lookup = functional
        else:
            lookup = dispersion
        p = d3bj_params_for(lookup)
        if p is None:
            raise ValueError(
                f"dispersion={dispersion!r}: no D3-BJ parameters found "
                f"for functional {lookup!r} (neither builtin nor dftd3 "
                f"backend knows this name). Install the dftd3 backend "
                f"for the full Grimme parameter set: "
                f"`pip install dftd3` into your vibe-qc venv, or "
                f"`pip install -e '.[dispersion]'` from the repo checkout."
            )
        return p
    if dispersion is True:
        if not functional:
            raise ValueError(
                "dispersion=True requires a DFT functional; pass functional='pbe', etc."
            )
        return d3bj_params_for(functional)
    raise TypeError(
        f"dispersion must be None, bool, str, or D3BJParams; got "
        f"{type(dispersion).__name__}"
    )


class _DispersionAugmented:
    """Transparent wrapper around an SCF result that exposes the
    dispersion contribution as extra attributes without overriding the
    raw SCF ``.energy``. Users opt in to the total energy via
    :attr:`energy_total`; everything else (mo_energies, density, fock,
    converged, n_iter, scf_trace, ...) forwards to the underlying
    pybind11 SCF-result struct.

    We wrap rather than mutate because the C++ result classes have
    ``def_readonly`` fields -- they can't be extended in place from
    Python.
    """

    __slots__ = ("_scf", "e_dispersion", "dispersion_params", "method")

    def __init__(
        self, scf_result, e_dispersion: float, params: D3BJParams, method: str = ""
    ):
        object.__setattr__(self, "_scf", scf_result)
        object.__setattr__(self, "method", method)
        object.__setattr__(self, "e_dispersion", e_dispersion)
        object.__setattr__(self, "dispersion_params", params)

    def __getattr__(self, name):
        return getattr(self._scf, name)

    @property
    def e_scf(self) -> float:
        """The bare SCF energy (Hartree) -- alias of ``.energy``, named
        so energy-collection code reads unambiguously next to
        :attr:`e_dispersion` and :attr:`energy_total`."""
        return float(self._scf.energy)

    @property
    def energy_total(self) -> float:
        """SCF + dispersion energy (Hartree)."""
        return float(self._scf.energy) + float(self.e_dispersion)

    def __repr__(self) -> str:
        return (
            f"{type(self._scf).__name__}(energy={self._scf.energy:.10f}, "
            f"e_dispersion={self.e_dispersion:+.6e}, "
            f"energy_total={self.energy_total:.10f})"
        )


class _MP2Augmented:
    """Transparent wrapper exposing a post-SCF MP2 result without
    overriding the raw SCF ``.energy``. :attr:`mp2` is the C++
    ``MP2Result`` (``e_hf`` / ``e_os`` / ``e_ss`` / ``e_correlation`` /
    ``e_total``); :attr:`energy_total` is the MP2 total energy. Everything
    else (mo_energies, density, fock, converged, n_iter, scf_trace, ...)
    forwards to the underlying pybind11 SCF-result struct.

    Mirrors :class:`_DispersionAugmented`: we wrap rather than mutate
    because the C++ result classes have ``def_readonly`` fields and no
    ``__dict__`` (so ``result.mp2 = ...`` would raise).
    """

    __slots__ = ("_scf", "mp2", "method")

    def __init__(self, scf_result, mp2_result, method: str = ""):
        object.__setattr__(self, "_scf", scf_result)
        object.__setattr__(self, "method", method)
        object.__setattr__(self, "mp2", mp2_result)

    def __getattr__(self, name):
        return getattr(self._scf, name)

    @property
    def energy_total(self) -> float:
        """SCF + MP2 correlation energy (Hartree)."""
        return float(self.mp2.e_total)

    def __repr__(self) -> str:
        return (
            f"{type(self._scf).__name__}+MP2("
            f"e_scf={float(self._scf.energy):.10f}, "
            f"e_corr={float(self.mp2.e_correlation):+.6e}, "
            f"e_mp2_total={float(self.mp2.e_total):.10f})"
        )


class _DLPNOMP2Augmented:
    """SCF result + DLPNO-MP2 result, attribute-forwarding wrapper.

    Mirrors :class:`_MP2Augmented` -- wraps rather than mutates because the
    C++ SCF result has ``def_readonly`` fields and no ``__dict__``.
    """

    __slots__ = ("_scf", "dlpno_mp2", "method")

    def __init__(self, scf_result, dlpno_result, method: str = ""):
        object.__setattr__(self, "_scf", scf_result)
        object.__setattr__(self, "method", method)
        object.__setattr__(self, "dlpno_mp2", dlpno_result)

    def __getattr__(self, name):
        return getattr(self._scf, name)

    @property
    def energy_total(self) -> float:
        """SCF + DLPNO-MP2 correlation energy (Hartree)."""
        return float(self.dlpno_mp2.e_total)

    def __repr__(self) -> str:
        return (
            f"{type(self._scf).__name__}+DLPNO-MP2("
            f"e_scf={float(self._scf.energy):.10f}, "
            f"e_corr={float(self.dlpno_mp2.e_corr):+.6e}, "
            f"e_total={float(self.dlpno_mp2.e_total):.10f})"
        )


class _DLPNOUMP2Augmented:
    """UHF result + open-shell DLPNO-UMP2 result, attribute-forwarding wrapper."""

    __slots__ = ("_scf", "dlpno_ump2", "method")

    def __init__(self, scf_result, dlpno_result, method: str = ""):
        object.__setattr__(self, "_scf", scf_result)
        object.__setattr__(self, "method", method)
        object.__setattr__(self, "dlpno_ump2", dlpno_result)

    def __getattr__(self, name):
        return getattr(self._scf, name)

    @property
    def energy_total(self) -> float:
        """UHF + DLPNO-UMP2 correlation energy (Hartree)."""
        return float(self.dlpno_ump2.e_total)

    def __repr__(self) -> str:
        return (
            f"{type(self._scf).__name__}+DLPNO-UMP2("
            f"e_scf={float(self._scf.energy):.10f}, "
            f"e_corr={float(self.dlpno_ump2.e_corr):+.6e}, "
            f"e_total={float(self.dlpno_ump2.e_total):.10f})"
        )


class _DLPNOCCSDAugmented:
    """SCF result + DLPNO-CCSD pilot result, attribute-forwarding wrapper."""

    __slots__ = ("_scf", "dlpno_ccsd", "method")

    def __init__(self, scf_result, cc_result, method: str = ""):
        object.__setattr__(self, "_scf", scf_result)
        object.__setattr__(self, "method", method)
        object.__setattr__(self, "dlpno_ccsd", cc_result)

    def __getattr__(self, name):
        return getattr(self._scf, name)

    @property
    def energy_total(self) -> float:
        """SCF + CCSD correlation (+ (T) if computed), Hartree."""
        return float(self.dlpno_ccsd.e_total)

    def __repr__(self) -> str:
        label = "DLPNO-UCCSD" if "uccsd" in self.method else "DLPNO-CCSD"
        return (
            f"{type(self._scf).__name__}+{label}("
            f"e_scf={float(self._scf.energy):.10f}, "
            f"e_corr={float(self.dlpno_ccsd.e_corr):+.6e}, "
            f"e_t={float(self.dlpno_ccsd.e_t):+.3e}, "
            f"e_total={float(self.dlpno_ccsd.e_total):.10f})"
        )


class _CCSDAugmented:
    """SCF result + CCSD/CCSD(T) result, attribute-forwarding wrapper.

    Mirrors :class:`_MP2Augmented`: wraps rather than mutates because the
    C++ SCF result has ``def_readonly`` fields and no ``__dict__``.
    :attr:`ccsd` is the C++ ``CCSDResult`` (``e_ccsd_correlation`` /
    ``e_ccsd`` / ``e_t`` / ``e_ccsd_t`` / ``cc_trace`` / ...).
    """

    __slots__ = ("_scf", "ccsd", "method")

    def __init__(self, scf_result, cc_result, method: str = ""):
        object.__setattr__(self, "_scf", scf_result)
        object.__setattr__(self, "method", method)
        object.__setattr__(self, "ccsd", cc_result)

    def __getattr__(self, name):
        return getattr(self._scf, name)

    @property
    def energy_total(self) -> float:
        """SCF + CCSD (+ (T) when computed) total energy (Hartree)."""
        return float(self.ccsd.e_total)

    def __repr__(self) -> str:
        return (
            f"{type(self._scf).__name__}+CCSD("
            f"e_scf={float(self._scf.energy):.10f}, "
            f"e_corr={float(self.ccsd.e_ccsd_correlation):+.6e}, "
            f"e_t={float(self.ccsd.e_t):+.6e}, "
            f"e_total={float(self.ccsd.e_total):.10f})"
        )


class _OVGFAugmented:
    """Transparent wrapper exposing OVGF / GF2 quasiparticle results.

    :attr:`ovgf` is the list of :class:`vibeqc.propagator.QuasiparticleResult`
    (one per corrected orbital, carrying e_scf / e_qp / pole strength).  All SCF
    attributes forward to the underlying result (mirrors :class:`_MP2Augmented`;
    the C++ result has no ``__dict__``).
    """

    __slots__ = ("_scf", "ovgf", "method")

    def __init__(self, scf_result, ovgf_results, method: str = ""):
        object.__setattr__(self, "_scf", scf_result)
        object.__setattr__(self, "method", method)
        object.__setattr__(self, "ovgf", ovgf_results)

    def __getattr__(self, name):
        return getattr(self._scf, name)

    def __repr__(self) -> str:
        n = len(self.ovgf)
        return f"{type(self._scf).__name__}+OVGF({n} quasiparticle orbitals)"


if TYPE_CHECKING:
    from .cc import CCSDOptions
    from .mlip import MLIPOptions
    from .semiempirical.methods.msindo_ccm import CCMOptions
    from .thermo import ThermoOptions


Method = Literal[
    "rhf",
    "rks",
    "uhf",
    "uks",
    # Restricted open-shell HF (spin-pure single determinant; Roothaan
    # 1960).  Pure-Python driver on the JK seam --- see vibeqc.rohf.
    "rohf",
    # Restricted open-shell Kohn--Sham (spin-pure KS; same Roothaan
    # coupling + libxc XC) --- see vibeqc.roks.
    "roks",
    "auto",
    "selected_ci",
    "dmrg",
    "v2rdm",
    "transcorrelated_ci",
    "casci",
    "mrci",
    "casscf",
    # CASSCF/CASCI-referenced multireference PT2 (internally-contracted
    # CASPT2 default; strongly-contracted NEVPT2).  Reachable through
    # run_job(method=...) and documented in docs/user_guide/non_hf_solvers.md
    # + handovers/HANDOVER_MULTIREF.md; listed here so the public method type
    # matches the documented, dispatched surface.
    "nevpt2",
    "caspt2",
    "fci",
    "ci",
    "cisd",
    "ccsd",
    "ccsd(t)",
    "cc3",
    "ccsdt",
    "bccd",
    "bccd(t)",
    # Approximate coupled-cluster / coupled-pair variants of the canonical
    # closed-shell DF-CCSD kernel (CCSDOptions.cc_variant; cpp/src/ccsd.cpp):
    # CC2, CCD (T1 frozen), linearized LCCD / LCCSD, and Meyer's CEPA(0..3).
    # RHF reference only; also reachable through method='ci' with citype=.
    "cc2",
    "ccd",
    "lccd",
    "lccsd",
    "cepa(0)",
    "cepa(1)",
    "cepa(2)",
    "cepa(3)",
    "qcisd",
    "qcisd(t)",
    # Post-SCF Moller-Plesset (RHF reference + native C++ MP2; SCS/SOS via
    # the c_os/c_ss spin-component scaling -- see vibeqc.correlation).
    "mp2",
    "scs-mp2",
    "sos-mp2",
    # DLPNO-MP2 (RHF reference + vibeqc.dlpno.mp2 -- Foster-Boys LMOs, PAO
    # domains, semicanonical PNOs, coupled LMP2 residuals; Pinski 2015).
    "dlpno-mp2",
    # DLPNO-CCSD / CCSD(T) correctness pilot (vibeqc.dlpno.ccsd --
    # subspace-projected through the FCI-anchored spin-orbital engine;
    # O(N^6), capped at max_nbf; Riplinger 2013).
    "dlpno-ccsd",
    "dlpno-ccsd(t)",
    # Green's-function quasiparticle IPs/EAs (RHF reference + the diagonal
    # second-order self-energy -- see vibeqc.propagator).
    "ovgf",
    # Composite 3c keywords (v0.9.0 -- see vibeqc.composites). The
    # dispatcher resolves these via _apply_composite before any of the
    # other Method literal branches see them.
    "hf-3c",
    "pbeh-3c",
    "b97-3c",
    "b3lyp-3c",
    "r2scan-3c",
    "wb97x-3c",
    "hse-3c",
    # Semiempirical methods.
    "dftb0",
    "scc_dftb",
    "pm6",
    "gfn2_xtb",
    "om1",
    "om2",
    "om3",
    "msindo",
    # MSINDO SECCM (needs ccm_options with the supercell lattice vectors).
    # ``ccm`` is the normalized compatibility key for public ``seccm``.
    "ccm",
    # Machine-learning interatomic potential -- external pre-trained
    # model (ACEsuit MACE). Routed through vibeqc.mlip.mace.
    "mace",
]

# Machine-learning interatomic potentials (external pre-trained models,
# routed through vibeqc.mlip). Basis-set-free like the semiempirical
# methods; unlike them, vibe-qc drives a third-party pre-trained forward
# pass (maintainer-approved CLAUDE.md Sec.10 extension). Kept as a module
# constant so the basis-skip sites in run_job stay in sync.
_MLIP_METHODS = frozenset({"mace"})

# Post-SCF correlation methods: an RHF/UHF SCF runs first (resolved_method),
# but citations + the output label key off the *original* method so the
# correlation papers (routes.methods.{ccsd,ccsd(t),mp2,scs-mp2,sos-mp2}) fire.
_POSTSCF_CITE_METHODS = frozenset(
    {
        "cisd",
        "ccsd",
        "ccsd(t)",
        "cc3",
        "ccsdt",
        "ccsd[t]",
        "a-ccsd(t)",
        "bccd",
        "bccd(t)",
        "cc2",
        "ccd",
        "lccd",
        "lccsd",
        "cepa(0)",
        "cepa(1)",
        "cepa(2)",
        "cepa(3)",
        "qcisd",
        "qcisd(t)",
        "mp2",
        "scs-mp2",
        "sos-mp2",
        "rohf-mp2",
        "dlpno-mp2",
        "dlpno-ump2",
        "dlpno-ccsd",
        "dlpno-ccsd(t)",
        "dlpno-uccsd",
        "dlpno-uccsd(t)",
        "ovgf",
    }
)


_CITYPE_SUPPORTED = {
    "cisd": "cisd",
    "ccsd": "ccsd",
    "ccsd(t)": "ccsd(t)",
    "cc3": "cc3",
    "ccsdt": "ccsdt",
    "bccd": "bccd",
    "bccd(t)": "bccd(t)",
    "cc2": "cc2",
    "ccd": "ccd",
    "lccd": "lccd",
    "lccsd": "lccsd",
    "cepa0": "cepa(0)",
    "cepa(0)": "cepa(0)",
    "cepa1": "cepa(1)",
    "cepa(1)": "cepa(1)",
    "cepa2": "cepa(2)",
    "cepa(2)": "cepa(2)",
    "cepa3": "cepa(3)",
    "cepa(3)": "cepa(3)",
    "qcisd": "qcisd",
    "qcisd(t)": "qcisd(t)",
}
_CITYPE_UNIMPLEMENTED = {
    "cepa": "CEPA",
    "cepa(n)": "CEPA(n)",
    "cepan": "CEPA(n)",
}

# Coupled-pair variants of the closed-shell DF-CCSD kernel: run_job method
# string -> CCSDOptions.cc_variant selector (cpp/src/ccsd.cpp).  All run an
# RHF SCF first and reuse the CCSD post-SCF machinery with the variant set
# and (T) off.
_CC_VARIANT_METHODS = {
    "cc2": "cc2",
    "ccd": "ccd",
    "lccd": "lccd",
    "lccsd": "lccsd",
    "cepa(0)": "cepa(0)",
    "cepa(1)": "cepa(1)",
    "cepa(2)": "cepa(2)",
    "cepa(3)": "cepa(3)",
    "qcisd": "qcisd",
    "qcisd(t)": "qcisd",
}
_CC_VARIANT_LABELS = {
    "cc2": "CC2",
    "ccd": "CCD",
    "lccd": "LCCD",
    "lccsd": "LCCSD",
    "cepa(0)": "CEPA(0)",
    "cepa(1)": "CEPA(1)",
    "cepa(2)": "CEPA(2)",
    "cepa(3)": "CEPA(3)",
    "qcisd": "QCISD",
    "qcisd(t)": "QCISD(T)",
}
_BCCD_METHODS = {
    "bccd": False,
    "bccd(t)": True,
}
_BCCD_LABELS = {
    "bccd": "BCCD",
    "bccd(t)": "BCCD(T)",
}


def _normalise_citype_selector(citype: object) -> str:
    if not isinstance(citype, str):
        raise ValueError(
            "citype= expects a string such as 'cisd', 'ccsd', or 'ccsd(t)'; "
            f"got {citype!r}."
        )
    key = citype.strip().lower().replace(" ", "").replace("_", "-")
    if key in _CITYPE_SUPPORTED:
        return _CITYPE_SUPPORTED[key]
    if key in _CITYPE_UNIMPLEMENTED:
        label = _CITYPE_UNIMPLEMENTED[key]
        raise NotImplementedError(
            f"citype={citype!r} selects {label}, which is a roadmap item but "
            "is not implemented yet. Currently supported selectors are "
            "citype='cisd', 'ccsd', 'ccsd(t)', 'cc3', 'ccsdt', 'bccd', 'bccd(t)', 'cc2', 'ccd', "
            "'lccd', 'lccsd', 'cepa(0)'..'cepa(3)', 'qcisd', and "
            "'qcisd(t)'."
        )
    raise ValueError(
        "citype= expects 'cisd', 'ccsd', 'ccsd(t)', 'cc3', 'ccsdt', 'bccd', 'bccd(t)', "
        "'cc2', 'ccd', 'lccd', 'lccsd', 'cepa(0)'..'cepa(3)', 'qcisd', "
        f"'qcisd(t)', or one of the named roadmap variants; got {citype!r}."
    )


def _apply_citype_selector(method: str, citype: object | None) -> str:
    if method == "ci" and citype is None:
        raise ValueError(
            "method='ci' requires citype='cisd', 'ccsd', 'ccsd(t)', 'cc3', 'ccsdt', "
            "'bccd', 'bccd(t)', or another supported CI/CC selector."
        )
    if citype is None:
        return method
    selected = _normalise_citype_selector(citype)
    if method not in ("auto", "rhf", "ci", selected):
        raise ValueError(
            f"citype={citype!r} conflicts with method={method!r}. Use "
            "method='ci', method='rhf', method='auto', or the matching "
            f"method={selected!r}."
        )
    return selected


_METHOD_ALIASES = SEMIEMPIRICAL_METHOD_ALIASES


def _normalise_method_alias(method: str) -> str:
    return normalise_semiempirical_method(method)


def _select_method(
    method: Method,
    molecule: Molecule,
    functional: Optional[str],
    ccsd_reference: str = "uhf",
    mp2_reference: str = "uhf",
) -> str:
    """Resolve ``method='auto'`` against molecule.multiplicity + functional.

    Heuristic wavefunction routing by electron count:
    - <=4 electrons -> FCI (exact for tiny systems)
    - <=8 electrons -> Selected-CI (systematically improvable)
    - >8 electrons -> HF (via SCF)

    ``ccsd`` and ``ccsd(t)`` run RHF SCF first, then post-SCF coupled-cluster."""
    if method == "auto":
        if functional:
            return "uks" if molecule.multiplicity > 1 else "rks"
        n_elec = molecule.n_electrons()
        if n_elec <= 4:
            return "fci"
        if n_elec <= 8:
            return "selected_ci"
        return "uhf" if molecule.multiplicity > 1 else "rhf"
    if method in ("ccsd", "ccsd(t)"):
        # Closed shell -> RHF + RCCSD; open shell -> UHF + spin-orbital
        # UCCSD by default, or ROHF + ROHF-CCSD when ccsd_reference='rohf'.
        if molecule.multiplicity > 1:
            return "rohf" if ccsd_reference == "rohf" else "uhf"
        return "rhf"
    if method in _BCCD_METHODS or method in _CC_VARIANT_METHODS:
        # Coupled-pair / QCI variants live in the closed-shell kernel only.
        if molecule.multiplicity > 1:
            raise NotImplementedError(
                f"method={method!r} requires a closed-shell (singlet) "
                "reference; open-shell coupled-pair/QCI variants are not "
                "implemented. Use method='ccsd' / 'ccsd(t)' (UHF or ROHF "
                "reference) for open-shell coupled cluster."
            )
        return "rhf"
    # Post-SCF MP2 (and its spin-component-scaled variants) runs the mean-field
    # SCF first -- RHF for closed shell, UHF for open shell (native run_ump2),
    # or ROHF when mp2_reference='rohf' (spin-pure semicanonical ROHF-MP2);
    # the post-SCF step keys off the original ``method`` downstream.
    if method in ("mp2", "scs-mp2", "sos-mp2"):
        if molecule.multiplicity > 1:
            return "rohf" if mp2_reference == "rohf" else "uhf"
        return "rhf"
    # DLPNO-MP2: closed shell -> RHF + DLPNO-MP2; open shell -> UHF +
    # DLPNO-UMP2 (auto-routed in the post-SCF dispatch).
    if method == "dlpno-mp2":
        return "uhf" if molecule.multiplicity > 1 else "rhf"
    # DLPNO-CCSD/(T): closed shell -> RHF + DLPNO-CCSD pilot; open shell ->
    # UHF + DLPNO-UCCSD(T) pilot (auto-routed in the post-SCF dispatch).
    if method in ("dlpno-ccsd", "dlpno-ccsd(t)"):
        return "uhf" if molecule.multiplicity > 1 else "rhf"
    # OVGF (diagonal-self-energy) runs the mean-field SCF first -- RHF for closed
    # shell, UHF for open shell (the spin-resolved spin-orbital self-energy).
    if method == "ovgf":
        return "uhf" if molecule.multiplicity > 1 else "rhf"
    return method


def _apply_composite(
    method: str,
    *,
    basis: Optional[str],
    functional: Optional[str],
    dispersion,
    molecule: Molecule,
) -> tuple[str, str, Optional[str], object, Optional[CompositeRecipe]]:
    """Resolve a composite-3c ``method=`` keyword (``"hf-3c"``,
    ``"pbeh-3c"``, ...) into the (method, basis, functional, dispersion,
    recipe) tuple the rest of :func:`run_job` consumes.

    Non-composite ``method`` values pass through unchanged with
    ``recipe = None``. For a composite:

    * basis / functional / dispersion are taken from the recipe when
      the caller left them at defaults; an explicit caller basis wins
      with a warning (deliberate-experiment escape hatch).
    * D3-BJ recipes carry their own re-fit damping (``d3bj_damping``);
      it is passed straight through as a :class:`D3BJParams` so the
      dispersion module doesn't fall back to a wrong per-functional
      lookup.
    * Recipes flagged ``PENDING_*`` raise :class:`CompositeUnavailable`
      with a pointer at the gating roadmap item.
    """
    recipe = resolve_composite(method)
    if recipe is None:
        return method, basis or "", functional, dispersion, None

    if recipe.availability in (
        Availability.PENDING_F1,
        Availability.PENDING_F3,
        Availability.PENDING_ECP,
    ):
        raise CompositeUnavailable(
            f"method={method!r}: {recipe.availability.value}. "
            f"This composite depends on infrastructure not yet on "
            f"main. See docs/user_guide/composites.md Sec. Availability.\n"
            f"  Notes: {recipe.notes}"
        )

    resolved_basis = basis if basis else recipe.basis
    if basis and basis.lower() != recipe.basis.lower():
        import warnings

        warnings.warn(
            f"method={method!r} specifies basis={recipe.basis!r}, but "
            f"caller passed basis={basis!r}. Honouring the explicit "
            f"basis -- the published 3c parameters were fit at the "
            f"composite's native basis and may not transfer.",
            stacklevel=2,
        )
    resolved_functional = functional if functional else recipe.functional

    if dispersion is None:
        if recipe.dispersion == "d3bj" and recipe.d3bj_damping is not None:
            resolved_dispersion: object = D3BJParams(
                s6=recipe.d3bj_damping.s6,
                s8=recipe.d3bj_damping.s8,
                a1=recipe.d3bj_damping.a1,
                a2=recipe.d3bj_damping.a2,
                s9=recipe.d3bj_damping.s9,
            )
        else:
            resolved_dispersion = recipe.dispersion
    else:
        resolved_dispersion = dispersion

    # Pure-HF composites (recipe.functional is None -- only HF-3c today)
    # must route straight to the HF SCF driver. Handing "auto" to
    # _select_method with no functional would drop into the
    # wavefunction auto-ladder (<=4e -> FCI, <=8e -> selected-CI), so a
    # turnkey hf-3c on H2 silently ran fci(ndet=4) instead of HF
    # (audit F1.1). DFT composites keep "auto" -> _select_method picks
    # rks/uks from multiplicity.
    if resolved_functional is None:
        resolved_method = "uhf" if molecule.multiplicity > 1 else "rhf"
    else:
        resolved_method = "auto"
    return (
        resolved_method,
        resolved_basis,
        resolved_functional,
        resolved_dispersion,
        recipe,
    )


def _default_open_shell_dlpno_uhf_options(
    method: str,
    molecule: Molecule,
    uhf_options: Optional[UHFOptions],
) -> Optional[UHFOptions]:
    """Return UHF options for open-shell DLPNO-MP2's reference SCF.

    Allyl/cc-pVTZ, the release-paper M22 DLPNO-UMP2 case, reaches the
    final UHF commutator tail after the generic 100-cycle cap and then
    converges cleanly at cycle 224.  Only materialise a larger default
    when the caller left ``uhf_options`` unset; explicit options remain
    authoritative.
    """
    if uhf_options is not None:
        return uhf_options
    if method == "dlpno-mp2" and molecule.multiplicity > 1:
        opts = UHFOptions()
        opts.max_iter = max(int(opts.max_iter), 250)
        return opts
    return None


def _detect_scf_accelerator(
    resolved_method: str,
    rhf_options: Optional[RHFOptions],
    uhf_options: Optional[UHFOptions],
    rks_options: Optional[RKSOptions],
    uks_options: Optional[UKSOptions],
) -> Optional[str]:
    """Return the lower-cased SCFAccelerator name (``"diis"`` / ``"ediis"``
    / ``"ediis_diis"`` / ``"kdiis"`` / ``"adiis"`` / ``"r_cdiis"`` /
    ``"ad_cdiis"``) actually used by the resolved SCF, or ``None`` when no
    options struct is in play (post-SCF / non-mean-field methods).

    The citation router uses this to fire the right SCF-accelerator
    references -- Hu-Yang 2010 for ADIIS, Kollmar 1997 for KDIIS,
    Kudin-Scuseria-Cancès 2002 for EDIIS, plus Garza-Scuseria 2012 for
    the EDIIS+DIIS hybrid -- instead of always crediting plain DIIS
    (Pulay 1980 / 1982).
    """
    opts = {
        "rhf": rhf_options,
        "uhf": uhf_options,
        "rks": rks_options,
        "uks": uks_options,
    }.get(resolved_method)
    if opts is None:
        return None
    accel = getattr(opts, "scf_accelerator", None)
    if accel is None:
        return None
    name = getattr(accel, "name", None) or str(accel)
    return name.strip().lower()


def _detect_uses_ecp(
    rhf_options: Optional[RHFOptions],
    uhf_options: Optional[UHFOptions],
    rks_options: Optional[RKSOptions],
    uks_options: Optional[UKSOptions],
) -> bool:
    """Return True when any options struct carries a non-empty
    ``ecp_centers`` or inline primitive blocks -- the runtime signal that
    libecpint will build the V_ECP operator.

    Conservative: returns False when no options struct is present at
    all (post-SCF / FCI / selected-CI paths build their own HF prep
    without going through these options).
    """
    for opts in (rhf_options, uhf_options, rks_options, uks_options):
        if opts is None:
            continue
        centers = getattr(opts, "ecp_centers", None)
        if centers:  # non-empty list
            return True
        primitive_blocks = getattr(opts, "ecp_primitive_blocks", None)
        if primitive_blocks:
            return True
    return False


def _detect_direct_scf(
    resolved_method: str,
    rhf_options: Optional[RHFOptions],
    uhf_options: Optional[UHFOptions],
    rks_options: Optional[RKSOptions],
    uks_options: Optional[UKSOptions],
    basis_name: str,
    molecule: Molecule,
) -> bool:
    """Return True when the SCF will use the direct (integral-driven)
    Fock build path -- either because ``scf_mode=DIRECT`` was explicitly
    set, or because AUTO resolved to DIRECT (basis larger than the
    auto threshold, and density_fit/cosx are off).

    Conservative: returns False when we can't determine the resolved
    mode (no options struct, or density_fit/cosx supersedes).
    """
    opts = {
        "rhf": rhf_options,
        "uhf": uhf_options,
        "rks": rks_options,
        "uks": uks_options,
    }.get(resolved_method)
    if opts is None:
        return False

    # density_fit + cosx supersede the four-index path -- direct SCF
    # screening is not used, so the direct-SCF references don't apply.
    if getattr(opts, "density_fit", False):
        return False

    scf_mode = getattr(opts, "scf_mode", None)
    if scf_mode is None:
        return False

    from ._vibeqc_core import SCFMode

    if scf_mode == SCFMode.DIRECT:
        return True
    if scf_mode == SCFMode.AUTO:
        threshold = getattr(opts, "scf_mode_auto_threshold", 200)
        n_bf = BasisSet(molecule, basis_name).nbasis
        return n_bf > threshold
    # CONVENTIONAL -- not direct.
    return False


def _detect_acceleration(
    resolved_method: str,
    rhf_options: Optional[RHFOptions],
    uhf_options: Optional[UHFOptions],
    rks_options: Optional[RKSOptions],
    uks_options: Optional[UKSOptions],
) -> list[str]:
    """Return the integral/exchange-acceleration technique keys engaged by
    the resolved SCF, for ``assemble(acceleration=...)``.

    * ``density_fit=True`` (RI-J Coulomb fitting) => ``["rij"]`` -- Whitten
      1973 / Dunlap 1979 / Eichkorn 1995 + 1997.
    * ``density_fit=True`` *and* ``cosx=True`` (RIJCOSX: RI-J Coulomb +
      chain-of-spheres exchange) => ``["rijcosx"]`` -- Neese 2009. COSX
      requires density fitting (the C++ JK dispatch rejects ``cosx`` without
      ``density_fit``), so a lone ``cosx`` flag still maps to RIJCOSX.

    Empty list for post-SCF / non-mean-field methods (no options struct in
    the rhf/uhf/rks/uks map) and for the conventional / direct four-index
    path (neither density_fit nor cosx set).
    """
    opts = {
        "rhf": rhf_options,
        "uhf": uhf_options,
        "rks": rks_options,
        "uks": uks_options,
    }.get(resolved_method)
    if opts is None:
        return []
    density_fit = bool(getattr(opts, "density_fit", False))
    cosx = bool(getattr(opts, "cosx", False))
    if cosx:  # COSX always pairs the RI-J Coulomb build -> RIJCOSX
        return ["rijcosx"]
    if density_fit:
        return ["rij"]
    return []


def _detect_soscf_trah(
    resolved_method: str,
    rhf_options: Optional[RHFOptions],
    uhf_options: Optional[UHFOptions],
    rks_options: Optional[RKSOptions],
    uks_options: Optional[UKSOptions],
) -> tuple[bool, bool]:
    """Return ``(uses_soscf, uses_trah)`` for the resolved SCF.

    Both second-order convergers are armed by a *positive* threshold on the
    options struct (``soscf_threshold`` / ``trah_threshold``): the driver
    switches from Roothaan diagonalisation to the second-order step once the
    orbital-gradient norm drops below it (cpp/src/soscf.hpp, trah.hpp). A
    threshold of 0.0 (the default) means "never engage" -- no citation. Both
    False for methods with no options struct.
    """
    opts = {
        "rhf": rhf_options,
        "uhf": uhf_options,
        "rks": rks_options,
        "uks": uks_options,
    }.get(resolved_method)
    if opts is None:
        return (False, False)

    def _armed(attr: str) -> bool:
        try:
            return float(getattr(opts, attr, 0.0) or 0.0) > 0.0
        except (TypeError, ValueError):
            return False

    return (_armed("soscf_threshold"), _armed("trah_threshold"))


def _detect_level_shift(
    resolved_method: str,
    rhf_options: Optional[RHFOptions],
    uhf_options: Optional[UHFOptions],
    rks_options: Optional[RKSOptions],
    uks_options: Optional[UKSOptions],
) -> bool:
    """Return whether the resolved SCF runs with a Saunders-Hillier shift.

    True when the options struct carries a non-zero ``level_shift`` or a
    non-empty ``level_shift_schedule`` -- either arms the shift during
    iteration, so the Saunders-Hillier paper is cited. False for methods
    with no options struct.
    """
    opts = {
        "rhf": rhf_options,
        "uhf": uhf_options,
        "rks": rks_options,
        "uks": uks_options,
    }.get(resolved_method)
    if opts is None:
        return False
    try:
        base = float(getattr(opts, "level_shift", 0.0) or 0.0)
    except (TypeError, ValueError):
        base = 0.0
    schedule = list(getattr(opts, "level_shift_schedule", None) or [])
    return base != 0.0 or any(float(s) != 0.0 for s in schedule)


def _auto_open_shell_optimizer_trah(
    resolved_method: str,
    molecule: Molecule,
    *,
    optimize: bool,
    uhf_options: Optional[UHFOptions],
    uks_options: Optional[UKSOptions],
) -> tuple[Optional[UHFOptions], Optional[UKSOptions], bool]:
    """Use TRAH for molecular open-shell geometry optimizations by default.

    Stretched radicals can land in DIIS-stagnating regions after a perfectly
    valid optimizer step. TRAH is the shipped robust SCF route for that case.
    Only arm it when the caller has not explicitly selected SOSCF/TRAH/Newton.
    """
    if not optimize or molecule.multiplicity <= 1:
        return uhf_options, uks_options, False
    if resolved_method not in ("uhf", "uks"):
        return uhf_options, uks_options, False

    opts = uhf_options if resolved_method == "uhf" else uks_options
    if opts is None:
        opts = UHFOptions() if resolved_method == "uhf" else UKSOptions()

    def _positive(attr: str) -> bool:
        try:
            return float(getattr(opts, attr, 0.0) or 0.0) > 0.0
        except (TypeError, ValueError):
            return False

    if any(
        _positive(attr)
        for attr in ("soscf_threshold", "trah_threshold", "newton_threshold")
    ):
        return uhf_options, uks_options, False

    opts.trah_threshold = 1.0
    if resolved_method == "uhf":
        return opts, uks_options, True
    return uhf_options, opts, True


def _copy_grid_options(src: GridOptions) -> GridOptions:
    dst = GridOptions()
    for attr in (
        "n_radial",
        "angular",
        "lebedev_order",
        "n_theta",
        "n_phi",
        "partition",
        "becke_k",
        "angular_pruning",
        "orca_angular_points",
    ):
        try:
            setattr(dst, attr, getattr(src, attr))
        except Exception:
            pass
    return dst


def _clone_mp2_like_options(src: object, cls: type) -> object:
    dst = cls()
    if src is None:
        return dst
    for attr in (
        "density_fit",
        "aux_basis",
        "c_os",
        "c_ss",
        "use_float_intermediates",
        "report_ri_residual",
    ):
        if hasattr(dst, attr) and hasattr(src, attr):
            try:
                setattr(dst, attr, getattr(src, attr))
            except Exception:
                pass
    return dst


def _auto_resolve_mp2_aux_basis(opts: object, basis: BasisSet) -> None:
    if not bool(getattr(opts, "density_fit", False)):
        return
    if getattr(opts, "aux_basis", ""):
        return
    try:
        from .density_fitting import default_aux_basis_for

        opts.aux_basis = default_aux_basis_for(basis.name, kind="ri")
    except Exception:
        # Leave empty so the native MP2/UMP2 driver raises its explicit
        # aux_basis error with the same autodetection hint as direct calls.
        pass


def _clone_rks_options_for_trah_retry(
    opts: RKSOptions,
    result: object,
) -> RKSOptions:
    retry = RKSOptions()
    for attr in (
        "functional",
        "max_iter",
        "conv_tol_energy",
        "conv_tol_grad",
        "damping",
        "dynamic_damping",
        "dynamic_damping_min",
        "dynamic_damping_max",
        "fock_mixing",
        "use_diis",
        "diis_start_iter",
        "diis_subspace_size",
        "diis_restart_tau",
        "diis_adaptive_delta",
        "scf_accelerator",
        "ediis_diis_switch_threshold",
        "linear_dep_threshold",
        "ecp_centers",
        "ecp_library",
        "ecp_primitive_blocks",
        "ecp_primitive_centers",
        "ecp_effective_charges",
        "ecp_total_ncore",
        "level_shift",
        "level_shift_warmup_cycles",
        "level_shift_schedule",
        "quadratic_fallback_iter",
        "quadratic_fallback_shift",
        "quadratic_fallback_max_step",
        "newton_threshold",
        "newton_opts",
        "soscf_threshold",
        "soscf_opts",
        "trah_opts",
        "density_fit",
        "aux_basis",
        "scf_mode",
        "scf_mode_auto_threshold",
        "schwarz_threshold",
        "incremental_fock",
        "incremental_fock_reset_freq",
        "schwarz_threshold_loose",
        "schwarz_threshold_tighten_at",
        "cosx",
        "cosx_variant",
        "cosx_grid_level",
        "dft_plus_u_sites",
        "dft_plus_u_ao_groups",
        "use_davidson",
        "davidson",
        "davidson_min_dim",
        "cosx",
        "cosx_grid",
        "cosx_variant",
        "cosx_grid_level",
        "dft_plus_u_sites",
        "dft_plus_u_ao_groups",
    ):
        try:
            setattr(retry, attr, getattr(opts, attr))
        except Exception:
            pass
    retry.grid = _copy_grid_options(opts.grid)
    retry.cosx_grid = _copy_grid_options(opts.cosx_grid)
    retry.initial_guess = InitialGuess.READ
    retry.read_path = ""
    retry.read_density = np.asarray(getattr(result, "density"), dtype=float)
    retry.trah_threshold = max(float(getattr(opts, "trah_threshold", 0.0)), 1.0e-2)
    retry.max_iter = max(40, min(int(getattr(opts, "max_iter", 100)), 80))
    return retry


def _clone_rhf_options_for_sad_retry(opts: RHFOptions) -> RHFOptions:
    retry = RHFOptions()
    for attr in (
        "max_iter",
        "conv_tol_energy",
        "conv_tol_grad",
        "damping",
        "dynamic_damping",
        "dynamic_damping_min",
        "dynamic_damping_max",
        "fock_mixing",
        "use_diis",
        "diis_start_iter",
        "diis_subspace_size",
        "diis_restart_tau",
        "diis_adaptive_delta",
        "scf_accelerator",
        "ediis_diis_switch_threshold",
        "linear_dep_threshold",
        "ecp_centers",
        "ecp_library",
        "ecp_primitive_blocks",
        "ecp_primitive_centers",
        "ecp_effective_charges",
        "ecp_total_ncore",
        "level_shift",
        "level_shift_warmup_cycles",
        "level_shift_schedule",
        "quadratic_fallback_iter",
        "quadratic_fallback_shift",
        "quadratic_fallback_max_step",
        "newton_threshold",
        "newton_opts",
        "soscf_threshold",
        "soscf_opts",
        "trah_opts",
        "density_fit",
        "aux_basis",
        "scf_mode",
        "scf_mode_auto_threshold",
        "schwarz_threshold",
        "incremental_fock",
        "incremental_fock_reset_freq",
        "schwarz_threshold_loose",
        "schwarz_threshold_tighten_at",
        "use_davidson",
        "davidson",
        "davidson_min_dim",
        "cosx",
        "cosx_grid",
        "cosx_variant",
        "cosx_grid_level",
        "dft_plus_u_sites",
        "dft_plus_u_ao_groups",
    ):
        try:
            setattr(retry, attr, getattr(opts, attr))
        except Exception:
            pass
    retry.initial_guess = InitialGuess.SAD
    retry.read_path = ""
    retry.soscf_threshold = 0.0
    retry.trah_threshold = 0.0
    retry.max_iter = max(120, int(getattr(opts, "max_iter", 100)))
    return retry


def _rhf_tail_sad_retry_supported(
    *,
    resolved_method: str,
    molecule: Molecule,
    basis: BasisSet,
    rhf_options: Optional[RHFOptions],
    result: object,
) -> bool:
    if resolved_method != "rhf" or rhf_options is None:
        return False
    if molecule.multiplicity != 1 or molecule.n_electrons() % 2 != 0:
        return False
    if int(getattr(result, "n_iter", 0) or 0) < 50:
        return False
    if int(getattr(basis, "nbasis", 0) or 0) < 150 and len(list(molecule.atoms)) < 20:
        return False
    density = getattr(result, "density", None)
    if density is None:
        return False
    if np.asarray(density).shape != (basis.nbasis, basis.nbasis):
        return False
    initial_guess = getattr(rhf_options, "initial_guess", None)
    if initial_guess in (InitialGuess.SAD, InitialGuess.READ, InitialGuess.FRAGMO):
        return False

    def _positive(attr: str) -> bool:
        try:
            return float(getattr(rhf_options, attr, 0.0) or 0.0) > 0.0
        except (TypeError, ValueError):
            return False

    if any(
        _positive(attr)
        for attr in ("newton_threshold", "soscf_threshold", "trah_threshold")
    ):
        return False
    if int(getattr(rhf_options, "quadratic_fallback_iter", 0) or 0) > 0:
        return False
    return True


def _rks_tail_trah_retry_supported(
    *,
    resolved_method: str,
    molecule: Molecule,
    basis: BasisSet,
    functional: Optional[str],
    rks_options: Optional[RKSOptions],
    result: object,
) -> bool:
    if resolved_method != "rks" or rks_options is None:
        return False
    if molecule.multiplicity != 1 or molecule.n_electrons() % 2 != 0:
        return False
    if int(getattr(result, "n_iter", 0) or 0) < 50:
        return False
    if int(getattr(basis, "nbasis", 0) or 0) < 150 and len(list(molecule.atoms)) < 20:
        return False
    density = getattr(result, "density", None)
    if density is None:
        return False
    if np.asarray(density).shape != (basis.nbasis, basis.nbasis):
        return False

    def _positive(attr: str) -> bool:
        try:
            return float(getattr(rks_options, attr, 0.0) or 0.0) > 0.0
        except (TypeError, ValueError):
            return False

    if any(
        _positive(attr)
        for attr in ("newton_threshold", "soscf_threshold", "trah_threshold")
    ):
        return False
    if int(getattr(rks_options, "quadratic_fallback_iter", 0) or 0) > 0:
        return False

    try:
        xc = Functional(functional or rks_options.functional or "lda")
    except Exception:
        return False
    if bool(getattr(xc, "is_range_separated", False)):
        return False
    if bool(getattr(xc, "is_double_hybrid", False)):
        return False
    return xc.kind in (XCKind.LDA, XCKind.GGA)


def _memory_estimator_method(resolved_method: str, effective_method: str) -> str:
    """Method label to hand to the memory estimator."""
    resolved = str(resolved_method or "").lower()
    effective = str(effective_method or resolved).lower()
    active_space_methods = {
        "casci",
        "casscf",
        "caspt2",
        "nevpt2",
        "fci",
        "mrci",
        "ci",
    }
    if effective == "mp2" and resolved == "uhf":
        return "ump2"
    if effective in ("ccsd", "ccsd(t)", "ccsd[t]", "a-ccsd(t)") and resolved in ("uhf", "rohf"):
        return {
            "ccsd": "uccsd",
            "ccsd(t)": "uccsd(t)",
            "ccsd[t]": "uccsd(t)",
            "a-ccsd(t)": "uccsd(t)",
        }[effective]
    if (
        effective in _POSTSCF_CITE_METHODS
        or effective in _CC_VARIANT_METHODS
        or effective in active_space_methods
    ):
        return effective
    return {"rohf": "uhf", "roks": "uks"}.get(resolved, resolved)


def _memory_reference_options(
    resolved_method: str,
    *,
    rhf_options: Optional[RHFOptions],
    uhf_options: Optional[UHFOptions],
    rks_options: Optional[RKSOptions],
    uks_options: Optional[UKSOptions],
    rohf_options: Optional[ROHFOptions],
    roks_options: Optional[ROKSOptions],
):
    return {
        "rhf": rhf_options,
        "uhf": uhf_options,
        "rks": rks_options,
        "uks": uks_options,
        "rohf": rohf_options,
        "roks": roks_options,
    }.get(resolved_method)


def _memory_estimator_options(
    estimator_method: str,
    *,
    resolved_method: str,
    rhf_options: Optional[RHFOptions],
    uhf_options: Optional[UHFOptions],
    rks_options: Optional[RKSOptions],
    uks_options: Optional[UKSOptions],
    rohf_options: Optional[ROHFOptions],
    roks_options: Optional[ROKSOptions],
    mp2_options: Optional[object],
    ump2_options: Optional[object],
    ccsd_options: Optional[object],
    cc3_options: Optional[CC3Options],
    ccsdt_options: Optional[CCSDTOptions],
    dlpno_options: Optional[object],
    dlpno_ccsd_options: Optional[object],
    caspt2_options: Optional[CASPT2Options],
    nevpt2_options: Optional[NEVPT2Options],
    active_space: Optional[tuple[int, int]],
    tddft: bool = False,
    tddft_n_states: int = 5,
    tddft_type: str = "tda",
    functional: Optional[str] = None,
):
    """Options bundle for the pre-flight memory estimator."""
    estimator_method = str(estimator_method).lower()
    scf_options = _memory_reference_options(
        resolved_method,
        rhf_options=rhf_options,
        uhf_options=uhf_options,
        rks_options=rks_options,
        uks_options=uks_options,
        rohf_options=rohf_options,
        roks_options=roks_options,
    )
    if estimator_method in ("rhf", "uhf", "rks", "uks"):
        if tddft:
            td_functional = functional
            if td_functional is None and scf_options is not None:
                td_functional = getattr(scf_options, "functional", None)
            return {
                "scf_options": scf_options,
                "tddft": True,
                "tddft_n_states": tddft_n_states,
                "tddft_type": tddft_type,
                "functional": td_functional,
            }
        if estimator_method in ("rks", "uks") and functional is not None:
            return {
                "scf_options": scf_options,
                "functional": functional,
            }
        return scf_options
    if resolved_method in ("rohf", "roks") and tddft:
        return {
            "scf_options": scf_options,
            "tddft": True,
            "tddft_n_states": tddft_n_states,
            "tddft_type": tddft_type,
            "functional": functional,
        }
    if estimator_method in ("mp2", "scs-mp2", "sos-mp2"):
        return {"scf_options": scf_options, "mp2_options": mp2_options}
    if estimator_method in ("ump2", "rohf-mp2"):
        return {"scf_options": scf_options, "ump2_options": ump2_options}
    if estimator_method == "ovgf":
        return {"scf_options": scf_options}
    if estimator_method in (
        "ccsd",
        "ccsd(t)",
        "ccsd[t]",
        "a-ccsd(t)",
        "uccsd",
        "uccsd(t)",
        "cc2",
        "ccd",
        "lccd",
        "lccsd",
        "cepa(0)",
        "cepa(1)",
        "cepa(2)",
        "cepa(3)",
        "qcisd",
        "qcisd(t)",
    ):
        return {"scf_options": scf_options, "ccsd_options": ccsd_options}
    if estimator_method == "cc3":
        return {
            "scf_options": scf_options,
            "cc3_options": cc3_options,
            "active_space": active_space,
        }
    if estimator_method == "ccsdt":
        return {
            "scf_options": scf_options,
            "ccsdt_options": ccsdt_options,
            "active_space": active_space,
        }
    if estimator_method in ("dlpno-mp2", "dlpno-ump2"):
        return {"scf_options": scf_options, "dlpno_options": dlpno_options}
    if estimator_method in (
        "dlpno-ccsd",
        "dlpno-ccsd(t)",
        "dlpno-uccsd",
        "dlpno-uccsd(t)",
    ):
        return {
            "scf_options": scf_options,
            "dlpno_ccsd_options": dlpno_ccsd_options,
        }
    if estimator_method in ("casci", "casscf"):
        return {"scf_options": scf_options, "active_space": active_space}
    if estimator_method == "caspt2":
        return {
            "scf_options": scf_options,
            "active_space": active_space,
            "caspt2_options": caspt2_options,
        }
    if estimator_method == "nevpt2":
        return {
            "scf_options": scf_options,
            "active_space": active_space,
            "nevpt2_options": nevpt2_options,
        }
    if estimator_method in (
        "cisd",
        "selected_ci",
        "dmrg",
        "v2rdm",
        "transcorrelated_ci",
        "mrci",
        "fci",
    ):
        return {"scf_options": scf_options}
    return None


# SCF initial-guess enum names -> citation-route keys. Only guesses with a
# defining-paper route appear; the traditional Hcore guess and AUTO (resolved
# at runtime) carry none, so they map to nothing.
_SCF_GUESS_ROUTE_KEYS = {
    "sad": "sad",
    "sap": "sap",
    "hueckel": "huckel",
    "huckel": "huckel",
    "patom": "patom",
    "minao": "minao",
}


def _coerce_initial_guess(guess: object) -> InitialGuess:
    if isinstance(guess, InitialGuess):
        return guess
    name = getattr(guess, "name", None)
    text = str(name if name is not None else guess).strip()
    if "." in text:
        text = text.rsplit(".", 1)[-1]
    key = text.upper().replace("-", "_").replace(" ", "_")
    aliases = {
        "CORE": "HCORE",
        "HUCKEL": "HUECKEL",
        "MOREAD": "READ",
        "COREAD": "READ",
    }
    key = aliases.get(key, key)
    try:
        return InitialGuess.__members__[key]
    except KeyError as exc:
        valid = ", ".join(InitialGuess.__members__.keys())
        raise ValueError(
            f"unknown initial_guess={guess!r} (valid: {valid}; aliases: "
            "huckel, moread, coread)"
        ) from exc


def _materialize_run_job_initial_guess(
    resolved_method: str,
    initial_guess: Optional[object],
    *,
    rhf_options: Optional[RHFOptions],
    uhf_options: Optional[UHFOptions],
    rks_options: Optional[RKSOptions],
    uks_options: Optional[UKSOptions],
    rohf_options: Optional[ROHFOptions],
    roks_options: Optional[ROKSOptions],
    read_from: Optional[object],
    fragments: Optional[object],
) -> tuple[object, object, object, object, object, object]:
    if initial_guess is None and read_from is None and fragments is None:
        return (
            rhf_options,
            uhf_options,
            rks_options,
            uks_options,
            rohf_options,
            roks_options,
        )

    option_map = {
        "rhf": rhf_options,
        "uhf": uhf_options,
        "rks": rks_options,
        "uks": uks_options,
    }
    factories = {
        "rhf": RHFOptions,
        "uhf": UHFOptions,
        "rks": RKSOptions,
        "uks": UKSOptions,
    }
    if resolved_method not in factories:
        raise ValueError(
            "run_job(initial_guess=...) only applies to molecular SCF-backed "
            "methods (RHF/UHF/RKS/UKS and their MP2/CCSD/DLPNO references). "
            f"method resolved to {resolved_method!r}."
        )

    if initial_guess is None:
        opts = option_map[resolved_method]
        guess = getattr(opts, "initial_guess", None) if opts is not None else None
        if guess is None:
            raise ValueError(
                "read_from= and fragments= are only used with READ/FRAGMO. "
                "Pass initial_guess='read' or initial_guess='fragmo', or set "
                "the resolved method options' initial_guess accordingly."
            )
        initial_guess = guess

    guess = _coerce_initial_guess(initial_guess)

    opt = option_map[resolved_method] or factories[resolved_method]()
    opt.initial_guess = guess
    option_map[resolved_method] = opt

    return (
        option_map["rhf"],
        option_map["uhf"],
        option_map["rks"],
        option_map["uks"],
        rohf_options,
        roks_options,
    )


def _prepare_molecular_special_guess(
    method: str,
    options: object,
    molecule: Molecule,
    basis: BasisSet,
    *,
    read_from: Optional[object],
    fragments: Optional[object],
) -> None:
    guess = _coerce_initial_guess(getattr(options, "initial_guess"))
    if fragments is not None and guess != InitialGuess.FRAGMO:
        raise ValueError(
            "fragments=... was supplied but the resolved SCF initial guess is "
            f"{guess.name}, not FRAGMO; pass initial_guess='fragmo'."
        )
    if read_from is not None and guess != InitialGuess.READ:
        raise ValueError(
            "read_from=... was supplied but the resolved SCF initial guess is "
            f"{guess.name}, not READ; pass initial_guess='read'."
        )

    if guess == InitialGuess.READ:
        _read_obj = read_from
        if isinstance(read_from, (str, os.PathLike)):
            options.read_path = os.fspath(read_from)
            _read_obj = None
        from .guess_read import (
            resolve_read_densities_open,
            resolve_read_density_closed,
        )

        if method in ("rhf", "rks"):
            options.read_density = resolve_read_density_closed(
                options, molecule, basis, _read_obj
            )
        else:
            da, db = resolve_read_densities_open(options, molecule, basis, _read_obj)
            options.read_density_alpha = da
            options.read_density_beta = db
    elif guess == InitialGuess.FRAGMO:
        from .guess_fragmo import (
            resolve_fragmo_densities_open,
            resolve_fragmo_density_closed,
        )

        if method in ("rhf", "rks"):
            options.read_density = resolve_fragmo_density_closed(
                options, molecule, basis, fragments
            )
        else:
            da, db = resolve_fragmo_densities_open(options, molecule, basis, fragments)
            options.read_density_alpha = da
            options.read_density_beta = db


def _detect_scf_guess(
    resolved_method: str,
    rhf_options: Optional[RHFOptions],
    uhf_options: Optional[UHFOptions],
    rks_options: Optional[RKSOptions],
    uks_options: Optional[UKSOptions],
) -> Optional[str]:
    """Return the citation route key for the SCF initial guess explicitly
    requested on the options struct (``"sad"`` / ``"sap"`` / ``"huckel"``),
    or ``None`` for AUTO / HCORE / unrouted guesses and for methods with no
    options struct.

    Only an *explicit* non-AUTO selection cites a guess paper -- the AUTO
    resolution is a runtime decision not surfaced on the options struct, and
    Hcore (Hückel-free bare-core guess) has no single defining publication.
    """
    opts = {
        "rhf": rhf_options,
        "uhf": uhf_options,
        "rks": rks_options,
        "uks": uks_options,
    }.get(resolved_method)
    if opts is None:
        return None
    guess = getattr(opts, "initial_guess", None)
    if guess is None:
        return None
    name = (getattr(guess, "name", None) or str(guess)).strip().lower()
    return _SCF_GUESS_ROUTE_KEYS.get(name)


def _run_single_point(
    method: str,
    molecule: Molecule,
    basis: BasisSet,
    *,
    functional: Optional[str],
    rhf_options: Optional[RHFOptions] = None,
    uhf_options: Optional[UHFOptions] = None,
    rks_options: Optional[RKSOptions] = None,
    uks_options: Optional[UKSOptions] = None,
    rohf_options: Optional[ROHFOptions] = None,
    roks_options: Optional[ROKSOptions] = None,
    cisd_options: Optional[CISDOptions] = None,
    cc3_options: Optional[CC3Options] = None,
    ccsdt_options: Optional[CCSDTOptions] = None,
    selected_ci_options: Optional[SelectedCIOptions] = None,
    dmrg_options: Optional[DMRGOptions] = None,
    v2rdm_options: Optional[V2RDMOptions] = None,
    transcorrelated_options: Optional[TranscorrelatedOptions] = None,
    casci_options: Optional[CASCIOptions] = None,
    caspt2_options: Optional[CASPT2Options] = None,
    nevpt2_options: Optional[NEVPT2Options] = None,
    casscf_options: Optional[CASSCFOptions] = None,
    active_space: Optional[tuple[int, int]] = None,
    cas_reference: Optional[str] = None,
    mlip_options: Optional[MLIPOptions] = None,
    ccm_options: Optional["CCMOptions"] = None,
    solvent: object = None,
    dft_plus_u: Optional[List["HubbardSite"]] = None,
    read_from: Optional[object] = None,
    fragments: Optional[object] = None,
    nddo: bool = False,
    _mrpt_gradient: bool = True,
):
    """Single-point dispatcher. ``solvent`` (v0.9.0) reroutes through
    :func:`vibeqc.solvation.run_cpcm_scf` and returns the underlying
    SCF result with an ``e_solv`` / ``solvent_result`` attribute
    attached for downstream output writers."""
    # Implicit solvation composes with the mean-field SCFs only (CPCM via
    # run_cpcm_scf: rhf/uhf/rks/uks) plus the dedicated MSINDO COSMO route
    # below; rohf/roks fall through to their own NotImplementedError gates.
    # Every other method used to *silently ignore* ``solvent`` and return a
    # gas-phase energy -- a solvated CASSCF request would quietly compute in
    # vacuum with no warning. Refuse instead (same discipline and wording as
    # the _run_molecular_scf gate in molecular_optimize.py). This also
    # covers the FD-optimizer energy path (_evaluate_energy forwards
    # ``solvent`` here), which previously relied on the silent drop and
    # walked the gas-phase surface while claiming solvation.
    if solvent is not None and method not in (
        "rhf",
        "uhf",
        "rks",
        "uks",
        "msindo",
        "rohf",
        "roks",
    ):
        raise ValueError(
            f"Implicit solvation is not supported for method={method!r}: "
            "CPCM (run_cpcm_scf) composes with rhf, uhf, rks, and uks "
            "only, and COSMO with method='msindo'. Run the calculation in "
            "gas phase, or use a solvent-capable method with solvent."
        )
    method_opts = {
        "rhf": rhf_options or RHFOptions(),
        "uhf": uhf_options or UHFOptions(),
    }
    if method in ("rks", "uks"):
        opts = (rks_options if method == "rks" else uks_options) or (
            RKSOptions() if method == "rks" else UKSOptions()
        )
        opts.functional = functional or opts.functional or "lda"
        method_opts[method] = opts

    if method in ("rhf", "uhf", "rks", "uks"):
        attach_inline_ecp_options_from_basis_sidecar(
            method_opts[method], molecule, basis
        )
        # Density-fitted / RIJCOSX SCF needs a JK auxiliary basis. If the
        # caller enabled density_fit or cosx but left aux_basis empty,
        # auto-resolve the matching JKfit aux from the orbital basis --
        # the same convenience the CCSD RI path provides -- instead of
        # letting the C++ driver reject the run with a bare "density_fit=
        # true requires aux_basis" (release-paper RIJCOSX glycine gap).
        _scf_opts = method_opts[method]
        _wants_df = bool(getattr(_scf_opts, "density_fit", False)) or bool(
            getattr(_scf_opts, "cosx", False)
        )
        if _wants_df and not getattr(_scf_opts, "aux_basis", ""):
            from .density_fitting import default_aux_basis_for

            # basis here is a BasisSet; the resolver takes the orbital-basis
            # name string.
            _basis_name = getattr(basis, "name", basis)
            try:
                _scf_opts.aux_basis = default_aux_basis_for(
                    _basis_name, kind="jk"
                )
            except Exception:
                # No registered default (e.g. pob-* bases): leave empty so
                # the driver raises its explicit aux_basis error, which now
                # also names the auto-resolve fallback.
                pass

    if solvent is not None and method in ("rhf", "uhf", "rks", "uks"):
        if dft_plus_u:
            raise NotImplementedError(
                "DFT+U combined with implicit solvation (CPCM) is not yet "
                "supported. The +U Fock term and the solvation macro-"
                "iteration would need to be combined inside "
                "vibeqc.solvation.run_cpcm_scf; defer until Increment "
                "3+ or run +U without solvent."
            )
        # CPCM macro-iteration wrapper. The C++ result types
        # (RHFResult / UHFResult / RKSResult / UKSResult) are
        # pybind11 ``def_readonly`` -- Python attribute set on them
        # silently fails inside try/except. Wrap the inner result in
        # an attribute-forwarding proxy so callers can still write
        # ``result.energy`` / ``result.density`` / etc., and *also*
        # read the new solvation diagnostics (``result.solvent_result``,
        # ``result.e_solv``, ``result.energy_in_solvent``).
        from .solvation import run_cpcm_scf
        from .solvation.driver import _solvent_aware_scf_result

        sol = run_cpcm_scf(
            molecule,
            basis,
            method=method,
            solvent=solvent,
            options=method_opts[method],
        )
        return _solvent_aware_scf_result(sol)

    if dft_plus_u and method in ("rhf", "uhf", "rks", "uks"):
        from .dft_plus_u import _apply_dft_plus_u_to_options

        _apply_dft_plus_u_to_options(method_opts[method], basis, dft_plus_u)

    if method in ("rhf", "uhf", "rks", "uks"):
        _prepare_molecular_special_guess(
            method,
            method_opts[method],
            molecule,
            basis,
            read_from=read_from,
            fragments=fragments,
        )

    if method == "rohf":
        # Restricted open-shell HF (pure-Python Roothaan driver). Solvent
        # / DFT+U are not yet combined with the ROHF Fock -- gate clearly
        # rather than silently ignoring them (roadmap: handovers/HANDOVER_ROHF.md).
        if solvent is not None:
            raise NotImplementedError(
                "Implicit solvation (CPCM) with method='rohf' is not yet "
                "supported; run ROHF in gas phase or use uhf/rks. See "
                "handovers/HANDOVER_ROHF.md."
            )
        if dft_plus_u:
            raise NotImplementedError(
                "DFT+U with method='rohf' is not yet supported. See handovers/HANDOVER_ROHF.md."
            )
        return run_rohf(molecule, basis, rohf_options or ROHFOptions())

    if method == "roks":
        # Restricted open-shell KS (pure-Python Roothaan driver + libxc XC).
        if solvent is not None:
            raise NotImplementedError(
                "Implicit solvation (CPCM) with method='roks' is not yet "
                "supported; run ROKS in gas phase or use uks. See "
                "handovers/HANDOVER_ROHF.md."
            )
        if dft_plus_u:
            raise NotImplementedError(
                "DFT+U with method='roks' is not yet supported. See handovers/HANDOVER_ROHF.md."
            )
        return run_roks(
            molecule,
            basis,
            roks_options or ROKSOptions(),
            functional=functional,
        )

    if method == "rhf":
        return run_rhf(molecule, basis, method_opts["rhf"])
    if method == "uhf":
        return run_uhf(molecule, basis, method_opts["uhf"])
    if method == "rks":
        return run_rks(molecule, basis, method_opts["rks"])
    if method == "uks":
        return run_uks(molecule, basis, method_opts["uks"])

    # ── Non-mean-field wavefunction methods ──
    if method in (
        "cisd",
        "cc3",
        "ccsdt",
        "selected_ci",
        "dmrg",
        "v2rdm",
        "transcorrelated_ci",
        "casci",
        "mrci",
        "casscf",
        "nevpt2",
        "caspt2",
    ):
        # Starting orbitals for the determinant-solver family.  The
        # open-shell default is UHF natural orbitals (UNO-CAS -- Pulay &
        # Hamilton, J. Chem. Phys. 88, 4926 (1988)): one spin-restricted
        # orbital set ordered by descending occupation, so the
        # doubly-occupied core and the active window are well-defined.
        # (Plain "uhf" keeps only the a orbitals; the b space -- and hence
        # the core -- is then only approximately represented.)  Override
        # with cas_reference="rhf"|"uhf"|"uno"|"rohf".  "rohf" gives a
        # spin-pure restricted-open-shell reference (Roothaan 1960) -- a
        # clean, contamination-free alternative to UNO for the active space.
        if cas_reference is not None and cas_reference not in (
            "rhf",
            "uhf",
            "uno",
            "rohf",
        ):
            raise ValueError(
                f"cas_reference must be 'rhf', 'uhf', 'uno' or 'rohf', got "
                f"{cas_reference!r}"
            )
        if method == "cc3":
            if molecule.multiplicity != 1:
                raise ValueError(
                    "CC3 requires a closed-shell singlet RHF reference"
                )
            if cas_reference not in (None, "rhf"):
                raise ValueError(
                    "CC3 supports cas_reference='rhf' only; its ground-state "
                    "equations require canonical closed-shell orbitals"
                )
            hf_method = "rhf"
        elif method == "ccsdt":
            hf_method = cas_reference or (
                "rohf" if molecule.multiplicity > 1 else "rhf"
            )
        else:
            hf_method = cas_reference or (
                "uno" if molecule.multiplicity > 1 else "rhf"
            )
        _iterative_cc_reference_result = None
        if method in ("cc3", "ccsdt"):
            C, _iterative_cc_reference_result = get_hf_orbital_provider(
                molecule,
                basis,
                method=hf_method,
                _with_result=True,
            )
        else:
            C = get_hf_orbital_provider(molecule, basis, method=hf_method)
        H = build_hamiltonian_mo(molecule, basis, C)

        # Optional active-space truncation.  The CAS methods (casci / nevpt2 /
        # caspt2) are handled separately below via casci()'s own frozen-core
        # path; these determinant/DMRG/v2RDM backends get the same standard
        # CAS partition through Hamiltonian.active_space(), which dresses the
        # active one-electron term with the inactive mean field and folds the
        # constant inactive energy E_core into nuclear_repulsion.  (A bare
        # integral slice would drop both and report the active-only energy,
        # off by ~E_core Hartree -- see ACTIVE_SPACE.md.)
        if active_space is not None and method not in (
            "casci",
            "mrci",
            "casscf",
            "nevpt2",
            "caspt2",
        ):
            n_active, n_elec = active_space
            H = H.active_space(n_active, n_elec)

        if method == "transcorrelated_ci":
            if transcorrelated_options is None:
                transcorrelated_options = TranscorrelatedOptions()
            H = build_transcorrelated_hamiltonian(H, transcorrelated_options)
            return solve_selected_ci(H, selected_ci_options or SelectedCIOptions())

        if method == "selected_ci":
            return solve_selected_ci(H, selected_ci_options or SelectedCIOptions())

        if method == "cisd":
            from .solvers import cisd as _run_cisd

            ci_opts = cisd_options or CISDOptions()
            ci = _run_cisd(
                H.h1e,
                H.h2e,
                H.nelec,
                H.norb,
                nuclear_repulsion=H.nuclear_repulsion,
                ms2=H.ms2,
                max_excitation=ci_opts.max_excitation,
                nroots=ci_opts.nroots,
                max_det=ci_opts.max_det,
            )
            roots = ci.e_totals if len(ci.e_totals) > 1 else None
            return SolverResult(
                energy=ci.e_total,
                method=f"cisd(ndet={ci.n_det})",
                converged=ci.converged,
                n_iter=1,
                energy_trace=[ci.e_total],
                root_energies=roots,
                ci_coeffs=ci.ci_coeffs,
                ci_labels=ci.determinants,
            )

        if method == "cc3":
            from .cc import chemical_core_orbital_count
            from .solvers import cc3 as _run_cc3

            cc_opts = cc3_options or CC3Options()
            if cc_opts.n_frozen_core is None:
                cc_opts = replace(
                    cc_opts,
                    n_frozen_core=(
                        0
                        if active_space is not None
                        else chemical_core_orbital_count(molecule)
                    ),
                )
            cc_result = _run_cc3(H, cc_opts)
            cc_result.scf_trace = list(
                getattr(_iterative_cc_reference_result, "scf_trace", []) or []
            )
            return cc_result

        if method == "ccsdt":
            from .cc import chemical_core_orbital_count
            from .solvers import ccsdt as _run_ccsdt

            cc_opts = ccsdt_options or CCSDTOptions()
            if cc_opts.n_frozen_core is None:
                cc_opts = replace(
                    cc_opts,
                    n_frozen_core=(
                        0
                        if active_space is not None
                        else chemical_core_orbital_count(molecule)
                    ),
                )
            cc_result = _run_ccsdt(H, cc_opts)
            cc_result.scf_trace = list(
                getattr(_iterative_cc_reference_result, "scf_trace", []) or []
            )
            return cc_result

        if method == "dmrg":
            return solve_dmrg(H, dmrg_options or DMRGOptions())

        if method == "v2rdm":
            return solve_v2rdm(H, v2rdm_options or V2RDMOptions())

        if method in ("casci", "mrci", "casscf", "nevpt2", "caspt2"):
            from .solvers._casci import casci as _run_casci

            # Standard CAS partition: full MO integrals with an explicit frozen
            # core.  active_space=(n_active_orb, n_active_elec) selects the
            # lowest n_core orbitals as doubly-occupied core, n_active_orb
            # active orbitals, the rest virtual (matching PySCF mcscf.CASCI).
            # H here is the full, untruncated Hamiltonian.
            n_elec_total = molecule.n_electrons()
            if active_space is not None:
                n_active_orb, n_active_elec = active_space
                if (n_elec_total - n_active_elec) % 2 != 0:
                    raise ValueError(
                        "CAS active_space=(n_orb, n_elec) needs an even "
                        f"frozen-core electron count; got n_elec_total="
                        f"{n_elec_total}, n_active_elec={n_active_elec}."
                    )
                n_core = (n_elec_total - n_active_elec) // 2
            else:
                n_active_orb, n_active_elec, n_core = H.norb, n_elec_total, 0
            n_virt = H.norb - n_core - n_active_orb

            if method == "casscf":
                from .solvers._casscf import casscf as _run_casscf

                c_opts = casscf_options or CASSCFOptions()
                if (
                    getattr(c_opts, "pt2", None) is not None
                    and c_opts.ci_solver != "selected_ci"
                ):
                    raise ValueError(
                        "CASSCFOptions.pt2 is the Epstein-Nesbet PT2 "
                        "stage on a SELECTED wavefunction; it requires "
                        "ci_solver='selected_ci' (the exact-CI backend "
                        "has no external perturbers)"
                    )
                sc = _run_casscf(
                    H.h1e,
                    H.h2e,
                    n_active_elec=n_active_elec,
                    n_active_orb=n_active_orb,
                    n_core=n_core,
                    nuclear_repulsion=H.nuclear_repulsion,
                    ms2=molecule.multiplicity - 1,
                    nroots=c_opts.nroots,
                    weights=c_opts.weights,
                    orbital_step=c_opts.orbital_step,
                    active_orbitals=c_opts.active_orbitals,
                    spin_pure=c_opts.spin_pure,
                    ci_solver=c_opts.ci_solver,
                    selected_ci_options=c_opts.selected_ci_options,
                )
                label = f"casscf({n_active_elec}e,{n_active_orb}o)"
                if c_opts.ci_solver == "selected_ci":
                    label += "_selci"
                if sc.e_totals:
                    label += f"_sa{len(sc.e_totals)}"
                _sel_pt2 = None
                if getattr(c_opts, "pt2", None) is not None:
                    # SHCI perturbative stage on the converged selected
                    # wavefunction, in the converged orbital basis (the
                    # variational headline energy is unchanged; the PT2
                    # estimate surfaces on its own).
                    from .solvers._selected_ci import selected_ci_pt2

                    p = c_opts.pt2
                    _sel_pt2 = selected_ci_pt2(
                        sc.h1e_cas,
                        sc.h2e_cas,
                        n_active_elec,
                        n_active_orb,
                        n_core,
                        result=sc.cas,
                        eps2=p.eps2,
                        n_samples=p.n_samples,
                        sample_size=p.sample_size,
                        eps2_loose=p.eps2_loose,
                        seed=p.seed,
                    )
                grad = None
                if sc.converged:
                    # Analytic CASSCF gradient.  Single-state: supports W^z
                    # correction.  SA: uses SA-RDMs, W^z not yet implemented.
                    from .gradient import compute_casscf_gradient
                    from .solvers._rdm import make_rdm12, make_rdm12_sa

                    C_conv = C @ sc.mo_rotation
                    _sa = (
                        c_opts.weights is not None and sc.cas.ci_coeffs_all is not None
                    )
                    if _sa:
                        rdm1_g, rdm2_g = make_rdm12_sa(
                            sc.cas.ci_coeffs_all,
                            sc.cas.determinants,
                            n_active_orb,
                            c_opts.weights,
                        )
                        _use_wz = False
                        _dets = None
                        _civec = None
                    else:
                        rdm1_g, rdm2_g = make_rdm12(
                            sc.cas.ci_coeffs, sc.cas.determinants, n_active_orb
                        )
                        _use_wz = c_opts.compute_wz
                        _dets = sc.cas.determinants
                        _civec = sc.cas.ci_coeffs
                    grad = compute_casscf_gradient(
                        molecule,
                        basis,
                        C_conv,
                        sc.h1e_cas,
                        sc.h2e_cas,
                        n_core=n_core,
                        n_active_orb=n_active_orb,
                        rdm1=rdm1_g,
                        rdm2=rdm2_g,
                        compute_wz=_use_wz,
                        determinants=_dets,
                        ci_coeffs=_civec,
                    )
                return SolverResult(
                    energy=sc.e_total,
                    method=label,
                    converged=sc.converged,
                    n_iter=sc.n_iter,
                    energy_trace=sc.energy_trace or [sc.e_total],
                    root_energies=list(sc.e_totals) or None,
                    root_s2=_root_s2_values(sc.cas, molecule.multiplicity - 1),
                    ci_coeffs=sc.cas.ci_coeffs,
                    ci_labels=sc.cas.determinants,
                    rdm1=_active_rdm1(sc.cas),
                    selected_pt2=_sel_pt2,
                    gradient=grad,
                )

            # CASSCF-referenced MR-PT2: supplying casscf_options switches the
            # nevpt2 / caspt2 reference from CASCI-on-HF-orbitals to a CASSCF
            # whose converged integrals + lowest-root CI vector seed the PT2 --
            # the standard CASSCF->CASPT2/NEVPT2 composition (mirrors the MRCI
            # pattern below).  Without casscf_options the historical
            # CASCI-on-HF reference is kept (matches the recorded OpenMolcas
            # `RASSCF CIonly` parity constants).
            h1_ref, h2_ref = H.h1e, H.h2e
            ref_suffix = ""
            ref_root_energies: list[float] = []
            _selected_ref = False
            _casscf_grad = None  # CASSCF gradient for MR-PT2 results
            _casscf_cm = None  # converged MO coefficients
            _casscf_rdm1 = None
            _casscf_rdm2 = None
            if method in ("nevpt2", "caspt2") and casscf_options is not None:
                from .solvers._casscf import casscf as _run_casscf

                c_opts = casscf_options
                if c_opts.active_orbitals is not None:
                    raise ValueError(
                        "CASSCF-referenced MR-PT2 does not support "
                        "active_orbitals window selection yet; use the "
                        "default lowest-core active window."
                    )
                if c_opts.ci_solver not in ("casci", "selected_ci"):
                    raise ValueError(
                        "casscf_options.ci_solver must be 'casci' or "
                        f"'selected_ci', got {c_opts.ci_solver!r}"
                    )
                _selected_ref = c_opts.ci_solver == "selected_ci"
                # The MS/XMS-CASPT2 model space re-solves its own multi-root
                # CASCI over the full active space, so it needs the exact CI
                # backend (a selected reference is single-state PT2 only).
                if (
                    _selected_ref
                    and method == "caspt2"
                    and caspt2_options is not None
                    and caspt2_options.multistate is not None
                ):
                    raise ValueError(
                        "selected-CI reference (ci_solver='selected_ci') is "
                        "not supported for multi-state CASPT2: the MS/XMS "
                        "model space re-solves a multi-root CASCI over the "
                        "full active space.  Use the exact CI backend for "
                        "multistate, or a single-state selected-reference "
                        "CASPT2."
                    )
                # spin_pure=None resolves inside casscf(): True for SA
                # references (nroots > 1) since 2026-06-11, covering the
                # multi-state CASPT2 composition (whose <S^2>-filtered
                # model space needs same-spin SA orbitals), and False for
                # single-state references.
                sc = _run_casscf(
                    H.h1e,
                    H.h2e,
                    n_active_elec=n_active_elec,
                    n_active_orb=n_active_orb,
                    n_core=n_core,
                    nuclear_repulsion=H.nuclear_repulsion,
                    ms2=molecule.multiplicity - 1,
                    nroots=c_opts.nroots,
                    weights=c_opts.weights,
                    orbital_step=c_opts.orbital_step,
                    spin_pure=c_opts.spin_pure,
                    ci_solver=c_opts.ci_solver,
                    selected_ci_options=c_opts.selected_ci_options,
                )
                if not sc.converged:
                    raise RuntimeError(
                        f"CASSCF reference for method={method!r} did not "
                        f"converge (|g| = {sc.grad_norm:.2e} after "
                        f"{sc.n_iter} macro-iterations)"
                    )
                cas = sc.cas
                h1_ref, h2_ref = sc.h1e_cas, sc.h2e_cas
                ref_suffix = "_casscf"
                ref_root_energies = list(sc.e_totals)
                _casscf_cm = C @ sc.mo_rotation
                if _mrpt_gradient:
                    # Compute CASSCF gradient for inclusion in MR-PT2 result.
                    from .gradient import compute_casscf_gradient
                    from .solvers._rdm import make_rdm12

                    rdm1_g, rdm2_g = make_rdm12(
                        sc.cas.ci_coeffs, sc.cas.determinants, n_active_orb
                    )
                    _casscf_rdm1 = rdm1_g
                    _casscf_rdm2 = rdm2_g
                    _casscf_grad = compute_casscf_gradient(
                        molecule,
                        basis,
                        _casscf_cm,
                        sc.h1e_cas,
                        sc.h2e_cas,
                        n_core=n_core,
                        n_active_orb=n_active_orb,
                        rdm1=rdm1_g,
                        rdm2=rdm2_g,
                        compute_wz=(
                            casscf_options.compute_wz if casscf_options else False
                        ),
                        determinants=sc.cas.determinants,
                        ci_coeffs=sc.cas.ci_coeffs,
                    )
            else:
                # casci_options is method="casci"-only; the CASCI built here
                # as the *reference* for nevpt2 / caspt2 / mrci keeps the
                # solver defaults (single root -- the PT2 / MRCI engines seed
                # from root 0; the MS-CASPT2 model space re-solves its own
                # multi-root CASCI inside ms_caspt2()).
                ci_opts = (
                    (casci_options or CASCIOptions())
                    if method == "casci"
                    else CASCIOptions()
                )
                cas = _run_casci(
                    H.h1e,
                    H.h2e,
                    n_active_elec=n_active_elec,
                    n_active_orb=n_active_orb,
                    n_core=n_core,
                    nuclear_repulsion=H.nuclear_repulsion,
                    ms2=molecule.multiplicity - 1,
                    nroots=ci_opts.nroots,
                    max_det=ci_opts.max_det,
                )

            if method == "casci":
                energy, label = cas.e_total, f"casci({n_active_elec}e,{n_active_orb}o)"
                if cas.nroots > 1:
                    # Multi-root CASCI: per-root energies + <S^2> reach
                    # SolverResult.root_energies / .root_s2 via the shared
                    # return below (headline ``energy`` stays root 0).
                    ref_root_energies = list(cas.e_totals)
            elif method == "mrci":
                from .solvers._mrci import mrci as _run_mrci

                # If casscf_options are provided, run CASSCF first for
                # an orbital-optimized reference.
                if casscf_options is not None:
                    from .solvers._casscf import casscf as _run_casscf

                    c_opts = casscf_options or CASSCFOptions()
                    if c_opts.ci_solver != "casci":
                        raise ValueError(
                            "CASSCF-referenced MRCI requires the exact CI "
                            "backend; ci_solver='selected_ci' is "
                            "method='casscf' only"
                        )
                    sc = _run_casscf(
                        H.h1e,
                        H.h2e,
                        n_active_elec=n_active_elec,
                        n_active_orb=n_active_orb,
                        n_core=n_core,
                        nuclear_repulsion=H.nuclear_repulsion,
                        ms2=molecule.multiplicity - 1,
                        nroots=c_opts.nroots,
                        weights=c_opts.weights,
                        orbital_step=c_opts.orbital_step,
                        active_orbitals=c_opts.active_orbitals,
                        spin_pure=c_opts.spin_pure,
                    )
                    h1, h2 = sc.h1e_cas, sc.h2e_cas
                else:
                    h1, h2 = H.h1e, H.h2e

                mc = _run_mrci(
                    h1,
                    h2,
                    n_active_elec=n_active_elec,
                    n_active_orb=n_active_orb,
                    n_core=n_core,
                    nuclear_repulsion=H.nuclear_repulsion,
                    ms2=molecule.multiplicity - 1,
                )
                label = f"mrci({n_active_elec}e,{n_active_orb}o)"
                if casscf_options is not None:
                    label += "_casscf"
                energy = mc.e_total
            elif method == "nevpt2":
                from .solvers._mrpt import nevpt2 as _run_nevpt2

                pt = _run_nevpt2(
                    cas,
                    h1_ref,
                    h2_ref,
                    n_core=n_core,
                    n_virt=n_virt,
                    selected_reference=_selected_ref,
                )
                energy = pt.e_total
                label = f"nevpt2({n_active_elec}e,{n_active_orb}o){ref_suffix}"
            else:  # caspt2 -- internally-contracted (default), un-gated
                from .solvers._mrpt import caspt2 as _run_caspt2

                cp = caspt2_options or CASPT2Options()
                if cp.multistate is not None:
                    # MS/XMS-CASPT2 (Finley 1998 / Granovsky 2011 +
                    # Shiozaki 2011): multi-state effective Hamiltonian over
                    # the lowest nroots spin-pure CASCI roots of the
                    # reference basis (HF orbitals, or the converged
                    # SA-CASSCF basis when casscf_options are supplied).
                    from .solvers._ms_caspt2 import ms_caspt2 as _run_ms

                    if cp.multistate not in ("ms", "xms"):
                        raise ValueError(
                            "caspt2_options.multistate must be None, 'ms' "
                            f"or 'xms', got {cp.multistate!r}"
                        )
                    if cp.variant != "ic":
                        raise ValueError(
                            "multi-state CASPT2 requires variant='ic' "
                            f"(got {cp.variant!r})"
                        )
                    if cp.ipea != 0.0:
                        raise ValueError(
                            "multi-state CASPT2 supports ipea=0 only; the "
                            "canonical eight-class IPEA contraction is "
                            "currently implemented for single-state "
                            "IC-CASPT2 only"
                        )
                    ms_nroots = cp.nroots or (
                        casscf_options.nroots if casscf_options else 0
                    )
                    if ms_nroots < 2:
                        raise ValueError(
                            "multi-state CASPT2 needs >= 2 model states: "
                            "set caspt2_options.nroots (CASCI reference) "
                            "or casscf_options.nroots (SA-CASSCF reference)"
                        )
                    msr = _run_ms(
                        h1_ref,
                        h2_ref,
                        n_core,
                        n_virt,
                        nroots=ms_nroots,
                        nuclear_repulsion=H.nuclear_repulsion,
                        mode=cp.multistate,
                        n_frozen=cp.n_frozen,
                        imaginary=cp.imaginary,
                        ms2=molecule.multiplicity - 1,
                        n_act_elec=n_active_elec,
                    )
                    label = (
                        f"caspt2({n_active_elec}e,{n_active_orb}o)"
                        f"_{cp.multistate}{ms_nroots}{ref_suffix}"
                    )
                    # Relaxed MS/XMS-CASPT2 gradient (SA-CASSCF Lagrangian
                    # z-vector + state Lagrangian; see _ms_caspt2_grad).
                    # Opt-in via compute_corr_grad=True, matching the
                    # single-state convention: the semi-numerical response
                    # costs O(n_pairs) extra multi-state solves, which
                    # energy-only runs must not pay.
                    _ms_grad = None
                    _want_ms_grad = getattr(cp, "compute_corr_grad", False)
                    if (
                        _want_ms_grad
                        and _casscf_grad is not None
                        and _casscf_cm is not None
                    ):
                        try:
                            from .gradient._ms_caspt2_grad import (
                                compute_ms_caspt2_gradient as _ms_grad_fn,
                            )

                            # Resolve the SA weights the same way casscf()
                            # does: nroots > 1 with weights=None means
                            # equal-weight averaging.
                            _sa_w = None
                            if casscf_options is not None:
                                _sa_w = casscf_options.weights
                                _c_nr = casscf_options.nroots
                                if _sa_w is None and _c_nr and _c_nr > 1:
                                    _sa_w = [1.0 / _c_nr] * _c_nr
                            _ms_grad = np.asarray(
                                _ms_grad_fn(
                                    molecule,
                                    basis,
                                    _casscf_cm,
                                    h1_ref,
                                    h2_ref,
                                    n_core,
                                    n_active_orb,
                                    n_active_elec=n_active_elec,
                                    sa_weights=_sa_w,
                                    nroots=ms_nroots,
                                    mode=cp.multistate,
                                    target_root=0,
                                    ms2=molecule.multiplicity - 1,
                                )
                            )
                        except Exception as _ms_grad_err:
                            import warnings

                            warnings.warn(
                                "MS-CASPT2 gradient computation failed "
                                f"({_ms_grad_err}); the result carries "
                                "no gradient.",
                                UserWarning,
                            )
                            _ms_grad = None
                    return SolverResult(
                        energy=msr.e_total,
                        method=label,
                        converged=True,
                        n_iter=1,
                        energy_trace=[msr.e_total],
                        root_energies=list(msr.energies),
                        ci_coeffs=cas.ci_coeffs,
                        ci_labels=cas.determinants,
                        rdm1=_active_rdm1(cas),
                        gradient=_ms_grad,
                        multistate=dict(
                            mode=msr.mode,
                            nroots=msr.nroots,
                            heff=msr.heff,
                            heff_asym=msr.heff_asym,
                            mixing=msr.mixing,
                            ss_energies=list(msr.ss_energies),
                            ref_energies=list(msr.ref_energies),
                            e2_corr=list(msr.e2_corr),
                            xms_rotation=msr.xms_rotation,
                            s2_values=list(msr.s2_values),
                        ),
                    )
                pt = _run_caspt2(
                    cas,
                    h1_ref,
                    h2_ref,
                    n_core=n_core,
                    n_virt=n_virt,
                    variant=cp.variant,
                    ipea=cp.ipea,
                    imaginary=cp.imaginary,
                    n_frozen=cp.n_frozen,
                    selected_reference=_selected_ref,
                    engine=cp.engine,
                )
                energy = pt.e_total
                _eng_suffix = "_cases" if cp.engine == "cases" else ""
                label = (
                    f"caspt2({n_active_elec}e,{n_active_orb}o){ref_suffix}{_eng_suffix}"
                )

            # Optionally replace the reference gradient with the derivative
            # of the full correlated energy.  Single-state IC-CASPT2 uses its
            # analytic Lagrangian inside the exact supported envelope;
            # SC-NEVPT2 retains the relaxed full-energy FD route.
            if _casscf_grad is not None and _casscf_cm is not None:
                _use_corr = False
                if method == "caspt2" and caspt2_options is not None:
                    _use_corr = getattr(caspt2_options, "compute_corr_grad", False)
                elif method == "nevpt2" and nevpt2_options is not None:
                    _use_corr = getattr(nevpt2_options, "compute_corr_grad", False)
                if not _use_corr:
                    import warnings

                    _corr_hint = (
                        "the analytic IC-CASPT2 gradient in its supported "
                        "single-state envelope"
                        if method == "caspt2"
                        else "the full relaxed correlation gradient"
                    )
                    warnings.warn(
                        f"{method.upper()} gradient: compute_corr_grad=False; "
                        f"the returned gradient is the CASSCF gradient only. "
                        f"Set {method}_options.compute_corr_grad=True for "
                        f"{_corr_hint}.",
                        UserWarning,
                    )
                if _use_corr and method == "caspt2":
                    from .solvers._mrpt import _ic_caspt2_uses_direct_active_ci

                    cp = caspt2_options
                    unsupported: list[str] = []
                    if cp is None or cp.variant != "ic":
                        unsupported.append("variant must be 'ic'")
                    if cp is not None and cp.multistate is not None:
                        unsupported.append("single-state only")
                    if cp is not None and cp.ipea != 0.0:
                        unsupported.append("ipea must be 0")
                    if cp is not None and cp.imaginary != 0.0:
                        unsupported.append("imaginary shift must be 0")
                    if cp is not None and cp.engine != "auto":
                        unsupported.append("engine must be 'auto'")
                    if _selected_ref:
                        unsupported.append("exact-CI CASSCF reference required")
                    if (c_opts.nroots or 1) != 1:
                        unsupported.append("state-specific CASSCF required")
                    if molecule.multiplicity != 1:
                        unsupported.append("closed-shell singlet required")
                    if cp is not None and _ic_caspt2_uses_direct_active_ci(
                        h1_ref.shape[0],
                        n_core,
                        n_active_orb,
                        n_frozen=cp.n_frozen,
                        ipea=cp.ipea,
                        imaginary=cp.imaginary,
                    ):
                        unsupported.append("explicit small-space engine required")
                    if unsupported:
                        raise NotImplementedError(
                            "Analytic IC-CASPT2 gradients currently support "
                            "single-state, unshifted, explicit-engine "
                            "closed-shell CASSCF references; unsupported: "
                            + "; ".join(unsupported)
                            + ". Use a full-energy finite-difference gradient "
                            "for this CASPT2 variant."
                        )
                    if _casscf_rdm1 is None or _casscf_rdm2 is None:
                        raise RuntimeError(
                            "IC-CASPT2 analytic gradient requires the "
                            "converged CASSCF reference RDMs"
                        )
                    from .gradient._caspt2 import (
                        compute_ic_caspt2_analytic_gradient,
                    )

                    _casscf_grad = compute_ic_caspt2_analytic_gradient(
                        molecule,
                        basis,
                        _casscf_cm,
                        h1_ref,
                        h2_ref,
                        n_core,
                        n_active_orb,
                        n_active_elec,
                        rdm1=_casscf_rdm1,
                        rdm2=_casscf_rdm2,
                        ci_coeffs=sc.cas.ci_coeffs,
                        determinants=sc.cas.determinants,
                        n_frozen=cp.n_frozen,
                    )
                if _use_corr and method == "nevpt2":
                    _grad_fd = np.zeros_like(_casscf_grad)
                    _atoms_list = list(molecule.atoms)
                    _h = 0.001
                    for _a in range(len(_atoms_list)):
                        for _c in range(3):
                            _xyz_p = np.array(
                                [at.xyz for at in _atoms_list], dtype=float
                            )
                            _xyz_m = _xyz_p.copy()
                            _xyz_p[_a, _c] += _h
                            _xyz_m[_a, _c] -= _h
                            _mp = Molecule(
                                [
                                    Atom(int(at.Z), list(xyz))
                                    for at, xyz in zip(_atoms_list, _xyz_p)
                                ]
                            )
                            _mm = Molecule(
                                [
                                    Atom(int(at.Z), list(xyz))
                                    for at, xyz in zip(_atoms_list, _xyz_m)
                                ]
                            )
                            _bp = BasisSet(_mp, basis.name)
                            _bm = BasisSet(_mm, basis.name)

                            _cp_energy = (
                                replace(caspt2_options, compute_corr_grad=False)
                                if caspt2_options is not None
                                else None
                            )
                            _np_energy = (
                                replace(nevpt2_options, compute_corr_grad=False)
                                if nevpt2_options is not None
                                else None
                            )
                            _ep = _run_single_point(
                                method,
                                _mp,
                                _bp,
                                functional=functional,
                                rhf_options=rhf_options,
                                uhf_options=uhf_options,
                                rks_options=rks_options,
                                uks_options=uks_options,
                                rohf_options=rohf_options,
                                roks_options=roks_options,
                                cisd_options=cisd_options,
                                selected_ci_options=selected_ci_options,
                                dmrg_options=dmrg_options,
                                v2rdm_options=v2rdm_options,
                                transcorrelated_options=transcorrelated_options,
                                casci_options=casci_options,
                                caspt2_options=_cp_energy,
                                nevpt2_options=_np_energy,
                                casscf_options=casscf_options,
                                active_space=active_space,
                                cas_reference=cas_reference,
                                mlip_options=mlip_options,
                                ccm_options=ccm_options,
                                solvent=solvent,
                                dft_plus_u=dft_plus_u,
                                read_from=read_from,
                                fragments=fragments,
                                nddo=nddo,
                                _mrpt_gradient=False,
                            ).energy
                            _em = _run_single_point(
                                method,
                                _mm,
                                _bm,
                                functional=functional,
                                rhf_options=rhf_options,
                                uhf_options=uhf_options,
                                rks_options=rks_options,
                                uks_options=uks_options,
                                rohf_options=rohf_options,
                                roks_options=roks_options,
                                cisd_options=cisd_options,
                                selected_ci_options=selected_ci_options,
                                dmrg_options=dmrg_options,
                                v2rdm_options=v2rdm_options,
                                transcorrelated_options=transcorrelated_options,
                                casci_options=casci_options,
                                caspt2_options=_cp_energy,
                                nevpt2_options=_np_energy,
                                casscf_options=casscf_options,
                                active_space=active_space,
                                cas_reference=cas_reference,
                                mlip_options=mlip_options,
                                ccm_options=ccm_options,
                                solvent=solvent,
                                dft_plus_u=dft_plus_u,
                                read_from=read_from,
                                fragments=fragments,
                                nddo=nddo,
                                _mrpt_gradient=False,
                            ).energy
                            _grad_fd[_a, _c] = (_ep - _em) / (2.0 * _h)
                    _casscf_grad = _grad_fd

            return SolverResult(
                energy=energy,
                method=label,
                # Direct CI + non-iterative PT2: a returned result means the
                # eigensolve succeeded.
                converged=True,
                n_iter=1,
                energy_trace=[energy],
                root_energies=ref_root_energies or None,
                root_s2=(
                    _root_s2_values(cas, molecule.multiplicity - 1)
                    if ref_root_energies
                    else None
                ),
                # Reference-wavefunction diagnostics (the CASCI / CASSCF
                # reference for PT2 methods): leading configurations +
                # active natural occupations in the .out solver block.
                ci_coeffs=cas.ci_coeffs,
                ci_labels=cas.determinants,
                rdm1=_active_rdm1(cas),
                gradient=_casscf_grad,
            )

    if method == "fci":
        from scipy.linalg import eigh

        from .solvers import (
            build_hamiltonian_matrix_unrestricted,
            generate_determinants,
        )

        hf_method = "uhf" if molecule.multiplicity > 1 else "rhf"
        C = get_hf_orbital_provider(molecule, basis, method=hf_method)
        ham = build_hamiltonian_mo(molecule, basis, C)

        if active_space is not None:
            # Standard CAS partition with a properly dressed frozen core
            # (effective one-electron term + E_core folded into
            # nuclear_repulsion); see Hamiltonian.active_space.
            n_active, n_elec = active_space
            ham = ham.active_space(n_active, n_elec)

        norb = ham.norb
        nelec = ham.nelec
        nalpha = (nelec + ham.ms2) // 2
        nbeta = (nelec - ham.ms2) // 2

        all_dets = generate_determinants(norb, nalpha, nbeta)
        H = build_hamiltonian_matrix_unrestricted(all_dets, ham.h1e, ham.h2e)
        evals, evecs = eigh(H)
        e_fci = evals[0] + ham.nuclear_repulsion

        return SolverResult(
            energy=e_fci,
            method=f"fci(ndet={len(all_dets)})",
            converged=True,
            n_iter=1,
            energy_trace=[e_fci],
            ci_coeffs=evecs[:, 0],
            ci_labels=all_dets,
        )

    # ── Semiempirical methods ──
    if method in MOLECULAR_SEMIEMPIRICAL_METHODS:
        if solvent is not None and method == "msindo":
            return _run_semiempirical(method, molecule, nddo=nddo, solvent=solvent)
        return _run_semiempirical(method, molecule, nddo=nddo)

    # ── Periodic MSINDO SECCM ──
    if method == "ccm":
        return _run_ccm(molecule, ccm_options)

    # ── Machine-learning interatomic potentials (MACE, ...) ──
    if method in _MLIP_METHODS:
        return _run_mlip(method, molecule, mlip_options)

    raise ValueError(
        f"Unknown method {method!r}; use 'rhf', 'uhf', 'rohf', 'rks', 'uks', "
        f"'roks', 'selected_ci', 'dmrg', 'v2rdm', 'transcorrelated_ci', 'casci', "
        f"'mrci', 'casscf', 'nevpt2', 'caspt2', 'fci', "
        f"'ci' with citype=, 'cisd', 'ccsd', 'ccsd(t)', 'bccd', 'bccd(t)', "
        f"'cc2', 'ccd', 'lccd', 'lccsd', 'cepa(0)'..'cepa(3)', "
        f"'qcisd', 'qcisd(t)', "
        f"'dftb0', 'scc_dftb', 'pm6', 'gfn2_xtb', "
        f"'om1', 'om2', 'om3', 'seccm', 'mace', or 'auto'"
    )


def _citation_manifest_rows(refs: Any) -> list[dict[str, Any]]:
    """Flatten an :class:`AssembledCitations` into ``.system``
    ``[citations]`` rows. Thin alias for
    :func:`vibeqc.output.citations.citation_manifest_rows`, kept so
    existing importers of the private runner helper stay working; the
    shared implementation also feeds ``run_periodic_job``."""
    from .output.citations import citation_manifest_rows

    return citation_manifest_rows(refs)


def _run_mlip(method: str, molecule: Molecule, mlip_options=None):
    """Run a machine-learning interatomic potential and return a
    result-like object (duck-typing the SCF-result interface).

    vibe-qc drives the external pre-trained model's forward pass; the
    returned ``.energy`` is the model's reference-shifted DFT-surface
    energy (Hartree), NOT a total electronic energy (``CLAUDE.md`` Sec.10 /
    energy-scale caveat -- do not mix with HF/DFT totals).

    ``mlip_options`` (:class:`vibeqc.mlip.MLIPOptions`) selects the model,
    device, and dtype, and carries the ASL acknowledgment. Selecting an
    ASL (academic, non-commercial) model without acknowledgment raises
    :class:`PermissionError` (see :mod:`vibeqc.mlip.mace`).
    """
    if method == "mace":
        from vibeqc.mlip.mace import MACEModel

        model = MACEModel(molecule, mlip_options)
        return _MLIPResult(
            model.energy(),
            model.gradient(),
            method,
            converged=True,
            n_iter=1,
            model_info=model.info,
        )
    raise ValueError(f"Unhandled MLIP method: {method!r}")


class _MLIPResult:
    """Machine-learning-interatomic-potential result wrapper -- duck-types
    the same interface as :class:`SemiempiricalResult` (energy, gradient(), method,
    converged, n_iter, scf_trace). ``energy`` is the model's
    reference-shifted DFT-surface energy (Hartree), not a total electronic
    energy."""

    def __init__(
        self,
        energy,
        gradient=None,
        method_name="",
        converged=True,
        n_iter=1,
        model_info=None,
    ):
        self.energy = energy
        self._gradient = gradient
        self.method = method_name
        self.converged = converged
        self.n_iter = n_iter
        self.scf_trace = []
        # MaceModelInfo (or None) -- provenance for the MLIP model actually
        # run; used by run_job for the per-model citation + .out/.system.
        self.model_info = model_info

    def gradient(self):
        return self._gradient


def _format_mlip_provenance(info) -> str:
    """A self-documenting MLIP provenance block for the .out -- which
    external pre-trained model produced these numbers, its license, and
    how to read the (model-specific, reference-shifted) energy."""
    lic = info.license.upper()
    if lic == "MIT":
        lic_note = "MIT  [free for commercial + academic use]"
    elif lic == "ASL":
        lic_note = "ASL  [ACADEMIC, NON-COMMERCIAL use only]"
    else:
        lic_note = f"{info.license}  [unverified -- treated as academic-only]"
    elts = f", {info.elements} elements" if info.elements else ""
    lines = [
        "  " + "-" * 60,
        "  MACE machine-learning interatomic potential",
        "  " + "-" * 60,
        f"  Model:     {info.key}  ({info.domain}{elts})",
        f"  License:   {lic_note}",
        f"  Training:  {info.training_data}   |   Theory: {info.theory}",
        "  Energy:    model-specific reference-shifted scale -- NOT a",
        "             vibe-qc total energy, not comparable across models.",
    ]
    if info.doi:
        lines.append(f"  Reference: doi:{info.doi}  (cited in the .bibtex sibling)")
    lines.append(
        "  vibe-qc drives ACEsuit MACE's pre-trained forward pass (CLAUDE.md Sec.10)."
    )
    lines.append("  " + "-" * 60)
    return "\n".join(lines)


def _active_rdm1(cas):
    """Active-space 1-RDM of a CASCI result's lowest root.

    Feeds the natural-occupation diagnostic in the .out solver block;
    routed through solvers._rdm.make_rdm12 so large full-CAS spaces use
    the C++ kernel.  Diagnostics must never kill a converged run, so any
    failure degrades to None (the block is simply omitted).
    """
    try:
        from .solvers._rdm import make_rdm12

        return make_rdm12(cas.ci_coeffs, cas.determinants, cas.n_active_orb)[0]
    except Exception:
        return None


#: Skip the per-root <S^2> diagnostic above this determinant count: the
#: spin-orbital-dict evaluation is Python-side and would add tens of
#: seconds on multi-million-determinant direct-CI vectors.
_ROOT_S2_MAX_DET = 200_000


def _root_s2_values(cas, ms2):
    """Per-root <S^2> of a multi-root CASCI result, for the .out root table.

    Shows which spin sector each state-averaged root lives in, the
    visibility surface for the 2026-06-11 spin-pure SA default (spin-pure
    runs print S(S+1) rows; ``spin_pure=False`` ensembles expose their
    mixed-spin composition).  Same degrade-to-omission contract as
    :func:`_active_rdm1`: any failure (or an oversized determinant list)
    returns None and the column is omitted.
    """
    try:
        if cas.ci_coeffs_all is None or cas.n_det > _ROOT_S2_MAX_DET:
            return None
        from .solvers._ms_caspt2 import _s2_expectation

        return [
            float(
                _s2_expectation(
                    cas.ci_coeffs_all[:, k],
                    cas.determinants,
                    cas.n_active_orb,
                    ms2,
                )
            )
            for k in range(cas.ci_coeffs_all.shape[1])
        ]
    except Exception:
        return None


def _format_solver_trace(result: SolverResult) -> str:
    """Format a non-mean-field solver result for the .out file."""
    import numpy as np

    lines = []
    lines.append(f"  Method:            {result.method}")
    lines.append(f"  Total energy:      {render_energy_labeled(result.energy, width=16, precision=10)}")
    lines.append(f"  Converged:         {result.converged}")
    lines.append(f"  Iterations/sweeps: {result.n_iter}")
    if result.pt2_correction is not None:
        e_var = result.energy - result.pt2_correction
        lines.append(f"  E(variational):    {render_energy_labeled(e_var, width=16, precision=10)}")
        lines.append(f"  E(PT2 correction): {render_energy_labeled(result.pt2_correction, width=16, precision=10)}")
    if result.bond_dim is not None:
        lines.append(f"  Bond dimension:    {result.bond_dim}")
    if result.truncation_error is not None:
        lines.append(f"  Truncation error:  {result.truncation_error:.2e}")
    if result.constraint_residual is not None:
        lines.append(f"  Constraint resid.: {result.constraint_residual:.2e}")
    if result.ci_labels is not None and result.ci_coeffs is not None:
        lines.append(f"  Determinants:      {len(result.ci_labels)}")
        c_abs = np.abs(result.ci_coeffs)
        n_show = min(8, len(c_abs))
        idx = np.argsort(-c_abs)
        lines.append("  Leading configurations:")
        norb = 0
        for rank in range(n_show):
            i = idx[rank]
            label = result.ci_labels[i]
            # Format SpinDet as a|...> b|...>, Det as |...>
            if (
                isinstance(label, tuple)
                and len(label) == 2
                and isinstance(label[0], tuple)
            ):
                a_str = "".join(
                    "1" if j in label[0] else "0"
                    for j in range(max(label[0]) + 1 if label[0] else 0)
                )
                b_str = "".join(
                    "1" if j in label[1] else "0"
                    for j in range(max(label[1]) + 1 if label[1] else 0)
                )
                lines.append(f"    |c_{rank}| = {c_abs[i]:.6f}  a|{a_str}> b|{b_str}>")
            else:
                norb = max(norb, max(label) + 1 if label else 0)
                occ_str = "".join("1" if j in label else "0" for j in range(norb))
                lines.append(f"    |c_{rank}| = {c_abs[i]:.6f}  |{occ_str}>")
    if result.root_energies is not None and len(result.root_energies) > 1:
        lines.append("  Root energies:")
        s2s = result.root_s2
        if s2s is not None and len(s2s) != len(result.root_energies):
            s2s = None
        for k, e_root in enumerate(result.root_energies):
            s2_note = f"   <S^2> = {s2s[k]:7.4f}" if s2s is not None else ""
            lines.append(f"    root {k}:  E = {render_energy_labeled(e_root, width=16, precision=10)}{s2_note}")
    if result.selected_pt2:
        lines.append(
            "  Epstein-Nesbet PT2 on the selected wavefunction"
            " (Sharma et al 2017; variational energy unchanged):"
        )
        for k, p in enumerate(result.selected_pt2):
            sig = f" +/- {p['stderr']:.8f}" if p.get("stderr") else ""
            npert = (
                f"   ({p['n_perturbers']} perturbers)"
                if p.get("n_perturbers") is not None
                else "   (semistochastic)"
            )
            lines.append(f"    root {k}:  E_PT2 = {render_energy_labeled(p['e_pt2'], width=16, precision=10)}{sig}")
            lines.append(f"             E_var+PT2 = {render_energy_labeled(p['e_total'], width=16, precision=10)}{npert}")
    if result.multistate is not None:
        ms = result.multistate
        nst = ms["nroots"]
        mode_label = "XMS-CASPT2" if ms["mode"] == "xms" else "MS-CASPT2"
        lines.append(f"  {mode_label} ({nst} states):")
        lines.append(
            "    model-space reference energies (Ha): "
            + " ".join(f"{e:16.10f}" for e in ms["ref_energies"])
        )
        if ms.get("s2_values"):
            lines.append(
                "    model-space <S^2> (spin-pure roots):  "
                + " ".join(f"{s:16.4f}" for s in ms["s2_values"])
            )
        lines.append(
            "    single-state CASPT2 diagonal (Ha):   "
            + " ".join(f"{e:16.10f}" for e in ms["ss_energies"])
        )
        if ms["mode"] == "xms":
            lines.append("    XMS model-space rotation U (column = rotated state):")
            for row in np.asarray(ms["xms_rotation"]):
                lines.append("      " + " ".join(f"{u:10.6f}" for u in row))
        lines.append("    effective Hamiltonian (symmetrized, Ha):")
        for row in np.asarray(ms["heff"]):
            lines.append("      " + " ".join(f"{h:16.10f}" for h in row))
        lines.append("    mixing (column k = multi-state root k):")
        for row in np.asarray(ms["mixing"]):
            lines.append("      " + " ".join(f"{c:10.6f}" for c in row))
    if result.rdm1 is not None:
        occ = np.linalg.eigvalsh(np.asarray(result.rdm1))[::-1]
        lines.append(
            "  Natural occupations (active): " + " ".join(f"{o:7.4f}" for o in occ)
        )
    if result.energy_trace:
        lines.append("  Energy trace:")
        for i, e in enumerate(result.energy_trace):
            lines.append(f"    iter {i + 1:4d}:  E = {render_energy_labeled(e, width=16, precision=10)}")
    return "\n".join(lines)


def _format_ccsdt_result(result: CCSDTResult) -> tuple[str, str]:
    """Render full-CCSDT summary and opt-in iteration detail semantically."""
    summary = OutputDocument(indent=2, label_width=27)
    summary.scalar("Method", "CCSDT (full iterative T1/T2/T3)")
    summary.scalar("Reference energy", Quantity(result.e_reference, "energy"))
    summary.scalar(
        "Correlation energy", Quantity(result.e_correlation, "energy_delta")
    )
    summary.scalar("Total energy", Quantity(result.energy, "energy"))
    summary.scalar("Converged", "yes" if result.converged else "no")
    summary.scalar("Iterations", str(result.n_iter))
    summary.scalar("Frozen core orbitals", str(result.n_frozen_core))
    summary.scalar(
        "S/D/T amplitudes",
        f"{result.n_singles}/{result.n_doubles}/{result.n_triples}",
    )
    summary.scalar("T1 norm", Quantity(result.t1_norm, "dimensionless"))
    summary.scalar("T2 norm", Quantity(result.t2_norm, "dimensionless"))
    summary.scalar("T3 norm", Quantity(result.t3_norm, "dimensionless"))
    summary.scalar(
        "Final residual RMS", Quantity(result.residual_rms, "dimensionless")
    )
    summary.scalar(
        "Final residual max", Quantity(result.residual_max, "dimensionless")
    )

    iterations = Table(
        [
            Column("Iter"),
            Column("E(CCSDT)"),
            Column("dE"),
            Column("RMS residual"),
            Column("Max residual"),
            Column("DIIS"),
        ],
        indent=2,
    )
    for step in result.ccsdt_trace:
        iterations.add_row(
            step.iteration,
            Quantity(step.energy, "energy"),
            Quantity(step.delta_energy, "energy_delta"),
            Quantity(step.residual_rms, "dimensionless"),
            Quantity(step.residual_max, "dimensionless"),
            step.diis_subspace,
        )
    policy = active_policy().with_spec(
        "dimensionless", precision=6, width=0, notation="e"
    )
    return summary.render(policy), iterations.render(policy)


def _format_cc3_result(result: CC3Result) -> tuple[str, str]:
    """Render the CC3 summary and opt-in iteration detail semantically."""
    summary = OutputDocument(indent=2, label_width=27)
    summary.scalar("Method", "CC3 (iterative T1/T2, approximate T3)")
    summary.scalar("Reference energy", Quantity(result.e_reference, "energy"))
    summary.scalar(
        "Correlation energy", Quantity(result.e_correlation, "energy_delta")
    )
    summary.scalar("Total energy", Quantity(result.energy, "energy"))
    summary.scalar("Converged", "yes" if result.converged else "no")
    summary.scalar("Iterations", str(result.n_iter))
    summary.scalar("Frozen core orbitals", str(result.n_frozen_core))
    summary.scalar(
        "S/D/T amplitudes",
        f"{result.n_singles}/{result.n_doubles}/{result.n_triples}",
    )
    summary.scalar("T1 norm", Quantity(result.t1_norm, "dimensionless"))
    summary.scalar("T2 norm", Quantity(result.t2_norm, "dimensionless"))
    summary.scalar("T3 norm", Quantity(result.t3_norm, "dimensionless"))
    summary.scalar(
        "Final residual RMS", Quantity(result.residual_rms, "dimensionless")
    )
    summary.scalar(
        "Final residual max", Quantity(result.residual_max, "dimensionless")
    )

    iterations = Table(
        [
            Column("Iter"),
            Column("E(CC3)"),
            Column("dE"),
            Column("RMS residual"),
            Column("Max residual"),
            Column("DIIS"),
        ],
        indent=2,
    )
    for step in result.cc3_trace:
        iterations.add_row(
            step.iteration,
            Quantity(step.energy, "energy"),
            Quantity(step.delta_energy, "energy_delta"),
            Quantity(step.residual_rms, "dimensionless"),
            Quantity(step.residual_max, "dimensionless"),
            step.diis_subspace,
        )
    policy = active_policy().with_spec(
        "dimensionless", precision=6, width=0, notation="e"
    )
    return summary.render(policy), iterations.render(policy)


def _geom_summary(molecule: Molecule) -> str:
    """Three-column per-atom geometry listing (bohr)."""
    lines = ["  Atoms (bohr)", "  " + "-" * 54]
    for i, atom in enumerate(molecule.atoms, start=1):
        lines.append(
            f"  {i:4d}  Z={atom.Z:3d}   "
            f"{atom.xyz[0]:14.8f}  {atom.xyz[1]:14.8f}  {atom.xyz[2]:14.8f}"
        )
    lines.append(
        f"  charge={molecule.charge}  multiplicity={molecule.multiplicity}  "
        f"n_electrons={molecule.n_electrons()}"
    )
    return "\n".join(lines)


def _job_header(
    method: str, basis_name: str, functional: Optional[str], scf_reference: str = "RHF"
) -> str:
    label = f"{method.upper()}"
    if method in ("rks", "uks") and functional:
        label = f"{label} / {functional}"
    # Post-SCF methods name their mean-field reference (RHF for closed shell,
    # UHF for an open-shell MP2/UMP2 run).
    if method in (
        "cisd",
        "ccsd",
        "ccsd(t)",
        "cc3",
        "ccsdt",
        "ccsd[t]",
        "a-ccsd(t)",
        "cc2",
        "ccd",
        "lccd",
        "lccsd",
        "cepa(0)",
        "cepa(1)",
        "cepa(2)",
        "cepa(3)",
        "qcisd",
        "qcisd(t)",
        "mp2",
        "scs-mp2",
        "sos-mp2",
        "dlpno-mp2",
        "dlpno-ccsd",
        "dlpno-ccsd(t)",
        "ovgf",
    ):
        label = f"{scf_reference.upper()} + {method.upper()}"
    return f"  Job: {label}  basis={basis_name}"


def _make_semiempirical_ase_calculator(
    molecule: Molecule,
    method: str,
):
    """Return an ASE Calculator that computes energy via the
    semiempirical dispatcher and forces via the shared gradient adapter."""
    try:
        from ase.calculators.calculator import Calculator
        from ase.units import Bohr, Hartree
    except ImportError:
        raise ImportError("Geometry optimization requires ASE: pip install ase")

    import numpy as np

    from ._vibeqc_core import Atom as _Atom
    from ._vibeqc_core import Molecule as _Molecule

    h = 0.001  # bohr

    class _SemiempiricalCalculator(Calculator):
        implemented_properties = ["energy", "forces"]

        def calculate(self, atoms, properties, system_changes):
            # Record self.atoms per the ASE Calculator contract. Without
            # it check_state() reports every query as changed, so cached
            # results are discarded (each get_* triggers a fresh solve)
            # and TrajectoryWriter finds no stored energy -- semiempirical
            # optimization trajectories lost their per-frame energies.
            Calculator.calculate(self, atoms, properties, system_changes)
            positions_bohr = atoms.positions / Bohr
            mol = _Molecule(
                [
                    _Atom(int(z), list(xyz))
                    for z, xyz in zip(atoms.numbers, positions_bohr)
                ],
                charge=molecule.charge,
                multiplicity=molecule.multiplicity,
            )

            result = _run_semiempirical(method, mol)
            energy_ha = float(getattr(result, "energy", 0.0))
            self.results["energy"] = energy_ha * Hartree

            if "forces" in properties:
                gradient = None
                gradient_fn = getattr(result, "gradient", None)
                if callable(gradient_fn):
                    gradient = gradient_fn()

                if gradient is not None:
                    gradient_ha_bohr = np.asarray(gradient, dtype=float)
                    expected_shape = (len(atoms), 3)
                    if gradient_ha_bohr.shape != expected_shape:
                        raise ValueError(
                            "Semiempirical gradient shape "
                            f"{gradient_ha_bohr.shape} does not match "
                            f"{expected_shape}."
                        )
                    if not np.all(np.isfinite(gradient_ha_bohr)):
                        raise ValueError("Semiempirical gradient contains NaN/Inf.")
                    forces_ha_bohr = -gradient_ha_bohr
                else:
                    forces_ha_bohr = np.zeros((len(atoms), 3))
                    for i in range(len(atoms)):
                        for c in range(3):
                            pos_plus = positions_bohr.copy()
                            pos_plus[i, c] += h
                            mol_p = _Molecule(
                                [
                                    _Atom(int(z), list(xyz))
                                    for z, xyz in zip(atoms.numbers, pos_plus)
                                ],
                                charge=molecule.charge,
                                multiplicity=molecule.multiplicity,
                            )
                            e_p = float(
                                getattr(
                                    _run_semiempirical(method, mol_p), "energy", 0.0
                                )
                            )

                            pos_minus = positions_bohr.copy()
                            pos_minus[i, c] -= h
                            mol_m = _Molecule(
                                [
                                    _Atom(int(z), list(xyz))
                                    for z, xyz in zip(atoms.numbers, pos_minus)
                                ],
                                charge=molecule.charge,
                                multiplicity=molecule.multiplicity,
                            )
                            e_m = float(
                                getattr(
                                    _run_semiempirical(method, mol_m), "energy", 0.0
                                )
                            )

                            forces_ha_bohr[i, c] = (e_m - e_p) / (2.0 * h)
                self.results["forces"] = forces_ha_bohr * (Hartree / Bohr)

    return _SemiempiricalCalculator()


def _make_wavefunction_ase_calculator(
    molecule: Molecule,
    basis_name: str,
    method: str,
    *,
    functional=None,
    cisd_options=None,
    cc3_options=None,
    ccsdt_options=None,
    selected_ci_options=None,
    dmrg_options=None,
    v2rdm_options=None,
    transcorrelated_options=None,
    casci_options=None,
    caspt2_options=None,
    nevpt2_options=None,
    casscf_options=None,
    active_space=None,
    cas_reference=None,
    dft_plus_u: Optional[List["HubbardSite"]] = None,
):
    """Return an ASE Calculator that computes energy via the wavefunction
    solver and forces via central finite differences (h = 0.001 bohr).

    When ``dft_plus_u`` is set, the per-geometry SCF (energy + each FD
    gradient probe) runs with +U via :func:`_run_single_point`'s
    ``dft_plus_u`` kwarg. The analytic +U gradient (Increment 3) is
    *not* used by this calculator -- FD is good enough for BFGS step
    direction and avoids re-plumbing the calculator's force-call
    contract. For a tight optimization with analytic +U gradient,
    call :func:`vibeqc.compute_gradient` (or sibling) directly with
    ``dft_plus_u=`` per-step in your own loop.
    """
    try:
        from ase.calculators.calculator import Calculator
        from ase.units import Bohr, Hartree
    except ImportError:
        raise ImportError("Geometry optimization requires ASE: pip install ase")

    import numpy as np

    from ._vibeqc_core import Atom, BasisSet
    from ._vibeqc_core import Molecule as _Molecule

    h = 0.001  # bohr -- central-finite-difference step

    class _WavefunctionCalculator(Calculator):
        implemented_properties = ["energy", "forces"]

        def calculate(self, atoms, properties, system_changes):
            # Record self.atoms per the ASE Calculator contract. Without
            # it check_state() reports every query as changed, so cached
            # results are discarded (each get_* triggers a fresh solve)
            # and TrajectoryWriter finds no stored energy -- wavefunction
            # optimization trajectories lost their per-frame energies.
            Calculator.calculate(self, atoms, properties, system_changes)
            # Convert ASE Atoms -> vibeqc Molecule
            positions_bohr = atoms.positions / Bohr
            mol = _Molecule(
                [
                    Atom(int(z), list(xyz))
                    for z, xyz in zip(atoms.numbers, positions_bohr)
                ],
                charge=molecule.charge,
                multiplicity=molecule.multiplicity,
            )
            basis = BasisSet(mol, basis_name)

            # Compute energy at current geometry.  This call and the two
            # FD-displaced calls below must pass the *same* solver options:
            # any mismatch makes the BFGS energy and its forces sample
            # different surfaces (pre-2026-06-12 the energy call dropped
            # casscf_options while the FD calls carried it).
            result = _run_single_point(
                method,
                mol,
                basis,
                functional=functional,
                cisd_options=cisd_options,
                cc3_options=cc3_options,
                ccsdt_options=ccsdt_options,
                selected_ci_options=selected_ci_options,
                dmrg_options=dmrg_options,
                v2rdm_options=v2rdm_options,
                transcorrelated_options=transcorrelated_options,
                casci_options=casci_options,
                caspt2_options=caspt2_options,
                nevpt2_options=nevpt2_options,
                casscf_options=casscf_options,
                active_space=active_space,
                cas_reference=cas_reference,
                dft_plus_u=dft_plus_u,
            )
            energy_ha = float(getattr(result, "energy", 0.0))
            self.results["energy"] = energy_ha * Hartree

            # Numerical forces via central differences
            if "forces" in properties:
                forces_ha_bohr = np.zeros((len(atoms), 3))
                for i in range(len(atoms)):
                    for c in range(3):
                        # +h displacement
                        pos_plus = positions_bohr.copy()
                        pos_plus[i, c] += h
                        mol_plus = _Molecule(
                            [
                                Atom(int(z), list(xyz))
                                for z, xyz in zip(atoms.numbers, pos_plus)
                            ],
                            charge=molecule.charge,
                            multiplicity=molecule.multiplicity,
                        )
                        basis_plus = BasisSet(mol_plus, basis_name)
                        result_plus = _run_single_point(
                            method,
                            mol_plus,
                            basis_plus,
                            functional=functional,
                            cisd_options=cisd_options,
                            cc3_options=cc3_options,
                            ccsdt_options=ccsdt_options,
                            selected_ci_options=selected_ci_options,
                            dmrg_options=dmrg_options,
                            v2rdm_options=v2rdm_options,
                            transcorrelated_options=transcorrelated_options,
                            casci_options=casci_options,
                            caspt2_options=caspt2_options,
                            nevpt2_options=nevpt2_options,
                            casscf_options=casscf_options,
                            active_space=active_space,
                            cas_reference=cas_reference,
                            dft_plus_u=dft_plus_u,
                        )
                        e_plus = float(getattr(result_plus, "energy", 0.0))

                        # -h displacement
                        pos_minus = positions_bohr.copy()
                        pos_minus[i, c] -= h
                        mol_minus = _Molecule(
                            [
                                Atom(int(z), list(xyz))
                                for z, xyz in zip(atoms.numbers, pos_minus)
                            ],
                            charge=molecule.charge,
                            multiplicity=molecule.multiplicity,
                        )
                        basis_minus = BasisSet(mol_minus, basis_name)
                        result_minus = _run_single_point(
                            method,
                            mol_minus,
                            basis_minus,
                            functional=functional,
                            cisd_options=cisd_options,
                            cc3_options=cc3_options,
                            ccsdt_options=ccsdt_options,
                            selected_ci_options=selected_ci_options,
                            dmrg_options=dmrg_options,
                            v2rdm_options=v2rdm_options,
                            transcorrelated_options=transcorrelated_options,
                            casci_options=casci_options,
                            caspt2_options=caspt2_options,
                            nevpt2_options=nevpt2_options,
                            casscf_options=casscf_options,
                            active_space=active_space,
                            cas_reference=cas_reference,
                            dft_plus_u=dft_plus_u,
                        )
                        e_minus = float(getattr(result_minus, "energy", 0.0))

                        # Central difference: F = -dE/dR
                        forces_ha_bohr[i, c] = -(e_plus - e_minus) / (2 * h)

                self.results["forces"] = forces_ha_bohr * (Hartree / Bohr)

    return _WavefunctionCalculator()


def _optimize_geometry(
    molecule: Molecule,
    basis_name: str,
    *,
    functional: Optional[str],
    trajectory_path: Path,
    fmax: float,
    max_steps: int,
    dispersion_params: Optional[D3BJParams] = None,
    method: str = "rhf",
    cisd_options=None,
    cc3_options=None,
    ccsdt_options=None,
    selected_ci_options=None,
    dmrg_options=None,
    v2rdm_options=None,
    transcorrelated_options=None,
    casci_options=None,
    caspt2_options=None,
    nevpt2_options=None,
    casscf_options=None,
    active_space=None,
    cas_reference=None,
    rhf_options: Optional[RHFOptions] = None,
    uhf_options: Optional[UHFOptions] = None,
    rks_options: Optional[RKSOptions] = None,
    uks_options: Optional[UKSOptions] = None,
    rohf_options: Optional[ROHFOptions] = None,
    mlip_options: Optional[MLIPOptions] = None,
) -> Molecule:
    """Run an ASE/BFGS geometry optimization, writing frames to a .traj file.

    Returns a new Molecule at the optimized geometry. The SCF options
    apply to every intermediate geometry, not just the final point -- use
    them to bump ``max_iter`` / add damping for systems where BFGS
    sometimes lands on a hard-to-converge geometry (H-bonded clusters,
    near-degenerate states).

    For wavefunction methods (``cisd``, ``selected_ci``, ``dmrg``, ``v2rdm``,
    ``transcorrelated_ci``, ``casci``, ``casscf``), forces are computed
    via central finite differences because the solvers do not yet provide
    analytic gradients. The per-step calculator receives the same solver
    options (``casscf_options``, ``casci_options``, ``cas_reference``, ...)
    as the final single point, so the optimizer walks the same surface
    the reported final energy is evaluated on.
    """
    # ASE < 3.23 line-search optimizers import scipy names removed in
    # SciPy >= 1.14; restore the aliases before touching ase.optimize.
    from .ase_optimizers import ensure_ase_scipy_compat

    ensure_ase_scipy_compat()
    try:
        from ase import Atoms
        from ase.io.trajectory import Trajectory
        from ase.optimize import BFGSLineSearch
        from ase.units import Bohr
    except ImportError as exc:
        raise ImportError(
            "Geometry optimization requires ASE. Install it with "
            "`pip install ase` into your vibe-qc venv."
        ) from exc

    from .ase import VibeQC

    positions_ang = [[coord * Bohr for coord in atom.xyz] for atom in molecule.atoms]
    atoms = Atoms(
        numbers=[atom.Z for atom in molecule.atoms],
        positions=positions_ang,
    )

    # casci / casscf joined 2026-06-12: they previously fell through to the
    # mean-field VibeQC calculator below, so the optimizer silently walked
    # the RHF/RKS surface while the final single point ran the CAS solver.
    # nevpt2 / caspt2 / mrci / fci still fall through (routing them onto an
    # FD-on-PT2 surface is a cost/policy call for the maintainer).
    wavefunction_methods = {
        "cisd",
        "cc3",
        "ccsdt",
        "selected_ci",
        "dmrg",
        "v2rdm",
        "transcorrelated_ci",
        "casci",
        "casscf",
    }
    if method in MOLECULAR_SEMIEMPIRICAL_METHODS:
        atoms.calc = _make_semiempirical_ase_calculator(
            molecule,
            method,
        )
    elif method in _MLIP_METHODS:
        # MACE geometry optimization: ASE BFGS drives the MACE ASE
        # calculator directly (it works in eV/Angstrom natively, the units
        # ASE optimizers expect). run_job's early ASL gate already
        # validated the model; MACEModel re-checks (defense in depth).
        from vibeqc.mlip.mace import MACEModel

        atoms.calc = MACEModel(molecule, mlip_options).calculator
    elif method == "rohf":
        # ROHF has an analytic gradient (compute_rohf_gradient), so route it
        # through the analytic VibeQC calculator (restricted_open=True) for
        # full-speed optimisation -- not the finite-difference path.
        atoms.calc = VibeQC(
            basis=basis_name,
            charge=molecule.charge,
            multiplicity=molecule.multiplicity,
            restricted_open=True,
            dispersion=dispersion_params,
            rohf_options=rohf_options,
        )
    elif method in wavefunction_methods or method == "roks":
        # ROKS + the gradient-less wavefunction solvers use the
        # finite-difference force path: ROKS's analytic XC-gradient term is
        # a later milestone (handovers/HANDOVER_ROHF.md M5b), so geometry optimisation
        # differentiates the (verified) energy numerically. functional is
        # threaded for ROKS.
        atoms.calc = _make_wavefunction_ase_calculator(
            molecule,
            basis_name,
            method,
            functional=functional,
            cisd_options=cisd_options,
            cc3_options=cc3_options,
            ccsdt_options=ccsdt_options,
            selected_ci_options=selected_ci_options,
            dmrg_options=dmrg_options,
            v2rdm_options=v2rdm_options,
            transcorrelated_options=transcorrelated_options,
            casci_options=casci_options,
            caspt2_options=caspt2_options,
            nevpt2_options=nevpt2_options,
            casscf_options=casscf_options,
            active_space=active_space,
            cas_reference=cas_reference,
        )
    else:
        atoms.calc = VibeQC(
            basis=basis_name,
            charge=molecule.charge,
            multiplicity=molecule.multiplicity,
            functional=functional,
            dispersion=dispersion_params,
            rhf_options=rhf_options,
            uhf_options=uhf_options,
            rks_options=rks_options,
            uks_options=uks_options,
        )

    with Trajectory(str(trajectory_path), "w", atoms) as traj:
        # BFGSLineSearch refuses uphill steps by construction -- essential
        # on flat / weakly-bound PESs (H-bonded clusters, dispersion-dominated
        # complexes) where plain BFGS's Hessian extrapolation routinely
        # pushes atoms apart. Works on covalent minima too, so we use it
        # uniformly. Default ASE line-search parameters are well-tuned;
        # don't clamp maxstep here or the line search can't converge.
        opt = BFGSLineSearch(atoms, logfile=None)
        opt.attach(traj)
        opt.run(fmax=fmax, steps=max_steps)

    # Re-build a vibe-qc Molecule at the optimized geometry.
    from ._vibeqc_core import Atom as _Atom

    new_positions_bohr = atoms.positions / Bohr
    optimized = Molecule(
        [_Atom(int(z), list(xyz)) for z, xyz in zip(atoms.numbers, new_positions_bohr)],
        molecule.charge,
        molecule.multiplicity,
    )
    return optimized


def _resolve_optimizer_backend(requested: str) -> str:
    """Resolve ``optimizer_backend`` to ``"ase"``, ``"native"``, or ``"brent"``."""
    if requested == "ase":
        try:
            import ase  # noqa: F401
        except ImportError:
            raise ImportError(
                "optimizer_backend='ase' requires ASE. Install with "
                "`pip install ase` or use optimizer_backend='native' / 'brent'."
            ) from None
        return "ase"
    if requested == "native":
        return "native"
    if requested == "brent":
        return "brent"
    if requested != "auto":
        raise ValueError(
            f"optimizer_backend={requested!r} -- expected 'auto', 'ase', "
            f"'native', or 'brent'."
        )
    # "auto": prefer ASE if installed, otherwise native.
    try:
        import ase  # noqa: F401
    except ImportError:
        return "native"
    return "ase"


[docs] def run_job( molecule: Molecule, *, basis: Optional[str] = None, method: Method = "auto", functional: Optional[str] = None, initial_guess: Optional[object] = None, citype: Any = None, triples: Any = None, output: str | os.PathLike = "output", name_molecule: bool = True, optimize: bool = False, write_molden_file: bool = True, write_xyz_file: bool = True, write_population_file: bool = True, write_cube: Union[bool, str, int, list, tuple, None] = False, cube_spacing: float = 0.2, cube_padding: float = 4.0, citations: bool = True, dry_run: bool = False, fmax: float = 0.05, max_opt_steps: int = 200, optimizer_backend: str = "auto", # Uniform geomopt keywords (v0.14+). When geom_opt is set, # the new geomopt framework is used directly. geom_opt: Optional[str] = None, geom_coords: str = "cartesian", geom_target: str = "minimum", geom_hessian_init: str = "diagonal", geom_hessian_update: str = "none", geom_line_search: str = "backtracking", geom_conv_gmax: Optional[float] = None, geom_freeze: Optional[List[int]] = None, geom_opt_options: Optional[dict[str, Any]] = None, geom_restart: Optional[str] = None, geom_checkpoint: Optional[str] = None, memory_override: bool = False, num_threads: Optional[int] = None, dispersion: DispersionSpec = None, solvent: object = None, record_hostname: bool = True, rhf_options: Optional[RHFOptions] = None, uhf_options: Optional[UHFOptions] = None, rks_options: Optional[RKSOptions] = None, uks_options: Optional[UKSOptions] = None, rohf_options: Optional[ROHFOptions] = None, roks_options: Optional[ROKSOptions] = None, cisd_options: Optional[CISDOptions] = None, cc3_options: Optional[CC3Options] = None, ccsdt_options: Optional[CCSDTOptions] = None, selected_ci_options: Optional[SelectedCIOptions] = None, dmrg_options: Optional[DMRGOptions] = None, v2rdm_options: Optional[V2RDMOptions] = None, transcorrelated_options: Optional[TranscorrelatedOptions] = None, casci_options: Optional[CASCIOptions] = None, caspt2_options: Optional[CASPT2Options] = None, nevpt2_options: Optional[NEVPT2Options] = None, casscf_options: Optional[CASSCFOptions] = None, ccsd_options: Optional["CCSDOptions"] = None, ccsd_reference: str = "uhf", mp2_reference: str = "uhf", mp2_options: Optional[MP2Options] = None, ump2_options: Optional[UMP2Options] = None, dlpno_options: Optional["DLPNOMP2Options"] = None, dlpno_ccsd_options: Optional["DLPNOCCSDPilotOptions"] = None, active_space: Optional[tuple[int, int]] = None, cas_reference: Optional[str] = None, mlip_options: Optional[MLIPOptions] = None, ccm_options: Optional["CCMOptions"] = None, read_from: Optional[object] = None, fragments: Optional[object] = None, progress: Union[bool, ProgressLogger, None] = None, verbose: Optional[int] = None, use_logging: bool = False, perf_log: Optional[Union[str, os.PathLike, bool]] = None, structured_log: Union[bool, str, os.PathLike, None] = False, crash_dump: Union[bool, str, os.PathLike, None] = True, # QVF visualisation archive (v1). output_qvf: bool = True, # Opt-in live QVF checkpointing for vibe-view hot-reload. When # ``checkpoint_qvf`` is set, a running snapshot is atomically written # there (start frame + terminal frame), labeled # ``provenance.run_status`` = ``"running"`` -> ``"converged"`` / # ``"failed"`` with a monotonic ``provenance.checkpoint.seq``. The # molecular SCF runs in compiled C++ with no per-iteration Python # hook, so a single-point job emits the start + terminal frames only; # ``checkpoint_every`` is honoured on iteration-exposing paths. checkpoint_qvf: Optional[Union[str, os.PathLike]] = None, checkpoint_every: int = 0, qtaim: bool = False, # TD-DFT excited states + Natural Transition Orbitals (opt-in, QVF-bound). tddft: bool = False, tddft_n_states: int = 5, tddft_type: Literal["tda", "casida"] = "tda", tddft_gradient: bool = False, tddft_spectrum: bool = False, tddft_molden: bool = False, nto: bool = False, hessian: bool = False, # Partial Hessian: atom indices to hold fixed so only the unfrozen # atoms are displaced (6M instead of 6N SCFs); the frequencies are # then vibrational-only (no gas-phase trans/rot). See HessianFDOptions. hessian_frozen_indices: Optional[List[int]] = None, # Thermochemistry (RRHO ideal-gas). When a Hessian is computed # (``hessian=True``), the harmonic frequencies feed an end-of-run # thermochemistry block (ZPE + thermal U/H/S/G at T, p). Pass a # ThermoOptions to set temperature / pressure / rotational symmetry # number; None uses the defaults (298.15 K, 1 atm, s=1). thermo_options: Optional["ThermoOptions"] = None, # Atomization energy (S E_atom - E_mol) for mean-field methods. When True, # free-atom ground-state references are computed at the same level (cached # per element) and an atomization block is written. Main-group H-Kr; see # vibeqc.atomization. atomization: bool = False, # DFT+U (Dudarev rotationally-invariant) -- Increment 2c. # List of HubbardSite objects; empty / None == no +U. dft_plus_u: Optional[List["HubbardSite"]] = None, # MSINDO NDDO mode (method="msindo"): the reference program's default mode # (separate parametrization + two-centre multipole 2e Fock). Ignored for # other methods. nddo: bool = False, # Eigensolver selection for Fock diagonalisation. # "dense" (default) -- full dense diagonalisation via LAPACK. # "davidson" -- block-Davidson iterative solver (C++ backend). # "lobpcg" -- LOBPCG iterative solver (C++ backend). For molecular SCF, # currently falls back to the Davidson path because the C++ molecular # SCF kernel only exposes a use_davidson switch; the Python-level # LOBPCG stack is available to periodic drivers and ROHF. solver: str = "dense", # Periodic-only trap parameters. Molecules have no lattice or Brillouin # zone; requesting a k-point feature here is a common mix-up with # run_periodic_job, so these raise a targeted error instead of Python's # bare "unexpected keyword argument". Always None on the molecular path. kpoints: object = None, dos_kmesh: object = None, jk_method: object = None, bz_integration: object = None, cutoff_ha: object = None, ) -> object: """Run a vibe-qc SCF job and write the standard output files. Parameters ---------- molecule The :class:`Molecule` describing the system (bohr coordinates). basis libint-recognized basis-set name. method ``"rhf"``, ``"uhf"``, ``"rks"``, ``"uks"``, ``"auto"``, ``"ccsd"``, ``"ccsd(t)"``, ``"cc3"``, ``"ccsdt"``, ``"cc2"``, ``"bccd"``, ``"bccd(t)"``, ``"qcisd"``, or ``"qcisd(t)"``. CC2 and the coupled-pair/QCI variants require a closed-shell reference; CCSD picks restricted vs unrestricted from ``molecule.multiplicity``. CC3 iterates singles and doubles with approximate triples; CCSDT is the dense full iterative singles/doubles/triples solver. CC3 requires a closed-shell RHF reference. functional XC functional for RKS / UKS (e.g. ``"PBE"``, ``"B3LYP"``). Ignored for HF. initial_guess High-level SCF initial-guess selector for molecular RHF/UHF/RKS/UKS and their post-SCF reference SCFs. Accepts :class:`vibeqc.InitialGuess` or spellings such as ``"sad"``, ``"hcore"``, ``"huckel"``, ``"minao"``, ``"read"``, and ``"fragmo"``. citype AutoCI-style selector for single-reference CI/CC methods. Supported now: ``"cisd"``, ``"ccsd"``, ``"ccsd(t)"``, ``"cc3"``, ``"ccsdt"``, ``"bccd"``, ``"bccd(t)"``, ``"cc2"``, ``"ccd"``, ``"lccd"``, ``"lccsd"``, ``"cepa(0)"``..``"cepa(3)"``, ``"qcisd"``, and ``"qcisd(t)"`` (the Brueckner/coupled-pair/QCI variants require a closed-shell RHF reference). CC3 also requires a closed-shell RHF reference. cisd_options Optional :class:`vibeqc.CISDOptions` controlling the high-level CISD route: ``max_excitation`` (2 for CISD, 1 for CIS), ``nroots``, and ``max_det``. cc3_options Optional :class:`vibeqc.CC3Options` controlling CC3 convergence thresholds, DIIS history, iteration limit, and frozen-core count. ``n_frozen_core=None`` uses the chemical-core default in :func:`run_job`. ccsdt_options Optional :class:`vibeqc.CCSDTOptions` controlling the full iterative CCSDT convergence thresholds, DIIS history, iteration limit, and frozen-core count. ``n_frozen_core=None`` uses the chemical-core default in :func:`run_job`. triples CCSD perturbative-triples selector. Use ``"none"`` for plain CCSD, ``"(t)"`` for the standard Raghavachari correction, ``"[t]"`` (equivalently ``"+T(CCSD)"``) for the fourth-order bracket correction CCSD[T], or ``"A-CCSD(T)"`` for the closed-shell asymmetric/Lambda triples correction. ccsd_options Optional :class:`vibeqc.CCSDOptions` controlling canonical CCSD iteration thresholds, density-fitting settings, frozen-core count, and triples memory mode. In ``run_job`` the effective triples calculation is selected by ``method`` / ``triples`` so output labels and citations stay aligned. mp2_options, ump2_options Optional :class:`vibeqc.MP2Options` / :class:`vibeqc.UMP2Options` for the native post-SCF MP2 step. ``mp2_options`` applies to closed-shell RMP2, ``ump2_options`` to open-shell UMP2. Set ``density_fit=True`` to run RI-MP2/RI-UMP2 through ``run_job``; if ``aux_basis`` is empty, the per-zeta RI auxiliary basis is auto-detected when available. output Path stem for the generated files. name_molecule If ``True`` (default), print the IUPAC name of *molecule* to the ``.out`` header and live progress log. ``{output}.out`` always; also ``{output}.molden`` unless disabled; and ``{output}.traj`` when ``optimize=True``. optimize Run a BFGS geometry optimization first (via ASE), then the final SCF on the optimized geometry. The trajectory is written for animation (openable with ASE-aware viewers). write_molden_file Emit ``{output}.molden`` at the converged geometry. Default True. fmax, max_opt_steps Optimizer tolerance (eV/Å) and iteration limit. Ignored unless ``optimize=True``. memory_override If ``False`` (default), the driver estimates peak memory and aborts with :class:`InsufficientMemoryError` when the estimate exceeds the machine's available RAM. Set to ``True`` to proceed anyway -- at the risk of swap-thrashing or a system freeze. num_threads If set, pin the OpenMP thread count for the duration of the calculation. ``None`` (default) leaves the current setting in place -- which is usually "all cores" unless the environment variable ``OMP_NUM_THREADS`` is set or :func:`vibeqc.set_num_threads` was called earlier. The actual thread count used is recorded in the output log for reproducibility. dispersion Post-SCF D3(BJ) dispersion correction. Accepts: * ``None`` (default) -- no dispersion. * ``True`` or ``"d3bj"`` -- use D3-BJ params for the current DFT functional. * A functional name (``"pbe"``, ``"b3lyp"``, ...) -- use its D3-BJ params (useful for ``method="rhf"`` + ``"hf"`` dispersion, or for overriding the SCF functional in the damping lookup). * A :class:`D3BJParams` instance -- used directly. The energy correction is written to the ``.out`` file, added to the returned object as ``e_dispersion`` / ``energy_total`` (the raw SCF ``.energy`` is preserved untouched), and, when ``optimize=True``, added to the forces the optimizer sees. Routes through :func:`vibeqc.compute_d3bj` with ``backend="auto"`` -- the reference ``dftd3`` backend is used when installed, otherwise the D1a framework stub. See :mod:`vibeqc.dispersion` for details. solvent v0.9.0 CPCM / COSMO implicit solvation. Accepts: * ``None`` (default) -- gas-phase SCF. * A preset name (``"water"``, ``"dmso"``, ``"acetonitrile"``, ``"chloroform"``, ``"benzene"``, ...) -- looks up the static dielectric e from :data:`vibeqc.SOLVENT_PRESETS`. * A numeric e (e.g. ``78.39``) -- custom dielectric. * A dict (``{"epsilon": 25.0, "variant": "cosmo", ...}``) or a :class:`vibeqc.SolventModel` instance for full control over cavity construction (Bondi radii, Lebedev order, switching width, max macro-iterations). Routes through :func:`vibeqc.run_cpcm_scf`; macro-iterates the apparent surface charge against the SCF density until ΔE_solv < 1e-6 Ha (typically 3-5 outer cycles). The total energy is the in-solvent value; the gas-phase reference is retained on ``result.solvent_result.e_gas``. See :doc:`/user_guide/solvation` for the full theory and the cavity / preset table. record_hostname If ``False``, the per-job ``{output}.system`` manifest writes ``hostname = "<redacted>"`` instead of the live hostname. The ``VIBEQC_NO_HOSTNAME=1`` environment variable does the same thing globally. Use it for public examples, paper artifacts, and shared reproductions so machine names do not leak. Other manifest fields (CPU model, OS, memory, library versions) are not redacted; the redaction is scoped to the hostname only. rhf_options / uhf_options / rks_options / uks_options Optional override for the respective SCF options struct. read_from Prior result object, .qvf path, or .molden path for ``initial_guess="read"``; rejected unless the resolved guess is READ. fragments Fragment partition for ``initial_guess="fragmo"``; same format as the direct SCF wrappers accept. casci_options CASCI knobs for ``method="casci"`` (the active space itself comes from ``active_space=``). ``CASCIOptions(nroots=N)`` requests N CI roots: ``result.energy`` stays the ground root, per-root energies land in ``result.root_energies`` (per-root <S^2> in ``result.root_s2``) and the .out solver block prints the root table. Ignored by every other method -- the SA-CASSCF root count is ``casscf_options.nroots``, the MS-CASPT2 model-space size ``caspt2_options.nroots``. progress Live progress logger for long-running jobs. Default behavior is **ON** -- the job emits a banner, per-stage milestones, and a final summary to stdout (line-flushed) so the canonical ``nohup python LiH.py > LiH.log 2>&1 &`` + ``tail -f LiH.log`` workflow shows progress in real time. The ``.out`` file is also line-buffered so ``tail -f output-LiH.out`` works without any extra setup. Pass ``progress=False`` to silence stdout (the ``.out`` file is still written normally -- only the live mirror is suppressed). Pass a :class:`vibeqc.ProgressLogger` instance for fully custom routing (tee to a persistent file, mute, thread one logger through nested calls). Set ``VIBEQC_LIVE_LOGGING=0`` in the environment to disable live progress globally -- useful for batch scripts that don't want to edit every input file. The env var only takes effect when ``progress`` is left at its default (``None``); explicit ``progress=True`` / ``progress=False`` / a ``ProgressLogger`` instance always wins, so a debugging session can re-enable progress for one shell. Per-iteration progress for periodic SCFs (which run in Python) is streamed live through this same logger via the lower-level ``run_*_periodic_*`` entry points; molecular SCFs run in C++ and only emit a pre-SCF banner + post-SCF summary live, with the per-iteration trace landing in the ``.out`` when the SCF returns. verbose Integer verbosity level (PySCF convention, 0..9, default 4). Each level is a strict superset of the one below, so bumping ``verbose`` only adds output: * ``0`` -- silent (nothing live; ``.out`` is still written) * ``1`` -- banner + warnings + final SCF summary only * ``2`` -- add per-stage milestones + ``info()`` lines * ``3`` -- add per-stage timing on stage exit * ``4`` -- add per-iteration SCF rows (DEFAULT) * ``5`` -- add inline RSS-memory snapshots * ``6+`` -- phase-level wall-clock breakdown live (overlaps the post-mortem ``.perf`` log on purpose) Pass ``verbose=None`` (the default) to read the ``VIBEQC_VERBOSE`` env var; if unset, falls back to 4. Ignored when ``progress`` is a :class:`ProgressLogger` instance -- that logger's own level wins. use_logging If ``True``, route progress through ``logging.getLogger("vibeqc.run_job")`` instead of bare ``stdout`` writes. Banner / stage milestones land at ``INFO``; per-iteration SCF rows at ``DEBUG``; warnings at ``WARNING``. Composes naturally with stdlib handlers (``RotatingFileHandler``, syslog, ``dictConfig``):: import logging logging.basicConfig(level=logging.INFO) vq.run_job(..., use_logging=True) ``progress=False`` still wins as a hard kill switch -- the verbose-level gate runs *before* the logging call, so a silent run stays silent regardless of the active logging config. Ignored when ``progress`` is a pre-built :class:`ProgressLogger` instance. perf_log Optional path (or ``True`` to use ``{output}.perf``) to write a post-mortem performance / debug breakdown -- phase-level wall + CPU times, memory snapshots, parallelism flags. The live ``progress=`` log shows progress *during* the run; the perf log shows where the time went *afterwards*. Off by default. Pass an explicit path, ``True`` to emit alongside ``{output}.out``, or set the ``VIBEQC_PERFLOG=path`` env var (which wins when ``perf_log`` is left at ``None``). See :mod:`vibeqc.perf` for the full instrumentation surface. structured_log Optional NDJSON (one-JSON-record-per-line) log capturing every SCF transition -- banner, job_start, memory_estimate, per-iter rows, scf_converged, properties, job_end. Off by default. Pass ``True`` to emit ``{output}.scf.jsonl``, a path-like to write there explicitly, or set the ``VIBEQC_STRUCTURED_LOG=path`` env var (which wins when ``structured_log`` is left at ``False``). The format is stable: events are append-only, fields are never renamed or removed. See :mod:`vibeqc.structured_log` for the full event catalog. crash_dump Write a snapshot to ``{output}.dump`` (TOML) plus binary attachments (``.dump.density.npy``, ``.dump.fock.npy``, ``.dump.mo.npy``) when the SCF fails ungracefully -- raised exception (NaN, linear dependence, memory error). Default ``True``: post-mortem reproducibility costs nothing on success and saves a re-run on failure. Pass ``False`` (or set ``VIBEQC_NO_CRASH_DUMP=1`` in the environment) to disable. The dump is written alongside ``{output}.out`` in the standard ``output.*`` family -- re-attach the ``.dump`` + ``.dump.density.npy`` to a bug report and the maintainer can reconstruct the failing state via :func:`vibeqc.load_dump`. See :mod:`vibeqc.crash_dump` for the dump format. The exception is always re-raised after the dump is written; ``crash_dump=True`` does *not* swallow failures. tddft When True, compute TD-DFT vertical excitation energies via the Tamm-Dancoff approximation (TDA, default) or the full Casida linear-response formalism (tddft_type="casida"). Requires a converged SCF that produces MO coefficients (RHF/RKS/UHF/UKS). tddft_n_states Number of excited states to compute when ``tddft=True``. Default 5. tddft_type ``"tda"`` (Tamm-Dancoff, default) or ``"casida"`` (full Casida). Casida includes the B matrix and is the complete linear-response TD-DFT; TDA is faster, Hermitian, and usually within 0.1-0.3 eV of Casida for valence states. tddft_gradient When True, compute finite-difference excited-state nuclear gradients for the lowest TD-DFT state. Cost: ~6N additional SCF evaluations per state. Writes the gradient to the .out file. Opt-in; not computed by default. tddft_n_states Number of excited states to compute when ``tddft=True``. Default 5. nto When True (and ``tddft=True``), compute Natural Transition Orbitals for each TD-DFT excited state and embed paired hole/particle NTOs into the QVF archive as ``wavefunction.gto`` sections with ``orbital_kind="natural"``. Requires ``output_qvf=True``. hessian When True, compute harmonic vibrational frequencies via finite-difference Hessian. Default False. Cost: ~6N SCF evals. Results printed to .out and embedded in QVF for vibe-view. Returns ------- The SCF result object (RHFResult / UHFResult / RKSResult / UKSResult). """ enforce_runtime_pin_from_env() # Fail fast on the wrong-runner call: a PeriodicSystem (crystal) handed # to the molecular driver. run_job computes isolated molecules; a # crystal needs the periodic driver. Detect by the lattice attribute a # PeriodicSystem carries and a Molecule does not (duck-typed so a bare # import cycle is avoided). if not isinstance(molecule, Molecule) and hasattr(molecule, "lattice"): raise TypeError( "run_job was given a periodic system (it has lattice vectors), " "but run_job computes isolated molecules. For a crystal use " "vibeqc.run_periodic_job(system, basis, ...) instead." ) if not isinstance(molecule, Molecule): raise TypeError( "run_job: 'molecule' must be a vibeqc.Molecule; got " f"{type(molecule).__name__}." ) # Periodic-only features requested on the molecular runner: fail with a # pointer instead of a bare TypeError. A molecule has no lattice, so # there is no Brillouin zone to sample -- k-points, band structures, # DOS meshes, PW cutoffs, and periodic J/K builders are all # run_periodic_job territory. _pbc_only = { "kpoints": kpoints, "dos_kmesh": dos_kmesh, "jk_method": jk_method, "bz_integration": bz_integration, "cutoff_ha": cutoff_ha, } _pbc_requested = [k for k, v in _pbc_only.items() if v is not None] if _pbc_requested: raise ValueError( f"run_job: {', '.join(_pbc_requested)} requested, but these are " f"periodic-boundary-condition (k-point) features and this is the " f"molecular runner -- a molecule has no lattice or Brillouin " f"zone, so band structures / DOS / k-meshes are not defined for " f"it. Build a PeriodicSystem and use vibeqc.run_periodic_job(" f"...) for crystals, slabs, and wires." ) output_stem = Path(os.fspath(output)) out_path = output_stem.with_suffix(".out") molden_path = output_stem.with_suffix(".molden") traj_path = output_stem.with_suffix(".traj") # Resolve perf_log target. ``True`` -> emit to {output}.perf; # str/Path -> use that path verbatim; None -> defer to # VIBEQC_PERFLOG env var inside perf_log() (or no-op if unset). if perf_log is True: _perf_target: Optional[Path] = output_stem.with_suffix(".perf") elif perf_log is False or perf_log is None: _perf_target = None else: _perf_target = Path(os.fspath(perf_log)) # Resolve structured_log target. Default OFF (False) -- only emit # when the caller opts in. Resolution mirrors perf_log: # True -> {output}.scf.jsonl # str/PathLike -> use verbatim # False / None -> defer to VIBEQC_STRUCTURED_LOG env var (or # leave disabled if the env var is unset) if structured_log is True: _structured_target: Optional[Path] = output_stem.with_suffix(".scf.jsonl") elif structured_log is False or structured_log is None: _structured_target = None else: _structured_target = Path(os.fspath(structured_log)) # Resolve crash_dump target. Default ON: dump on failure unless the # caller explicitly opts out (or VIBEQC_NO_CRASH_DUMP=1). The # post-mortem snapshot is cheap on success (zero bytes written), # and saves a re-run on failure. Resolution: # True -> {output}.dump (default) # str/PathLike -> use verbatim as the stem # False -> disabled # None -> also default-on (treated as True) _crash_off_env = os.environ.get( "VIBEQC_NO_CRASH_DUMP", "", ).strip().lower() in ("1", "true", "yes", "on") if crash_dump is False or _crash_off_env: _crash_stem: Optional[Path] = None elif crash_dump is True or crash_dump is None: _crash_stem = output_stem else: _crash_stem = Path(os.fspath(crash_dump)) # Default-ON progress: when the caller didn't pass a value, # check the env-var opt-out (VIBEQC_LIVE_LOGGING=0) and otherwise # turn it on. This lets every backgrounded ``python input.py > # log 2>&1 &`` show progress without the user having to learn # a new kwarg -- answering the "is my job stuck or actually # running?" question by default. Explicit progress= (True/False/ # logger) bypasses the env var entirely. if progress is None: env = os.environ.get("VIBEQC_LIVE_LOGGING", "").strip().lower() if env in ("0", "false", "no", "off"): progress = False else: progress = True # Verbose level resolution (v0.5.3). When the caller leaves # ``verbose=None`` we read VIBEQC_VERBOSE; otherwise the # explicit argument wins. Falls back to the package default # (level 4 -- banner + stages + per-iter SCF) when neither # is set. The env var is a parsing best-effort: a junk value # silently falls back to the default rather than raising, # since a typo in $VIBEQC_VERBOSE shouldn't kill someone's # overnight run. if verbose is None: env_verbose = os.environ.get("VIBEQC_VERBOSE", "").strip() if env_verbose: try: verbose_level = int(env_verbose) except ValueError: verbose_level = None # type: ignore[assignment] else: verbose_level = max(0, verbose_level) else: verbose_level = None # type: ignore[assignment] else: verbose_level = max(0, int(verbose)) plog = resolve_progress( progress, verbose=verbose_level, use_logging=use_logging, ) # Composite 3c keyword resolution (e.g. ``method="hf-3c"``). For # non-composite ``method`` values this is a pass-through. Composite # values get their (basis, functional, dispersion) tuple inferred # from the :mod:`vibeqc.composites` registry; raises # :class:`CompositeUnavailable` for recipes whose prerequisites # have not yet landed. method = _normalise_method_alias(method) method, basis, functional, dispersion, composite_recipe = _apply_composite( method, basis=basis, functional=functional, dispersion=dispersion, molecule=molecule, ) if composite_recipe is not None and citype is not None: raise ValueError( "run_job: citype= cannot be combined with composite method " f"{composite_recipe.name!r}; choose an explicit basis/method pair." ) method = _normalise_method_alias(_apply_citype_selector(method, citype)) if nddo and method != "msindo": raise ValueError( f"run_job: nddo=True is only valid with method='msindo'; got " f"method={method!r}." ) if ccm_options is not None and method != "ccm": raise ValueError( f"run_job: ccm_options= is only valid with method='seccm' " f"(legacy 'ccm'); got " f"method={method!r}." ) # Semiempirical methods don't use a Gaussian basis set -- accept # any basis value (including None / empty). if method in SEMIEMPIRICAL_METHODS or method in _MLIP_METHODS: if not basis: basis = "" # placeholder; semiempirical / MLIP code ignores it elif not basis: raise ValueError( "run_job: basis is required. Pass basis='def2-svp' (or " "another supported basis), or use a composite keyword " "(method='hf-3c', ...) which carries its own basis. " "Composite catalogue: " f"{[r.name for r in __import__('vibeqc').list_composites()]}" ) resolved_method = _select_method( method, molecule, functional, ccsd_reference, mp2_reference ) # Fail-fast solvent gate (the dispatcher gate in _run_single_point is # the backstop). Two cases only run_job can catch cleanly: # * post-SCF methods (mp2 / ccsd / dlpno / ovgf / coupled-pair # families) resolve to a mean-field reference, so the dispatcher # would run a CPCM reference SCF -- but the correlation step then # builds its reported total from the bare reference energy, not the # in-solvent total, and the output claims an "in-solvent" result it # does not compute. Refuse until a validated post-SCF-in-solvent # composition exists. # * unsupported methods combined with optimize=True would burn the # whole gas-phase optimization before the final single point raises. # rohf/roks pass through to their dedicated NotImplementedError gates # (handovers/HANDOVER_ROHF.md). if solvent is not None: _solvent_capable = ("rhf", "uhf", "rks", "uks", "msindo") if resolved_method in _solvent_capable and method not in ( *_solvent_capable, "auto", ): raise ValueError( f"Implicit solvation is not supported for method={method!r}: " "CPCM composes with the mean-field SCF reference only; the " f"post-SCF {method} treatment in solvent is not implemented. " "Run the calculation in gas phase, or use rhf/uhf/rks/uks " "(CPCM) or msindo (COSMO) with solvent." ) if resolved_method not in (*_solvent_capable, "rohf", "roks"): _auto_hint = ( f" (method='auto' selected {resolved_method!r} for this system;" " pass a mean-field method explicitly)" if method == "auto" else "" ) raise ValueError( "Implicit solvation is not supported for " f"method={resolved_method!r}{_auto_hint}: CPCM " "(run_cpcm_scf) composes with rhf, uhf, rks, and uks only, " "and COSMO with method='msindo'. Run the calculation in " "gas phase, or use a solvent-capable method with solvent." ) _effective_method = method _ccsd_compute_triples: Optional[bool] = None if triples is not None and method not in ( "ccsd", "ccsd(t)", "bccd", "bccd(t)", "qcisd", "qcisd(t)", ): raise ValueError( "run_job: triples= is only valid with method='ccsd', " "method='ccsd(t)', 'bccd', 'bccd(t)', 'qcisd', or " "'qcisd(t)'. The other approximate/coupled-pair variants " "(cc2, ccd, lccd, " "lccsd, cepa(n)) do not define a (T) correction." ) if ccsd_options is not None and method not in ( "ccsd", "ccsd(t)", "bccd", "bccd(t)", "qcisd", "qcisd(t)", *_CC_VARIANT_METHODS, ): raise ValueError( "run_job: ccsd_options= is only valid with method='ccsd', " "'ccsd(t)', 'bccd', 'bccd(t)', 'qcisd', 'qcisd(t)', or a " "approximate coupled-cluster/coupled-pair/QCI variant " "(cc2, ccd, lccd, lccsd, " "cepa(0)..cepa(3), qcisd)." ) if ccsdt_options is not None and method != "ccsdt": raise ValueError( "run_job: ccsdt_options= is only valid with method='ccsdt' " "or method='ci', citype='ccsdt'." ) if cc3_options is not None and method != "cc3": raise ValueError( "run_job: cc3_options= is only valid with method='cc3' " "or method='ci', citype='cc3'." ) if ( method == "cc3" and active_space is not None and cc3_options is not None and cc3_options.n_frozen_core not in (None, 0) ): raise ValueError( "run_job: use either active_space= or " "CC3Options(n_frozen_core=...), not both." ) if ( method == "ccsdt" and active_space is not None and ccsdt_options is not None and ccsdt_options.n_frozen_core not in (None, 0) ): raise ValueError( "run_job: use either active_space= or " "CCSDTOptions(n_frozen_core=...), not both." ) if (mp2_options is not None or ump2_options is not None) and method not in ( "mp2", "scs-mp2", "sos-mp2", ): raise ValueError( "run_job: mp2_options=/ump2_options= are only valid with " "method='mp2', 'scs-mp2', or 'sos-mp2'." ) _ccsd_triples_variant = "(t)" if method in ("ccsd", "ccsd(t)", "bccd", "bccd(t)", "qcisd", "qcisd(t)"): from .cc import resolve_triples_variant _tv = ( resolve_triples_variant(triples) if triples is not None else ( "(t)" if method in ("ccsd(t)", "bccd(t)", "qcisd(t)") else "none" ) ) _ccsd_compute_triples = _tv != "none" if _tv != "none": _ccsd_triples_variant = _tv if method.startswith("bccd") and _tv in ("[t]", "a-ccsd(t)"): raise NotImplementedError( "triples='[t]' / CCSD+T(CCSD) and triples='A-CCSD(T)' " "are implemented for CCSD only. Use triples='(t)' for " "the standard BCCD(T) " "correction." ) if method.startswith("qcisd") and _tv in ("[t]", "a-ccsd(t)"): raise NotImplementedError( "triples='[t]' / CCSD+T(CCSD) and triples='A-CCSD(T)' " "are implemented for CCSD only. Use triples='(t)' for " "the standard QCISD(T) " "correction." ) if _tv in ("[t]", "a-ccsd(t)") and molecule.multiplicity > 1: raise NotImplementedError( "triples='[t]' and triples='A-CCSD(T)' are implemented in " "the closed-shell kernel only; the open-shell (T) route is " "the standard Raghavachari correction (triples='(t)')." ) if method.startswith("qcisd"): _effective_method = { "none": "qcisd", "(t)": "qcisd(t)", }[_tv] elif method.startswith("bccd"): _effective_method = { "none": "bccd", "(t)": "bccd(t)", }[_tv] else: _effective_method = { "none": "ccsd", "(t)": "ccsd(t)", "[t]": "ccsd[t]", "a-ccsd(t)": "a-ccsd(t)", }[_tv] elif method in _CC_VARIANT_METHODS: _ccsd_compute_triples = False # method='dlpno-mp2' auto-routes an open-shell reference to the # UHF-based DLPNO-UMP2 path; the effective label drives the header, # output block and citation route (open-shell adds Saitow 2017). if method == "dlpno-mp2" and molecule.multiplicity > 1: _effective_method = "dlpno-ump2" uhf_options = _default_open_shell_dlpno_uhf_options( method, molecule, uhf_options, ) # method='dlpno-ccsd'/'dlpno-ccsd(t)' auto-route an open-shell reference # to the UHF-based DLPNO-UCCSD(T) pilot (spin-orbital); the effective # label drives the header, output block and citation route (open-shell # adds Saitow 2017). if method in ("dlpno-ccsd", "dlpno-ccsd(t)") and molecule.multiplicity > 1: _effective_method = ( "dlpno-uccsd(t)" if method == "dlpno-ccsd(t)" else "dlpno-uccsd" ) # method in {mp2, scs-mp2, sos-mp2} with mp2_reference='rohf' on an # open-shell system runs an ROHF SCF + semicanonical ROHF-MP2; the # effective label drives the header + the rohf-mp2 citation route # (Roothaan + Knowles RMP2 + RI), distinct from the UHF-reference UMP2. _use_rohf_mp2 = ( method in ("mp2", "scs-mp2", "sos-mp2") and molecule.multiplicity > 1 and mp2_reference == "rohf" ) if _use_rohf_mp2: _effective_method = "rohf-mp2" if mp2_options is not None or ump2_options is not None: raise ValueError( "run_job: mp2_options=/ump2_options= control the native " "RMP2/UMP2 kernels and are not used with mp2_reference='rohf'." ) elif method in ("mp2", "scs-mp2", "sos-mp2"): if molecule.multiplicity > 1 and mp2_options is not None: raise ValueError( "run_job: mp2_options= controls closed-shell RMP2; use " "ump2_options= for open-shell UMP2." ) if molecule.multiplicity == 1 and ump2_options is not None: raise ValueError( "run_job: ump2_options= controls open-shell UMP2; use " "mp2_options= for closed-shell RMP2." ) ( rhf_options, uhf_options, rks_options, uks_options, rohf_options, roks_options, ) = _materialize_run_job_initial_guess( resolved_method, initial_guess, rhf_options=rhf_options, uhf_options=uhf_options, rks_options=rks_options, uks_options=uks_options, rohf_options=rohf_options, roks_options=roks_options, read_from=read_from, fragments=fragments, ) uhf_options, uks_options, _auto_open_shell_trah = _auto_open_shell_optimizer_trah( resolved_method, molecule, optimize=bool(optimize), uhf_options=uhf_options, uks_options=uks_options, ) # Early SECCM gate (fail fast, clean "unavailable" -- not a silent gap): # the MSINDO cyclic-cluster route is single-point only through run_job. # gradients (finite-difference) + geometry optimisation live in # msindo_ccm.ccm_gradient_fd / ccm_optimize; analytic CCM gradients and the # run_job optimiser hook are not wired up. if resolved_method == "ccm" and optimize: raise NotImplementedError( "method='seccm' geometry optimisation is not available through " "run_job. Use vibeqc.semiempirical.methods.msindo_ccm.ccm_optimize " "(finite-difference, fixed Wigner-Seitz) for relaxed CCM geometries." ) # ROKS geometry optimisation runs on finite-difference forces (routed # to the wavefunction ASE calculator below). The ROKS Hessian instead # finite-differences the *analytic* gradient (compute_hessian_fd), # which needs the molecular XC-gradient term ROKS doesn't have yet, so # gate it. ROHF has an analytic gradient (compute_rohf_gradient), so # the ROHF Hessian / frequencies are supported. if resolved_method == "roks" and hessian: raise NotImplementedError( "method='roks': the Hessian finite-differences the analytic " "ROKS gradient (needs the molecular XC-gradient term), not yet " "implemented (single-point energies + FD geometry optimisation " "work; ROHF Hessians work). Track: handovers/HANDOVER_ROHF.md milestone M5b." ) # Early MLIP gate (fail fast, before any output / SCF machinery): an # academic-only (ASL) MACE model needs explicit acknowledgment, and the # model must cover every element in the system -- else a config error # would otherwise surface as a crashed SCF. Both checks run again inside # MACEModel (defense in depth). if resolved_method in _MLIP_METHODS: from vibeqc.mlip import MLIPOptions, resolve_model from vibeqc.mlip.mace import element_coverage_error, enforce_academic_license _mlopts = mlip_options or MLIPOptions() _minfo = resolve_model(_mlopts.model) enforce_academic_license(_minfo, _mlopts) _ecov = element_coverage_error(_minfo, molecule) if _ecov is not None: raise ValueError(_ecov) # Composite recipes may request D4 dispersion; the D4 path is # wired through compute_d4 in the post-SCF block below. A D4 # request short-circuits the D3-BJ resolution. use_d4 = isinstance(dispersion, str) and dispersion.strip().lower() == "d4" # Build the declarative OutputPlan once. It drives BOTH the # dry-run pre-flight (below) and the real run's manifest # lifecycle (the OutputWriter constructed before the SCF). One # source of truth for "what files will this job produce". _cube_req_plan = parse_write_cube_kwarg(write_cube) _output_plan = OutputPlan.from_run_job_kwargs( output=output_stem, method=resolved_method, basis=basis, functional=functional, optimize=optimize, write_molden_file=write_molden_file, write_xyz=write_xyz_file, citations=citations, write_population=write_population_file, write_cube_density=_cube_req_plan.density, cube_mo_labels=tuple(_cube_req_plan.mo_labels), perf_log=perf_log, structured_log=structured_log, crash_dump=crash_dump, output_qvf=output_qvf, ) # Dry-run short-circuit (Phase O3). When ``dry_run=True`` or the # ``VIBEQC_DRY_RUN`` env var is set, write a one-shot # ``{output}.system`` with ``[outputs].status = "dry_run"``, # print the declared-artefacts summary to stdout, and return # ``None`` without running the SCF. This is the pre-flight path # that ``vq submit`` uses to learn which files a job will # produce. Run before any heavyweight setup (basis-set # construction, memory estimate, perf tracker) so the # short-circuit is genuinely cheap. if dry_run or is_dry_run_requested(): # Make sure the parent directory exists so we don't fail on a # freshly-typed `-o /tmp/new-subdir/foo` path. output_stem.parent.mkdir(parents=True, exist_ok=True) # Opt-in peak-memory estimate for memory-aware `vq submit auto` # RAM-fit placement (VIBEQC_DRY_RUN_ESTIMATE). Default-off so the # cheap output-discovery dry-run stays cheap; only under the flag # do we build the basis + estimate. Mirrors the real-run estimate # below (search `estimate_memory(`). Mean-field methods keep their # lightweight default options; active-space methods must pass the CAS # partition so scheduler preflight sees the real post-SCF workspace. # Best-effort: any failure (unestimable method, basis build error, # ...) leaves estimate_bytes=None so the dry-run never breaks. _estimate_bytes: Optional[int] = None if is_dry_run_estimate_requested() and resolved_method not in _MLIP_METHODS: try: if resolved_method in SEMIEMPIRICAL_METHODS: _estimate_bytes = estimate_semiempirical_memory( molecule, method=resolved_method, nddo=nddo, solvent=solvent, optimize=optimize, max_steps=max_opt_steps, ccm_options=ccm_options, ).total_bytes else: _estimate_method = _memory_estimator_method( resolved_method, _effective_method, ) _estimate_bytes = estimate_memory( molecule, BasisSet(molecule, basis), method=_estimate_method, options=_memory_estimator_options( _estimate_method, resolved_method=resolved_method, rhf_options=rhf_options, uhf_options=uhf_options, rks_options=rks_options, uks_options=uks_options, rohf_options=rohf_options, roks_options=roks_options, mp2_options=mp2_options, ump2_options=ump2_options, ccsd_options=ccsd_options, cc3_options=cc3_options, ccsdt_options=ccsdt_options, dlpno_options=dlpno_options, dlpno_ccsd_options=dlpno_ccsd_options, caspt2_options=caspt2_options, nevpt2_options=nevpt2_options, active_space=active_space, tddft=tddft, tddft_n_states=tddft_n_states, tddft_type=tddft_type, functional=functional, ), ).total_bytes except Exception: _estimate_bytes = None dry_run_manifest( _output_plan, record_hostname=record_hostname, estimate_bytes=_estimate_bytes, ) return None # Resolve dispersion up front so a bad spec fails before we touch # the filesystem. d3_params is None iff dispersion is disabled. # A D4 request (composite recipes r^2SCAN-3c / wB97X-3c) short- # circuits the D3-BJ path -- D4 is applied in the post-SCF block. if use_d4: d3_params = None else: d3_params = _resolve_dispersion(dispersion, functional) # Pin the thread count if the caller asked for one; otherwise leave # the current state alone. We always query at the end so the output # reports what was actually used (which may differ from what the # user asked for -- e.g. on a single-core build or OpenMP-disabled # environment). if num_threads is not None: set_num_threads(int(num_threads)) threads_in_use = get_num_threads() # Ensure the parent directory exists so users can give names like # "runs/water" without pre-creating "runs/". out_path.parent.mkdir(parents=True, exist_ok=True) # Construct the OutputWriter -- the per-job coordinator that owns # ``{output}.system`` for the rest of the run. This writes the # manifest immediately with the ``[plan]`` section + ``[outputs] # .status = "running"`` *before* the SCF starts, so a ``vq`` # daemon polling the workspace sees the declared output set and a # liveness signal from the first moment. ``finish()`` / # ``crash()`` at the exit paths flip the status; the end-of-job # record-sweep fills ``[[outputs.files]]`` with the artefacts # that actually landed. Replaces the bare end-of-job # ``write_system_manifest`` call (pre-v1.0 dispatch-overhaul). _output_writer = OutputWriter( _output_plan, record_hostname=record_hostname, ) # Opt-in live QVF checkpointing (vibe-view hot-reload). Disabled unless # ``checkpoint_qvf`` is set; the molecular SCF is compiled C++ with no # per-iteration Python hook, so this emits a start frame + a terminal # ("converged" / "failed") frame -- both atomic, both status-labeled. from .output.checkpoint import QvfCheckpointer as _QvfCheckpointer _checkpointer = _QvfCheckpointer( checkpoint_qvf if output_qvf else None, checkpoint_every, plan=_output_plan, ) # Initial checkpoint frame: the input geometry, so a live viewer has a # structure to show from the first moment the job is submitted. if _checkpointer.enabled: _checkpointer.snapshot( molecule=molecule, method=resolved_method, basis=basis, functional=functional, ) t_job_start = time.perf_counter() # ``OutputChannel.to_file`` opens the .out line-buffered so a # `tail -f` shows new lines as the SCF / property pipeline emits # them. We also call ``flush()`` explicitly after major blocks for # the same reason -- line buffering only kicks in on newlines. # Installing it as the ambient channel lets any module downstream # emit into the .out via ``vibeqc.output.write`` without being # handed a file object. # # The perf-log context manager wraps the whole body so PerfScope # entries from anywhere downstream -- periodic SCF stages, gradient # calls, anything that calls ``with PerfScope("..."): ...`` -- # accumulate into the same tracker. When ``_perf_target`` is None # (and ``$VIBEQC_PERFLOG`` isn't set), the context manager is a # cheap no-op: PerfScope.__enter__/__exit__ become a single # ContextVar lookup with no I/O. hessian_result = None with ( _perf_log_ctx(_perf_target), _structured_log_ctx(_structured_target) as _slog, crash_dump_context(_crash_stem) as _crash_target, OutputChannel.to_file(out_path) as _out_channel, PerfScope("run_job.total"), ): # Structured log: banner + job_start records. Always safe to # emit -- _slog.emit is a no-op when the log is disabled. _libs = library_versions() _fp = run_fingerprint( method=resolved_method, basis=basis, functional=functional, molecule=molecule, ) _slog.emit( "banner", vibeqc_version=VIBEQC_VERSION, libint=_libs.get("libint", "unknown"), libxc=_libs.get("libxc", "unknown"), spglib=_libs.get("spglib", "unknown"), run_fingerprint=_fp, ) _slog.emit( "job_start", method=resolved_method, basis=basis, functional=functional, optimize=bool(optimize), threads=int(threads_in_use), n_atoms=int(len(list(molecule.atoms))), charge=int(molecule.charge), multiplicity=int(molecule.multiplicity), n_electrons=int(molecule.n_electrons()), output_stem=str(output_stem), ) write(banner() + "\n\n") # Post-SCF methods resolve their SCF to rhf/uhf; the *header* should # still name the correlation method (RHF + MP2/CCSD), so display the # original keyword for those (but keep "auto" -> resolved everywhere # else). _header_method = ( _effective_method if _effective_method in _POSTSCF_CITE_METHODS else resolved_method ) # resolved_method is the mean-field SCF a post-SCF method ran on # (rhf/uhf/rohf); name it as the reference in the header. ROHF is the # reference for CCSD(T) with ccsd_reference="rohf". _scf_ref = ( resolved_method if resolved_method in ("rhf", "uhf", "rohf") else "rhf" ) write( _job_header(_header_method, basis, functional, scf_reference=_scf_ref) + "\n" ) write(_geom_summary(molecule) + "\n") if name_molecule: write_iupac_name(molecule) write(f" Threads: {threads_in_use} (OpenMP shared-memory parallelism)\n\n") if _auto_open_shell_trah: write( " Auto convergence profile: open-shell geometry optimization " "uses TRAH (trah_threshold=1.0)\n\n" ) flush() plog.banner( f"run_job {_job_header(_header_method, basis, functional, scf_reference=_scf_ref).strip()}" ) plog.info(f"Threads: {threads_in_use} (OpenMP shared-memory parallelism)") plog.info(f"Output file: {out_path}") # ── Solver selection ─────────────────────────────────────────────── if solver not in ("dense", "davidson", "lobpcg"): raise ValueError( f"run_job: solver='{solver}' is not recognised. " f"Supported: 'dense', 'davidson', 'lobpcg'." ) if solver != "dense" and resolved_method in ( "rhf", "uhf", "rks", "uks", ): # Materialise default options for the molecular C++ SCF path. # "davidson" -> use_davidson=True (C++ Davidson backend). # "lobpcg" -> use_davidson=True for now (C++ molecular SCF # doesn't expose LOBPCG directly; periodic / ROHF drivers # use the Python solver stack via _scf_diagonalize). if resolved_method == "rhf": if rhf_options is None: rhf_options = RHFOptions() rhf_options.use_davidson = True elif resolved_method == "uhf": if uhf_options is None: uhf_options = UHFOptions() uhf_options.use_davidson = True elif resolved_method == "rks": if rks_options is None: rks_options = RKSOptions() rks_options.use_davidson = True elif resolved_method == "uks": if uks_options is None: uks_options = UKSOptions() uks_options.use_davidson = True t_opt = 0.0 _traj_frames = [] _traj_energies = [] # Whether the ASE BFGS backend actually drove the relaxation. Only # then is the ASE citation honest -- the native / brent / geomopt # (RFO / FIRE / GDIIS / ...) backends use no ASE optimizer, so they # must not pull the larsen_ase_2017 BFGS row (CLAUDE.md § 8: attribute, # never claim). Set True inside the ASE branch below. _used_ase_optimizer = False _used_geomopt_optimizer = False # Citation gate: True unless a coupled-cluster step actually ran on # the canonical (non-DF) integral route, in which case the DF-CCSD # assembly citation (DePrince-Sherrill 2013) is dropped from the # method bundle (assemble(cc_density_fit=...); CLAUDE.md § 8: # attribute, never claim). Set in the CCSD block below. _cc_used_density_fit = True _mp2_used_density_fit = False _mp2_options_for_run = None _ump2_options_for_run = None if optimize: # Increment 3 -- analytic +U gradient via the # variational F = F_HF + S V_AO S Fock contribution. # The ASE _WavefunctionCalculator path is plumbed below. # For dft_plus_u, pre-populate the relevant method-options # struct's dft_plus_u_sites / dft_plus_u_ao_groups fields # once at the initial geometry -- the AO-group indices are # geometry-invariant (depend only on shell layout per atom # Z + basis name), so the same translation is valid at # every optimization step. if dft_plus_u and resolved_method in ( "rhf", "uhf", "rks", "uks", ): from .dft_plus_u import _apply_dft_plus_u_to_options # Materialise a defaulted options struct if the user # didn't pass one, so the C++ binding sees a real # object with the dft_plus_u_sites field populated. if resolved_method == "rhf": if rhf_options is None: rhf_options = RHFOptions() _opts_for_dft_plus_u = rhf_options elif resolved_method == "uhf": if uhf_options is None: uhf_options = UHFOptions() _opts_for_dft_plus_u = uhf_options elif resolved_method == "rks": if rks_options is None: rks_options = RKSOptions() _opts_for_dft_plus_u = rks_options else: # uks if uks_options is None: uks_options = UKSOptions() _opts_for_dft_plus_u = uks_options _basis_for_dft_plus_u = BasisSet(molecule, basis) _apply_dft_plus_u_to_options( _opts_for_dft_plus_u, _basis_for_dft_plus_u, dft_plus_u, ) _opt_backend = _resolve_optimizer_backend(optimizer_backend) # Common gradient tolerance (eV/Angstrom -> Ha/bohr). _conv_grad = fmax * _EV_PER_ANGSTROM_TO_HA_PER_BOHR # Semi-empirical and MLIP methods must use ASE -- the native # L-BFGS-B and Brent backends drive SCF+gradient through the # molecular ab-initio code path, not semi-empirical drivers. _se_or_mlip = ( resolved_method in SEMIEMPIRICAL_METHODS or resolved_method in _MLIP_METHODS ) if geom_opt is not None and _se_or_mlip: raise ValueError( "run_job(geom_opt=...) uses the native molecular geomopt " "provider and is only available for Gaussian-basis " "electronic-structure methods. Semi-empirical and MLIP " "methods must use optimizer_backend='ase'." ) if _se_or_mlip and _opt_backend != "ase": if optimizer_backend == "auto": raise ImportError( "run_job(optimize=True) for semi-empirical and MLIP " "methods requires ASE. Install ASE or set " "optimize=False; the native and brent optimizers use " "Gaussian-basis wavefunction providers and cannot run " f"method={resolved_method!r}." ) raise ValueError( "run_job(optimize=True) for semi-empirical and MLIP methods " "requires optimizer_backend='ase'; native and brent " "optimizers use Gaussian-basis wavefunction providers and " f"cannot run method={resolved_method!r}." ) # ---- geomopt-native path (v0.14+) ---------------------------------- if geom_opt is not None and not _se_or_mlip: _used_geomopt_optimizer = True write( f" Geometry optimization (geomopt/{geom_opt})\n" f" target = {geom_target}, coords = {geom_coords}\n" f" line_search = {geom_line_search}, " f"hessian = {geom_hessian_init}/{geom_hessian_update}\n" f" gmax = {_conv_grad:.2e} Ha/bohr " f"({fmax:.4g} eV/A), max_steps = {max_opt_steps}\n\n" ) flush() with ( plog.stage( "geometry_optimization", detail=f"{geom_opt}, gmax={_conv_grad:.1e} Ha/bohr", ), PerfScope("geometry_optimization"), ): t0 = time.perf_counter() from .geomopt import ( ConvergencePolicy, MolecularSCFProvider, run_geomopt, ) _conv_pol = ConvergencePolicy( gmax=geom_conv_gmax if geom_conv_gmax is not None else _conv_grad ) _provider = MolecularSCFProvider( basis, method=resolved_method, functional=functional, rhf_options=rhf_options, uhf_options=uhf_options, rks_options=rks_options, uks_options=uks_options, dispersion_params=d3_params, solvent=solvent, cisd_options=cisd_options, selected_ci_options=selected_ci_options, dmrg_options=dmrg_options, v2rdm_options=v2rdm_options, transcorrelated_options=transcorrelated_options, casci_options=casci_options, caspt2_options=caspt2_options, nevpt2_options=nevpt2_options, casscf_options=casscf_options, active_space=active_space, cas_reference=cas_reference, ) _opt_result = run_geomopt( molecule, _provider, geom_opt=geom_opt, geom_coords=geom_coords, geom_target=geom_target, geom_hessian_init=geom_hessian_init, geom_hessian_update=geom_hessian_update, geom_line_search=geom_line_search, geom_conv=_conv_pol, geom_max_iter=max_opt_steps, geom_freeze=geom_freeze, geom_opt_options=geom_opt_options, geom_restart=geom_restart, geom_checkpoint=geom_checkpoint, record_trajectory=bool(output_qvf), progress=_out_channel, ) t_opt = time.perf_counter() - t0 molecule = _opt_result.system _traj_frames = _opt_result.trajectory_frames or [] _traj_energies = _opt_result.trajectory_energies or [] write(" Optimized geometry\n") write(_geom_summary(molecule) + "\n\n") flush() elif _opt_backend == "ase": _used_ase_optimizer = True write(f" Geometry optimization (ASE/BFGS) -> {traj_path.name}\n") write(f" fmax = {fmax} eV/A, max_steps = {max_opt_steps}\n\n") flush() with ( plog.stage( "geometry_optimization", detail=f"BFGS, fmax={fmax} eV/A, max_steps={max_opt_steps}", ), PerfScope("geometry_optimization"), ): t0 = time.perf_counter() molecule = _optimize_geometry( molecule, basis, functional=functional, trajectory_path=traj_path, fmax=fmax, max_steps=max_opt_steps, dispersion_params=d3_params, method=resolved_method, cisd_options=cisd_options, cc3_options=cc3_options, ccsdt_options=ccsdt_options, selected_ci_options=selected_ci_options, dmrg_options=dmrg_options, v2rdm_options=v2rdm_options, transcorrelated_options=transcorrelated_options, casci_options=casci_options, caspt2_options=caspt2_options, nevpt2_options=nevpt2_options, casscf_options=casscf_options, active_space=active_space, cas_reference=cas_reference, rhf_options=rhf_options, uhf_options=uhf_options, rks_options=rks_options, uks_options=uks_options, rohf_options=rohf_options, mlip_options=mlip_options, ) t_opt = time.perf_counter() - t0 write(" Optimized geometry\n") write(_geom_summary(molecule) + "\n\n") flush() # Read trajectory frames for QVF archive. _traj_frames = [] _traj_energies = [] try: from ase.io.trajectory import Trajectory from ase.units import Bohr, Hartree from ._vibeqc_core import Atom traj = Trajectory(str(traj_path)) for atoms in traj: pos_bohr = atoms.positions / Bohr frame_mol = Molecule( [ Atom(int(z), list(xyz)) for z, xyz in zip(atoms.numbers, pos_bohr) ], molecule.charge, molecule.multiplicity, ) _traj_frames.append(frame_mol) try: e_eV = atoms.get_potential_energy() if e_eV is not None and e_eV == e_eV: _traj_energies.append(float(e_eV) / Hartree) except Exception as _traj_e_exc: # ASE calculator may not expose energies # per frame; compatibility shim -- never # crash on this. warn_output_failure( _traj_e_exc, traj_path, role="trajectory_frame_energy", category=OutputFailureKind.compatibility_fallback, ) except Exception as _traj_read_exc: # The traj file was written by ASE just above but # we can't read it back -- log it (the QVF archive # will lose its trajectory section but the SCF # itself is fine). warn_output_failure( _traj_read_exc, traj_path, role="trajectory_read_for_qvf", category=OutputFailureKind.optional_artifact, ) _traj_frames = [] _traj_energies = [] elif _opt_backend == "native": # Native scipy L-BFGS-B backend. from .molecular_optimize import optimize_molecule as _native_opt write( f" Geometry optimization (native L-BFGS-B)\n" f" gtol = {_conv_grad:.2e} Ha/bohr, max_steps = {max_opt_steps}\n\n" ) flush() with ( plog.stage( "geometry_optimization", detail=f"L-BFGS-B, gtol={_conv_grad:.1e} Ha/bohr", ), PerfScope("geometry_optimization"), ): t0 = time.perf_counter() _opt_result = _native_opt( molecule, basis, method=resolved_method, functional=functional, rhf_options=rhf_options, uhf_options=uhf_options, rks_options=rks_options, uks_options=uks_options, cisd_options=cisd_options, selected_ci_options=selected_ci_options, dmrg_options=dmrg_options, v2rdm_options=v2rdm_options, transcorrelated_options=transcorrelated_options, casci_options=casci_options, caspt2_options=caspt2_options, nevpt2_options=nevpt2_options, casscf_options=casscf_options, active_space=active_space, cas_reference=cas_reference, max_iter=max_opt_steps, conv_tol_grad=_conv_grad, dispersion_params=d3_params, solvent=solvent, record_trajectory=bool(output_qvf), progress=False, ) t_opt = time.perf_counter() - t0 molecule = _opt_result.system _traj_frames = _opt_result.trajectory_frames or None _traj_energies = _opt_result.trajectory_energies or None write(" Optimized geometry\n") write(_geom_summary(molecule) + "\n\n") flush() elif _opt_backend == "brent": # Brent steepest-descent + line-search backend. from .molecular_optimize import optimize_molecule_brent as _brent_opt write( f" Geometry optimization (Brent steepest-descent)\n" f" gtol = {_conv_grad:.2e} Ha/bohr, max_steps = {max_opt_steps}\n\n" ) flush() with ( plog.stage( "geometry_optimization", detail=f"Brent, gtol={_conv_grad:.1e} Ha/bohr", ), PerfScope("geometry_optimization"), ): t0 = time.perf_counter() _opt_result = _brent_opt( molecule, basis, method=resolved_method, functional=functional, rhf_options=rhf_options, uhf_options=uhf_options, rks_options=rks_options, uks_options=uks_options, cisd_options=cisd_options, selected_ci_options=selected_ci_options, dmrg_options=dmrg_options, v2rdm_options=v2rdm_options, transcorrelated_options=transcorrelated_options, casci_options=casci_options, caspt2_options=caspt2_options, nevpt2_options=nevpt2_options, casscf_options=casscf_options, active_space=active_space, cas_reference=cas_reference, max_iter=max_opt_steps, conv_tol_grad=_conv_grad, dispersion_params=d3_params, solvent=solvent, record_trajectory=bool(output_qvf), progress=False, ) t_opt = time.perf_counter() - t0 molecule = _opt_result.system _traj_frames = _opt_result.trajectory_frames or None _traj_energies = _opt_result.trajectory_energies or None write(" Optimized geometry\n") write(_geom_summary(molecule) + "\n\n") flush() else: raise ValueError(f"Unknown optimizer_backend={_opt_backend!r}") # Semiempirical and MLIP methods don't use a Gaussian basis; skip # BasisSet construction. Semiempirical routes still allocate valence # matrices, SCC histories, and route-specific scratch, so estimate # those without a basis object. if resolved_method in SEMIEMPIRICAL_METHODS: basis_obj = None estimate = estimate_semiempirical_memory( molecule, method=resolved_method, nddo=nddo, solvent=solvent, optimize=optimize, max_steps=max_opt_steps, ccm_options=ccm_options, ) elif resolved_method in _MLIP_METHODS: basis_obj = None estimate = None else: with PerfScope("basis_set_construction"): basis_obj = BasisSet(molecule, basis) if method in ("mp2", "scs-mp2", "sos-mp2") and not _use_rohf_mp2: if molecule.multiplicity > 1: _ump2_options_for_run = _clone_mp2_like_options( ump2_options, UMP2Options ) _auto_resolve_mp2_aux_basis(_ump2_options_for_run, basis_obj) else: _mp2_options_for_run = _clone_mp2_like_options( mp2_options, MP2Options ) _auto_resolve_mp2_aux_basis(_mp2_options_for_run, basis_obj) # Memory pre-flight _estimate_method = _memory_estimator_method( resolved_method, _effective_method, ) est_opts = _memory_estimator_options( _estimate_method, resolved_method=resolved_method, rhf_options=rhf_options, uhf_options=uhf_options, rks_options=rks_options, uks_options=uks_options, rohf_options=rohf_options, roks_options=roks_options, mp2_options=_mp2_options_for_run, ump2_options=_ump2_options_for_run, ccsd_options=ccsd_options, cc3_options=cc3_options, ccsdt_options=ccsdt_options, dlpno_options=dlpno_options, dlpno_ccsd_options=dlpno_ccsd_options, caspt2_options=caspt2_options, nevpt2_options=nevpt2_options, active_space=active_space, tddft=tddft, tddft_n_states=tddft_n_states, tddft_type=tddft_type, functional=functional, ) estimate = estimate_memory( molecule, basis_obj, method=_estimate_method, options=est_opts, ) if estimate is not None: write( " " + format_memory_report( estimate, override_requested=memory_override, ).replace("\n", "\n ") + "\n\n" ) flush() _slog.emit( "memory_estimate", total_gb=float(estimate.total_gb), raw_total_bytes=int(estimate.raw_total_bytes), headroom_factor=float(estimate.headroom_factor), by_category={k: int(v) for k, v in estimate.by_category.items()}, ) check_memory(estimate, allow_exceed=memory_override) if resolved_method in _MLIP_METHODS: plog.info( f"Evaluating {resolved_method.upper()} pre-trained MLIP " "(single forward pass; no SCF)." ) else: plog.info( f"Starting molecular SCF ({resolved_method.upper()}" + (f" / {functional}" if functional else "") + "). Per-iteration progress runs in compiled C++ and is " "not streamed live; the trace will be written when the SCF " "returns." ) # Memory snapshot before SCF: lets the perf log show RSS # growth caused by the integral / Fock build, separate from # the basis-set / pre-flight overhead. from .perf import active_tracker as _at _tracker = _at() if _tracker is not None: _tracker.snapshot_memory("start_of_scf") t0 = time.perf_counter() # Crash-dump scope: if the C++ SCF raises (NaN, linear # dependence, memory error, ...), we want a snapshot of the # last known state on disk before the exception bubbles up # and the user has nothing to debug from. The state we can # capture from outside the C++ call is limited (no live # density / Fock) -- we record the geometry, options, and # phase. Any deeper introspection needs a future C++ hook. try: with PerfScope(f"scf.{resolved_method}"): result = _run_single_point( resolved_method, molecule, basis_obj, functional=functional, rhf_options=rhf_options, uhf_options=uhf_options, rks_options=rks_options, uks_options=uks_options, rohf_options=rohf_options, roks_options=roks_options, cisd_options=cisd_options, cc3_options=cc3_options, ccsdt_options=ccsdt_options, selected_ci_options=selected_ci_options, dmrg_options=dmrg_options, v2rdm_options=v2rdm_options, transcorrelated_options=transcorrelated_options, casci_options=casci_options, caspt2_options=caspt2_options, nevpt2_options=nevpt2_options, casscf_options=casscf_options, active_space=active_space, cas_reference=cas_reference, mlip_options=mlip_options, ccm_options=ccm_options, solvent=solvent, dft_plus_u=dft_plus_u, read_from=read_from, fragments=fragments, nddo=nddo, ) except BaseException as _scf_exc: t_scf = time.perf_counter() - t0 record( "scf_failed", f"\n SCF FAILED: {type(_scf_exc).__name__}: {_scf_exc}\n", exception_type=type(_scf_exc).__name__, exception=str(_scf_exc), wall_s=float(t_scf), ) flush() if _crash_target is not None: _opts_for_dump = ( { "rhf": rhf_options, "uhf": uhf_options, "rks": rks_options, "uks": uks_options, }.get(resolved_method) if resolved_method in ("rhf", "uhf", "rks", "uks") else None ) dump_on_failure( _crash_target, _scf_exc, {"phase": f"scf.{resolved_method}", "n_iters_completed": 0}, options=_opts_for_dump, molecule=molecule, ) write( f" Crash dump written to " f"{_crash_target.with_suffix('.dump').name}\n" ) flush() # Flip the manifest status to "crashed" before the # exception propagates -- a ``vq`` daemon polling # ``{output}.system`` then sees the job aborted rather # than mistaking a stale "running" for a live job. try: _output_writer.crash(wall_seconds=t_scf) except Exception as _crash_exc: # Manifest bookkeeping must never mask the real SCF # exception we're about to re-raise. warn_output_failure( _crash_exc, _output_writer.manifest_path, role="manifest_crash_status", category=OutputFailureKind.manifest_recording, ) # Label the checkpoint QVF "failed" so a live viewer stops # watching (mirrors the OutputWriter.crash status flip). _checkpointer.finalize( "failed", molecule=molecule, method=resolved_method, basis=basis, functional=functional, ) # Re-raise: crash_dump=True does NOT swallow the failure; # it just makes the failure debuggable. raise t_scf = time.perf_counter() - t0 if ( not bool(getattr(result, "converged", False)) and basis_obj is not None and _rhf_tail_sad_retry_supported( resolved_method=resolved_method, molecule=molecule, basis=basis_obj, rhf_options=rhf_options, result=result, ) ): retry_opts = _clone_rhf_options_for_sad_retry(rhf_options) record( "scf_retry", "\n Auto convergence profile: large closed-shell RHF " "returned non-converged; restarting once from the SAD " "initial guess.\n", method="rhf", reason="large_rhf_tail_nonconvergence", initial_n_iter=int(getattr(result, "n_iter", 0)), initial_energy=float(getattr(result, "energy", 0.0)), retry_initial_guess="sad", retry_max_iter=int(retry_opts.max_iter), ) flush() t_retry0 = time.perf_counter() try: with PerfScope("scf.rhf.sad_retry"): result = run_rhf(molecule, basis_obj, retry_opts) except BaseException as _retry_exc: t_scf += time.perf_counter() - t_retry0 record( "scf_retry_failed", f"\n SCF RETRY FAILED: {type(_retry_exc).__name__}: {_retry_exc}\n", exception_type=type(_retry_exc).__name__, exception=str(_retry_exc), wall_s=float(t_scf), ) flush() if _crash_target is not None: dump_on_failure( _crash_target, _retry_exc, { "phase": "scf.rhf.sad_retry", "n_iters_completed": 0, }, options=retry_opts, molecule=molecule, ) try: _output_writer.crash(wall_seconds=t_scf) except Exception as _crash_exc: warn_output_failure( _crash_exc, _output_writer.manifest_path, role="manifest_crash_status", category=OutputFailureKind.manifest_recording, ) _checkpointer.finalize( "failed", molecule=molecule, method=resolved_method, basis=basis, functional=functional, ) raise t_retry = time.perf_counter() - t_retry0 t_scf += t_retry rhf_options = retry_opts record( "scf_retry_done", " Auto convergence profile: RHF SAD restart " f"{'converged' if bool(getattr(result, 'converged', False)) else 'did not converge'} " f"in {int(getattr(result, 'n_iter', 0))} iterations; " f"E = {render_energy_labeled(float(getattr(result, 'energy', 0.0)), width=0, precision=10)}.\n", method="rhf", converged=bool(getattr(result, "converged", False)), n_iter=int(getattr(result, "n_iter", 0)), energy=float(getattr(result, "energy", 0.0)), wall_s=float(t_retry), ) flush() if ( not bool(getattr(result, "converged", False)) and basis_obj is not None and _rks_tail_trah_retry_supported( resolved_method=resolved_method, molecule=molecule, basis=basis_obj, functional=functional, rks_options=rks_options, result=result, ) ): retry_opts = _clone_rks_options_for_trah_retry(rks_options, result) record( "scf_retry", "\n Auto convergence profile: large closed-shell RKS " "returned non-converged; retrying from the final density " "with TRAH (trah_threshold=1.0e-2).\n", method="rks", reason="large_rks_tail_nonconvergence", initial_n_iter=int(getattr(result, "n_iter", 0)), initial_energy=float(getattr(result, "energy", 0.0)), trah_threshold=float(retry_opts.trah_threshold), retry_max_iter=int(retry_opts.max_iter), ) flush() t_retry0 = time.perf_counter() try: with PerfScope("scf.rks.trah_retry"): result = run_rks(molecule, basis_obj, retry_opts) except BaseException as _retry_exc: t_scf += time.perf_counter() - t_retry0 record( "scf_retry_failed", f"\n SCF RETRY FAILED: {type(_retry_exc).__name__}: {_retry_exc}\n", exception_type=type(_retry_exc).__name__, exception=str(_retry_exc), wall_s=float(t_scf), ) flush() if _crash_target is not None: dump_on_failure( _crash_target, _retry_exc, { "phase": "scf.rks.trah_retry", "n_iters_completed": 0, }, options=retry_opts, molecule=molecule, ) try: _output_writer.crash(wall_seconds=t_scf) except Exception as _crash_exc: warn_output_failure( _crash_exc, _output_writer.manifest_path, role="manifest_crash_status", category=OutputFailureKind.manifest_recording, ) _checkpointer.finalize( "failed", molecule=molecule, method=resolved_method, basis=basis, functional=functional, ) raise t_retry = time.perf_counter() - t_retry0 t_scf += t_retry rks_options = retry_opts record( "scf_retry_done", " Auto convergence profile: RKS TRAH retry " f"{'converged' if bool(getattr(result, 'converged', False)) else 'did not converge'} " f"in {int(getattr(result, 'n_iter', 0))} iterations; " f"E = {render_energy_labeled(float(getattr(result, 'energy', 0.0)), width=0, precision=10)}.\n", method="rks", converged=bool(getattr(result, "converged", False)), n_iter=int(getattr(result, "n_iter", 0)), energy=float(getattr(result, "energy", 0.0)), wall_s=float(t_retry), ) flush() if _tracker is not None: _tracker.snapshot_memory("end_of_scf") # Also record the per-iteration trace so the perf log # carries the same info as the .out's SCF trace, in # tabular form. The C++ result.scf_trace is the canonical # source for these numbers. for step in getattr(result, "scf_trace", []) or []: _tracker.add_scf_iter( iter=int(step.iter), energy=float(step.energy), dE=float(step.delta_e) if step.iter > 1 else None, grad=float(step.grad_norm), diis=int(step.diis_subspace), ) # Structured log: per-iter rows sourced from the C++ trace, # which is the canonical record of what the SCF did. Periodic # SCFs (Python-driven) emit ``scf_iter`` live via # ProgressLogger.iteration() -- see vibeqc.progress; the # molecular C++ path runs as a black box so we replay its # trace here. The matching ``scf_converged`` record is emitted # below via plog.converged() so periodic and molecular paths # share the funnel. for step in getattr(result, "scf_trace", []) or []: _slog.emit( "scf_iter", iter=int(step.iter), energy=float(step.energy), dE=(float(step.delta_e) if step.iter > 1 else None), grad_norm=float(step.grad_norm), diis_subspace=int(step.diis_subspace), ) # Non-convergence path: write a crash dump too, since "ran # to max_iter" is a real failure mode. We don't raise -- the # SCF returned a result and the user can inspect it -- but we # leave the .dump alongside the .out so the diagnostic recipe # is the same as the exception path. if not bool(getattr(result, "converged", False)) and _crash_target is not None: _nonconv_kind = "Solver" if isinstance(result, SolverResult) else "SCF" _nonconv_phase = _nonconv_kind.lower() dump_state = { "phase": f"{_nonconv_phase}.{resolved_method} (max_iter)", "scf_trace": list(getattr(result, "scf_trace", []) or []), "n_iters_completed": int(getattr(result, "n_iter", 0)), } for k_state, k_attr in ( ("density", "density"), ("density_alpha", "density_alpha"), ("density_beta", "density_beta"), ("fock", "fock"), ("fock_alpha", "fock_alpha"), ("fock_beta", "fock_beta"), ("mo_coeffs", "mo_coeffs"), ("mo_coeffs_alpha", "mo_coeffs_alpha"), ("mo_coeffs_beta", "mo_coeffs_beta"), ): v = getattr(result, k_attr, None) if v is not None: dump_state[k_state] = v _opts_for_dump = ( { "rhf": rhf_options, "uhf": uhf_options, "rks": rks_options, "uks": uks_options, }.get(resolved_method) if resolved_method in ("rhf", "uhf", "rks", "uks") else None ) dump_on_failure( _crash_target, None, dump_state, phase=f"{_nonconv_phase}.{resolved_method} (max_iter)", options=_opts_for_dump, molecule=molecule, ) write( f"\n {_nonconv_kind} did not converge -- crash dump written to " f"{_crash_target.with_suffix('.dump').name}\n" ) flush() # Per-analysis success flags from the .out properties block, so the # citation assembler below can gate best-effort analyses (Hirshfeld) # on whether they actually computed + surfaced for this job. Stays # empty on the solver-trace / MLIP branches (no properties block), # which correctly suppresses the Hirshfeld citation there. _prop_status: dict[str, bool] = {} # Check if we got a SolverResult (non-mean-field method) if isinstance(result, SolverResult): write("\n " + "-" * 60 + "\n") write(section_header("Non-mean-field solver result", width=60)) if isinstance(result, CC3Result): _cc3_summary, _cc3_iterations = _format_cc3_result(result) write(_cc3_summary + "\n", Level.QUIET) write("\n" + _cc3_iterations + "\n", Level.VERBOSE) elif isinstance(result, CCSDTResult): _ccsdt_summary, _ccsdt_iterations = _format_ccsdt_result(result) write(_ccsdt_summary + "\n", Level.QUIET) write("\n" + _ccsdt_iterations + "\n", Level.VERBOSE) else: write(_format_solver_trace(result) + "\n") elif getattr(result, "model_info", None) is not None: # MLIP (method="mace"): a single pre-trained forward pass -- there # is NO SCF here, no Fock matrix, no DIIS. Emitting the empty SCF # iteration table (||[F,DS]|| / DIIS columns, "converged in 1 # iterations") would misrepresent what MACE does, so print the # single-shot energy instead, then the model provenance block # (model + license + energy-scale caveat) so the .out is self- # documenting about the external model behind these numbers. write("\n " + "-" * 60 + "\n") write(" Single-point energy -- pre-trained MLIP forward pass (no SCF)\n") write(f" E = {render_energy_labeled(float(result.energy), width=0, precision=10)}\n") # _format_mlip_provenance opens with its own separator rule. write(_format_mlip_provenance(result.model_info) + "\n") else: _scf_energy_label = ( "SCF energy" if d3_params is not None or use_d4 or composite_recipe is not None else "Total energy" ) write_scf_trace( result, molecule=molecule, basis=basis_obj, include_banner=False, property_status=_prop_status, energy_label=_scf_energy_label, trailing="\n", ) # MSINDO COSMO (run_job(method="msindo", solvent=...)) -- surface the # solvation energy + gas reference alongside the in-solvent total. _esolv = getattr(result, "e_solv", None) if _esolv is not None: _Hk = 627.509474063 _eg = getattr(result, "e_gas", None) _energy_in_solvent = getattr(result, "energy_in_solvent", None) _solvent_variant = getattr(result, "solvent_variant", None) _solvent_result = getattr(result, "solvent_result", None) if _eg is None and _solvent_result is not None: _eg = getattr(_solvent_result, "e_gas", None) if _energy_in_solvent is None and _solvent_result is not None: _energy_in_solvent = getattr(_solvent_result, "energy", None) if _energy_in_solvent is None and _eg is not None: _energy_in_solvent = float(_eg) + float(_esolv) if _solvent_variant is None and _solvent_result is not None: _solvent_variant = getattr(_solvent_result, "solvent_variant", None) _solvent_label = ( "COSMO" if str(_solvent_variant or "").strip().lower() == "cosmo" else "CPCM" ) write( f"\n ## Implicit solvation ({_solvent_label})\n " + "-" * 52 + "\n" + ( f" Gas-phase total = {render_energy_labeled(_eg, width=16, precision=10)}\n" if _eg is not None else "" ) + ( f" In-solvent total = {render_energy_labeled(_energy_in_solvent, width=16, precision=10)}\n" if _energy_in_solvent is not None else "" ) + f" Solvation energy = {render_energy_labeled(_esolv, width=16, precision=10)}" f" ({_esolv * _Hk:9.2f} kcal/mol)\n\n" ) _slog.emit( "e_solv", value=float(_esolv), value_kcal=float(_esolv * _Hk) ) # MSINDO reports an atomization/binding energy (E - S atomic # reference, ATENG) directly -- surface it, the semiempirical # analogue of the mean-field atomization block. _be = getattr(result, "binding_energy", None) if _be is not None: _Hk = 627.509474063 record( "binding_energy", "\n ## Atomization / binding energy\n " + "-" * 52 + "\n" f" Binding energy (E - S ATENG) = {render_energy_labeled(_be, width=16, precision=10)}" f" ({_be * _Hk:9.2f} kcal/mol)\n\n", value=float(_be), value_kcal=float(_be * _Hk), ) flush() if resolved_method in _MLIP_METHODS: plog.info( "MLIP energy evaluated (single forward pass, no SCF); " f"E = {render_energy_labeled(float(getattr(result, 'energy', 0.0)), width=0, precision=10)}" ) else: plog.converged( n_iter=int(getattr(result, "n_iter", 0)), energy=float(getattr(result, "energy", 0.0)), converged=bool(getattr(result, "converged", False)), ) if resolved_method in ("rhf", "uhf", "rks", "uks", "rohf", "roks") and not bool( getattr(result, "converged", False) ): _msg = ( f"{resolved_method.upper()} SCF did not converge after " f"{int(getattr(result, 'n_iter', 0))} iterations; refusing " "to treat the energy or post-SCF properties as a successful " "calculation." ) record( "scf_nonconverged", f"\n FATAL: {_msg}\n", method=resolved_method, n_iter=int(getattr(result, "n_iter", 0)), energy=float(getattr(result, "energy", 0.0)), ) flush() try: _output_writer.crash(wall_seconds=t_scf) except Exception as _crash_exc: warn_output_failure( _crash_exc, _output_writer.manifest_path, role="manifest_crash_status", category=OutputFailureKind.manifest_recording, ) _checkpointer.finalize( "failed", molecule=molecule, method=resolved_method, basis=basis, functional=functional, ) raise RuntimeError(_msg) if ( isinstance(result, SolverResult) and resolved_method in ("v2rdm", "cc3", "ccsdt") and not bool(getattr(result, "converged", False)) ): _msg = ( f"{result.method} did not converge after {result.n_iter} " "iterations; refusing to treat the solver energy as a " "successful calculation." ) record( "solver_nonconverged", f"\n FATAL: {_msg}\n", method=str(result.method), n_iter=int(result.n_iter), energy=float(result.energy), ) flush() try: _output_writer.crash(wall_seconds=t_scf) except Exception as _crash_exc: warn_output_failure( _crash_exc, _output_writer.manifest_path, role="manifest_crash_status", category=OutputFailureKind.manifest_recording, ) _checkpointer.finalize( "failed", molecule=molecule, method=resolved_method, basis=basis, functional=functional, ) raise RuntimeError(_msg) # ---- SCF diagnostics: "possibly conducting state" warning ----- # If the converged HOMO-LUMO gap is below 1e-4 Ha (~3 meV) the # Aufbau-filled ground state is unreliable -- the user should # enable Fermi-Dirac smearing. _CONDUCTING_GAP_THRESHOLD_HA = 1.0e-4 has_mo_energies = hasattr(result, "mo_energies") if has_mo_energies and bool(getattr(result, "converged", False)): eps = result.mo_energies # Determine occupation count for this method. n_elec = sum(a.Z for a in molecule.atoms) - getattr(molecule, "charge", 0) if method in ("rhf", "rks"): n_occ = n_elec // 2 else: n_occ = None if n_occ is not None and n_occ > 0 and n_occ < len(eps): gap = float(eps[n_occ] - eps[n_occ - 1]) if gap < _CONDUCTING_GAP_THRESHOLD_HA: _ev_factor = 27.211386245988 # The HOMO-LUMO gap is a deliberate dual Ha/eV display # (like the orbital table), kept hardcoded so it does not # become "X eV (X eV)" under eV output units. _gap_msg = ( f"POSSIBLY CONDUCTING STATE -- " f"HOMO-LUMO gap = {gap:.6f} Ha ({gap * _ev_factor:.4f} eV). " f"Consider Fermi-Dirac smearing (smearing_options=...)." ) # The surrounding blank lines preserve the historical # spacing; warn() emits the " WARNING: ..." line itself # (at QUIET, plus a structured warning event). write("\n") warn(_gap_msg, role="conducting_state") write("\n") flush() import warnings as _warnings _warnings.warn(" WARNING: " + _gap_msg) # Post-SCF Coupled-Cluster (CCSD / CCSD(T)), Brueckner CCD, and # closed-shell kernel variants (CCD / LCCD / LCCSD / CEPA(n) / # QCISD). The closed-shell DF kernel is anchored against the # in-repo spin-orbital SGWB-1991 reference # (tests/test_ccsd_anchor.py, tests/test_cc_variants.py); dense # O(N^6) pilot, small molecules only (see handovers/HANDOVER_CCSD.md). if ( method in ("ccsd", "ccsd(t)") or method in _BCCD_METHODS or method in _CC_VARIANT_METHODS ) and bool(getattr(result, "converged", False)): from .cc import ( CCSDOptions, _copy_ccsd_options, chemical_core_orbital_count, ) from .cc import run_bccd as _run_bccd_py from .cc import run_ccsd as _run_ccsd_py from .cc import run_rohf_ccsd as _run_rohf_ccsd_py from .cc import run_uccsd as _run_uccsd_py if ccsd_options is None: cc_opts = CCSDOptions() # Default frozen core: one chemical-core count per atom # (noble-gas cores; H2O -> 1, CO2 -> 3). cc_opts.n_frozen_core = chemical_core_orbital_count(molecule) else: cc_opts = _copy_ccsd_options(ccsd_options) if ( getattr(cc_opts, "fno", False) and int(cc_opts.n_frozen_core) == 0 and not getattr(cc_opts, "_n_frozen_core_explicit", False) ): # run_job's CCSD(T) default is frozen-core. Keep the # FNO convenience path on the same convention unless the # caller uses the lower-level run_fno_ccsd API directly. cc_opts.n_frozen_core = chemical_core_orbital_count(molecule) _variant_method = ( _effective_method if ( _effective_method in _CC_VARIANT_METHODS or _effective_method in _BCCD_METHODS ) else method ) cc_opts.compute_triples = bool(_ccsd_compute_triples) cc_opts.triples_variant = _ccsd_triples_variant # Coupled-pair / QCI variants: select the kernel variant; # _select_method guaranteed a closed-shell RHF reference. cc_opts.cc_variant = ( "bccd" if _variant_method in _BCCD_METHODS else _CC_VARIANT_METHODS.get(_variant_method, "ccsd") ) if cc_opts.density_fit: cc_opts.resolve_aux_basis(basis) # Open-shell references (multiplicity > 1) route to the UHF # reference and the spin-orbital UCCSD(T) kernel by default. # ccsd_reference='rohf' selects an ROHF SCF + ROHF-CCSD(T) # (spin-pure restricted-open-shell reference, same kernel). # Closed shell uses the RHF kernel. Both share CCSDOptions / # CCSDResult and the same chemical-core default. _open_shell = molecule.multiplicity > 1 _use_rohf = _open_shell and ccsd_reference == "rohf" # The DF- prefix is provenance: only claim it when density # fitting is actually on. density_fit=False runs the canonical # exact four-index integral route. _df_prefix = "DF-" if cc_opts.density_fit else "" _cc_used_density_fit = bool(cc_opts.density_fit) if _variant_method in _BCCD_METHODS: _cc_label = _df_prefix + _BCCD_LABELS[_variant_method] elif _variant_method in _CC_VARIANT_METHODS: _cc_label = _df_prefix + _CC_VARIANT_LABELS[_variant_method] else: if _ccsd_triples_variant == "a-ccsd(t)": _cc_label = _df_prefix + "A-CCSD(T)" else: _t_suffix = "" if cc_opts.compute_triples: _t_suffix = ( "[T]" if _ccsd_triples_variant == "[t]" else "(T)" ) _cc_label = _df_prefix + ( "ROHF-CCSD" if _use_rohf else ("UCCSD" if _open_shell else "CCSD") ) + _t_suffix with ( plog.stage("ccsd", detail=_cc_label), PerfScope("ccsd"), ): if _variant_method in _BCCD_METHODS: cc_result = _run_bccd_py(molecule, basis_obj, result, cc_opts) elif _use_rohf: cc_result = _run_rohf_ccsd_py(molecule, basis_obj, result, cc_opts) elif _open_shell: cc_result = _run_uccsd_py(molecule, basis_obj, result, cc_opts) else: cc_result = _run_ccsd_py(molecule, basis_obj, result, cc_opts) _slog.emit( "ccsd_converged", cc_variant=str(getattr(cc_opts, "cc_variant", "ccsd")), converged=bool(cc_result.converged), n_iter=int(cc_result.n_iter), n_frozen_core=int(cc_opts.n_frozen_core), frozen_core_convention=( "explicit" if getattr(cc_opts, "_n_frozen_core_explicit", False) else "chemical-core-default" ), algorithm=_cc_label, density_fit=bool(cc_opts.density_fit), aux_basis=str(cc_opts.aux_basis or ""), e_ccsd_correlation=float(cc_result.e_ccsd_correlation), e_ccsd=float(cc_result.e_ccsd), e_t=float(cc_result.e_t) if cc_opts.compute_triples else None, e_total=float(cc_result.e_total), t1_norm=float(cc_result.t1_norm), t2_norm=float(cc_result.t2_norm), brueckner_iterations=( int(cc_result.brueckner_iterations) if hasattr(cc_result, "brueckner_iterations") else None ), brueckner_t1_norm=( float(cc_result.brueckner_t1_norm) if hasattr(cc_result, "brueckner_t1_norm") else None ), ) # Write the CC iteration table. _cc_kind is the plain method # label ("CCSD", "CCSD(T)", "CCD", "CEPA(1)", ...) used in the # block header and the energy lines below. _is_bccd = _variant_method in _BCCD_METHODS _is_pair_variant = _variant_method in _CC_VARIANT_METHODS or _is_bccd if _is_bccd: _cc_kind = _BCCD_LABELS[_variant_method] _base_cc_kind = "BCCD" _block_title = "\n Brueckner Coupled-Cluster " + _cc_kind elif _variant_method in _CC_VARIANT_METHODS: _cc_kind = _CC_VARIANT_LABELS[_variant_method] _base_cc_kind = ( "QCISD" if _variant_method == "qcisd(t)" else _cc_kind ) _block_title = ( "\n Approximate Coupled-Cluster CC2" if _variant_method == "cc2" else "\n Coupled-Pair / Coupled-Cluster " + _cc_kind ) else: if not cc_opts.compute_triples: _cc_kind = "CCSD" elif _ccsd_triples_variant == "[t]": _cc_kind = "CCSD[T]" elif _ccsd_triples_variant == "a-ccsd(t)": _cc_kind = "A-CCSD(T)" else: _cc_kind = "CCSD(T)" _base_cc_kind = "CCSD" _block_title = "\n Coupled-Cluster " + _cc_kind write(_block_title + "\n " + "-" * 78 + "\n") _e_col = ( "E(" + _base_cc_kind + ")" if _is_pair_variant else "E(CCSD)" ) hdr = " {:<5s} {:>16s} {:>12s} {:>12s} {:>12s} {:>6s}".format( "Iter", _e_col, "dE", "|R1|", "|R2|", "DIIS" ) write(hdr + "\n") write(" " + "-" * 78 + "\n") for step in cc_result.cc_trace: write( " {:<5d} {:>16.10f} {:>12.6e} {:>12.6e} {:>12.6e} {:>6d}\n".format( int(step.iter), float(step.energy), float(step.delta_e) if step.iter > 1 else 0.0, float(step.r1_norm), float(step.r2_norm), int(step.diis_subspace), ) ) write(" " + "-" * 78 + "\n") _conv_kind = _base_cc_kind if cc_result.converged: write( f" {_conv_kind} converged in {cc_result.n_iter} iterations\n" ) else: write(f" {_conv_kind} DID NOT CONVERGE\n") _fc_label = ( "explicit" if getattr(cc_opts, "_n_frozen_core_explicit", False) else "chemical-core default" ) write( f" Frozen core orbitals = {int(cc_opts.n_frozen_core):d}" f" ({_fc_label})\n" ) write(f" Algorithm = {_cc_label}\n") write( " Density fitting = " f"{'on' if bool(cc_opts.density_fit) else 'off'}\n" ) if bool(cc_opts.density_fit): write(f" RI auxiliary basis = {cc_opts.aux_basis}\n") if hasattr(cc_result, "brueckner_iterations"): write( " Brueckner iterations = " f"{cc_result.brueckner_iterations:d}; " f"final CCSD |T1| = {cc_result.brueckner_t1_norm:.6e}\n" ) # FNO summary (only present on an FNOCCSDResult). if hasattr(cc_result, "n_virtual_kept"): write( f" FNO: kept {cc_result.n_virtual_kept}/" f"{cc_result.n_virtual_total} virtual natural orbitals; " f"delta-MP2 correction = {cc_result.delta_mp2:+.6e} Ha\n" ) write( f" E({_conv_kind} correlation) = " f"{render_energy_labeled(cc_result.e_ccsd_correlation, width=16, precision=10)}\n" f" E({_conv_kind}) = {render_energy_labeled(cc_result.e_ccsd, width=16, precision=10)}\n" ) if cc_opts.compute_triples: _e_t4 = float(getattr(cc_result, "e_t4", 0.0)) _e_t5 = float(getattr(cc_result, "e_t5_st", 0.0)) if _ccsd_triples_variant == "[t]": write( f" E[T] correction = {render_energy_labeled(cc_result.e_t, width=16, precision=10)}\n" f" (omitted 5th-order singles-triples E_ST = " f"{render_energy_labeled(_e_t5, width=0, precision=10, sign=True)})\n" f" E({_cc_kind}) = " f"{render_energy_labeled(cc_result.e_ccsd_t, width=16, precision=10)}\n" ) elif _ccsd_triples_variant == "a-ccsd(t)": write( f" E(A-T) correction = {render_energy_labeled(cc_result.e_t, width=16, precision=10)}\n" ) if hasattr(cc_result, "lambda_residual_norm"): lambda_doc = OutputDocument(indent=2, label_width=20) lambda_doc.scalar( "Lambda residual =", Quantity( cc_result.lambda_residual_norm, "dimensionless", ), ) lambda_policy = active_policy().with_spec( "dimensionless", precision=6, width=16, notation="e", ) write( lambda_doc.render(lambda_policy) + "\n", Level.VERBOSE, ) write( f" E({_cc_kind}) = " f"{render_energy_labeled(cc_result.e_ccsd_t, width=16, precision=10)}\n" ) else: write( f" E(T) correction = {render_energy_labeled(cc_result.e_t, width=16, precision=10)}\n" ) if _e_t4 != 0.0 or _e_t5 != 0.0: write( f" (4th-order E[T] = {render_energy_labeled(_e_t4, width=0, precision=10, sign=True)}, " f"5th-order E_ST = {render_energy_labeled(_e_t5, width=0, precision=10, sign=True)})\n" ) write( f" E({_cc_kind}) = " f"{render_energy_labeled(cc_result.e_ccsd_t, width=16, precision=10)}\n" ) write( f" T1 norm = {cc_result.t1_norm:16.10f}\n" f" T2 norm = {cc_result.t2_norm:16.10f}\n" ) write("\n") flush() # Expose the CC result without mutating the pybind11 SCF # struct (def_readonly fields, no __dict__). result = _CCSDAugmented(result, cc_result, method=_effective_method) # Post-SCF Moller-Plesset MP2 (+ spin-component-scaled SCS / SOS). # RHF-reference only (closed-shell guarded early). The native C++ MP2 # carries the c_os / c_ss spin scaling; the factors are taken from the # single source of truth in vibeqc.correlation so the run_job variants # and the general/MSINDO kernels never drift. The unscaled e_os / e_ss # are always reported so a user can re-derive any variant from one run. if method in ("mp2", "scs-mp2", "sos-mp2") and bool( getattr(result, "converged", False) ): from .correlation import _MP2_SCALES _os_s, _ss_s = _MP2_SCALES[method] # Closed shell -> restricted MP2; open shell -> native UMP2 (UHF # reference). Both carry the c_os/c_ss spin-component scaling and # report e_hf / e_correlation / e_total; the spin channels differ # (RMP2: e_os/e_ss; UMP2: e_ab opposite-spin, e_aa+e_bb same-spin). _open = molecule.multiplicity > 1 if _use_rohf_mp2: # Open-shell, mp2_reference='rohf': spin-pure semicanonical # ROHF-MP2 (pure-Python kernel, vibeqc.cc.run_rohf_mp2). The # c_os/c_ss scaling applies to the doubles; the Knowles singles # term (which UMP2 lacks) rides in e_corr and is reported below. from .cc import run_rohf_mp2 _detail = "ROHF-" + method.upper() with plog.stage("mp2", detail=_detail), PerfScope("mp2"): # All-electron, matching the native RMP2/UMP2 run_job path # (which has no frozen-core option); the standalone # vibeqc.run_rohf_mp2 defaults to a frozen chemical core. mp2_result = run_rohf_mp2( molecule, basis_obj, result, n_frozen_core=0, os_scale=_os_s, ss_scale=_ss_s, ) _e_os = float(mp2_result.e_os) _e_ss = float(mp2_result.e_ss) else: if _open: from ._vibeqc_core import run_ump2 as _run_ump2 _mp2_opts = _ump2_options_for_run or UMP2Options() else: from ._vibeqc_core import run_mp2 as _run_mp2 _mp2_opts = _mp2_options_for_run or MP2Options() _mp2_opts.c_os = _os_s _mp2_opts.c_ss = _ss_s _mp2_used_density_fit = bool(getattr(_mp2_opts, "density_fit", False)) _detail = ( ("RI-" if _mp2_used_density_fit else "") + ("U" if _open else "") + method.upper() ) with plog.stage("mp2", detail=_detail), PerfScope("mp2"): if _open: mp2_result = _run_ump2(molecule, basis_obj, result, _mp2_opts) else: mp2_result = _run_mp2(molecule, basis_obj, result, _mp2_opts) # Uniform opposite-/same-spin view across R/U (Grimme channels: # opposite = ab, same = aa+bb). _e_os = float(mp2_result.e_ab if _open else mp2_result.e_os) _e_ss = float( (mp2_result.e_aa + mp2_result.e_bb) if _open else mp2_result.e_ss ) _slog.emit( "mp2_done", variant=method, open_shell=_open, c_os=float(_os_s), c_ss=float(_ss_s), density_fit=bool(_mp2_used_density_fit), aux_basis=( str(getattr(_mp2_opts, "aux_basis", "")) if not _use_rohf_mp2 else "" ), e_os=_e_os, e_ss=_e_ss, e_correlation=float(mp2_result.e_correlation), e_total=float(mp2_result.e_total), ) _ref = "ROHF" if _use_rohf_mp2 else ("UHF" if _open else "RHF") write("\n Moller-Plesset MP2 (" + _detail + ")\n " + "-" * 78 + "\n") if _use_rohf_mp2: _algo_note = ( "semicanonical ROHF-MP2; all-electron, no RI auxiliary basis" ) else: if _mp2_used_density_fit: _aux = str(getattr(_mp2_opts, "aux_basis", "") or "<auto>") _algo_note = ( f"RI-{'U' if _open else ''}MP2; " f"all-electron, aux_basis={_aux}" ) else: _algo_note = ( f"direct canonical {'U' if _open else ''}MP2; " "all-electron, no RI auxiliary basis" ) write(f" Algorithm = {_algo_note}\n") write( f" E({_ref} reference) = {render_energy_labeled(mp2_result.e_hf, width=16, precision=10)}\n" f" E(OS, opposite-spin) = {render_energy_labeled(_e_os, width=16, precision=10)}" f" (c_os = {_os_s:.4f})\n" f" E(SS, same-spin) = {render_energy_labeled(_e_ss, width=16, precision=10)}" f" (c_ss = {_ss_s:.4f})\n" ) if _use_rohf_mp2: # The semicanonical ROHF reference violates Brillouin, so the # singles term is non-zero and is part of the correlation. write( f" E(singles, Brillouin) = " f"{render_energy_labeled(float(mp2_result.e_singles), width=16, precision=10)}\n" ) write( f" E(MP2 correlation) = {render_energy_labeled(mp2_result.e_correlation, width=16, precision=10)}\n" ) _total_label = f"E({method.upper()} total)" write(f" {_total_label:<22s} = {render_energy_labeled(mp2_result.e_total, width=16, precision=10)}\n") write("\n") flush() # Expose the MP2 result for programmatic access. The C++ SCF # result has no __dict__, so we wrap it in a transparent proxy # (mirrors the dispersion path): result.mp2 carries the # MP2Result, result.energy_total is the MP2 total, and every # SCF attribute still forwards through. result = _MP2Augmented(result, mp2_result, method=_effective_method) # Post-SCF DLPNO-MP2 (vibeqc.dlpno.mp2): Foster-Boys-localised # occupieds, PAO domains with semicanonical virtual bases, PNO # compression with the semicanonical truncation correction, and the # coupled LMP2 residual iteration (Pinski et al., JCP 143, 034108 # (2015)). Closed-shell RHF reference only for now. The RI fitting # basis resolves per the correlation ("ri") aux family of the # orbital basis; pass DLPNOMP2Options via dlpno_options to control # thresholds / frozen core. if method == "dlpno-mp2" and bool(getattr(result, "converged", False)): from .density_fitting import ( DensityFitting as _PyDF, ) from .density_fitting import ( default_aux_basis_for as _aux_for, ) _aux_name = _aux_for(basis_obj.name, kind="ri") _aux_obj = BasisSet(molecule, _aux_name) if molecule.multiplicity > 1: # Open-shell: UHF-reference DLPNO-UMP2 (spin-channel resolved # aa/bb/ab; Saitow 2017). Auto-routed from method='dlpno-mp2'. from .dlpno.ump2 import DLPNOUMP2Options, run_dlpno_ump2 _u_opts = ( dlpno_options if isinstance(dlpno_options, DLPNOUMP2Options) else DLPNOUMP2Options() ) with ( plog.stage("dlpno-ump2", detail=_aux_name), PerfScope("dlpno_ump2"), ): _df = _PyDF(basis_obj, _aux_obj, aux_basis_name=_aux_name) ur = run_dlpno_ump2(molecule, basis_obj, result, _df, _u_opts) _n_pairs = ur.n_pairs_aa + ur.n_pairs_bb + ur.n_pairs_ab _slog.emit( "dlpno_ump2_done", aux_basis=_aux_name, converged=bool(ur.converged), localise=str(ur.localise), n_frozen=int(ur.n_frozen), n_pairs=int(_n_pairs), n_screened=int(ur.n_screened), e_aa=float(ur.e_aa), e_bb=float(ur.e_bb), e_ab=float(ur.e_ab), e_correlation=float(ur.e_corr), e_total=float(ur.e_total), ) write( "\n DLPNO-UMP2 (UHF reference; Saitow 2017; RI: " + _aux_name + ")\n " + "-" * 78 + "\n" ) write( f" E(UHF reference) = {render_energy_labeled(ur.e_hf, width=16, precision=10)}\n" f" localise / screened = {ur.localise} / {ur.n_screened:d}" f" (frozen core: {ur.n_frozen:d})\n" f" pairs (aa/bb/ab) = " f"{ur.n_pairs_aa:d} / {ur.n_pairs_bb:d} / {ur.n_pairs_ab:d}\n" f" E(aa same-spin) = {render_energy_labeled(ur.e_aa, width=16, precision=10)}\n" f" E(bb same-spin) = {render_energy_labeled(ur.e_bb, width=16, precision=10)}\n" f" E(ab opposite-spin) = {render_energy_labeled(ur.e_ab, width=16, precision=10)}\n" f" E(DLPNO-UMP2 corr) = {render_energy_labeled(ur.e_corr, width=16, precision=10)}\n" f" E(DLPNO-UMP2 total) = {render_energy_labeled(ur.e_total, width=16, precision=10)}\n" ) if not ur.converged: warn("coupled LMP2 iteration did not converge", method="dlpno_ump2") write("\n") flush() result = _DLPNOUMP2Augmented(result, ur, method=_effective_method) else: from .dlpno.mp2 import DLPNOMP2Options, run_dlpno_mp2 from types import SimpleNamespace _dlpno_opts = ( dlpno_options if dlpno_options is not None else DLPNOMP2Options() ) with plog.stage("dlpno-mp2", detail=_aux_name), PerfScope("dlpno_mp2"): if bool(getattr(_dlpno_opts, "local_df", False)): _df = SimpleNamespace( orbital_basis=basis_obj, aux_basis=_aux_obj, aux_basis_name=_aux_name, n_orb=int(basis_obj.nbasis), n_aux=int(_aux_obj.nbasis), ) else: _df = _PyDF(basis_obj, _aux_obj, aux_basis_name=_aux_name) dlpno_result = run_dlpno_mp2( molecule, basis_obj, result, _df, _dlpno_opts ) _n_kept = dlpno_result.n_pairs _n_scr = dlpno_result.n_pairs_screened _avg_pno = ( sum(dlpno_result.pno_per_pair.values()) / _n_kept if _n_kept else 0.0 ) _slog.emit( "dlpno_mp2_done", aux_basis=_aux_name, converged=bool(dlpno_result.converged), n_iter=int(dlpno_result.n_iter), n_frozen=int(dlpno_result.n_frozen), n_pairs=int(_n_kept), n_pairs_screened=int(_n_scr), avg_pno_per_pair=float(_avg_pno), e_corr_iterated=float(dlpno_result.e_corr_iterated), e_pno_correction=float(dlpno_result.e_pno_correction), e_distant=float(dlpno_result.e_distant), e_correlation=float(dlpno_result.e_corr), e_total=float(dlpno_result.e_total), ) write( "\n DLPNO-MP2 (Pinski 2015; RI: " + _aux_name + ")\n " + "-" * 78 + "\n" ) write( f" E(RHF reference) = {render_energy_labeled(dlpno_result.e_hf, width=16, precision=10)}\n" f" pairs kept / screened = {_n_kept:d} / {_n_scr:d}" f" (frozen core: {dlpno_result.n_frozen:d})\n" f" avg PNOs per pair = {_avg_pno:14.1f}\n" f" E(iterated pairs) = {render_energy_labeled(dlpno_result.e_corr_iterated, width=16, precision=10)}\n" f" E(PNO truncation corr) = {render_energy_labeled(dlpno_result.e_pno_correction, width=16, precision=10)}\n" f" E(distant-pair est.) = {render_energy_labeled(dlpno_result.e_distant, width=16, precision=10)}\n" f" E(DLPNO-MP2 corr) = {render_energy_labeled(dlpno_result.e_corr, width=16, precision=10)}\n" f" E(DLPNO-MP2 total) = {render_energy_labeled(dlpno_result.e_total, width=16, precision=10)}\n" ) if not dlpno_result.converged: warn("coupled LMP2 iteration did not converge", method="dlpno_mp2") write("\n") flush() result = _DLPNOMP2Augmented( result, dlpno_result, method=_effective_method ) # Post-SCF DLPNO-CCSD / CCSD(T). # dlpno-ccsd -> the reduced-scaling local solver (M3c, # vibeqc.dlpno.ccsd_local_solver): each pair's # residual evaluated in its own PNO basis, # full-domain == canonical CCSD; FCI-anchored. # dlpno-ccsd(t) -> the same local solver + DLPNO-(T1) on the # converged amplitudes (vibeqc.dlpno.triples_local), # the scaling-preserving exact (T) (Guo 2018): # full-domain == canonical CCSD(T) to machine # precision. The O(N^6) correctness pilot is # reachable by passing a DLPNOCCSDPilotOptions. if ( method in ("dlpno-ccsd", "dlpno-ccsd(t)") and molecule.multiplicity == 1 and bool(getattr(result, "converged", False)) ): from .density_fitting import ( DensityFitting as _PyDF, ) from .density_fitting import ( default_aux_basis_for as _aux_for, ) _is_t = method == "dlpno-ccsd(t)" # Honor an explicit aux_basis on the DLPNO options; else # auto-resolve from the orbital basis. Minimal / Pople bases # (sto-3g, 6-31g*, ...) ship no default RI aux, so an explicit # aux_basis is the only route to DLPNO on those. _aux_name = getattr(dlpno_ccsd_options, "aux_basis", None) or _aux_for( basis_obj.name, kind="ri" ) _aux_obj = BasisSet(molecule, _aux_name) with plog.stage("dlpno-ccsd", detail=_aux_name), PerfScope("dlpno_ccsd"): _df = _PyDF(basis_obj, _aux_obj, aux_basis_name=_aux_name) from .dlpno.ccsd import DLPNOCCSDPilotOptions, run_dlpno_ccsd_pilot from .dlpno.ccsd_local_solver import ( LocalCCSDOptions, run_local_dlpno_ccsd, ) # The O(N^6) correctness pilot is opt-in (pass a # DLPNOCCSDPilotOptions); otherwise both variants run the # reduced-scaling local solver, with the local DLPNO-(T) for # dlpno-ccsd(t). if _is_t and isinstance(dlpno_ccsd_options, DLPNOCCSDPilotOptions): _cc_opts = dlpno_ccsd_options _cc_opts.compute_triples = True cc_result = run_dlpno_ccsd_pilot( molecule, basis_obj, result, _df, _cc_opts ) _engine = "pilot (Riplinger 2013; O(N^6), max_nbf-capped)" _note = ( " note: (T) on the O(N^6) correctness pilot " "(max_nbf-capped), explicitly selected; the default " "dlpno-ccsd(t) uses the local (T).\n" ) else: _cc_opts = ( dlpno_ccsd_options if isinstance(dlpno_ccsd_options, LocalCCSDOptions) else LocalCCSDOptions() ) if _is_t: _cc_opts.compute_triples = True cc_result = run_local_dlpno_ccsd( molecule, basis_obj, result, _df, _cc_opts ) _engine = "local reduced-scaling (M3c)" if _is_t: _note = ( " note: reduced-scaling local solver; local " "DLPNO-(T1) on the converged amplitudes -- exact " "(== canonical CCSD(T)) at full domains " "(Guo, Riplinger et al. 2018).\n" ) else: _note = ( " note: reduced-scaling local solver -- full-domain " "limit == canonical CCSD (FCI-anchored).\n" ) _avg_pno = ( sum(cc_result.pno_per_pair.values()) / cc_result.n_pairs if cc_result.n_pairs else 0.0 ) _slog.emit( "dlpno_ccsd_done", variant=method, engine=_engine, aux_basis=_aux_name, converged=bool(cc_result.converged), n_iter=int(cc_result.n_iter), n_frozen=int(cc_result.n_frozen), n_pairs=int(cc_result.n_pairs), avg_pno_per_pair=float(_avg_pno), t1_norm=float(cc_result.t1_norm), e_ccsd_correlation=float(cc_result.e_corr), e_t=float(cc_result.e_t), e_total=float(cc_result.e_total), ) write( "\n DLPNO-" + ("CCSD(T)" if _is_t else "CCSD") + " " + _engine + " (RI: " + _aux_name + ")\n " + "-" * 78 + "\n" ) write( f" {'E(RHF reference)':<24s} = {render_energy_labeled(cc_result.e_hf, width=16, precision=10)}\n" f" {'pairs / avg PNOs':<24s} = {cc_result.n_pairs:d} / {_avg_pno:.1f}" f" (frozen core: {cc_result.n_frozen:d})\n" f" {'E(CCSD correlation)':<24s} = {render_energy_labeled(cc_result.e_corr, width=16, precision=10)}\n" ) if _is_t: write( f" {'E((T) correction)':<24s} = {render_energy_labeled(cc_result.e_t, width=16, precision=10)}\n" f" {'E(DLPNO-CCSD(T) corr)':<24s} = " f"{render_energy_labeled((cc_result.e_corr + cc_result.e_t), width=16, precision=10)}\n" f" {'E(DLPNO-CCSD(T) total)':<24s} = " f"{render_energy_labeled(cc_result.e_total, width=16, precision=10)}\n" ) else: write( f" {'E(DLPNO-CCSD total)':<24s} = {render_energy_labeled(cc_result.e_total, width=16, precision=10)}\n" ) write(_note) if not cc_result.converged: warn("DLPNO-CCSD iteration did not converge", method="dlpno_ccsd") write("\n") flush() result = _DLPNOCCSDAugmented(result, cc_result, method=_effective_method) # Open-shell DLPNO-CCSD/(T): UHF-reference spin-orbital UCCSD(T) pilot # (Saitow 2017), auto-routed from method='dlpno-ccsd'/'dlpno-ccsd(t)' # on multiplicity > 1. Only the O(N^6) correctness pilot exists for # open-shell (the reduced-scaling open-shell engine is a later # milestone); pass a DLPNOUCCSDPilotOptions via dlpno_ccsd_options to # control localise / tcut_pno / frozen core. if ( method in ("dlpno-ccsd", "dlpno-ccsd(t)") and molecule.multiplicity > 1 and bool(getattr(result, "converged", False)) ): from .density_fitting import ( DensityFitting as _PyDF, ) from .density_fitting import ( default_aux_basis_for as _aux_for, ) from .dlpno.uccsd import ( DLPNOUCCSDPilotOptions, run_dlpno_uccsd_pilot, ) _is_t = method == "dlpno-ccsd(t)" # Honor an explicit aux_basis on the DLPNO options; else # auto-resolve from the orbital basis (see the closed-shell path). _aux_name = getattr(dlpno_ccsd_options, "aux_basis", None) or _aux_for( basis_obj.name, kind="ri" ) _aux_obj = BasisSet(molecule, _aux_name) _u_opts = ( dlpno_ccsd_options if isinstance(dlpno_ccsd_options, DLPNOUCCSDPilotOptions) else DLPNOUCCSDPilotOptions() ) if _is_t: _u_opts.compute_triples = True with plog.stage("dlpno-uccsd", detail=_aux_name), PerfScope("dlpno_uccsd"): _df = _PyDF(basis_obj, _aux_obj, aux_basis_name=_aux_name) cc_result = run_dlpno_uccsd_pilot( molecule, basis_obj, result, _df, _u_opts ) _avg_pno = float(cc_result.avg_pno) _slog.emit( "dlpno_uccsd_done", variant=method, aux_basis=_aux_name, converged=bool(cc_result.converged), n_iter=int(cc_result.n_iter), localise=str(cc_result.localise), n_frozen=int(cc_result.n_frozen), n_pairs=int(cc_result.n_pairs), avg_pno_per_pair=_avg_pno, t1_norm=float(cc_result.t1_norm), e_ccsd_correlation=float(cc_result.e_corr), e_t=float(cc_result.e_t), e_total=float(cc_result.e_total), ) write( "\n DLPNO-U" + ("CCSD(T)" if _is_t else "CCSD") + " (UHF reference; spin-orbital pilot; Saitow 2017; RI: " + _aux_name + ")\n " + "-" * 78 + "\n" ) write( f" E(UHF reference) = {render_energy_labeled(cc_result.e_hf, width=16, precision=10)}\n" f" localise / pairs = {cc_result.localise} / " f"{cc_result.n_pairs:d}" f" (avg PNOs: {_avg_pno:.1f}; frozen core: " f"{cc_result.n_frozen:d})\n" f" E(UCCSD correlation) = {render_energy_labeled(cc_result.e_corr, width=16, precision=10)}\n" ) if _is_t: write(f" E((T) correction) = {render_energy_labeled(cc_result.e_t, width=16, precision=10)}\n") write(f" E(total) = {render_energy_labeled(cc_result.e_total, width=16, precision=10)}\n") write( " note: O(N^6) spin-orbital correctness pilot (max_nbf-capped); " "the reduced-scaling open-shell engine is a later milestone.\n" ) if not cc_result.converged: warn("DLPNO-UCCSD iteration did not converge", method="dlpno_uccsd") write("\n") flush() result = _DLPNOCCSDAugmented(result, cc_result, method=_effective_method) # Post-SCF OVGF / GF2 Green's-function quasiparticle IPs / EAs. Closed # shell uses the spatial diagonal second-order self-energy; open shell # (UHF reference) uses the spin-resolved spin-orbital self-energy # (vibeqc.propagator) -- the electron-propagator analogue of UMP2. _ovgf_ok = method == "ovgf" and bool(getattr(result, "converged", False)) if _ovgf_ok and molecule.multiplicity != 1: import numpy as _np from ._vibeqc_core import compute_eri as _compute_eri from .propagator import ( unrestricted_quasiparticle_energies as _u_qp_energies, ) EV = 27.211386245988 Ca = _np.asarray(result.mo_coeffs_alpha) Cb = _np.asarray(result.mo_coeffs_beta) ea_mo = _np.asarray(result.mo_energies_alpha) eb_mo = _np.asarray(result.mo_energies_beta) nbf = len(ea_mo) # The spin-orbital tensor is (2.n_bf)⁴ -- guard tighter than the # closed-shell n_bf⁴ path. if 2 * nbf > 80: raise NotImplementedError( f"open-shell method='ovgf' builds the full spin-orbital MO " f"two-electron tensor (2.n_bf={2 * nbf}); it is limited to " f"2.n_bf <= 80 (n_bf <= 40). Use a smaller basis." ) n_elec = molecule.n_electrons() na = (n_elec + molecule.multiplicity - 1) // 2 nb = n_elec - na # a/b frontier orbitals: spin-HOMO (IP) + spin-LUMO (EA) per channel. orbs = [] for _sp, _no in (("alpha", na), ("beta", nb)): if _no >= 1: orbs.append((_sp, _no - 1)) if _no < nbf: orbs.append((_sp, _no)) with plog.stage("ovgf", detail="open-shell GF2"), PerfScope("ovgf"): eri_ao = _np.asarray(_compute_eri(basis_obj)) res = _u_qp_energies( eri_ao, Ca, Cb, ea_mo, eb_mo, na, nb, orbs, iterate=True ) def _occ(sp, ix): return ix < (na if sp == "alpha" else nb) ips = [-q.eps_qp * EV for sp, q in res if _occ(sp, q.orbital)] eas = [-q.eps_qp * EV for sp, q in res if not _occ(sp, q.orbital)] first_ip = min(ips) if ips else None ea_val = max(eas) if eas else None _slog.emit( "ovgf_done", open_shell=True, orbitals=[[sp, q.orbital] for sp, q in res], eps_scf=[float(q.eps_scf) for sp, q in res], eps_qp=[float(q.eps_qp) for sp, q in res], pole_strength=[float(q.pole_strength) for sp, q in res], ip_first_ev=(None if first_ip is None else float(first_ip)), ea_ev=(None if ea_val is None else float(ea_val)), ) write( "\n OVGF / GF2 quasiparticle energies (open-shell, " "spin-resolved second-order self-energy)\n " + "-" * 78 + "\n" ) write( " {:>5s} {:>5s} {:>4s} {:>13s} {:>13s} {:>8s}\n".format( "spin", "MO", "occ", "Koopmans(eV)", "OVGF (eV)", "pole Z" ) ) for sp, q in res: write( " {:>5s} {:>5d} {:>4s} {:>13.4f} {:>13.4f} {:>8.4f}\n".format( sp, q.orbital, "occ" if _occ(sp, q.orbital) else "vir", q.eps_scf * EV, q.eps_qp * EV, q.pole_strength, ) ) write(" " + "-" * 78 + "\n") if first_ip is not None: write(f" OVGF first ionization potential = {first_ip:.4f} eV\n") if ea_val is not None: write( f" OVGF electron affinity (lowest virtual) = " f"{ea_val:.4f} eV" f" (small-basis EA -- qualitative only)\n" ) write("\n") flush() result = _OVGFAugmented(result, res, method=_effective_method) elif _ovgf_ok: import numpy as _np from ._vibeqc_core import compute_eri as _compute_eri from .propagator import quasiparticle_energies as _qp_energies C = _np.asarray(result.mo_coeffs) eps_mo = _np.asarray(result.mo_energies) nbf = len(eps_mo) # The diagonal self-energy needs the full MO 2e tensor (n_bf⁴); guard # large systems rather than OOM on the transform. if nbf > 100: raise NotImplementedError( f"method='ovgf' builds the full MO two-electron tensor " f"(n_bf={nbf}); it is currently limited to small/medium " f"systems (n_bf <= 100). Use a smaller basis, or the " f"vibeqc.propagator API with on-demand integral blocks." ) nocc = molecule.n_electrons() // 2 with plog.stage("ovgf", detail="GF2 self-energy"), PerfScope("ovgf"): eri_ao = _np.asarray(_compute_eri(basis_obj)) eri_mo = _np.einsum( "pqrs,pi,qj,rk,sl->ijkl", eri_ao, C, C, C, C, optimize=True ) # Outer valence: up to 3 highest occupied + LUMO. lo = max(0, nocc - 3) orbs = ( list(range(lo, nocc + 1)) if nocc < nbf else list(range(lo, nocc)) ) qp = _qp_energies(eps_mo, nocc, eri_mo, orbs, iterate=True) EV = 27.211386245988 _homo_q = next(q for q in qp if q.orbital == nocc - 1) _lumo_q = next((q for q in qp if q.orbital == nocc), None) _slog.emit( "ovgf_done", orbitals=orbs, eps_scf=[float(q.eps_scf) for q in qp], eps_qp=[float(q.eps_qp) for q in qp], pole_strength=[float(q.pole_strength) for q in qp], ip_homo_ev=float(-_homo_q.eps_qp * EV), ea_lumo_ev=(None if _lumo_q is None else float(-_lumo_q.eps_qp * EV)), ) write( "\n OVGF / GF2 quasiparticle energies (diagonal " "second-order self-energy)\n " + "-" * 78 + "\n" ) write( " {:>5s} {:>6s} {:>13s} {:>13s} {:>8s}\n".format( "MO", "label", "Koopmans(eV)", "OVGF (eV)", "pole Z" ) ) for q in qp: lbl = ( "HOMO" if q.orbital == nocc - 1 else "LUMO" if q.orbital == nocc else "" ) write( " {:>5d} {:>6s} {:>13.4f} {:>13.4f} {:>8.4f}\n".format( q.orbital, lbl, q.eps_scf * EV, q.eps_qp * EV, q.pole_strength ) ) write(" " + "-" * 78 + "\n") write( f" OVGF ionization potential (HOMO) = {-_homo_q.eps_qp * EV:.4f} eV\n" ) if _lumo_q is not None: # EA = -eps_qp(LUMO). The diagonal GF2 self-energy gives only # qualitative EAs in a small/non-augmented basis (non-variational # virtuals; no diffuse functions for a bound anion) -- flagged so # users don't read a positive value as a stable anion. write( f" OVGF electron affinity (LUMO) = " f"{-_lumo_q.eps_qp * EV:.4f} eV" f" (small-basis EA -- qualitative only)\n" ) if 2 * nbf <= 60: # Renormalized GF2 (full third-order self-energy + geometric # screening) -- trims the bare-GF2 overcorrection back toward # experiment. Small systems only (the third-order contractions # scale steeply). See vibeqc.propagator (renormalized second- # order GF; lineage Cederbaum 1975 / von Niessen 1984). from .propagator import renormalized_quasiparticle_energies as _ren_qp _ren = _ren_qp( eri_ao, C, C, eps_mo, eps_mo, nocc, nocc, [("alpha", nocc - 1)], iterate=True, )[0][1] write( f" GF2(renorm) ionization potential (HOMO) = " f"{-_ren.eps_qp * EV:.4f} eV" f" (renormalized; pole Z={_ren.pole_strength:.3f})\n" ) write("\n") flush() result = _OVGFAugmented(result, qp, method=_effective_method) # Atomization energy (S E_atom - E_mol) at the molecule's mean-field # level. Free-atom ground-state references are computed at the same # level (cached per element). Mean-field methods only (a post-SCF # method's atomization would need atomic post-SCF references too). if ( atomization and method in ("rhf", "uhf", "rks", "uks", "rohf", "roks") and bool(getattr(result, "converged", False)) ): try: from .atomization import atomization_energy as _atomization _atm = _atomization( molecule, float(result.energy), method, basis, functional=functional ) _Hk = 627.509474063 _slog.emit( "atomization", method=method, functional=functional, e_molecule=_atm.e_molecule, e_atoms_sum=_atm.e_atoms_sum, atomization=_atm.atomization, atomization_per_atom=_atm.atomization_per_atom, atomic_energies={ str(z): e for z, e in _atm.atomic_energies.items() }, ) _flabel = f"/{functional}" if functional else "" write("\n ## Atomization energy\n " + "-" * 52 + "\n") write( f" Free-atom references ({method.upper()}{_flabel}/{basis}):\n" ) for _z in sorted(_atm.atomic_energies): write( f" Z = {_z:<3d} E = {render_energy_labeled(_atm.atomic_energies[_z], width=16, precision=10)}\n" ) write( f" S E(atoms) = {render_energy_labeled(_atm.e_atoms_sum, width=16, precision=10)}\n" f" E(molecule) = {render_energy_labeled(_atm.e_molecule, width=16, precision=10)}\n" f" Atomization energy = {render_energy_labeled(_atm.atomization, width=16, precision=10)}" f" ({_atm.atomization_kcal:9.2f} kcal/mol)\n" f" per atom (n={_atm.n_atoms}) = " f"{_atm.atomization_per_atom * _Hk:9.2f} kcal/mol\n\n" ) flush() except Exception as _atm_exc: write( f"\n (warning: atomization not available: " f"{type(_atm_exc).__name__}: {_atm_exc})\n\n" ) flush() # Post-SCF dispersion (D3-BJ) as an additive correction. # Captured in a local so the composite-total sum below is # independent of whether ``result`` got wrapped in # _DispersionAugmented -- a SolverResult is never wrapped (it has # no .e_dispersion attribute), and reading the term back off the # result then silently dropped D3-BJ from the composite total # (audit F1.2). e_d3bj = 0.0 if d3_params is not None: disp = compute_d3bj(molecule, d3_params) e_d3bj = float(disp.energy) e_scf = float(getattr(result, "energy", 0.0)) e_total = e_scf + e_d3bj # Name the variant so the log distinguishes the two-body # correction from the ATM-inclusive one (s9 != 0). _d3_label = "D3-BJ(ATM)" if d3_params.s9 else "D3-BJ" write( f"\n Dispersion correction ({_d3_label})\n" " " + "-" * 52 + "\n" f" {'s6':>10s} {d3_params.s6:14.6f}\n" f" {'s8':>10s} {d3_params.s8:14.6f}\n" f" {'a1':>10s} {d3_params.a1:14.6f}\n" f" {'a2':>10s} {d3_params.a2:14.6f}\n" f" {'s9':>10s} {d3_params.s9:14.6f}\n" f" {'E_disp':>10s} {render_energy_labeled(disp.energy, width=14, precision=8)}" f" ({disp.energy * 627.5094740631:+.4f} kcal/mol)\n" f" {'E_SCF':>10s} {render_energy_labeled(e_scf, width=14, precision=8)}\n" f" {'E_total':>10s} {render_energy_labeled(e_total, width=14, precision=8)}\n" ) # Wrap the SCF result so callers can access both components # without breaking existing accesses to mo_coeffs, fock, etc. if not isinstance(result, SolverResult): result = _DispersionAugmented( result, e_d3bj, d3_params, method=_effective_method ) # ---- Composite 3c post-SCF corrections (D4 + gCP + SRB) ------- # Only runs when a composite recipe is active. Each piece is # additive and logged separately; the composite total is the # SCF energy + every correction. e_d4 = 0.0 e_gcp = 0.0 e_srb = 0.0 if use_d4: if not dftd4_available(): raise ImportError( "composite recipe requests D4 dispersion but the " "optional 'dftd4' package is not installed.\n " "Install via `pip install -e '.[dispersion]'` or " "`pip install dftd4`." ) # dftd4 ships per-composite damping keyed on the composite # name (e.g. 'r2scan-3c', 'wb97x-3c') -- distinct from the # parent-functional damping. Prefer the composite name. d4_func = ( composite_recipe.name if composite_recipe is not None else (functional or "hf") ) d4_result = compute_d4( molecule, d4_func, charge=float(molecule.charge), ) e_d4 = float(d4_result.energy) write( "\n Dispersion correction (D4)\n" " " + "-" * 52 + "\n" f" {'functional':>10s} {d4_result.functional!s:>14s}\n" f" {'E_disp':>10s} {render_energy_labeled(e_d4, width=14, precision=8)}" f" ({e_d4 * 627.5094740631:+.4f} kcal/mol)\n" ) if composite_recipe is not None and composite_recipe.gcp_basis: # Damped composites (pbeh-3c / hse-3c / r2scan-3c) carry a # GCPDamping; pass it through as (dmp_scal, dmp_exp). Undamped # recipes (hf-3c, b3lyp-3c) leave it None. b97-3c has # gcp_basis=None (SRB-only) so this block is skipped for it. _gcp_damping = ( ( composite_recipe.gcp_damping.dmp_scal, composite_recipe.gcp_damping.dmp_exp, ) if composite_recipe.gcp_damping is not None else None ) try: gcp_result = compute_gcp( molecule, composite_recipe.gcp_basis, variant=composite_recipe.gcp_variant, damping=_gcp_damping, ) e_gcp = float(gcp_result.energy) # The four fit constants are per (method, basis); surface which # set was used so a shared parent basis (pbeh-3c / b3lyp-3c / # hse-3c all on def2-mSVP) is not mistaken for a shared fit. _gcp_variant = gcp_result.params.variant or "(basis default)" write( "\n Geometric counterpoise correction (gCP)\n" " " + "-" * 52 + "\n" f" {'basis':>10s} {gcp_result.basis_name!s:>14s}\n" f" {'params':>10s} {_gcp_variant!s:>14s}\n" f" {'E_gCP':>10s} {render_energy_labeled(e_gcp, width=14, precision=8)}" f" ({e_gcp * 627.5094740631:+.4f} kcal/mol)\n" ) except GCPDataMissing as exc: # gCP data table not bundled for this basis -- log and # continue with E_gCP = 0 so the composite total is # explicitly marked incomplete rather than silently # wrong. Same posture as the dispersion builtin-stub. write( "\n Geometric counterpoise correction (gCP)\n" " " + "-" * 52 + "\n" f" SKIPPED -- {exc}\n" ) if composite_recipe is not None and composite_recipe.sr_mod is not None: srb = composite_recipe.sr_mod atomic_numbers = [int(a.Z) for a in molecule.atoms] positions = [list(a.xyz) for a in molecule.atoms] e_srb = float(srb.evaluate(atomic_numbers, positions)) write( f"\n {srb.name}\n" " " + "-" * 52 + "\n" f" {'E_SRB':>10s} {render_energy_labeled(e_srb, width=14, precision=8)}" f" ({e_srb * 627.5094740631:+.4f} kcal/mol)\n" ) if composite_recipe is not None: composite_total = ( float(getattr(result, "energy", 0.0)) + e_d3bj + e_d4 + e_gcp + e_srb ) write( f"\n Composite total ({composite_recipe.name})\n" " " + "-" * 52 + "\n" f" {'E_SCF':>10s} " f"{render_energy_labeled(float(getattr(result, 'energy', 0.0)), width=14, precision=8)}\n" f" {'E_disp':>10s} " f"{render_energy_labeled(e_d3bj + e_d4, width=14, precision=8)}\n" f" {'E_gCP':>10s} {render_energy_labeled(e_gcp, width=14, precision=8)}\n" f" {'E_SRB':>10s} {render_energy_labeled(e_srb, width=14, precision=8)}\n" f" {'E_total':>10s} {render_energy_labeled(composite_total, width=14, precision=8)}\n" ) has_mos = hasattr(result, "mo_energies") or hasattr(result, "mo_energies_alpha") if write_molden_file and has_mos: with ( plog.stage("write_molden", detail=str(molden_path.name)), PerfScope("write_molden"), ): _output_writer.dispatch_role( "orbitals", only_format="molden", molecule=molecule, basis=basis_obj, result=result, title=str(output_stem.name), raise_on_error=True, ) write(f"\n Molecular orbitals written to {molden_path.name}\n") flush() # Population dump (Phase O6). Always-on for molecular runs by # default -- cheap to compute (the SCF density is in memory), # cheap to write, and downstream tooling shouldn't have to # screen-scrape the .out for charges + bond orders + dipole. # The matching block stays in .out for human reading; the # .txt + .json siblings are the machine-readable form. _population_summary = None _population_written = False if write_population_file and has_mos: pop_txt = output_stem.parent / (output_stem.name + ".population.txt") try: with ( plog.stage("write_population", detail=str(pop_txt.name)), PerfScope("write_population"), ): from vibeqc.output.formats.population import ( compute_population_summary, ) _population_summary = compute_population_summary( result, basis_obj, molecule, ) _output_writer.dispatch_role( "population", result=result, basis=basis_obj, molecule=molecule, population_summary=_population_summary, raise_on_error=True, ) _population_written = True write( f" Population dump written to " f"{pop_txt.name} + " f"{output_stem.name}.population.json\n" ) flush() except Exception as _pop_exc: write( f" (warning: population dump failed: " f"{type(_pop_exc).__name__}: {_pop_exc})\n" ) flush() warn_writer_failure( _pop_exc, pop_txt, role="population_summary", category=OutputFailureKind.optional_artifact, writer=_output_writer, ) # Volumetric cubes (Phase O6). Opt-in via write_cube=: True / # "density" / "homo" / "lumo" / list-of-those-or-int-indices. # Each cube is wrapped in its own try/except so a single # grid-evaluation failure on one MO doesn't block the others. _cube_req = parse_write_cube_kwarg(write_cube) if _cube_req and has_mos: if _cube_req.density: try: with ( plog.stage("write_cube_density"), PerfScope("write_cube_density"), ): cube_path = _output_writer.dispatch_role( "density", only_format="cube", result=result, basis=basis_obj, molecule=molecule, cube_spacing=cube_spacing, cube_padding=cube_padding, raise_on_error=True, )[0] write(f" Density cube written to {cube_path.name}\n") flush() except Exception as _cube_exc: write( f" (warning: density cube write failed: " f"{type(_cube_exc).__name__}: {_cube_exc})\n" ) flush() warn_writer_failure( _cube_exc, output_stem.with_suffix(".density.cube"), role="density_cube", category=OutputFailureKind.optional_artifact, writer=_output_writer, ) if _cube_req.mo_labels: try: _mo_targets = requested_mo_indices( list(_cube_req.mo_labels), result, molecule, ) except Exception as _exc: write( f" (warning: MO label resolution failed: " f"{type(_exc).__name__}: {_exc})\n" ) warn_writer_failure( _exc, output_stem.with_suffix(".cube"), role="mo_label_resolution", category=OutputFailureKind.optional_artifact, writer=_output_writer, ) _mo_targets = [] for _idx, _name in _mo_targets: try: with ( plog.stage(f"write_cube_mo_{_name}"), PerfScope(f"write_cube_mo_{_name}"), ): _mo_expected = output_stem.parent / ( output_stem.name + f".{_name}.cube" ) _mo_path = _output_writer.dispatch_role( "orbital_vol", only_path=_mo_expected, result=result, basis=basis_obj, molecule=molecule, mo_index=_idx, display_name=_name, cube_spacing=cube_spacing, cube_padding=cube_padding, raise_on_error=True, )[0] write(f" MO cube written to {_mo_path.name}\n") flush() except Exception as _cube_exc: write( f" (warning: MO {_name} cube write failed: " f"{type(_cube_exc).__name__}: {_cube_exc})\n" ) flush() warn_writer_failure( _cube_exc, output_stem.with_suffix(f".{_name}.cube"), role=f"mo_cube_{_name}", category=OutputFailureKind.optional_artifact, writer=_output_writer, ) # Citations (Phase O5b). Assemble the reference list for this # job (software + libint + libxc-if-DFT + basis-set + # functional + DIIS + dispersion-if-used + ECP-if-used) and # emit {stem}.bibtex + .references siblings. The matching # "## References" block is appended to the .out a few lines # below so the text log carries the same references. Failures # are non-fatal: a routing miss or a writer crash leaves a # warning in the .out and the SCF result is unaffected. cite_block_text: str | None = None _refs = None if citations: try: _cite_db = load_default_database() _dispersion_key = None _dispersion_params_key = None if d3_params is not None: # The database routes "d3bj" -> Grimme 2010 + 2011. _dispersion_key = "d3bj" elif use_d4: # "d4" -> Caldeweyher 2019 (method paper), plus -- # for parametrizations fit outside that paper -- # the damping-parameter fit paper via # routes.dispersion_params["d4:<key>"]. The key # mirrors the damping lookup in the post-SCF D4 # block above: composite name when a 3c recipe is # active, else the SCF functional ("hf" for pure # Hartree-Fock + D4). _dispersion_key = "d4" _dispersion_params_key = normalize_d4_key( composite_recipe.name if composite_recipe is not None else (functional or "hf") ) # Detect direct SCF usage for citation routing. _uses_direct = _detect_direct_scf( resolved_method, rhf_options, uhf_options, rks_options, uks_options, basis, molecule, ) # SCF accelerator picked up from opts.scf_accelerator -- # fires the matching DIIS / EDIIS / ADIIS / KDIIS # citation instead of always crediting plain DIIS. _scf_accel = _detect_scf_accelerator( resolved_method, rhf_options, uhf_options, rks_options, uks_options, ) # ECP detection -- libecpint citation only fires when # at least one options struct carries an ecp_centers # list (manual ECPCenter recipe or auto_ecp_centers). _uses_ecp = _detect_uses_ecp( rhf_options, uhf_options, rks_options, uks_options, ) # CPCM solvation citation fires when run_job was # invoked with a non-None ``solvent``; vibe-qc on main # only ships CPCM-style models, so the variant key # defaults to "cpcm". _uses_cpcm = solvent is not None # For composite-3c jobs (hf-3c / pbeh-3c / ...) route # citations by the *composite keyword*, not the # resolved mean-field method -- the composite carries # its own defining paper (+ gCP / D3) in # routes.methods. ``composite_recipe`` is None for # non-composite jobs, in which case the resolved # method name is the right routing key. # Post-SCF correlation methods run an RHF/UHF SCF first # (resolved_method = "rhf"/"uhf"), but their citation key is # the *original* method -- routes.methods.{ccsd,mp2,scs-mp2,...} # carry the correlation papers (Moller-Plesset, Grimme SCS, # Jung SOS, Purvis-Bartlett ...). Routing on resolved_method # would silently drop those references. _mp2_cite_method = None if _mp2_used_density_fit and method in ("mp2", "scs-mp2", "sos-mp2"): _mp2_cite_method = { "mp2": "ri-mp2", "scs-mp2": "scs-ri-mp2", "sos-mp2": "sos-ri-mp2", }[method] _cite_method = ( composite_recipe.name if composite_recipe is not None else _mp2_cite_method if _mp2_cite_method is not None else _effective_method if _effective_method in _POSTSCF_CITE_METHODS else resolved_method ) # MLIP engines (method="mace") evaluate no Gaussian # integrals and run no SCF -- suppress the always-on # libint + DIIS routes so the references reflect what # actually ran. The MACE / e3nn / PyTorch citations come # in via routes.methods[<mlip>] instead. _is_mlip = resolved_method in _MLIP_METHODS # INDO-family engines (MSINDO molecular + SECCM) use # analytic Slater (STO) integrals, not libint Gaussians -- so the # always-on libint integral citation must not fire (they do use # Pulay DIIS, so uses_scf stays on). _is_indo = resolved_method in ("msindo", "ccm") # Per-model MLIP foundation-model citation: the model # actually run (result.model_info) decides which foundation # paper to cite (MPA-0 -> Batatia 2024; OFF23 -> Kovács # 2023). The MACE *method* paper fires via the static # routes.methods.mace route. _mlip_extra: list[str] = [] if _is_mlip: _mi = getattr(result, "model_info", None) if _mi is not None and getattr(_mi, "citation", ""): _mlip_extra.append(_mi.citation) # UNO-CAS starting reference (Pulay-Hamilton 1988): cite when # an open-shell determinant-solver job resolved to the UHF # natural-orbital reference (the open-shell default; see the # cas_reference resolution in _run_single_point). if ( resolved_method in ( "cisd", "selected_ci", "dmrg", "v2rdm", "transcorrelated_ci", "casci", "mrci", "casscf", "nevpt2", "caspt2", ) and ( cas_reference or ("uno" if molecule.multiplicity > 1 else "rhf") ) == "uno" ): _mlip_extra.append("pulay_hamilton_uno_1988") # Spin-pure ROHF starting reference (Roothaan 1960): cite when # a determinant-solver job used cas_reference="rohf". if ( resolved_method in ( "cisd", "selected_ci", "dmrg", "v2rdm", "transcorrelated_ci", "casci", "mrci", "casscf", "nevpt2", "caspt2", ) and cas_reference == "rohf" ): _mlip_extra.append("roothaan_rohf_1960") # Multi-state CASPT2: the effective-Hamiltonian formalism # (Finley 1998) fires for both modes; the extended (XMS) # rotation adds Granovsky 2011 + Shiozaki 2011. if ( resolved_method == "caspt2" and caspt2_options is not None and caspt2_options.multistate is not None ): _mlip_extra.append("finley_ms_caspt2_1998") if caspt2_options.multistate == "xms": _mlip_extra.append("granovsky_xmcqdpt2_2011") _mlip_extra.append("shiozaki_xms_caspt2_2011") # Relaxed MS/XMS gradient: the SA-MCSCF state-specific # gradient formalism (Stalring 2001) + the (MS-)CASPT2 # analytic-gradient Lagrangian (Celani-Werner 2003). if getattr(result, "gradient", None) is not None: _mlip_extra.append("stalring_sa_mcscf_gradient_2001") _mlip_extra.append("celani_werner_ms_caspt2_gradient_2003") elif ( resolved_method == "caspt2" and caspt2_options is not None and caspt2_options.compute_corr_grad and getattr(result, "gradient", None) is not None ): # Single-state internally-contracted CASPT2 analytic # Lagrangian (Celani-Werner 2003). _mlip_extra.append("celani_werner_ms_caspt2_gradient_2003") # Selected-CI CASSCF backend: the CI inside the macro- # iteration is the CIPSI selection algorithm (the static # routes only fire for method="selected_ci"). if ( resolved_method == "casscf" and casscf_options is not None and casscf_options.ci_solver == "selected_ci" ): _mlip_extra.append("huron_malrieu_cipsi_1973") _mlip_extra.append("holmes_tubman_umrigar_shci_2016") # The Epstein-Nesbet PT2 stage on the selected # wavefunction cites the semistochastic-HCI paper # (the routes.methods key fires only for a literal # selected_ci_pt2 method; this hook is the live # path for the CASSCF composition). if getattr(casscf_options, "pt2", None) is not None: _mlip_extra.append("sharma_semistochastic_hci_2017") # Integral/exchange acceleration (RI-J Coulomb fitting / # RIJCOSX chain-of-spheres exchange) from the density_fit / # cosx flags on the resolved options struct. _accel = _detect_acceleration( resolved_method, rhf_options, uhf_options, rks_options, uks_options, ) # Second-order convergers (SOSCF / TRAH) cite their defining # papers when armed by a positive threshold. _uses_soscf, _uses_trah = _detect_soscf_trah( resolved_method, rhf_options, uhf_options, rks_options, uks_options, ) # Saunders-Hillier level shift (opts.level_shift or an # explicit level_shift_schedule) is a cited convergence # technique. _uses_level_shift = _detect_level_shift( resolved_method, rhf_options, uhf_options, rks_options, uks_options, ) # Explicit non-AUTO SCF initial guess (SAD / SAP / extended # Hückel) carries a defining-paper route. _scf_guess = _detect_scf_guess( resolved_method, rhf_options, uhf_options, rks_options, uks_options, ) # Analytic-gradient + vibrational-analysis drivers. The # Hessian block (further below) runs a finite-difference # Hessian *of the analytic atomic gradient*, but only when the # SCF converged -- so the Hessian and gradient driver # citations gate on convergence. Geometry optimisation also # evaluates the analytic gradient at every step. Neither # applies to MLIP engines, whose forces come from the model # (cited via routes.methods[<mlip>]), not Pulay/Hellmann- # Feynman theory. _uses_basis_free = ( resolved_method in SEMIEMPIRICAL_METHODS or resolved_method in _MLIP_METHODS ) _uses_hessian = ( bool(hessian) and bool(getattr(result, "converged", False)) and not _uses_basis_free ) _uses_gradient = ( bool(optimize) or _uses_hessian ) and not _uses_basis_free # Population analysis (Mulliken / Löwdin charges + Mayer bond # orders) is computed and surfaced together whenever the # population dump runs (Phase O6, above) -- cite each analysis # the user actually sees in the .population.* siblings + .out. # Hirshfeld is best-effort on top (Becke-Lebedev grid + SAD # promolecule, either of which can fail independently): cite it # only when it actually computed and reached the .out Atomic- # charges table for *this* job. `_prop_status["hirshfeld"]` is # the success flag format_scf_trace recorded above -- gating on # it (rather than the route merely existing) keeps us from # over-claiming on a grid/promolecule build failure (Sec. 8). _props: list[str] = [] if _population_written: # Wiberg rides the same population dump (v0.22.0 BOND). # NPA is independently fail-closed until the production # occupancy-weighted NAO route exists, so cite it only # when this job actually emitted non-empty NPA rows. _props = [ "mulliken", "lowdin_charges", "mayer_bond_order", "wiberg", ] if ( _population_summary is not None and bool(_population_summary.npa_atoms) and not _population_summary.errors.get("npa") ): _props.append("npa") if _prop_status.get("hirshfeld"): _props.append("hirshfeld") if nto: _props.append("nto") _refs = _cite_db.assemble( method=_cite_method, cc_density_fit=_cc_used_density_fit, basis=basis, functional=functional, dispersion=_dispersion_key, dispersion_params=_dispersion_params_key, scf_accelerator=_scf_accel, # ASE is cited only when its BFGS optimizer actually ran; # the native / brent / geomopt backends do not use ASE. uses_ase=_used_ase_optimizer, # Geometry-optimization algorithm + coordinate system. Only # the uniform geomopt path (geom_opt set) carries a # keyword-selected optimizer / coordinate citation; the # legacy ase/native/brent backends leave these None. geom_optimizer=( geom_opt if _used_geomopt_optimizer else None ), geom_coords=( geom_coords if _used_geomopt_optimizer else None ), uses_ecp=_uses_ecp, uses_cpcm=_uses_cpcm, # MSINDO COSMO uses its GEPOL cavity (+ Silla 1991); the # HF/DFT Lebedev path keeps the default "cpcm" bundle. solvent_variant=( "cosmo_gepol" if (_uses_cpcm and resolved_method == "msindo") else None ), direct_scf=_uses_direct, dft_plus_u=bool(dft_plus_u), uses_integrals=not (_is_mlip or _is_indo), uses_scf=not _is_mlip, acceleration=_accel, uses_soscf=_uses_soscf, uses_trah=_uses_trah, uses_level_shift=_uses_level_shift, scf_guess=_scf_guess, uses_gradient=_uses_gradient, uses_hessian=_uses_hessian, uses_tddft=tddft, tddft_variant=tddft_type if tddft else None, properties=_props, extra_entries=_mlip_extra, ) # Feed the full assembled provenance into the .system # manifest's [citations] section (CLAUDE.md Sec.8.3/Sec.8.5), # before writing the user-facing siblings so a brittle # .bibtex write can't deprive .system of the record. _output_writer.set_citations(_citation_manifest_rows(_refs)) _output_writer.dispatch_role( "citations", citations=_refs, raise_on_error=True, ) cite_block_text = format_references_block(_refs) _bib_name = output_stem.with_suffix(".bibtex").name _ref_name = output_stem.with_suffix(".references").name write(f" Citations written to {_bib_name} + {_ref_name}\n") flush() except Exception as _cite_exc: write( f" (warning: citation emission failed: " f"{type(_cite_exc).__name__}: {_cite_exc})\n" ) flush() warn_writer_failure( _cite_exc, output_stem.with_suffix(".bibtex"), role="citations", category=OutputFailureKind.optional_artifact, writer=_output_writer, ) # Final geometry as a plain XYZ sibling (Phase O3). ``molecule`` # at this point is the geometry the SCF actually ran on -- the # optimised one when ``optimize=True``, otherwise the input. # Energy in Hartree is appended to the comment line so ASE / # Open Babel can recover the SCF result from the .xyz alone. if write_xyz_file: xyz_path = output_stem.with_suffix(".xyz") try: energy_ha = float(getattr(result, "energy", float("nan"))) except (TypeError, ValueError): energy_ha = float("nan") try: with ( plog.stage("write_xyz", detail=str(xyz_path.name)), PerfScope("write_xyz"), ): _output_writer.dispatch_role( "geometry", only_format="xyz", molecule=molecule, energy_ha=(None if energy_ha != energy_ha else energy_ha), raise_on_error=True, ) write(f" Final geometry written to {xyz_path.name}\n") flush() except Exception as _xyz_exc: # Geometry writer is best-effort -- never let a `.xyz` # failure tank a finished SCF. The user can always # re-run with ``write_xyz_file=False``. write( f" (warning: .xyz writer failed: " f"{type(_xyz_exc).__name__}: {_xyz_exc})\n" ) flush() warn_writer_failure( _xyz_exc, xyz_path, role="final_xyz_geometry", category=OutputFailureKind.optional_artifact, writer=_output_writer, ) # Structured log: properties event. Best-effort -- the helpers # raise on basis-set shapes vibe-qc's Python side can't # parse; a failure here must never tank the run, so we wrap # each section. The event only fires when at least one # property was successfully computed. if _slog.enabled and has_mos: _props_payload: dict[str, Any] = {} try: from .properties import ( dipole_moment as _dipole, ) from .properties import ( loewdin_charges as _low, ) from .properties import ( mulliken_charges as _mul, ) _props_payload["mulliken"] = [ float(x) for x in _mul(result, basis_obj, molecule) ] _props_payload["loewdin"] = [ float(x) for x in _low(result, basis_obj, molecule) ] _dip = _dipole(result, basis_obj, molecule) _props_payload["dipole"] = { "x": float(_dip.x), "y": float(_dip.y), "z": float(_dip.z), "total": float(_dip.total), "total_debye": float(_dip.total_debye), } except Exception as _props_exc: # Properties are best-effort; the trace already # carries a degraded properties block, the structured # log just skips the event. Surface a warning so the # user knows the structured-log `properties` event was # dropped. warn_output_failure( _props_exc, output_stem.with_suffix(".out"), role="structured_log_properties", category=OutputFailureKind.compatibility_fallback, ) if _props_payload: _slog.emit("properties", **_props_payload) if optimize: if _opt_backend == "ase" and traj_path.is_file(): write(f" Optimization trajectory written to {traj_path.name}\n") flush() t_total = time.perf_counter() - t_job_start n_iter = getattr(result, "n_iter", 0) iter_avg = (t_scf / n_iter) if n_iter > 0 else float("nan") write("\n Timings (wall clock, seconds)\n " + "-" * 52 + "\n") if optimize: write(f" {'Geometry optimization':<32s} {render_duration(t_opt)}\n") if resolved_method in _MLIP_METHODS: # MLIP: a single forward pass, not an SCF -- honest phase label, # no per-iteration average. write(f" {'MLIP energy evaluation':<32s} {render_duration(t_scf)}\n") elif isinstance(result, SolverResult): write(f" {'Solver total':<32s} {render_duration(t_scf)}\n") write( f" {'Solver avg. per iteration':<32s} " f"{render_duration(iter_avg)} ({n_iter} iters)\n" ) else: write(f" {'SCF total':<32s} {render_duration(t_scf)}\n") write( f" {'SCF avg. per iteration':<32s} {render_duration(iter_avg)} ({n_iter} iters)\n" ) write(f" {'Job total':<32s} {render_duration(t_total)}\n") write( f" Used {threads_in_use} OpenMP thread" f"{'s' if threads_in_use != 1 else ''}.\n" ) flush() plog.info(f"Job total {t_total:.2f}s -- output written to {out_path}") # Post-SCF additive corrections (D3-BJ / D4 / gCP / SRB) are NOT # part of ``energy`` (kept as the bare SCF/solver energy for # backward compatibility -- the .energy convention of the # returned result object). When any correction is active the # event carries the explicit split so machine consumers can take # e_total instead of silently under-reporting the method energy # (the glycine SI matrix recorded PBE0-D3(BJ) totals that were # missing the -0.0097 Ha dispersion term this way). _corr_fields: dict = {} _e_corr_sum = e_d3bj + e_d4 + e_gcp + e_srb if d3_params is not None or use_d4 or e_gcp != 0.0 or e_srb != 0.0: # ``result.energy`` is the bare pre-correction energy in every # case: the _DispersionAugmented wrapper forwards ``.energy`` # to the underlying SCF result, and SolverResults are never # wrapped. Same value the .out D3 block prints as E_SCF. _e_scf_bare = float(getattr(result, "energy", 0.0)) _corr_fields = dict( e_scf=_e_scf_bare, e_dispersion=float(e_d3bj + e_d4), e_total=float(_e_scf_bare + _e_corr_sum), ) if e_gcp != 0.0 or e_srb != 0.0: _corr_fields["e_gcp"] = float(e_gcp) _corr_fields["e_srb"] = float(e_srb) _slog.emit( "job_end", total_wall_s=float(t_total), scf_wall_s=float(t_scf), opt_wall_s=float(t_opt), n_iter=int(getattr(result, "n_iter", 0)), converged=bool(getattr(result, "converged", False)), energy=float(getattr(result, "energy", 0.0)), out_path=str(out_path), **_corr_fields, ) # --- Harmonic vibrational analysis (finite-difference Hessian) --- _thermo_result_for_qvf = None if hessian: _scf_converged = bool(getattr(result, "converged", False)) if not _scf_converged: write( "\n ## Vibrational Frequencies\n" " " + "-" * 52 + "\n" " SKIPPED -- SCF did not converge.\n" ) flush() elif ( resolved_method in SEMIEMPIRICAL_METHODS or resolved_method in _MLIP_METHODS ): write( "\n ## Vibrational Frequencies\n" " SKIPPED -- finite-difference Hessians are currently " "available only for Gaussian-basis molecular methods.\n\n" ) flush() else: with ( plog.stage("hessian_fd", detail="finite-difference Hessian"), PerfScope("hessian_fd"), ): try: from .hessian import ( HessianFDOptions, compute_hessian_fd, ir_intensities, ) _hess_scf_opts = { "rhf": rhf_options, "uhf": uhf_options, "rks": rks_options, "uks": uks_options, "rohf": rohf_options, }.get(resolved_method) _hess_opts = HessianFDOptions( include_dipole_derivatives=True, frozen_indices=hessian_frozen_indices, ) hessian_result = compute_hessian_fd( molecule, basis, method=resolved_method.upper(), scf_options=_hess_scf_opts, hessian_options=_hess_opts, ) write( f"\n ## Vibrational Frequencies\n" f" " + "-" * 52 + "\n" f" Finite-difference Hessian" f" (step = {_hess_opts.step_bohr:.3f} bohr," f" {hessian_result.n_displacements} displacements)\n" f" Imaginary modes: {hessian_result.imaginary_count}\n" f" Linear molecule: {hessian_result.is_linear}\n\n" ) _n_skip = 5 if hessian_result.is_linear else 6 _freqs = hessian_result.frequencies_cm1 _ir = None try: _ir = ir_intensities(hessian_result) except Exception as _ir_exc: write( f" (warning: IR intensities not available: " f"{type(_ir_exc).__name__}: {_ir_exc})\n" ) # The frequency table through the document-layer # Table primitive: columns + rule size to content, # instead of the old hardcoded "-" * 52 rule and # fixed 6/10/12 widths. The IR column is present only # when intensities computed. Freq magnitudes render # via render_frequency (cm-1, policy-aware); imaginary # modes keep the trailing "i", so the column is text. _freq_cols = [ Column("Mode", "<"), Column("Freq/cm\u207b\u00b9", ">"), ] if _ir is not None: _freq_cols.append(Column("IR/(km/mol)", ">")) _freq_tbl = Table(_freq_cols) for k in range(_n_skip, len(_freqs)): _label = f"{k - _n_skip + 1}" _freq = _freqs[k] if _freq < 0: _freq_str = f"{render_frequency(abs(_freq))}i" else: _freq_str = render_frequency(_freq) if _ir is not None: _freq_tbl.add_row( _label, _freq_str, f"{_ir[k]:.2f}" ) else: _freq_tbl.add_row(_label, _freq_str) write(_freq_tbl.render() + "\n\n") flush() # Thermochemistry (RRHO ideal gas) from the harmonic # frequencies -- ZPE + thermal U/H/S/G at T, p. Reuses # the general vibeqc.thermo engine; method-agnostic # (any reference that produced the Hessian). _thermo_result_for_qvf = None try: from .thermo import ( ThermoOptions as _ThermoOptions, ) from .thermo import ( compute_thermochemistry as _compute_thermo, ) _topts = thermo_options or _ThermoOptions() _thermo = _compute_thermo( molecule, hessian_result, options=_topts ) _thermo_result_for_qvf = _thermo _Hced = 627.509474063 # Hartree -> kcal/mol _slog.emit( "thermochemistry", temperature=float(_topts.temperature), pressure=float(_topts.pressure), symmetry_number=int(_topts.symmetry_number), rotor_type=_thermo.rotor_type, zpe=float(_thermo.zpe), u_thermal=float(_thermo.u_thermal), h_thermal=float(_thermo.h_thermal), g_thermal=float(_thermo.g_thermal), s_total=float(_thermo.s_total), e_scf=float(getattr(result, "energy", float("nan"))), ) _e0 = float(getattr(result, "energy", float("nan"))) write( " ## Thermochemistry (RRHO ideal gas)\n" " " + "-" * 52 + "\n" f" T = {render_temperature(_topts.temperature, precision=2)} K" f" p = {_topts.pressure:.0f} Pa" f" s_rot = {_topts.symmetry_number}" f" rotor = {_thermo.rotor_type}\n" ) if _thermo.n_imaginary_modes_excluded: write( f" (excluded {_thermo.n_imaginary_modes_excluded}" f" imaginary mode(s) from the partition function)\n" ) write( f" Zero-point energy = {render_energy_labeled(_thermo.zpe, width=16, precision=10)}" f" ({_thermo.zpe * _Hced:9.3f} kcal/mol)\n" f" Thermal corr. to U = {render_energy_labeled(_thermo.u_thermal, width=16, precision=10)}\n" f" Thermal corr. to H = {render_energy_labeled(_thermo.h_thermal, width=16, precision=10)}\n" f" Thermal corr. to G = {render_energy_labeled(_thermo.g_thermal, width=16, precision=10)}\n" f" Total entropy S = {render_energy_labeled(_thermo.s_total, width=16, precision=10)}/K" f" ({_thermo.s_total * _Hced * 1000:8.3f} cal/mol/K)\n" ) if _e0 == _e0: # not NaN write( f" E(elec) + ZPE = {render_energy_labeled(_e0 + _thermo.zpe, width=16, precision=10)}\n" f" H = E(elec) + H_corr = {render_energy_labeled(_e0 + _thermo.h_thermal, width=16, precision=10)}\n" f" G = E(elec) + G_corr = {render_energy_labeled(_e0 + _thermo.g_thermal, width=16, precision=10)}\n" ) write("\n") flush() except Exception as _thermo_exc: write( f" (warning: thermochemistry not available: " f"{type(_thermo_exc).__name__}: {_thermo_exc})\n\n" ) flush() except Exception as _hess_exc: write( f"\n ## Vibrational Frequencies\n" f" " + "-" * 52 + "\n" f" FAILED: {type(_hess_exc).__name__}: {_hess_exc}\n" ) flush() hessian_result = None # TD-DFT excited states (output to .out; NTOs handled in QVF below). _tddft_result = None _is_uhf = False # set by TD-DFT block below if tddft and has_mos and bool(getattr(result, "converged", False)): try: from vibeqc.tddft import ( run_tddft_casida as _run_tddft_casida, ) from vibeqc.tddft import ( run_tddft_casida_uhf as _run_tddft_casida_uhf, ) from vibeqc.tddft import ( run_tddft_tda as _run_tddft_tda, ) from vibeqc.tddft import ( run_tddft_tda_uhf as _run_tddft_tda_uhf, ) _is_uhf = bool( hasattr(result, "mo_coeffs_alpha") and not hasattr(result, "mo_coeffs") ) _is_rohf = bool( hasattr(result, "mo_coeffs") and not hasattr(result, "mo_coeffs_alpha") and molecule.multiplicity > 1 ) _density_ao = ( np.asarray(result.density, dtype=float) if getattr(result, "density", None) is not None else None ) if _is_uhf: _n_el = molecule.n_electrons() _mult = molecule.multiplicity _n_occ_a = int(getattr(result, "n_occ_a", 0) or 0) _n_occ_b = int(getattr(result, "n_occ_b", 0) or 0) if _n_occ_a <= 0 and _n_occ_b <= 0: _n_occ_a = (_n_el + (_mult - 1)) // 2 _n_occ_b = _n_el - _n_occ_a _uhf_tddft_runner = ( _run_tddft_casida_uhf if tddft_type == "casida" else _run_tddft_tda_uhf ) _tddft_result = _uhf_tddft_runner( molecule, basis_obj, np.asarray(result.mo_energies_alpha, dtype=float), np.asarray(result.mo_energies_beta, dtype=float), np.asarray(result.mo_coeffs_alpha, dtype=float), np.asarray(result.mo_coeffs_beta, dtype=float), _n_occ_a, _n_occ_b, n_states=tddft_n_states, functional=functional, density_alpha_ao=np.asarray( result.density_alpha, dtype=float, ), density_beta_ao=np.asarray( result.density_beta, dtype=float, ), ) elif _is_rohf: from vibeqc.tddft import ( run_tddft_tda_rohf as _run_tddft_tda_rohf, ) _n_el = molecule.n_electrons() _mult = molecule.multiplicity _n_docc = (_n_el - (_mult - 1)) // 2 _n_socc = _mult - 1 _tddft_result = _run_tddft_tda_rohf( molecule, basis_obj, np.asarray(result.mo_energies, dtype=float), np.asarray(result.mo_coeffs, dtype=float), _n_docc, _n_socc, n_states=tddft_n_states, ) elif tddft_type == "casida": _tddft_result = _run_tddft_casida( molecule, basis_obj, np.asarray(result.mo_energies, dtype=float), np.asarray(result.mo_coeffs, dtype=float), molecule.n_electrons() // 2, n_states=tddft_n_states, functional=functional, density_ao=_density_ao, ) else: _tddft_result = _run_tddft_tda( molecule, basis_obj, np.asarray(result.mo_energies, dtype=float), np.asarray(result.mo_coeffs, dtype=float), molecule.n_electrons() // 2, n_states=tddft_n_states, functional=functional, density_ao=_density_ao, ) # Emit to .out file. _td_method = _tddft_result.method _td_func = f" ({functional})" if functional else "" write( f"\n ## TD-DFT excited states" f" ({_td_method}{_td_func})\n" f" {'─' * 50}\n" ) # Excited-states table via the document-layer Table # primitive: columns + rule size to content instead of the # old fixed 6/10/10/10 widths. The trailing dominant- # transition column is free text, left-aligned. _es_tbl = Table([ Column("State", ">"), Column("E (eV)", ">"), Column("λ (nm)", ">"), Column("f_osc", ">"), Column("Dominant transition", "<"), ]) for _st in _tddft_result.states: _dom_str = ", ".join( f"{occ}{virt} ({abs(amp):.3f})" for occ, virt, amp in _st.dominant_amplitudes[:3] ) _es_tbl.add_row( _st.index, f"{_st.excitation_energy_ev:.4f}", f"{_st.wavelength_nm:.1f}", f"{_st.oscillator_strength:.4f}", _dom_str, ) write(_es_tbl.render() + "\n\n") flush() # UV/Vis spectrum (Gaussian-broadened stick spectrum). if tddft_spectrum and _tddft_result.states: _emin = min( _st.excitation_energy_ev for _st in _tddft_result.states ) _emax = max( _st.excitation_energy_ev for _st in _tddft_result.states ) _pad = 1.0 # eV _sigma = 0.15 # eV (typical vibronic broadening) _npts = 200 _egrid = np.linspace(max(0, _emin - _pad), _emax + _pad, _npts) _spec = np.zeros(_npts) for _st in _tddft_result.states: _spec += _st.oscillator_strength * np.exp( -0.5 * ((_egrid - _st.excitation_energy_ev) / _sigma) ** 2 ) _spec_max = float(_spec.max()) if _spec.max() > 0 else 1.0 _spec /= _spec_max # normalise to 1 write( f"\n ## UV/Vis spectrum (Gaussian, σ={_sigma} eV)\n" f" {'─' * 50}\n" ) write( f" {'E (eV)':>10s} {'Intensity':>10s} " f"{'├' + '─' * 40 + '┤'}\n" ) _step = max(1, _npts // 40) for _k in range(0, _npts, _step): _bar = "█" * max(0, min(40, int(_spec[_k] * 40))) write(f" {_egrid[_k]:>10.4f} {_spec[_k]:>10.4f} {_bar}\n") write("\n") flush() except Exception as _tddft_exc: write( f"\n ## TD-DFT excited states\n" f" {'─' * 50}\n" f" FAILED: {type(_tddft_exc).__name__}:" f" {_tddft_exc}\n" ) flush() _tddft_result = None # FD excited-state nuclear gradient (opt-in, costly). if tddft_gradient and _tddft_result is not None and _tddft_result.states: try: from vibeqc.excited_gradient import ( cis_state_gradients_fd as _cis_grad_fd, ) from vibeqc.excited_gradient import ( make_hf_cis_energy_fn as _make_hf_cis_energy_fn, ) _mol = molecule _Z = [int(a.Z) for a in _mol.atoms] _coords = np.array([a.xyz for a in _mol.atoms], dtype=float) _fn = _make_hf_cis_energy_fn( _Z, basis, charge=_mol.charge, spin="singlet" if _mol.multiplicity == 1 else "triplet", n_states=min(tddft_n_states, 10), ) _grads = _cis_grad_fd(_fn, _coords, states=[1]) _grad1 = _grads.get(1) if _grad1 is not None: write( f"\n ## Excited-state gradient (S₁, FD, step=1e-3 Å)\n" f" {'─' * 50}\n" ) write( f" {'Atom':>6s} {'dE/dx':>12s}" f" {'dE/dy':>12s} {'dE/dz':>12s}\n" ) for _k, _g in enumerate(_grad1): write( f" {_k + 1:>6d} " f"{_g[0]:>12.8f} " f"{_g[1]:>12.8f} " f"{_g[2]:>12.8f}\n" ) write("\n") flush() except Exception as _tdgrad_exc: write( f"\n ## Excited-state gradient\n" f" {'─' * 50}\n" f" FAILED: {type(_tdgrad_exc).__name__}:" f" {_tdgrad_exc}\n" ) flush() # "## References" block (Phase O5b) -- the citation footer, emitted # LAST so it follows every post-SCF results block (vibrational # analysis, thermochemistry, TD-DFT) instead of appearing mid-report # ahead of them. Embeds the assembled citation list so the .out is # self-contained; goes through the citation printer's own channel # writer (mirrors write_scf_trace). For a plain SCF job the post-SCF # blocks above are all skipped, so this lands in the same place the # earlier emission did -- the .out is byte-identical there. if cite_block_text: write_references_block(block=cite_block_text) flush() # --- QVF visualisation archive (v1) ---------------------------------- if output_qvf: try: from vibeqc.output.formats.qvf import ( scf_history_from_result as _scf_history_from_result, ) _qvf_path_for_warn = output_stem.with_suffix(".qvf") _qvf_scf_history = _scf_history_from_result(result) _bibtex = None if _refs is not None: try: from vibeqc.output.citations.bibtex import format_bibtex _bibtex = format_bibtex(_refs) except Exception as _bib_exc: warn_output_failure( _bib_exc, _qvf_path_for_warn, role="qvf_bibtex_prep", category=OutputFailureKind.compatibility_fallback, ) _pop = _population_summary _qvf_bond_orders = None _qvf_dipole = None if write_population_file and has_mos: try: from vibeqc.output.formats.population import ( compute_population_summary, ) if _pop is None: _pop = compute_population_summary( result, basis_obj, molecule, ) # Extract bond-order table for bond_orders QVF section. if _pop.mayer_bonds: import math _pos = [(a.xyz[0], a.xyz[1], a.xyz[2]) for a in molecule.atoms] _qvf_bond_orders = { "method": "mayer", "pairs": [ { "i": int(i), "j": int(j), "order": float(order), "symbol_i": si, "symbol_j": sj, "distance_ang": float( math.dist(_pos[i], _pos[j]) * 0.529177210903 ), } for (i, j, si, sj, order) in _pop.mayer_bonds ], } # Extract dipole moment for manifest root metadata. if _pop.dipole is not None: _qvf_dipole = { "total_debye": float(_pop.dipole["total_debye"]), "vector_debye": [ float(_pop.dipole["x_ebohr"]) * 2.541746473, float(_pop.dipole["y_ebohr"]) * 2.541746473, float(_pop.dipole["z_ebohr"]) * 2.541746473, ], "origin": str(_pop.dipole.get("origin_bohr", "origin")), } except Exception as _qpop_exc: warn_output_failure( _qpop_exc, _qvf_path_for_warn, role="qvf_population_prep", category=OutputFailureKind.compatibility_fallback, ) _qvf_vol_data = None if has_mos and _cube_req.density: try: from vibeqc.output.formats.qvf import qvf_density_data _qvf_vol_data = qvf_density_data( result, basis_obj, molecule, spacing=cube_spacing, padding=cube_padding, ) except Exception as _qvol_exc: warn_output_failure( _qvol_exc, _qvf_path_for_warn, role="qvf_density_prep", category=OutputFailureKind.compatibility_fallback, ) _qvf_mo_list = None if has_mos and _cube_req.mo_labels: try: from vibeqc.output.formats.qvf import qvf_mo_data _indices = requested_mo_indices( _cube_req.mo_labels, result, molecule ) _qvf_mo_list = qvf_mo_data( result, basis_obj, molecule, [int(i) for i, _ in _indices], spacing=cube_spacing, padding=cube_padding, ) except Exception as _qmo_exc: warn_output_failure( _qmo_exc, _qvf_path_for_warn, role="qvf_mo_prep", category=OutputFailureKind.compatibility_fallback, ) # Package the wavefunction for the QVF archive so vibe-view # can resample any MO on demand (wavefunction.gto section). # Mirrors the periodic runner; gated on write_molden_file so # the .molden sidecar and the embedded basis+MO stay in sync. _qvf_wf = None if write_molden_file and has_mos: try: from vibeqc.output.formats.qvf import qvf_wf_data _qvf_wf = qvf_wf_data(result, basis_obj, molecule) except Exception as _qwf_exc: warn_output_failure( _qwf_exc, _qvf_path_for_warn, role="qvf_wavefunction_prep", category=OutputFailureKind.compatibility_fallback, ) # QTAIM topological analysis (opt-in, QVF-bound). _qvf_qtaim = None if qtaim and has_mos and bool(getattr(result, "converged", False)): try: from vibeqc.qtaim import qtaim_analysis, qtaim_result_to_qvf _qtaim_result = qtaim_analysis( result, basis_obj, molecule, ) _qvf_qtaim = qtaim_result_to_qvf(_qtaim_result) except Exception as _qtaim_exc: warn_output_failure( _qtaim_exc, _qvf_path_for_warn, role="qvf_qtaim_prep", category=OutputFailureKind.compatibility_fallback, ) # TD-DFT excited states + NTO analysis (opt-in, QVF-bound). # TD-DFT computation runs before this block (inside the with-open # context so it can write to the .out file). Here we only compute # NTOs and package them for QVF. _qvf_nto = None if tddft and _tddft_result is not None and nto and _tddft_result.states: try: from vibeqc.output.formats.qvf import ( qvf_wf_data as _qvf_wf_data, ) from vibeqc.tddft import ( compute_nto as _compute_nto, ) from vibeqc.tddft import ( compute_nto_uhf as _compute_nto_uhf, ) _gs_wf = _qvf_wf_data(result, basis_obj, molecule) _shells = _gs_wf["basis"] if _gs_wf is not None else [] _pure = bool(_gs_wf.get("pure", True)) if _gs_wf else True _n_basis = basis_obj.nbasis _nto_list: list[dict[str, object]] = [] if _is_uhf: # UHF: alpha and beta NTOs for each state. _n_occ_a = int(getattr(result, "n_occ_a", 0)) _n_occ_b = int(getattr(result, "n_occ_b", 0)) for _state in _tddft_result.states: _nto_uhf = _compute_nto_uhf( _state, np.asarray(result.mo_coeffs_alpha, dtype=float), np.asarray(result.mo_coeffs_beta, dtype=float), _n_occ_a, _n_occ_b, ) for _spin, _nto_res in _nto_uhf.items(): _hole_coeffs = np.ascontiguousarray( np.asarray( _nto_res.hole_orbitals_ao, dtype=np.float64, ).T ) _n_hole = int(_hole_coeffs.shape[0]) _part_coeffs = np.ascontiguousarray( np.asarray( _nto_res.particle_orbitals_ao, dtype=np.float64, ).T ) _n_part = int(_part_coeffs.shape[0]) _nto_list.append( { "hole": { "basis": _shells, "structure_ref": "structure", "pure": _pure, "mo_metadata": { "n_mo": _n_hole, "n_ao": _n_basis, "spin": "restricted", "orbital_kind": "natural", "energies": [0.0] * _n_hole, "occupations": [ _nto_res.hole_weights[i] for i in range(_n_hole) ], }, "mo_coefficients": _hole_coeffs, }, "electron": { "basis": _shells, "structure_ref": "structure", "pure": _pure, "mo_metadata": { "n_mo": _n_part, "n_ao": _n_basis, "spin": "restricted", "orbital_kind": "natural", "energies": [0.0] * _n_part, "occupations": [ _nto_res.particle_weights[i] for i in range(_n_part) ], }, "mo_coefficients": _part_coeffs, }, "state_index": int(_state.index), "excitation_energy_ev": float( _state.excitation_energy_ev ), "spin": _spin, } ) else: # Restricted: single-spin NTOs. _n_occ = molecule.n_electrons() // 2 for _state in _tddft_result.states: _nto_res = _compute_nto( _state, np.asarray(result.mo_coeffs, dtype=float), _n_occ, ) _hole_coeffs = np.ascontiguousarray( np.asarray( _nto_res.hole_orbitals_ao, dtype=np.float64 ).T ) _n_hole = int(_hole_coeffs.shape[0]) _hole_wf: dict[str, object] = { "basis": _shells, "structure_ref": "structure", "pure": _pure, "mo_metadata": { "n_mo": _n_hole, "n_ao": _n_basis, "spin": "restricted", "orbital_kind": "natural", "energies": [0.0] * _n_hole, "occupations": [ _nto_res.hole_weights[i] for i in range(_n_hole) ], }, "mo_coefficients": _hole_coeffs, } _part_coeffs = np.ascontiguousarray( np.asarray( _nto_res.particle_orbitals_ao, dtype=np.float64 ).T ) _n_part = int(_part_coeffs.shape[0]) _electron_wf: dict[str, object] = { "basis": _shells, "structure_ref": "structure", "pure": _pure, "mo_metadata": { "n_mo": _n_part, "n_ao": _n_basis, "spin": "restricted", "orbital_kind": "natural", "energies": [0.0] * _n_part, "occupations": [ _nto_res.particle_weights[i] for i in range(_n_part) ], }, "mo_coefficients": _part_coeffs, } _nto_list.append( { "hole": _hole_wf, "electron": _electron_wf, "state_index": int(_state.index), "excitation_energy_ev": float( _state.excitation_energy_ev ), } ) _qvf_nto = _nto_list # Write NTO molden files for visualisation. if tddft_molden and _nto_list: try: for _entry in _nto_list: _si = _entry["state_index"] _hole_wf = _entry["hole"] _electron = _entry["electron"] _e_ev = _entry.get("excitation_energy_ev", 0) _spin_sfx = ( f"_{_entry['spin']}" if "spin" in _entry else "" ) for _role, _wf, _sfx in [ ("hole", _hole_wf, "hole"), ("electron", _electron, "electron"), ]: _mo_c = np.asarray( _wf["mo_coefficients"] ).T # (n_ao, n_mo) _n_mo = _mo_c.shape[1] _fake = type( "_FakeResult", (), { "mo_coeffs": _mo_c, "mo_energies": [0.0] * _n_mo, }, )() _path = output_stem.with_suffix( f".nto_S{_si}{_spin_sfx}_{_sfx}.molden" ) _output_writer.dispatch_role( "orbitals", runtime_path=_path, runtime_format="nto-molden", runtime_description=( "Natural transition orbital " f"{_role} Molden file for state {_si}." ), molecule=molecule, basis=basis_obj, result=_fake, raise_on_error=True, ) except Exception as _nto_mol_exc: # This block runs after the .out channel has # closed, so the warning cannot land in the # .out file. It never did: the old code called # ``f.write`` on the already-closed handle, # which raised ValueError, which the enclosing # handler below then reported as a # "qvf_tddft_nto_prep" failure -- masking the # real cause. Route it through the non-fatal # output-failure taxonomy, which needs no # channel and records the true role. warn_output_failure( _nto_mol_exc, output_stem.with_suffix(".nto.molden"), role="nto_molden", category=OutputFailureKind.compatibility_fallback, ) except Exception as _tddft_exc: warn_output_failure( _tddft_exc, _qvf_path_for_warn, role="qvf_tddft_nto_prep", category=OutputFailureKind.compatibility_fallback, ) _qvf_path = _output_writer.dispatch_role( "qvf", molecule=molecule, result=result, method=resolved_method, basis=basis, functional=functional, population_summary=_pop, bibtex_content=_bibtex, wall_seconds=t_total, trajectory_frames=_traj_frames if _traj_frames else None, trajectory_energies=_traj_energies if _traj_energies else None, volume_data=_qvf_vol_data, mo_data=_qvf_mo_list, wf_data=_qvf_wf, hessian_result=hessian_result, scf_history_data=_qvf_scf_history, bond_orders_data=_qvf_bond_orders, dipole_moment_data=_qvf_dipole, qtaim_data=_qvf_qtaim, nto_data=_qvf_nto, thermochemistry_data=( { "zpve_eh": float(_thermo_result_for_qvf.zpe), "enthalpy_eh": float( getattr(result, "energy", 0.0) + _thermo_result_for_qvf.h_thermal ), "entropy_cal_mol_k": float( _thermo_result_for_qvf.s_total * 627.509474063 * 1000 ), "gibbs_free_energy_eh": float( getattr(result, "energy", 0.0) + _thermo_result_for_qvf.g_thermal ), "temperature_k": float(_thermo_result_for_qvf.temperature), "pressure_atm": float( _thermo_result_for_qvf.pressure / 101325.0 ), } if _thermo_result_for_qvf is not None else None ), raise_on_error=True, )[0] except Exception as _qvf_exc: warn_writer_failure( _qvf_exc, output_stem.with_suffix(".qvf"), role="qvf_archive", category=OutputFailureKind.optional_artifact, writer=_output_writer, ) # Finalise the {output}.system manifest (pre-v1.0 dispatch- # overhaul -- replaces the bare end-of-job write_system_manifest # call). The OutputWriter, constructed before the SCF, already # wrote the [vibeqc] / [host] / [cpu] / ... / [plan] sections + # [outputs].status = "running". Here we (1) record coordinator-owned # streams that cannot use single-shot dispatch (.out / perf / # structured / trajectory), and (2) flip the status to "complete" with # the final wall-time. Single-shot artefacts were recorded inline by # dispatch_role. Non-converged molecular SCF routes raise before this # point so failed wavefunctions cannot be mistaken for publishable # outputs. # The manifest lifecycle owns its own self-excluded outcome row: hashing # it here would necessarily become stale when finish() rewrites the file. _stream_roles = {"log", "perf", "structured", "trajectory"} for _pf in _output_plan.files: if _pf.role not in _stream_roles: continue if _pf.path.is_file(): try: _output_writer.record(_pf.path) except Exception as _rec_exc: # record() stats + hashes the file; a brittle write # at wrap-up must never tank a finished job. We do # surface it though -- a manifest with stale rows # confuses ``vq fetch``. warn_writer_failure( _rec_exc, _pf.path, role=f"manifest_record_{_pf.role}", category=OutputFailureKind.manifest_recording, writer=_output_writer, ) _output_writer.finish(wall_seconds=t_total) if _tddft_result is not None: try: result.tddft = _tddft_result except AttributeError: # pybind11 result types (RHFResult, RKSResult, ...) don't # carry __dict__ and object.__setattr__ also fails; # dynamic_attr() on the C++ side is the real fix. # For now, the TDDFT result is still accessible through # the QVF output; we just can't attach it to the SCF result. pass # Terminal checkpoint frame: label the checkpoint QVF "converged" so a # live viewer knows the job settled (final geometry + converged SCF). if _checkpointer.enabled: _checkpointer.finalize( "converged", molecule=molecule, result=result, method=resolved_method, basis=basis, functional=functional, ) return result
__all__ = ["run_job"]