Design decisions#

What was chosen, and what was chosen instead. Decisions that reversed an earlier one are kept here with their evidence, because a reversal without its reason tends to get re-reversed.

Locked defaults#

Area

Choice

CLI

Typer

Output

Rich, confined to ui/

Config

pydantic v2, path-tagged errors

State

SQLite only, WAL, atomic claim

Remote data

declarative rsync/SSH staging, behind a transport seam

Packaging

src-layout, hatchling

License

MIT

Quality

ruff + mypy (strict) + pytest

These are settled. Changing one is a project-level decision, not a refactor.

Boundaries that are not negotiable#

Clean-room. MatMelt shares no code with the production pipeline it mirrors architecturally. Nothing is imported or copied from that tree. The amendment worth knowing: the boundary is about code. aiida-core, aiida-mydftbp and ase are declared user-owned runtime requirements of the optional calculate stage — lazily imported inside the runner, never at package level, never in the dev environment, with every AiiDA-touching test faking the modules.

The base install stays light. pip install matmelt must not drag in torch, and the package never bundles a model. The science stack is the user’s, in the user’s environment. What MatMelt owns is the automation around it: each stage declares its requirements, doctor and run report exactly what is missing and the exact command to fix it, and a model download is always disclosed and consented to — never silent, never a silent failure.

No secrets in git, ever. Any config value may be an env:NAME reference; health_check never leaks one into its detail.

No new dependencies without asking. The runtime list is four packages (typer, rich, pydantic, pyyaml) and it is meant to stay short.

Reversals, with their evidence#

Shared filesystem → declarative staging. The original design assumed a remote saw the same paths over NFS. Inverted before Phase 4: staging is the default and shared_filesystem: true is the opt-in. A shared filesystem is a property of some clusters, and assuming it excludes every machine that is merely reachable by SSH.

Mid-funnel stop_after: retire → rest. Originally a stopped run marked its materials DONE. That retired 550 real materials into a state no later run could claim, and continuing the study required editing the database by hand. Now materials rest at the stop stage’s output state, and only a run through the configured last stage retires anything (The funnel, and what stopping means).

calculate: deferred → optional stage. Higher-fidelity calculation was first deferred past v1, then added as a skippable stage rather than a required one. Configs written before it existed behave identically today, which is the property that made adding it safe.

Compose sizes: binaries → all sizes. combination_sizes defaulted to [2]. Omitting it now means every size from 1 to the element count, and size 1 is legal — which is what makes elemental allotropes reachable.

sphinx-click → sphinxcontrib-typer. Typer 0.20+ vendors its own copy of click, so sphinx-click’s isinstance checks all fail and its directive emits an empty section without raising — a hole -W cannot catch. Documented in docs/conf.py so it is not re-adopted.

Decisions that look odd until explained#

Threads are not used in core. Parallelism is at the process level: jobs are OS subprocesses, so one bad calculation cannot take the tool down, and the loop stays single-threaded and debuggable. Concurrency is added where a need is demonstrated, never speculatively.

Runners duplicate a little logic rather than import a shared module. They must be self-contained to be shippable and hand-runnable, and that is worth more than deduplicating a formula parser.

Physical-sanity bounds are constants, not options. Configurable bounds make verdicts incomparable across a database, which defeats screening at scale (What the stages actually compute).

_mcli survived the rename. The project became MatMelt, but the data namespace in every record.json and CIF did not, because renaming it would break every record already produced and every downstream consumer. One deliberate inconsistency, documented rather than tidied.

The version lives in two places. pyproject.toml and __init__.py — and they must be bumped together, because --version reads the second while the release guard checks the first. A stale __init__ once shipped a package misreporting its own version. Deriving it from installed metadata is the obvious follow-up.