The stage contract#

A stage is autonomous. It declares what it reads, declares what it writes, checks both, builds job descriptions, and judges results. Nothing else in the system knows what a composition or a space group is — which is what lets the orchestrator be small and the stages be replaceable.

Four obligations#

Every stage implements the same four-part contract:

Step

Obligation

validate_inputs

Refuse clearly if the material lacks what this stage needs

build_jobspec

Turn a material into a portable job description

interpret_result

Turn a finished job into a verdict

validate_outputs

Confirm the promised files actually appeared

The first and last exist because of a specific failure: starting in the middle. A funnel is a chain of stages that each assume the previous one ran. Point screen at a material with no structure file and the honest outcomes are “refuse, saying exactly what is missing” or “produce garbage confidently”. The contract makes the first mandatory:

stage 'screen' cannot run on item 'Cl1Na1_3D-1': missing or empty required
input file(s) ['structure.cif'] in materials-work/Cl1Na1_3D-1 (one candidate
crystal from the structure stage)

An empty file counts as missing. A truncated write is a real thing that happens, and a zero-byte structure.cif is not a structure.

validate_outputs closes the other end: a job that exits 0 without producing what it promised has not succeeded. Exit codes are a claim; the files are the evidence.

Verdicts and faults are different, and the difference is doctrine#

When a job comes back, the stage decides which of two things happened:

A verdict is the science answering. The relaxation converged to something unphysical; the DFTB+ WorkChain exhausted its own healing ladders. Re-running reproduces it exactly, so retrying is pure waste. A terminal verdict fails the material immediately, at attempt 1, and spends no retry budget.

A fault is everything else: a crashed process, a missing input, an unreachable backend, a result file that will not parse. It might not recur, so it is retried until retry_budget is spent.

The rule when it is genuinely unclear is written into the code and worth stating plainly: when in doubt, fault. Wrongly calling something a verdict discards a material forever on the strength of a guess. Wrongly calling it a fault costs some compute and then fails it anyway. The costs are not symmetric, so the tie-break is not neutral.

This is why an unparseable verdict file is a fault while a parsed FAILED verdict is terminal, and why a crashed relaxation — which could be an out-of-memory kill — never counts as science.

Declare every file#

A stage names every file it reads and every file it writes, in requires, produces and the job’s artifact lists. On a local run this looks like bookkeeping, because everything already sits in the material’s directory. It stops looking like bookkeeping the moment a stage runs on another machine: the declaration is the transport manifest. Anything undeclared does not travel.

This has bitten in practice, which is why it is a rule rather than a suggestion — a fan-out stage that wrote a whole children/ subtree while declaring only the manifest worked perfectly on a laptop and failed every material as soon as the stage ran over SSH.

The declaration also lets the fake backend fabricate outputs in tests, so a stage’s whole lifecycle is exercisable with no science installed at all.

The science ships as a standalone script#

Each stage is really a pair: a thin Stage class that decides and judges, and a self-contained runner script that computes. The runner imports the standard library and its own science package — never MatMelt. It is shipped to the backend with each job and executed there.

Three properties follow, and each one is load-bearing:

Remote machines need nothing of ours. A job carries its own runner, so there is no package to install on a cluster and no PYTHONPATH to arrange. See Remote execution.

Every stage is debuggable alone. The runner is an ordinary program with --help:

$ python3 screen_runner.py --help
$ python3 structure_runner.py --composition composition.json --dims 3D

When a stage misbehaves, you can run exactly what it ran, in your own shell, with no orchestrator, no database and no config.

The heavy dependency never reaches the base install. Science imports live inside runner functions, so pip install matmelt pulls in no torch, and a missing package produces an actionable message naming the exact install command rather than an import error at startup.

Options are the contract too#

A stage’s constructor signature is its option contract. Config options are unpacked onto it and validated there, so a typo or an out-of-range value is caught while the run is being wired — not after two hundred jobs have failed identically. That is also why the documentation of stage options can be checked against the code automatically: there is exactly one place they are defined.