"""The value objects that cross the stage <-> backend boundary (Phase 1.1).
This module is **pure data** -- stdlib only, no I/O, no third-party imports -- so
everything else (stages, backends, the orchestrator, tests) can depend on it
without dragging anything in.
The types encode the tool's central thesis: **separate *what* to compute from
*where* it runs**. A stage describes work as a portable ``JobSpec`` ("run this
command; here are its input files; it needs these resources") and never names a
host, a queue, or a transport. A backend turns that spec into a real execution --
a local subprocess today, a remote SSH/SLURM job later -- and reports back through
these same types. Keeping them free of any backend detail is what makes "run the
screening stage on my GPU box instead" a one-line config change.
The second load-bearing idea is **durability**: ``matmelt run`` is an
on-demand tool the user may interrupt at any time. Every reference to in-flight
work (``Handle``) must therefore be plain persistable identity -- a PID, a SLURM
id -- never a live object, so an interrupted run can be resumed and its jobs
re-polled instead of orphaned.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from typing import Any
[docs]
class BackendError(Exception):
"""An *infrastructure* failure in the compute plane.
Raised for what the backend itself could not do: an unreachable host, a handle
it does not recognise, a malformed ``JobSpec``, a submission the target
rejected. It is **never** used for a job that ran and exited non-zero -- that
is a normal ``JobResult`` for the stage to judge. The distinction is what lets
the orchestrator treat a sick backend (pause, retry later) differently from a
failed calculation (a per-material verdict).
"""
[docs]
class JobStatus(str, Enum):
"""Where a submitted job is in its lifecycle, as reported by ``poll``.
A ``str`` enum: members compare equal to their plain-string values and
serialise as-is, so a status can be persisted to SQLite or a log line without
custom conversion. The four states are deliberately backend-agnostic -- a
local subprocess and a SLURM job both map onto exactly these (e.g. SLURM
``PD`` -> ``PENDING``, ``R`` -> ``RUNNING``, ``CD`` -> ``DONE``,
``F``/``CA``/``TO`` -> ``FAILED``).
``DONE`` and ``FAILED`` are terminal: once reached, ``poll`` keeps returning
the same status and ``fetch`` becomes meaningful. ``DONE`` means "the backend
ran the job to completion", **not** "the science succeeded" -- inspect the
``JobResult`` for that.
Examples
--------
>>> JobStatus.DONE == "DONE"
True
>>> str(JobStatus.RUNNING.value)
'RUNNING'
"""
PENDING = "PENDING" # accepted / queued, not yet running
RUNNING = "RUNNING" # actively executing
DONE = "DONE" # ran to completion (inspect JobResult for success)
FAILED = "FAILED" # the backend could not run it to completion
@property
def is_terminal(self) -> bool:
"""Whether this status can still change.
Returns
-------
bool
``True`` for ``DONE`` and ``FAILED`` -- the job is over and
``fetch`` is meaningful. ``False`` for ``PENDING`` and ``RUNNING``.
Examples
--------
>>> JobStatus.RUNNING.is_terminal
False
>>> JobStatus.FAILED.is_terminal
True
"""
return self in (JobStatus.DONE, JobStatus.FAILED)
[docs]
@dataclass(frozen=True)
class Resources:
"""A portable hint of what a job needs -- carried by ``JobSpec.resources``.
A stage declares needs here; a backend asserts them against its own
``Capabilities`` before accepting work, so an impossible placement (a GPU
stage on a CPU-only backend) is caught up front rather than as a late job
failure.
Attributes
----------
cores : int
CPU cores the job should be given. A SLURM-wrapping backend maps this
onto ``--cpus-per-task``.
gpu : bool
Whether the job needs a GPU, asserted against ``Capabilities.gpu``.
walltime_s : int or None
Wall-clock limit in seconds, or ``None`` when the stage makes no
promise. A SLURM-wrapping backend may translate it into a time limit;
a local backend may ignore it.
Examples
--------
>>> Resources()
Resources(cores=1, gpu=False, walltime_s=None)
>>> Resources(cores=8, gpu=True, walltime_s=3600).gpu
True
"""
cores: int = 1
gpu: bool = False
walltime_s: int | None = None
[docs]
@dataclass(frozen=True)
class JobSpec:
"""An immutable description of *what* to run -- handed to ``submit``.
Built by a stage (``Stage.build_jobspec`` is the only place a command is
constructed); the only thing that crosses into a backend. It must be runnable
by *any* backend, so it names no host, queue, or transport.
Attributes
----------
command : list of str
The argv to execute -- list form, never a shell string, so there are no
quoting or injection surprises across local vs remote execution.
input_artifacts : dict
Name -> path of files the job needs staged before it runs; a local
backend links them in place, a remote one uploads them. The *name* is
where the file appears **relative to the job's effective cwd**
(symmetric with ``output_artifacts``), so a command may reference a
staged file -- e.g. its runner script -- by bare name. Paths, never
contents: file bodies do not travel inside the spec.
output_artifacts : list of str
Names of files the job is expected to produce, located and collected by
``fetch`` afterwards. A name may denote a directory tree, which is
staged back whole.
resources : Resources
The portable resource hint, asserted against the backend's
``Capabilities``.
env : dict
Environment overlay applied to the job's process.
cwd : str or None
Working directory the command runs in -- the item's directory, so
outputs land beside its science data. ``None`` lets the backend choose.
scratch_inputs : frozenset of str
The subset of ``input_artifacts`` *names* that are the job's own
implementation (its self-contained runner script), not the item's
science data. A backend stages these into its **private per-job dir**
rather than the effective cwd, and rewrites the matching command token
to that staged path, so the material's working directory keeps only
science artifacts. Empty by default (every input staged into cwd).
Examples
--------
A stage builds a spec that names a runner, its input, and its promised
output -- and nothing about where any of it runs:
>>> spec = JobSpec(
... command=["python3", "screen_runner.py", "--structure", "structure.cif"],
... input_artifacts={"screen_runner.py": "/pkg/screen_runner.py",
... "structure.cif": "/work/mat-1/structure.cif"},
... scratch_inputs=frozenset({"screen_runner.py"}),
... output_artifacts=["screen_result.json", "relaxed.cif"],
... cwd="/work/mat-1",
... )
>>> spec.resources.cores
1
"""
command: list[str]
input_artifacts: dict[str, str] = field(default_factory=dict)
output_artifacts: list[str] = field(default_factory=list)
resources: Resources = field(default_factory=Resources)
env: dict[str, str] = field(default_factory=dict)
cwd: str | None = None
scratch_inputs: frozenset[str] = field(default_factory=frozenset)
[docs]
@dataclass(frozen=True)
class Handle:
"""An opaque, **durable** reference to one submitted job.
Returned by ``submit``; passed back to ``poll`` / ``fetch`` / ``release``.
Deliberately not a live object -- just enough identity to find the job again.
Attributes
----------
backend_name : str
Which configured backend issued it, so a handle is never resolved
against the wrong one.
job_id : str
That backend's native id (a PID, a SLURM job id). **Must keep resolving
after the tool restarts** -- never a socket, ``Popen``, or any
in-memory object. This is what lets an interrupted ``matmelt run``
resume and re-poll its in-flight jobs instead of orphaning or
resubmitting them.
extra : dict
Free-form backend bookkeeping (the job dir, stream paths) needed to
reconstruct the job later without any in-memory map. Values must be
JSON-serialisable: the whole handle is persisted to the state store's
in-flight ledger.
Examples
--------
>>> handle = Handle(backend_name="gpubox", job_id="41231",
... extra={"job_dir": "/tmp/matmelt-jobs/mcli-41231"})
>>> handle.job_id
'41231'
"""
backend_name: str
job_id: str
extra: dict[str, Any] = field(default_factory=dict)
[docs]
@dataclass(frozen=True)
class JobResult:
"""What a finished job produced -- returned by ``fetch``.
Meaningful once ``poll`` reports a terminal status.
Attributes
----------
exit_code : int or None
Load-bearing semantics -- do not weaken. ``0`` is conventional success;
non-zero means the job ran and failed, reported **faithfully, never
raised** (judging scientific success is the stage's job); ``None`` means
the job ran but its code could not be obtained (killed before exiting, a
missing exit-code sentinel after a crash). "Ran, code unknown" must
**never** be defaulted to ``0`` -- a stage treats ``None`` as
not-proven-successful.
stdout : str
The job's captured standard output. A backend may truncate very large
streams.
stderr : str
The job's captured standard error, which is where a runner's actionable
failure messages arrive.
output_artifacts : dict
Name -> path of the files named in ``JobSpec.output_artifacts`` that
were actually found. A missing expected file is simply absent from the
dict, for the stage to judge.
Examples
--------
>>> JobResult(exit_code=0, output_artifacts={"record.json": "/work/m/record.json"}).exit_code
0
>>> JobResult(exit_code=None).exit_code is None # ran, code unknown -- not success
True
"""
exit_code: int | None
stdout: str = ""
stderr: str = ""
output_artifacts: dict[str, str] = field(default_factory=dict)
[docs]
@dataclass(frozen=True)
class Capabilities:
"""What a backend can offer -- returned by ``capabilities``.
The counterpart a stage's ``Resources`` needs are asserted against before any
work is submitted.
Attributes
----------
cpu : bool
Whether the backend can run CPU work at all. Effectively always ``True``.
gpu : bool
Whether a GPU is available to jobs on this backend.
slurm : bool
Whether jobs are submitted through a SLURM queue rather than launched
directly.
max_parallel : int
How many jobs the backend will run at once -- advertised here, enforced
by the orchestrator.
extra : dict
Open-ended extras (memory, partitions, accelerators) so a backend can
describe itself more richly without changing this type.
"""
cpu: bool = True
gpu: bool = False
slurm: bool = False
max_parallel: int = 1
extra: dict[str, Any] = field(default_factory=dict)
[docs]
@dataclass(frozen=True)
class HealthStatus:
"""Whether a backend is reachable right now -- returned by ``health_check``.
``health_check`` itself never raises: being down is the *normal answer it
reports*.
Attributes
----------
ok : bool
Whether the backend can take work right now.
detail : str
Short, human-readable reason, shown verbatim in logs and ``doctor``
output. Must **never** contain a secret -- no keys, tokens, or
passwords.
Examples
--------
>>> HealthStatus(ok=False, detail="ssh: connect to host gpubox port 22: No route to host").ok
False
"""
ok: bool
detail: str = ""