The run directory layout¶
Status: IMPLEMENTED. build-md writes this layout. md-openmm build-md -odir REST2-run1
produces it directly, and the nine migrated reference runs under
data/reference/{ALA-explicit-HMR,ALA-implicit-HMR,RGDfV-implicit-HMR}/ already have this shape
-- which is how the errors in the first three drafts of this document were found: the missing
exchange ledger, build/ being a sibling rather than absent, and the equilibration streams being
solvent-dependent.
WHAT THE IMPLEMENTATION ADDED that this document did not originally anticipate, each because a test found it:
min/run.configandeq/run.config. A generated script reads theresolved.configstrictly beside itself, so every directory holding one needs the declaration AND the per-run seed it was resolved with.min/is shared, so it carries its OWN seed, which may differ from every run on the system: minimisation draws no velocities and has no seeded stochastic element. Withouteq/run.config, every equilibration stage refused witheq/resolved.config describes a different run: dynamics.seed: was <run>, now 1.min/resolved.configANDeq/resolved.configare METHOD-NEUTRAL, through one shared projection so the two cannot drift apart. Both directories hold scripts driven by the sharedinput/*.in, which carry noprotocol-- that absence is what lets onemin.inand oneeq_<k>.inserve every method -- so a per-run document here recordedprotocol: REST2while the input it is compared against resolves to the default, and every preparation stage of a ladder refused withprotocol: was 'REST2', now 'cMD'. The stored document matches the input rather than the run. THE COST: nothing insidemin/oreq/says which method the run was. That is recorded at the run root and inbuild-md.log.- A recorded path is resolved against the run directory, never reduced to its basename. Five
places assumed a run's artefacts sit flat in one directory --
_stage_inputs,_locateand the cMD-cparent inreference/export.py, the ladder's starting state inreference/rest2_export.py, and_stage_inputs' record glob, which searched the run root only and so bundled ONE stage where two had run, silently omitting the equilibration input a reproduction needs. Each now resolves the recorded path first and falls back to the bare name, which is where it sits for a run generated before the split. min.incarries no collective-variable cadence. Minimisation produces no series -- its iterations have no timestep -- so the cadence cannot change what it does, and leaving it in made two runs differing only in CV reporting collide on a shared input.- Every declaration travels with the definition it names.
resolved.configrecords the CV and umbrella copies by BARE NAME, so that a generated directory stays movable and the name resolves beside whichever declaration a reader started from. The content-addressed copy is therefore written into the run root, the SHAREDinput/,min/andeq/alike.
It was written into the run root ALONE, and the layout then put declarations in four places. So
input/cMD.in named cv.<digest>.yaml while input/ held no such file — md-run -i
../input/cMD.in could not resolve its own definition from any directory, and an eq/ stage
resolving beside its own declaration found nothing either. md_tools.run.continuation has to
LOAD the definition to know what an invocation intends to continue, so it got None and
returned early: the read-only boundary silently did nothing and let a REFUSED md-run write
resolved.config, <stage>.out and <stage>.log into a tree it was declining to touch.
Sharing one copy is safe by construction rather than by convention — the name carries the
digest, so a definition that changed gets a different name and cannot quietly replace the one a
previous run read. An existing copy of the same name whose bytes differ is refused, because then
one of the two digests does not describe its own file — and --overwrite replaces it, as it does
every other file in the shared tree, so one corrupt copy cannot block regenerating the run.
STILL OPEN, and deliberately not decided here: two runs on one system that differ only in CV
reporting have different eq_*.in bytes and the second is refused. Reporting is observation and
changes no Hamiltonian -- it adds no Force, and the System, force inventory, force groups and a
single-point energy are asserted identical with it on and off -- so those two runs are the same
experiment in every respect except what they record.
--overwrite is NOT the remedy, and this was established by measurement rather than argument.
It rewrites input/eq_*.in to the second run's form, and every later generation with the first
run's setting then refuses against that: within one root the collision moves rather than
resolving, whichever order the runs go in. So the choice is real and narrow:
- the cadence becomes a per-run value beside the seed, which widens
RUN_CONFIG_ALLOWED-- a deliberately one-key schema, on the reasoning that an override able to set everything is a second configuration authority; or - such runs take separate
<system>roots, which is what the tests do, at the cost that a CV-on and a CV-off run on the same molecule are no longer one comparable dataset.
The migration scripts under data/reference/ are deliberately THROWAWAY and untracked: the point
is for the code to write this layout natively, so there is nothing to migrate in future. They are
not part of the package and will not be maintained.
Read the tree, correct what is wrong, and the implementation follows the corrected version.
1. The target¶
<system>/ THE DATASET ROOT. One dataset is a system and every run on it.
build/ the built system: shared by every method and every repeat
min/ the minimised structure: shared for the same reason
input/ every .in: shared, because an input is not per-repeat
REST2-run1/ one run
run.config the ONLY per-run declaration: the seed
resolved.config authoritative, written per run from input/ + run.config
eq/ this run's equilibration
remd0/ remd1/ ... one directory per thermodynamic STATE
remd_records/ the ladder's own records, one set per segment
rank/ per-PROCESS reports
bundles/ reproducibility, with or without MD-tools
REST2-run2/
cMD-run1/
AIS-run1/
What is shared and what is not follows from the physics, not from tidiness.
build/, min/ and input/ are shared because every run on this system starts from the same
built System, the same minimised coordinates and the same instructions.
min/ is shared for a stronger reason than tidiness, and this page used to give the wrong one. It
said minimisation draws no velocities and has no seeded stochastic element, so two runs minimising
the same System produce the same structure. The premise is true and the conclusion is false on
CUDA. Measured on the softcore System (2026-09-30): single-point energies and forces are
bit-identical across ten fresh Contexts — spread exactly 0.000e+00 — and yet five minimisations
from that identical start scatter by 16 kJ/mol in mixed precision and 24 in double. Minimisation
has no seed, so nothing can be set to make it agree. Sharing min/ is therefore load-bearing:
it is what makes every run on a system start from the same minimised coordinates, rather than
from coordinates that merely ought to be the same. eq/ is per-run because
equilibration draws Maxwell velocities from that run's own seed: two repeats are supposed to
diverge there, and that divergence is the point of a repeat.
build/ — at the dataset root¶
build/ built.xml the serialised OpenMM System
built.pdb the topology
built.solute.pdb the solute alone
build-top.config what produced them
built.log the provenance record
build-top.out the human-readable report
solute.yaml the resolved scaling selection
The names are build-top's own. -os defaults to built.xml, -op to built.pdb and
-log to built.log, so the layout does not rename the tool's output -- an earlier draft called
them system.{xml,pdb}, which invented a second name for a file that already had one.
This is the existing convention made explicit rather than a new one, and the reference run proves
it: its group file names -p ../../../build/built.pdb -s ../../../build/built.xml, i.e. a
directory OUTSIDE the run, already shared between that run and its siblings. The layout keeps that
directory and moves it inside the dataset -- one level up from a run instead of three, so the same
relative reference becomes ../build/built.xml.
min/ — at the dataset root¶
min/ min.xml the minimised coordinates: what every run's eq_1 starts from
min.out min.log human-readable, and the provenance record
min.checkpoints/
A run's first equilibration stage installs ../min/min.xml. Nothing in a run writes here.
min.config is listed in neither this block nor the source data: a minimisation's resolved
declaration lives in the shared resolved.config, and inventing a per-stage copy would be a
second authority.
input/ — at the dataset root¶
Every .in, shared. An input says what a method was asked to do, and two repeats of one method
on one system are asked the same thing — so a second copy could only drift from the first.
min.in and eq_*.in are shared across methods as well as repeats. That sharing is a claim,
and it is enforced rather than assumed: a run registers only if the inputs it resolved against
are byte-identical to these (§3). If two methods on one system would need different
equilibration, they are not comparable and belong under a different <system> — a refusal, not a
subdirectory.
AIS reads neither min.in nor eq_*.in: a switching campaign starts from a source ensemble that
already exists, so it has no minimisation or equilibration chain of its own.
Every segment shares one <method>.in. The runtime loops it, so a segment is not a stage and
one .in no longer corresponds to one stage — a change to a currently tested contract
(test_method_example_inputs.py). The segment number is a runtime fact, not an input fact.
run.config — the only thing that is per-run¶
<method>-run<N>/ run.config dynamics.seed, and nothing that is not genuinely per-run
resolved.config AUTHORITATIVE: input/ + run.config, resolved and written here
The seed is the whole reason two repeats differ. derive_seed(base, *purpose) hashes the base
seed with each stage and replica name, so every stream in a run descends from that one number:
two runs sharing an identical input and an identical seed would be repeats of nothing — which is
why the seed is per run.
On CUDA that is a statement about intent, not a guarantee. Two runs with the same input and
the same seed are not bit-identical on a card: minimisation diverges with no seed to set (above),
and the AIS entry in the guide already records that a resume is exact in committed state and not
in trajectory, because the mixing force's inner Contexts keep atom-ordering state no checkpoint
captures. On CPU and Reference the guarantee holds. Nothing downstream should be written as
though a seed made a card reproduce.
The migrated reference run carries dynamics.seed: 700501 here.
It is a file rather than a command-line flag (md-run has no seed override, and adding one
would leave the seed living only in a shell history until resolved.config was written — a re-run
typed without it would silently repeat run 1), and rather than a number parsed out of the
directory name, which would make a filename load-bearing data.
This needs config layering, which does not exist yet. resolve_md_config takes one path, and
run.config must be restricted to the seed rather than able to set anything: an override that can
set everything is a second configuration authority.
eq/ — per run¶
eq/ eq_1.{xml,out,log,chk} eq_1.py eq_1.checkpoints/
whole_eq_1.nc solute_eq_1.nc
mdout_eq_1.csv energy_components_eq_1.csv
eq_2.* eq_3.*
Every name here is the stage's FILING KEY, not the stage name: eq_nvt_posres is filed as eq_1,
because the ensemble moved out of the filename so that a renamed stage cannot make a filename
claim something false. stage_plan stamps the key onto every plan entry, so the layout that writes
run.sh and the runtime that writes the files read it from one place. They did not, once: the
runtime named outputs from the stage, run.sh chained -c eq/eq_1.xml, and no cMD or REST2 chain
could complete through run.sh — the documented way to run one.
The stream and table names keep the affix style the runtime uses everywhere else
(solute_<key>.nc, mdout_<key>.csv), rather than a eq_1.solute.nc form that would apply to
these files alone:
Two trajectory streams per stage, as the reference data has: a single eq_N.nc cannot hold
both the whole-system and the solute stream.
The renaming loses the ensemble from the filename. Today the stages are eq_nvt_posres,
eq_npt_posres, eq_npt_free, and implicit solvent renames them (eq_nvt_posres_2,
eq_nvt_free) precisely so a pressure-coupled name never appears on a boxless run — an invariant
with its own tests. Under eq_1/eq_2/eq_3 that cannot live in the name, so it moves into each
stage's .out header and the resolved configuration, which state the ensemble explicitly. The
invariant survives; the evidence for it changes location. Stage order comes from build-md.log:
eq_nvt_posres → 1, eq_npt_posres → 2, eq_npt_free → 3.
remd<n>/ — one directory per thermodynamic state¶
remd<n>/ build_state<n>.xml the rung Hamiltonian, serialised
remd_state<n>_prod<x>.nc whole-system trajectory, segment x
solute_state<n>_prod<x>.nc solute trajectory, segment x
cv_state<n>_prod<x>.dat CV series
cv_state<n>_prod<x>.json its sidecar
restart_state<n>_prod<x>.xml final positions/velocities/box
restart_state<n>_prod<x>.json this state's record for this segment
The index is the STATE's, never the walker's. After an accepted exchange the configuration in
remd2/ is a different walker's, and that is the point: MD-tools writes the state-sorted thing
directly, which is why Amber needs remdtrajtemp and GROMACS ships demux.pl and we do not.
Grouped by state rather than by segment, so remd0/*.nc concatenates one state across every
segment in order — which is the common analysis operation. The segment is a suffix because these
files are indexed by state and segment; the records in remd_records/ are indexed by segment
alone, which is why they are grouped the other way.
build_state<n>.xml is new, and cannot be backfilled. The preflight builds every rung
(rung_systems) and never serialises one; no XmlSerializer.serialize call for a rung exists in
md_tools/remd/. Writing one for the migrated run would mean re-scaling built.xml to each tau
now and asserting the result matches a run from September. Migrated runs therefore legitimately
lack it, and the migration records its absence rather than synthesising it.
remd_records/ — the ladder's own records, per segment¶
remd_records/ ledger_prod<x>.nc the exchange ledger (in 0.6.1: REST2.nc)
ledger_prod<x>.solute.nc
checkpoint_prod<x>.nc configurations, mapping, RNG states, rule state
rem_prod<x>.log Amber-format, cpptraj-readable as type Hamiltonian
exchange_prod<x>.csv the flat per-(exchange, state) view
restart_prod<x>.json this segment's completion manifest
runstate_prod<x>.json initialized -> running -> completed/interrupted/failed
REST2_prod<x>.out the ladder's human-readable report
REST2_prod<x>.log its provenance record
REST2_prod<x>.group the group file (per segment: the -c path differs)
One directory for every segment, with the segment kept in each filename — these records are
indexed by segment only, so a prod<x>/ directory per segment would scatter one subject across
five places for no gain.
ledger_prod<x>.nc is NOT a trajectory, and its old name said it was. It carries no
coordinates and no Conventions attribute, so cpptraj will not read it. Its variables are the
exchange history: u, u_evaluated, proposed and accepted, each (exchange, state, state)
— 10000×6×6 in the reference run — plus state_to_walker at (exchange, state), the tau ladder,
and the frame bookkeeping. restart.json calls it authoritative because the run's whole history
is reconstructable from it.
It stays NetCDF rather than becoming .csv or .dat because four of its variables are 3-D: a
flat table cannot hold a 10000×6×6 array without exploding to 360,000 rows or inventing a column
encoding. exchange_prod<x>.csv beside it already IS the flat view —
exchange,step,time_ps,state,tau,walker,reduced_potential,potential_energy_kj_per_mol,
proposed_with_next,accepted_with_next, one row per (exchange, state), carrying the
neighbour-pair subset. NetCDF for the full matrix, CSV for the flat view.
rank/¶
Not under remd<n>/, and this is correctness rather than tidiness. A rank is a process and a
state is a thermodynamic state; they are not in bijection. The reference ladder ran 6 states on
5 ranks, so REST2.out.rank01 is the report of a process that owned more than one state and
belongs to no single one of them.
bundles/¶
Reproducibility, with or without MD-tools installed: the exported reference bundle
(engine.py, core.py, rung_equilibration.py, verify_rungs.py) and its manifest. It keeps
its own copy of what it needs from build/ and min/, because standing alone is its job.
2. What moves¶
| in 0.6.1 | target |
|---|---|
build/ beside the run (three levels up from md_script/) |
<system>/build/ — same names, one level up from a run |
min.{xml,out,log}, min.checkpoints/ (per run) |
<system>/min/ — shared |
min.in, eq_*.in, REST2.in (run root) |
<system>/input/ — shared |
resolved.config (run root) |
<run>/resolved.config — stays per run |
REST2.config (the per-run declaration) |
<run>/run.config |
eq_nvt_posres.*, eq_npt_posres.*, eq_npt_free.* |
<run>/eq/eq_1.*, eq_2.*, eq_3.* |
whole_state<n>_prod<x>.nc |
<run>/remd<n>/remd_state<n>_prod<x>.nc |
solute_state<n>_prod<x>.nc |
<run>/remd<n>/solute_state<n>_prod<x>.nc |
cv_state<n>.csv / .json |
<run>/remd<n>/cv_state<n>_prod<x>.dat / .json |
REST2.nc, REST2.solute.nc |
<run>/remd_records/ledger_prod<x>.nc, .solute.nc |
REST2_checkpoint.nc |
<run>/remd_records/checkpoint_prod<x>.nc |
rem.log, exchange.csv |
<run>/remd_records/rem_prod<x>.log, exchange_prod<x>.csv |
restart.json (ladder-wide) |
<run>/remd_records/restart_prod<x>.json |
restart.json (per-state blocks) |
<run>/remd<n>/restart_state<n>_prod<x>.json |
REST2.runstate.json |
<run>/remd_records/runstate_prod<x>.json |
REST2.out, REST2.log, REST2.group |
<run>/remd_records/REST2_prod<x>.{out,log,group} |
REST2.out.rank<r>, REST2.log.rank<r> |
<run>/rank/ |
| (nothing) | <run>/remd<n>/build_state<n>.xml — new, not backfillable |
| (nothing) | <run>/run.config — the seed |
3. Registration¶
A <method>-run<N>/ is registered only after the build/, min/ and input/ it was run
against validate as the same construct. Not by path and not by directory name: each run records
the sha256 of build/built.xml, min/min.xml and every .in it read, in its own .log and
restart record, and registration recomputes them and compares. A run whose digests do not match
is refused rather than filed beside them, because "these runs are on the same system, from the
same structure, under the same instructions" is the claim every comparison between them rests on.
The seed is deliberately outside that check: run.config is expected to differ per run, and it
is the only thing that may.
The dataset root is <system>/, so one dataset is the system, its minimised structure, and
every run on it — the natural scientific unit. dataset.yaml sits there, and every path in it
stays relative to it, as the contract already requires.
4. Migrating the finished reference runs¶
REST2-run1/ was five chunk0N/md_script/ directories. All five are complete segments: each
reports run_status: completed with 10,000 committed exchanges, at cumulative steps 25M, 50M,
75M, 100M and 125M. What distinguishes chunk01 is that it carries the ONE-TIME SETUP — min.*,
eq_*, build-md.log, run.sh, the checkpoint trees — because 02–05 are continuations that
correctly did not re-minimise or re-equilibrate. Nothing is missing from any of them.
Every chunk wrote its per-state trajectories as _prod1, which is the defect being corrected.
The migration:
- assigns segment numbers from the chain, verified not assumed —
chunk01has noextends; 02→01, 03→02, 04→03, 05→04 by parent path and parent checkpoint sha256. Sochunk0N→prodN. - checks by digest that the shared files really are shared before hoisting one copy.
REST2.in,resolved.config,solute.yaml,_protocol.py,REST2.pyare byte-identical in all five.REST2.groupis NOT — the-cpath differs — so it is per segment. - copies the sibling
build/into the dataset root. It sits beside the run rather than inside it, which is why the first draft of this document wrongly concluded it did not exist. - moves only production artefacts, leaving each
chunk0N/md_script/intact beside the new layout. Nothing is lost and the step is reversible. - dry-runs first, printing every planned move; verifies sha256 after copying; and removes a source only after its copy verifies — and only when explicitly told to.
- records what does not exist rather than synthesising it. Absent from the source and
therefore from the migrated tree:
build_state<n>.xml, per-state restart records, CV series, per-state.out/.log,bundles/.restart.json'sstatesentries carry onlyindex,trajectory,tauandeffective_temperature_k, and the per-state digests insideextendsdescribe the PARENT segment's outputs — deriving this segment's per-state record from them would be fabrication. - refuses
run.sh. Every path in it is relative tochunk01/md_script, so a copy at the run root would be an executable resolving to the wrong place.
First pass: 219 files, 964,582,362 bytes, every destination sha256-verified, idempotent on a
second --apply, sources intact at 251 files.
5. Consequences to accept before implementing¶
- A run directory is not self-contained, by design. It reads
../build/,../min/and../input/. A run moved out of its dataset is incomplete, and detectably so: the digests it recorded will not resolve. - One
.instops mapping to one stage, sotest_method_example_inputs.py's production-stage assertion needs rethinking. validate.py:65refuses any manifest entry containing a path separator, on purpose. Entries therefore need an implicitremd<index>/orremd_records/prefix resolved from the entry's own fields, which keeps that no-traversal guard intact rather than weakening it.- Existing datasets stop validating in place. Migration is the answer rather than a compatibility shim.
- The extension-segment vocabulary collides.
driver.pyalready says "segment" for an extension — a new output directory chained to a parent — which is a different thing fromstages.number_of_segmentsinside one run. Both cannot keep the word.
6. Implementation order¶
- A path authority: one module returning every path above from
(dataset_root, run, state, segment). Partly done —md_tools.remd.amber_trajectoryhas the per-state names. number_of_segmentsreaching the runtime. The field exists and validates; nothing reads it.- Config layering: shared
input/*.inplus a narrow per-runrun.config. build/,min/andinput/hoisted, with the digests a run records.- The per-run reorganisation, one directory at a time, with the suite green between each.
validate.pyand the manifest, including the implicit directory prefix.- Serialising
build_state<n>.xmlper rung, which is new behaviour rather than a move. - The registration gate.
- ~~A migration tool~~ -- not needed. The nine existing reference runs are already migrated, and future runs are written into this layout directly, so no tool has to exist for it.
data-registerand the data contract.