Module map#

src/matmelt/
├── cli.py  __main__.py        Typer app — a thin dispatcher, no logic
├── commands/                  one module per command, each run_<name>() -> int
│   init  doctor  run  status  monitor  results  retry  cif  update
├── core/
│   ├── jobspec.py             JobSpec / Handle / JobResult / Resources / …
│   ├── stages/                the funnel
│   │   ├── base.py            the Stage contract
│   │   ├── registry.py        FUNNEL_ORDER, OPTIONAL_STAGES, STAGE_OUTPUT_STATE
│   │   ├── compose.py structure.py screen.py calculate.py extract.py
│   │   └── *_runner.py        self-contained scripts, shipped per job
│   ├── orchestrator.py        the pass loop
│   ├── state.py               MaterialState + StateStore (SQLite)
│   ├── preflight.py           dependency / backend / model / AiiDA probes
│   ├── monitor.py             build_snapshot — pure, no Rich, no SQL
│   ├── cpusample.py           /proc + nvidia-smi sampling, best-effort
│   ├── cif.py                 stdlib .gen → CIF writer
│   └── seeds.py               element_combinations
├── backends/  base.py registry.py local.py ssh_exec.py
├── config/    schema.py loader.py
└── ui/        console.py monitor.py monitor_live.py

The dependency rule#

Arrows point inward, and there is exactly one direction:

cli.py → commands/ → core/ , backends/ , config/
                       ↑
                      ui/  (rendering only; core never imports it)

core/ never imports commands/, cli.py or ui/. That is what lets the orchestrator, the stages and the state store be tested without a terminal, and what keeps a rendering change from being able to break a pipeline.

All human-facing output goes through ui/. No print() in core/, no Rich console constructed anywhere else. The point is not tidiness: it is that --json output stays parseable because nothing else can write to stdout behind its back. Deprecation notices and progress go to a separate stderr console for the same reason.

What belongs where#

cli.py wires Typer options to run_<command>() functions and returns their exit codes. When it starts containing logic, that logic belongs in commands/.

commands/ is the only layer allowed to know about both config and core. A command loads config, builds a runtime, calls into core, and hands values to ui/ for display. Each returns an int — the exit code — rather than calling sys.exit, which is what makes commands testable as functions.

core/ is the tool. It takes plain objects: the orchestrator receives a list of stages and a {stage_name: backend} mapping, and has no idea a YAML file exists. config/loader.py is the seam that turns a file into those objects.

core/stages/*_runner.py are not library modules. They are programs, staged to a backend and executed there, and they import the standard library plus their own science package — never matmelt. A test parses each runner’s imports and fails if that is violated, because the property is invisible until a remote job breaks.

backends/ knows how to run an argv somewhere and bring files back. It knows nothing about materials, stages or science.

ui/ turns values into Rich renderables. It computes nothing: core/monitor.py builds the snapshot, ui/monitor.py draws it.

Where a change usually goes#

Change

Where

New CLI flag

cli.py + the matching commands/ module

New stage option

that stage’s __init__ (the signature is the contract)

New science step

a new *_runner.py + a thin Stage class + registry entry

New compute target

a ComputeBackend subclass + register(kind, cls)

New config key

config/schema.py, then reference/config.md (a guard enforces it)

New displayed value

core/monitor.py for the value, ui/ for the drawing