Source code for matmelt.core.stages.extract
"""The extract stage: one screened-in structure -> OPTIMADE-shaped record (3.4).
The thin ``Stage`` half of the pair; the logic lives entirely in
``extract_runner.py`` -- which, unlike the other runners, is **stdlib only**:
extraction is bookkeeping, not science, so it runs in any python anywhere
with nothing to install (there is no exit-3 path).
1:1 and the funnel's terminal stage: the runner reads the item's
``relaxed.cif`` (parsed by its own minimal CIF reader -- geometry is
best-effort, a parse failure degrades to a geometry-less record, never a
crash), ``screen_result.json`` (must say ``RELAXED``: extract serves only
screened-in materials), the optional ``structure.json`` provenance from
the structure stage and the optional ``calc_result.json`` from the
calculate stage (folded under ``_mcli.calculate`` -- healing-attempt
history included), and writes ``record.json`` -- an OPTIMADE
``structures``-shaped document ready for a database or an OPTIMADE-serving
layer, with the non-standard extras namespaced under ``_mcli``.
``validate_inputs`` refuses clearly when ``screen_result.json`` does not
parse to a ``RELAXED`` verdict -- someone starting mid-pipeline on a
screened-OUT (or hand-made) item is told exactly why, before any job runs.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import TYPE_CHECKING
from matmelt.core.jobspec import JobSpec
from matmelt.core.stages.base import (
Stage,
StageError,
StageInputError,
StageInputSpec,
StageOutputSpec,
StageVerdict,
)
from matmelt.core.stages.registry import register
if TYPE_CHECKING:
from matmelt.core.jobspec import JobResult
from matmelt.core.stages.base import WorkItem
#: The self-contained (stdlib-only) script shipped with every extract job.
RUNNER_NAME = "extract_runner.py"
#: Inputs from the screen stage -- declared as input artifacts so the Phase-4
#: staging transport knows to push them (in-place locally; staged as a no-op).
RELAXED_NAME = "relaxed.cif"
SCREEN_NAME = "screen_result.json"
#: Optional provenance from the structure stage (space group, lattice type);
#: folded into the record when present, never required.
META_NAME = "structure.json"
#: Optional result from the calculate stage (DFTB+ WorkChain outcome with
#: the full healing-attempt history); folded under ``_mcli.calculate`` when
#: present, never required -- extract stays startable without calculate.
CALC_NAME = "calc_result.json"
#: The OPTIMADE-shaped document this stage produces.
RECORD_NAME = "record.json"
#: How many characters of a failed runner's stderr to surface in the verdict.
_REASON_TAIL = 300
[docs]
class ExtractStage(Stage):
"""Assemble one screened-in material's OPTIMADE-shaped ``record.json``.
Parameters
----------
python
Interpreter to invoke on the backend this stage is wired to. The
runner needs nothing beyond the standard library, so any python
works -- the option exists for backend-environment symmetry with the
other stages, never because a dependency demands it.
Raises
------
StageError
When ``python`` is not a non-empty string.
"""
# No runtime_requirements: stdlib only, by contract -- the base default
# () IS the declaration, and the preflight reports it as always-ready.
def __init__(self, python: str = "python3") -> None:
if not isinstance(python, str) or not python.strip():
raise StageError(f"extract: python must be a non-empty string, got {python!r}")
self.runner_python = python
@property
def name(self) -> str:
return "extract"
@property
def requires(self) -> StageInputSpec:
return StageInputSpec(
files=(RELAXED_NAME, SCREEN_NAME),
description="the relaxed structure + RELAXED screening verdict from the screen stage",
)
@property
def produces(self) -> StageOutputSpec:
return StageOutputSpec(
files=(RECORD_NAME,),
description="OPTIMADE structures-shaped record for the results database",
)
[docs]
def validate_inputs(self, item: WorkItem) -> None:
"""Refuse clearly unless the screening verdict actually says RELAXED.
Extends the base file-presence check by reading the verdict, which is
the mid-pipeline-start guard: extract serves only screened-in
materials.
Parameters
----------
item
The material about to be extracted.
Raises
------
StageInputError
When a required file is missing or empty, when the screening
verdict is unreadable or not valid JSON, or when it names any
category other than ``RELAXED``.
"""
super().validate_inputs(item)
path = item.dir / SCREEN_NAME
try:
verdict = json.loads(path.read_text(encoding="utf-8"))
except OSError as exc:
raise StageInputError(
f"stage 'extract' cannot run on item {item.id!r}: {SCREEN_NAME} is "
f"unreadable: {exc}"
) from exc
except ValueError as exc:
raise StageInputError(
f"stage 'extract' cannot run on item {item.id!r}: {SCREEN_NAME} is "
f"not valid JSON: {exc}"
) from exc
category = verdict.get("category") if isinstance(verdict, dict) else None
if category != "RELAXED":
raise StageInputError(
f"stage 'extract' cannot run on item {item.id!r}: {SCREEN_NAME} says "
f"category {category!r}, but extract serves only RELAXED (screened-in) "
f"materials"
)
[docs]
def build_jobspec(self, item: WorkItem) -> JobSpec:
runner = Path(__file__).with_name(RUNNER_NAME)
input_artifacts = {
RUNNER_NAME: str(runner),
RELAXED_NAME: str(item.dir / RELAXED_NAME),
SCREEN_NAME: str(item.dir / SCREEN_NAME),
}
# The structure stage's provenance file and the calculate stage's
# result are optional enrichments: an item that never ran those
# stages legitimately lacks them. Declare each only when it exists
# -- a declared artifact must be stageable.
if (item.dir / META_NAME).is_file():
input_artifacts[META_NAME] = str(item.dir / META_NAME)
if (item.dir / CALC_NAME).is_file():
input_artifacts[CALC_NAME] = str(item.dir / CALC_NAME)
return JobSpec(
command=[self.runner_python, RUNNER_NAME, "--id", item.id],
input_artifacts=input_artifacts,
output_artifacts=[RECORD_NAME],
cwd=str(item.dir),
scratch_inputs=frozenset({RUNNER_NAME}),
)
[docs]
def interpret_result(self, item: WorkItem, result: JobResult) -> StageVerdict:
"""Judge a finished extract job.
Parameters
----------
item
The material whose job finished.
result
What the backend reported.
Returns
-------
StageVerdict
``ok`` on exit 0, meaning the record was written. Anything else is
a fault carrying the runner's stderr tail, and **never terminal**:
with a stdlib-only runner a failure is a broken input file or an
environment accident, and the item already carries a RELAXED
verdict -- science never rejects here.
"""
if result.exit_code == 0:
return StageVerdict(ok=True)
tail = (result.stderr or result.stdout).strip()[-_REASON_TAIL:]
code = "unknown" if result.exit_code is None else str(result.exit_code)
return StageVerdict(ok=False, reason=f"extract runner exited {code}: {tail}")
register("extract", ExtractStage)