Source code for vibesketch.inputs.recipe

"""Resolve a recipe's stages into the calls vibe-qc would run, refusing the rest.

A recipe (:class:`vibesketch.document.model.Recipe`) is program-neutral. This
module spells each stage as the keywords of vibe-qc's ``run_job``, the way
:mod:`vibesketch.inputs.vibeqc` spells a job, so a reviewer sees exactly what
a launch would send, and refuses, with the reason, every option the route
cannot express (AGENTS.md, ground rule 5): a dispersion correction on the
molecular DFT and Hessian routes until vibe-qc propagates it (vibe-qc #548),
a functional or basis vibe-qc does not resolve, a MACE run without the
operator's licence acknowledgement, which a recipe never carries. Nothing is
translated into a different method.

A recipe runs as QVF job containers (:mod:`vibesketch.document.campaign`),
and a container carries only the options vibe-qc can read back from JSON. A
stage that needs one it cannot is refused here, at review and compile time,
rather than by vibe-qc when the campaign is already running: vibe-qc
0.17.23 has no container encoding for ``dlpno_ccsd_options``
(``vibeqc._qvf_options._OPAQUE_RECORDS``), so a DLPNO stage cannot pin its
``triples_mode`` (vibe-qc #558, found by the 2026-10-20 ethene pilot).

The vocabulary comes from vibe-qc's source: ``method="mace"`` with
``MLIPOptions(model, device, dtype, weight_sha256, accept_academic_license)``;
``method="rks"`` with ``functional``, ``basis``, ``density_fit``, ``cosx``,
``grid_level``, ``rks_options`` (``conv_tol_energy``, ``conv_tol_grad``),
``optimize`` with ``optimizer_backend="ase"``, ``fmax`` and
``max_opt_steps``, and ``hessian`` with the finite-difference displacement;
``method="dlpno-ccsd(t)"`` with ``dlpno_thresholds`` (``loose``, ``normal``,
``tight``), ``frozen_core`` (``chemical`` or ``all-electron``) and
``dlpno_ccsd_options={"triples_mode": ...}``.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from typing import Any

from vibesketch.document.model import Recipe, Species, Stage
from vibesketch.inputs.vibeqc import _DISPERSION_INCLUDED, _FUNCTIONALS, _basis

__all__ = ["ResolvedStage", "resolve_recipe", "resolve_stage"]

#: vibe-qc's preset names for the published PNO threshold sets.
_THRESHOLDS = {"LoosePNO": "loose", "NormalPNO": "normal", "TightPNO": "tight"}

#: ``run_job`` keywords a QVF job container cannot carry, and why (vibe-qc 0.17.23).
_CONTAINER_UNENCODED = {
    "dlpno_ccsd_options": (
        "vibe-qc's QVF job containers have no encoding for dlpno_ccsd_options, so the "
        "stage's triples_mode cannot be pinned (vibe-qc #558); the stage is refused rather "
        "than left to vibe-qc's default, and vibe-qc's triples keyword, which selects the "
        "canonical CCSD triples variant, is no substitute"
    ),
}


[docs] @dataclass(frozen=True) class ResolvedStage: """What one stage of a recipe would run, or why it cannot.""" stage: str kind: str program: str #: The ``run_job`` keywords, in vibe-qc's names; empty when refused. keywords: dict[str, Any] = field(default_factory=dict) #: Settings the operator supplies at launch, never saved in the recipe. at_launch: dict[str, str] = field(default_factory=dict) #: What the stage's validator checks on the result, in the recipe's units. checks: dict[str, Any] = field(default_factory=dict) refusals: list[str] = field(default_factory=list)
[docs] @property def refused(self) -> bool: return bool(self.refusals)
[docs] def to_dict(self) -> dict[str, Any]: return { "stage": self.stage, "kind": self.kind, "program": self.program, "keywords": dict(self.keywords), "at_launch": dict(self.at_launch), "checks": dict(self.checks), "refusals": list(self.refusals), }
def _surface_keywords(surface: Any, refusals: list[str]) -> dict[str, Any]: functional = _FUNCTIONALS.get(surface.functional.lower()) if functional is None: refusals.append( f"vibe-qc's XC table does not resolve the functional {surface.functional!r}; " "it is not swapped for another" ) basis = _basis(surface.basis) if basis is None: refusals.append(f"vibe-qc ships no basis set {surface.basis!r}") if surface.dispersion is not None: if functional in _DISPERSION_INCLUDED: refusals.append( f"{surface.functional} carries its own dispersion; a {surface.dispersion} " "correction on top is not expressible" ) else: refusals.append( f"a {surface.dispersion} correction is not expressible on vibe-qc's molecular " "DFT and finite-difference Hessian routes until vibe-qc #548 propagates it; " "the publication baseline runs without dispersion" ) return { "method": "rks", "functional": functional, "basis": basis, "density_fit": surface.density_fit, "cosx": surface.cosx, "grid_level": surface.grid, "dispersion": None, "rks_options": { "conv_tol_energy": surface.scf_energy_tolerance, "conv_tol_grad": surface.scf_gradient_tolerance, }, }
[docs] def resolve_stage(stage: Stage, species: Species | None = None) -> ResolvedStage: """``stage`` as vibe-qc's ``run_job`` keywords, or the reasons it is refused. ``species`` adds the charge and multiplicity vibe-qc's ``Molecule`` takes; a species with an unpaired electron makes the Kohn-Sham route ``uks``. """ refusals: list[str] = [] keywords: dict[str, Any] = {} at_launch: dict[str, str] = {} checks: dict[str, Any] = {} if species is not None: keywords["charge"] = species.charge keywords["multiplicity"] = species.multiplicity if stage.kind == "prepare": return ResolvedStage(stage.id, stage.kind, "vibe-sketch", {"method": "etkdg+mmff94"}) if stage.kind == "mace" and stage.mace is not None: options = stage.mace keywords |= { "method": "mace", "optimize": True, "optimizer_backend": "ase", "fmax": options.fmax_ev_per_angstrom, "max_opt_steps": options.max_steps, "mlip_options": { "model": options.model, "device": options.device, "dtype": options.dtype, "weight_sha256": options.weight_sha256, }, } at_launch["accept_academic_license"] = ( "an eligible operator acknowledges the model's licence when launching; a recipe " "never carries the acknowledgement" ) checks = {"fmax_ev_per_angstrom": options.fmax_ev_per_angstrom} elif stage.kind == "dft" and stage.dft is not None: keywords |= _surface_keywords(stage.dft.surface, refusals) keywords |= { "optimize": True, "optimizer_backend": stage.dft.optimizer, "fmax": stage.dft.fmax_ev_per_angstrom, "max_opt_steps": stage.dft.max_steps, } checks = {"fmax_ev_per_angstrom": stage.dft.fmax_ev_per_angstrom} elif stage.kind == "hessian" and stage.hessian is not None: keywords |= _surface_keywords(stage.hessian.surface, refusals) keywords["hessian"] = True # vibe-qc's finite-difference Hessian steps its default 0.005 bohr; # the recipe's displacement and mode threshold are what the # validator checks, not run_job keywords. checks = { "displacement_bohr": stage.hessian.displacement_bohr, "imaginary_mode_threshold_cm": stage.hessian.imaginary_mode_threshold_cm, } elif stage.kind == "dlpno" and stage.dlpno is not None: dlpno = stage.dlpno basis = _basis(dlpno.basis) if basis is None: refusals.append(f"vibe-qc ships no basis set {dlpno.basis!r}") aux = None if dlpno.aux_basis is not None: aux = _basis(dlpno.aux_basis) if aux is None: refusals.append(f"vibe-qc ships no fitting basis {dlpno.aux_basis!r}") if species is not None and species.multiplicity != 1: refusals.append( "the DLPNO stage is specified for a closed-shell reference; " f"{species.id} has multiplicity {species.multiplicity}" ) keywords |= { "method": dlpno.method, "basis": basis, "aux_basis": aux, "dlpno_thresholds": _THRESHOLDS[dlpno.thresholds], "frozen_core": dlpno.frozen_core, "dlpno_ccsd_options": {"triples_mode": dlpno.triples_mode}, } else: # unreachable for a validated stage refusals.append(f"stage {stage.id} has no options for its kind {stage.kind!r}") refusals.extend(why for key, why in _CONTAINER_UNENCODED.items() if key in keywords) if species is not None and species.multiplicity != 1 and keywords.get("method") == "rks": keywords["method"] = "uks" keywords["uks_options"] = keywords.pop("rks_options") return ResolvedStage( stage.id, stage.kind, "vibeqc", {} if refusals else keywords, at_launch, checks, refusals, )
[docs] def resolve_recipe(recipe: Recipe, species: dict[str, Species]) -> dict[str, list[ResolvedStage]]: """Every stage of ``recipe`` resolved per species id, in stage order.""" return { identifier: [resolve_stage(stage, species[identifier]) for stage in recipe.stages] for identifier in recipe.species if identifier in species }