Skip to content

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

from md_tools.md import run_generated_stage
raise SystemExit(run_generated_stage(__file__, "cMD"))

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) and eq_nvt_free (unrestrained_npt_steps) under its own tau at fixed volume -- the stages a scaled run gets -- then equilibration_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 for cMD.csv. The production state table is mdout.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_1b and test_example_3b read a directory another test creates as a side effect. Under -n 24 they 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 45 and 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, because run.sh execs 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_lane asked OpenMM for device 8 -- "Illegal value for DeviceIndex: 8". It took the device count from nvidia-smi, which lists every card on the host and ignores CUDA_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_flag read a md_script directory 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 in build/ -- 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.