Source code for vibesketch.inputs.base
"""The input-writer interface.
A writer turns one species and one program-neutral job specification into
the input files of one program. It fails closed (AGENTS.md, ground rule 5):
before rendering anything it lists everything in the request it cannot
express, and if that list is not empty it raises :class:`WriterRefusal` with
the reasons instead of guessing a keyword. The vibe-qc QCSchema adapter's
stance is the model: reject what you cannot represent, never drop it.
Subclasses implement :meth:`InputWriter._refusals` and
:meth:`InputWriter._render`. :meth:`InputWriter.write` is the only public
entry point and always runs the checks first, so no subclass can skip them.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import Sequence
from dataclasses import dataclass
from typing import ClassVar
from vibesketch.document.model import Conformer, JobSpec, Species, Task
__all__ = [
"InputBundle",
"InputFile",
"InputWriter",
"WriterRefusal",
]
[docs]
@dataclass(frozen=True)
class InputFile:
"""One file of a program input."""
#: Suggested file name, without directories.
name: str
content: str
media_type: str
[docs]
@dataclass(frozen=True)
class InputBundle:
"""Everything a writer produced for one job."""
program: str
species: str
files: tuple[InputFile, ...]
[docs]
class WriterRefusal(ValueError):
"""The writer cannot express the request; ``reasons`` says what and why."""
def __init__(self, program: str, reasons: Sequence[str]) -> None:
self.program = program
self.reasons = tuple(reasons)
super().__init__(f"{program} cannot write this input:\n " + "\n ".join(self.reasons))
#: How each specification field reads in a refusal.
_FIELD_LABELS = {
"method": "method",
"basis": "basis set",
"aux_basis": "auxiliary basis set",
"approximation": "integral approximation",
"dispersion": "dispersion correction",
"solvent": "solvation model",
"states": "number of excited states",
"constraints": "geometry constraint",
"resources": "resources",
}
[docs]
class InputWriter(ABC):
"""Translate a job specification into one program's input, or refuse."""
#: The registry name, also the value of ``Job.program``.
name: ClassVar[str]
#: One line for listings.
description: ClassVar[str]
#: The tasks this writer can express.
tasks: ClassVar[frozenset[Task]]
#: The specification fields, besides ``task``, this writer can express.
fields: ClassVar[frozenset[str]] = frozenset()
[docs]
def refusals(self, species: Species, spec: JobSpec) -> list[str]:
"""Everything in the request this writer cannot express; empty if it can."""
reasons: list[str] = []
if spec.task not in self.tasks:
supported = ", ".join(sorted(self.tasks))
reasons.append(
f"{self.name} cannot express task {spec.task!r}; it supports: {supported}"
)
for field in (*JobSpec.calculation_fields(), "resources"):
value = getattr(spec, field)
if value is not None and field not in self.fields:
shown = (
value
if isinstance(value, (str, int))
else [c.model_dump(exclude_none=True) for c in value]
if isinstance(value, list)
else value.model_dump(exclude_none=True)
)
reasons.append(
f"{self.name} cannot express a {_FIELD_LABELS[field]} ({field}={shown!r})"
)
from vibesketch.document.solids import solid_refusal
solid = solid_refusal(self.name, species)
if solid:
return [*reasons, *solid] # a solid has no structure: nothing else applies (#142)
reasons.extend(self._refusals(species, spec))
return reasons
[docs]
def write(self, species: Species, spec: JobSpec) -> InputBundle:
"""The input files for ``species`` and ``spec``; raises :class:`WriterRefusal`."""
reasons = self.refusals(species, spec)
if reasons:
raise WriterRefusal(self.name, reasons)
return InputBundle(self.name, species.id, tuple(self._render(species, spec)))
@abstractmethod
def _refusals(self, species: Species, spec: JobSpec) -> list[str]:
"""Writer-specific reasons to refuse, beyond the task and fields."""
@abstractmethod
def _render(self, species: Species, spec: JobSpec) -> Sequence[InputFile]:
"""Produce the files; only called when there is nothing to refuse."""
def needs_conformer(writer: str, species: Species) -> list[str]:
"""The refusal for a species without 3D coordinates, if it has none."""
if species.conformers:
return []
return [
f"{writer} needs 3D coordinates, and species {species.id} has no conformer; "
"embed it in 3D first"
]
def first_conformer(species: Species) -> Conformer:
"""The conformer writers use: the first, the one the document ranks highest."""
return species.conformers[0]