---
description: Why every stage validates its own inputs and outputs, why a rejection is not an error, and why the science ships as a script that does not import MatMelt.
---

# 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:

```text
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
{doc}`remote-execution`.

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

```console
$ 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.

## Related

- {doc}`what-vs-where` — the split this contract sits inside.
- {doc}`funnel-and-stop-after` — how stages compose into a pipeline.
- {doc}`../how-to/diagnose-a-failure` — reading verdicts and faults in practice.
