0.5.2 — a finished run can leave this package behind¶
The headline is export-reference: a finished run becomes a directory that someone can run in ten
years with OpenMM and nothing of ours. cMD and REST2 today; umbrella and AIS are not done.
There is also a correction in here that is worth reading before the feature, because it is the reason to trust the feature.
md-openmm export-reference -idata <run> -odir <out>¶
A registered reference simulation outlives the tool that made it. The generated entry points in a run directory do not: in full, they are
which is right for a working directory — a correction reaches every generated directory at once — and useless as a durable artefact.
A bundle holds the System that was integrated, the topology, the state the stage continued from, a
standalone run.py and run.sh, the settings read from the run's own record, provenance, and a
SHA256SUMS inventory. Nothing in it imports md_tools, and that is enforced by running the
bundle with an import hook that raises on any md_tools import, not by reading the source.
The bug this shipped with, and what it says about testing¶
The first version exported implicit.xml as system.xml — the build System, which no stage
integrates. Between that file and the Context, _prepare_stage scales the solute at tau, adds the
positional-restraint force, and adds the barostat for explicit solvent. At tau = 0.5 that is a
different Hamiltonian. Three more of the same kind: the integrator seed is
derive_seed(config_seed, stage_name) and not the config value; a production stage continues from
the state its -c named rather than from the built coordinates; and Context.setState restores
global parameters, so a state written by a restrained equilibration comes back still restrained
unless the runner resets it.
All four ran perfectly. Every one produced a trajectory. None was visible from reading the script.
They were invisible to the test as well, because the test compared the exported runner against a Context built inside the test file — which shared every one of the bundle's assumptions and therefore agreed with all four mistakes. A second implementation checked against a third written by the same hand is not a check.
The comparison is against the engine's own output now: a real scaled run that continues from a
real equilibration state, OPENMM_CPU_THREADS=1 on both sides, and the bundle's serialised final
state required to equal the engine's cMD.xml element for element.
REST2 bundles do not reimplement the ladder¶
A cMD loop is about thirty lines and writing it out is honest. A ladder is not: N contexts, a sweep schedule, a reduced-potential matrix, an acceptance rule, state-to-walker bookkeeping.
So the modules that decide what happens — core.py, rules.py, engine.py, statistics.py,
rem_log.py — are copied byte for byte into the bundle. They import nothing from md_tools,
which took moving two things into remd/core.py: BAR_NM3_TO_KJ_PER_MOL, and
driver._stream_seed. The second is not housekeeping — it decides which random stream the
acceptance draw comes from, so a bundle deriving it independently would propose the same swaps and
accept a different set of them.
Verified against a four-rung ladder, engine under mpirun -n 4, bundle serial with md_tools
blocked:
exchange 0 engine [1, 0, 3, 2] bundle [1, 0, 3, 2]
exchange 1 engine [1, 3, 0, 2] bundle [1, 3, 0, 2]
... 10/10 identical
That is the state-to-walker mapping, not the acceptance rate. A rate is easy to match by accident.
Refuses rREST2 and CV-enabled ladders before writing anything, and the CLI picks the exporter from the record type rather than from a flag — asking the caller to say which would let them say the wrong one.
A ladder that equilibrates each state is reproduced too. The bundle's runner skipped the
ladder's equilibration_steps -- the per-state relaxation the engine's _equilibrate runs before
the first exchange, each rung under its own Hamiltonian from the shared equilibrated start. So a
bundle of a ladder with that setting above zero started its hot rungs from the wrong
configurations. It still ran, and still reported a plausible acceptance rate; against the engine it
diverged from exchange 2 onward. The first equivalence test used the default of zero and could not
see it. A second ladder with equilibration_steps: 200 is now compared exchange for exchange, and
the runner relaxes each state exactly as the engine does, leaving the step counter and the exchange
random stream untouched. The registered 2026-09 ladder used zero, so its bundle was unaffected.
How the rungs were built travels with them. A bundle carried the four scaled Systems and a
sentence saying how they were scaled, but not the code that scaled them or the solute atoms it
applied to -- so checking the derivation needed md-tools. The rung construction now lives in
md_tools/rest2/hamiltonian.py, which imports only OpenMM (a test asserts it) and which
scaler.py re-exports, so the driver, the preflight and the exporter all build rungs with it.
The exporter copies it byte for byte into ladder/, records the solute atoms and the omega bonds
in provenance.json's derivation block, and writes verify_rungs.py: it rebuilds every rung from
system_rung0.xml with that code and requires each to serialise identically to the bundled one.
It runs with md_tools blocked, and a test changes one torsion in one rung to show it names that
rung.
input/: what the run was made from. Every bundle, cMD and REST2, now holds the files a run
was made from -- the structure build-top -i read, the configuration build-top --config read,
the run's resolved.config as the build-md input, the built System and topology every stage ran
on (-s, -p), and the Amber-like .in each stage and the ladder read. None is taken on its
name. The structure must match the digest in the build-top record; the built pair must match the
digests the run records hold; the build-top configuration must resolve, through the same function
build-top uses, to the resolved configuration and stated keys the record holds, or the bundle
carries the record's resolved configuration instead and says so; each .in is the file the stage
record names, copied from the run directory the record sits in; and anything unproven refuses the
export before a file is written. The reason is in this campaign: its protocol configurations were
edited after the runs started, so a file with the right name beside a registered ladder was not
the one that produced it.
input/README.md gives four ways to reproduce the run, the first needing only OpenMM: the bundle's
own run.sh; input/build_system.py; the recorded md-openmm md-run commands pointed at the
built System, with no build-top; and build-top plus build-md from the structure. The earlier
README gave only the last, which made it read as if md-tools were needed to build a topology at
all. Recorded commands are rebuilt from md-run onwards and every absolute path is cut to its file
name: the records hold argv as launched, starting with the absolute path of the environment's
executable.
The build, without md-tools. input/build_system.py is build-top's steps written out as plain
library calls -- the SMILES embedding (RDKit ETKDGv3, MMFF94s), the charges (AM1-BCC through
AmberTools, or NAGL), hydrogens at pH on the Reference platform, the box and its growth for the
cutoff, water and ions, createSystem; or for implicit solvent tleap with mbondi3 radii and
ParmEd's GBn2 System -- reading the values build-top used from input/build_settings.json, which
the exporter derives from the build record through build-top's own mapping. It runs in
openmm-env without the md-openmm command and compares its result with the bundled pair: exit 0
means the System is byte-identical and the topology identical apart from the date OpenMM writes
into it. That equality is possible because every random step in build-top is seeded, and a test
holds it for four routes -- peptide and SMILES, implicit and explicit, one with hydrogen mass
repartitioning -- by building each with build-top and with the script under an import hook that
refuses md_tools. For a molecule built from SMILES with AM1-BCC charges that equality is not
guaranteed: the OpenFF toolkit computes the charges on a conformer it generates itself, unseeded
(docs/backlog.md entry 6), so a flexible molecule such as cyclo(Gly-Asp-Arg) can rebuild with
different charges. Phenol, which is rigid, rebuilds byte for byte and is what the test uses. The
script reports such a difference and exits 1 rather than passing it, and the README of a bundle
whose ligand was charged with AM1-BCC says why; the bundled System stays the one the run used.
-idata and -odir are resolved before the exporter searches for anything. Exporting with
-idata . from inside a run directory was refused, because the built System is found "beside or
above" the run directory and a bare . has no parents; found by exporting the test datasets, and
tested now. A peptide-like
solute in implicit solvent is the one route not covered, because its mbondi3 corrections come from
md-tools' peptide map; its settings say so and the script refuses rather than building without
them.
Where the structure came from. A SMILES input has no structure to explain: the script makes it,
and the README states the embedding and its seed. A peptide PDB does, and ALA.pdb was made by
tleap's sequence { ACE ALA NME }. The exporter now checks that rather than asserting it: it runs
tleap on the residue names read from the file and compares every atom record's names, residues and
coordinates. When they agree, the bundle carries input/structure.leap and the README says the
structure is that sequence's output; when they do not, it says the origin is not recorded. (The
element column is not compared: current tleap writes one, and the fixture, made by an older tleap,
has none.)
Two commits, not one. A bundle's md_tools_commit is the engine that ran the ladder, as the
run recorded it, and null when it recorded none, exactly as the cMD bundle leaves it. The commit
the ladder/ modules were copied from is the exporter's, and is recorded separately as
ladder_modules_from. Before release, the REST2 exporter filled the first from the second: the
campaign's implicit ladder, run on 514e44b, was exported with md_tools_commit: cd2b1d1 beside
the run's own version string and timestamps. The modules were in fact identical apart from one
moved import, but the record claimed an engine the ladder never ran on. A test now doctors the
recorded commit, because in a checkout the two coincide and the conflation is invisible.
Per-tau equilibration¶
A ladder starts from one state, and by default that state was equilibrated at tau = 0 only.
rest2.equilibration_per_tau: true (REST2/rREST2, off by default, refused by name for any other
protocol) runs the equilibration chain on every rung under that rung's Hamiltonian instead:
- implicit solvent: the tau = 0 chain is minimisation alone, and every rung starts from
min.xml; - explicit solvent: the tau = 0 chain keeps its NPT stages, which fix the volume every rung shares,
and every rung starts from
eq_npt_free.xml; - then every rung, tau = 0 included, runs
eq_nvt_posres(restrained_nvt_steps),eq_nvt_posres_2(restrained_npt_steps) andeq_nvt_free(unrestrained_npt_steps) under its own tau at fixed volume -- the stages a scaled run gets -- thenequilibration_steps, then the first exchange.
Each stage is done the way the stage chain does it: a fresh integrator per stage, seeded per stage
and per rung, velocities carried from the starting state, the chain's positional restraint on the
same atoms toward the topology's coordinates, its strength set after the configuration is
installed. The restraint goes on a copy of each rung, so the Systems the ladder propagates -- the
ones the force audit, the resume identity and verify_rungs.py describe -- never carry it. The
integrator uses the ladder's constraint tolerance (1e-8), the one the rung is propagated with next.
It runs in the ladder runtime, one rank per rung in parallel, in md_tools.remd.rung_equilibration,
which imports nothing from md_tools, so an exported bundle runs the same bytes; derive_seed and the
positional restraint moved there and are re-exported from md._stages, still one definition. Each
rung's end state is kept as per_tau_state<i>.xml, described by per_tau_equilibration.json, and
verified into restart.json (null when off). An interruption is honoured between stages; the run
state then records phase: per_tau_equilibration, and --resume refuses it by name, read-only,
asking for --overwrite -- there is no checkpoint, and the equilibration is deterministic from the
recorded seeds.
Off, nothing changes: the build-md output at the previous HEAD was captured before the change and
every script, run.sh, _protocol.py, describe() and the ladder description are byte-identical,
pinned by a test; each .in and resolved.config gains exactly one equilibration_per_tau: false
line. The key is added to the resume identity and _protocol.py only when it is on, because both
are compared whole and a present-but-null key would refuse every existing ladder's --resume.
Tested: resolution and both refusals; the default-off golden files; a rung's end state reproduced
bit for bit from plain OpenMM, the documented seed formula and the documented restraint; one box
across explicit rungs; the refused resume leaving the directory unchanged; a fault during it
aborting every rank; the engine and an exported bundle making the same exchange decisions, with
per-tau stages and equilibration_steps together; a four-rank CUDA run.
Test datasets¶
docs/campaigns/test-systems-2026-09/ holds the configurations and the driver for short worked
examples: ALA (tests/data/ALA.pdb) and phenol (Oc1ccccc1), in GBn2 and in TIP3P with 0.15 M
NaCl, as cold cMD (tau = 0), hot cMD (tau = 0.5) and REST2 (tau 0 to 0.5; four states implicit,
six explicit), 1 ns per run or state. They are registered as
$MD_DATA/2026/md-tools/<system>-test/<method>-<solvent>, each with its OpenMM-only bundle.
A wheel now knows which commit it is¶
md_tools_commit was null in every run record, every dataset manifest and every exported bundle.
A wheel is built from a directory: nothing in the build consults git, and the only origin note
pip keeps is {"dir_info": {}, "url": "file:///.../MD-tools"} — a path, no commit.
Establishing the commit for a campaign that had already finished meant hashing all 99 installed files against the git trees until one matched. That worked. It is not a provenance system.
_build_backend.py is an in-tree PEP 517 backend that delegates to setuptools and first writes the
commit being packaged. A dirty tree bakes nothing: the commit would not describe what is in the
wheel, and a provenance record that is confidently wrong is worse than one that is honestly empty.
source_commit() prefers live git when running from a checkout — a developer moves between commits
without rebuilding — and decides "am I a checkout" by file identity, because git rev-parse
walks upward from wherever it starts and an environment sitting inside an unrelated repository
would otherwise report that repository's commit as the engine's.
Shared datasets leave the year¶
--common-data registered to {year}/common/{project}/{data}. A reference simulation is used by
whoever needs it for as long as it is the best one available, so filing it under 2026/ means
finding it requires already knowing when it was made. Common datasets now register to
common/{project}/{data}; project datasets are unchanged and still year-first.
data_name may also be several segments deep, each validated separately, so a reference set can be
browsed by system and by method instead of flattened into one globbable name.
origin was the wrong repository¶
_origin ran every git command with no -C, so a dataset manifest's "the project repository this
data came from" actually meant "whichever repository the person was standing in". Three reference
datasets were registered claiming MD-tools' HEAD for a campaign belonging to another project, and
both guards around it — refuse a dirty tree, refuse a non-https remote — passed while it happened.
It is resolved from -idata now, and refuses when the data are not in a repository, naming
--project-repo. There is deliberately no fall back to the cwd: the silent fall back was the
defect.
--notes TEXT stores free text — the only manifest field asserted by the person registering rather
than derived from the records, which is why the help says to state how you know it.
Three tests that were not running, and said so as if it were a fact about the software¶
test_a_resume_truncates_the_state_csv...looked forcMD.csv. The production state table ismdout.csv. The file was never there, so the guard fired every time and blamed the stage: "this stage wrote no state CSV, so there is nothing to truncate". Not flaky — it had never run. With the right filename it passes, so the truncation was correct all along and nothing had checked it.test_example_1bandtest_example_3bread a directory another test creates as a side effect. Under-n 24they land on different workers, so which tests ran came down to how xdist distributed them. Both take fixtures now.- An interrupted-chain test used
timeout -s INT 45and skipped when 45 seconds had not reached production — "this machine is too loaded to demonstrate the resume". It waits for the production stage to commit a checkpoint and signals then, to the process group, becauserun.shexecs children and signalling only the shell leaves the integrator running.
The suite goes from three skips to one: test_cuda_coverage_matrix declining to write an evidence
file when none was asked for, which is an opt-in writer rather than a test that stopped testing.
--dist loadgroup is now the default, so a module-scoped fixture that builds a system is built
once rather than once per worker that draws one of its tests.
And three that failed for reasons outside the code¶
The release suite was first run confined to five of the machine's nine GPUs, because a campaign held the other four. It came back with three failures, and none of them was the package:
test_explicit_device_placement_laneasked OpenMM for device 8 -- "Illegal value for DeviceIndex: 8". It took the device count fromnvidia-smi, which lists every card on the host and ignoresCUDA_VISIBLE_DEVICES; the process could see five.test_multi_rank_rest2_ladders_of_several_sizes[6]has a guard, "6 states need 6 devices", that exists to fail loudly on a machine that cannot run it. It counted nine, passed the guard, ran, and failed afterwards with two ranks sharing a card. With the count fixed it now fails at the guard, which is the intended report: a six-state CUDA ladder is an unmet criterion on five cards, not something to skip.test_the_installed_command_refuses_the_retired_platform_flagread amd_scriptdirectory that another test in the module created, so it passed or failed on how xdist distributed the module. The same module built its wheel inside the checkout, where parallel workers collided inbuild/-- and a fixture that turned any build failure into "the wheel could not be built here" reported the collision as a skip. Every wheel test now builds from a copy of the working tree (tests/wheel_build.py, one implementation) and a failed build fails.
The capacity checks use the engine's own visible_cuda_devices, the function the runtime places
ranks with; nvidia-smi's inventory is still what the evidence records.
That change exposed a fourth. The suite's own conftest.py gives each ordinary CUDA test one
card by writing CUDA_VISIBLE_DEVICES into the worker's environment -- and never took it back. A
test that received no assignment inherited the previous test's card, including
test_cuda_coverage_matrix.py, the module listed as needing to see the machine exactly as it is.
On a machine with nine free GPUs its four-state ladder reported "4 states need 4 devices; 1
visible", and reproduced every time behind a single ordinary CUDA test in the same process.
Before the guard counted visible devices, the same leak ran all four ranks on that one card; the
conftest's own comment quoted that failure and blamed the assignment rather than the leak. The hook
now restores the variable to its starting value before every test, and
tests/test_conftest_device_assignment.py drives it with stand-in items so the leak cannot return
unnoticed.
The environment is openmm-env¶
environment-cuda.yml now creates openmm-env, environment-ci.yml creates openmm-env-ci, and
the README installs to envs/openmm-env. md-openmm is the command the environment provides;
naming the environment after it made "install md-openmm" and "run md-openmm" sentences about
different objects.
The history came with the code¶
The instructions and execution journals behind the REST2, rREST2 and file-interface work were
written in the project repository where that work was done. They are in
docs/history/ now, copied byte for byte. Four tests cited "the MD-project
journal" as their real-run evidence and cite a file in this repository instead. The ALA reference
campaign's configurations, built Systems and journals are in
docs/campaigns/ala-2026-09/, with an account of what was
registered and why the explicit-solvent arm was withdrawn.
Upgrading¶
Nothing in a configuration file changes. Three things move:
a --common-data registration |
writes to common/... instead of {year}/common/... |
an existing common manifest |
its path no longer matches the canonical form and will fail validation |
| the documented environment | is openmm-env (openmm-env-ci for the CPU file). An existing environment keeps working under its old name; only new installs follow the new one |
There is no migration tool. There was one such dataset on the machine this was developed on, and it was re-registered by moving it back out of the store and registering it again.