Skip to content

0.5.3 — the run directory layout, and an extension that can run its own group file

Three fixes, all found by the hpREST2 campaign running this engine in anger, and all of the same shape: a default that means something specific, where nothing was meant.

An extension generated a group file its own parser rejected

--extend-from takes the physical state from the parent's checkpoint — positions, velocities, box, the state-to-walker map, the RNG stream, the exchange count — so the group file it writes correctly carries no -c. The parser required coordinates on every line regardless, so every rank aborted:

replica executor: REST2.group:7: no coordinates given
[rank 0/6] fatal: REST2 rank 0: the executor returned 2

This stopped an explicit-solvent ladder at 100 ns/state that was asked for 500. Nothing exercised the generated file against the parser that has to read it, which is how the two halves came to disagree; tests/test_extension_group_file.py now does, including on the exact shape --extend-from emits.

coordinates is now required only when an extension is not in force, and the refusal says so. The other required fields are unchanged, a fresh ladder still cannot start without a starting state, and a caller who passes -c on an extension anyway is not refused — the value is inert, not forbidden.

Both consumers of that group file had the same omission

Teaching the parser that a coordinate-free line is legal was half of it. _announce and run_grouped still read group["coordinates"] unconditionally, so an extension aborted anyway: every rank raised KeyError: 'coordinates' immediately after writing its header — which is worse than the original refusal, because the run had already printed as though it had started — and, once that was fixed, again one step later while building the driver's files.

Found by running an extension of a real two-rung ladder on the CPU, which is the first thing that gets past the parser at all. The announcement now says where the state comes from (-c (none: continued from the parent's checkpoint)) and run_grouped passes None, which is what an extension means: positions, velocities, box, the state-to-walker map and the RNG stream all come from the parent's checkpoint. Two tests: one drives _announce with the exact shape --extend-from emits, and one refuses any unguarded [...]["coordinates"] anywhere in the executor, because this was one omission in two places and the second only became visible once the first was fixed.

An interrupted extension is redone, not resumed

Making extensions usable surfaced the question immediately after: what happens when a segment is interrupted half way? It holds a committed checkpoint and a run-state record, and no restart.json, which is exactly the shape an ordinary interrupted run has — so --resume is what anyone reaches for.

It is the wrong reach, and the two obvious routes fail in opposite ways. --resume together with --extend-from is refused as two different operations, which sounds like a complaint about flags. --resume alone, on the segment's own directory, does not fail: _continue would carry the run to its budget and write a restart.json with no extends block at all, leaving a segment that reads as an ordinary run with its parent pinning, segment-local counts and chain accounting gone. With per-tau equilibration it cannot even get that far, because the missing-record excuse is granted only to a state that carries extends.

So an extension segment is atomic, and atomic by omission rather than by a guard: no resume path knows extends exists. The fix is not a new resume — it is telling the truth at the four places that were advising --resume unconditionally: the clean-stop run-state note, the driver's printed interruption line, the DriverError raised when a run stops short of its budget, and the executor's stderr on status 130. Each now distinguishes an in-place run from a segment and, for a segment, names re-running the whole thing with --extend-from into a fresh directory. Re-running into the partial one is refused three ways already (--force is refused with --extend-from, --overwrite does not apply, and the existing outputs collide), which is why fresh is stated rather than implied.

tests/test_extension_directory.py now interrupts a genuine extension once a checkpoint is committed, asserts the advice names the redo and never the resume, and asserts the parent is still byte-for-byte unchanged; the --resume + --extend-from dead end is a test of its own. The practical mitigation, for a chain long enough that this matters: several smaller --extend segments, each extending the previous completed one, so an interruption costs one small segment.

-o, -log, -r and -chk now default by segment

The trajectories, the state table and the per-force-group energies have carried a segment for some time. These four did not, because they come from the command line and whatever path is handed over is what gets written — and SimOut opens its file "w".

So a second segment run in place with the same command line destroyed the first segment's human-readable output and its provenance record, leaving a directory in which mdout_prod2.csv and solute_prod2.nc sat beside a cMD.log describing only segment 2. The half of the directory this package named was safe; the half the caller named was not, and the two halves look alike.

stage_artifact_name applies info_csv_name's rule to all four: cMD.log at segment 1, cMD_prod2.log past one, and never a segment on a stage that runs once per chain. An explicitly passed path still wins — this changes the default, not the override.

A remedy message named flags the command in hand does not define

0.5.1 fixed messages that told an md-run user to pass --force, and added a test to keep them fixed. The test scanned a list of files on the md-run path and that list omitted remd/executor.py, which md-run dispatches into; and its pattern captured only the first flag after the word "pass", so a sentence offering three remedies had one of them checked.

Both are widened, and together they found a live instance: "Pass --resume to continue that run, --extend N to lengthen it, or --force to replace it deliberately" — where md-run defines neither --extend nor --force. The imperative now names only the flag both commands have, and the rest are described and attributed to the command that has them.

A ladder can carry torsion restraints

hpREST2 runs a REST2 ladder with restraints on every rung, and this engine refused the combination: umbrella.file was accepted only with protocol: umbrella, and nothing in remd/ had ever seen a restraint. (It has no nmropt/DISANG surface either; restraints are declared in this package's own umbrella.yaml, against named collective variables.)

umbrella.file is now accepted with REST2 and rREST2, and means the same restraints on every rung. They are resolved by the preflight against collective_variables.file — before -odir exists, so a selector that does not resolve refuses nothing-written — and added inside build_rung_systems, the one function the driver, the preflight and the exporter all build rungs through, after each rung is scaled.

Why after, and why identical. The exchange criterion installs a configuration in a rung's own Context and reads its potential energy, so a bias present identically on both rungs enters u_i and u_j alike and cancels from log alpha exactly; the ladder samples the restrained ensemble with the acceptance probability it would have had unbiased. A restraint scaled by tau, or one that differed between rungs, would enter the acceptance probability and tilt the ladder towards whichever rung restrains least — silently, with every output still well formed. Per-rung variation is therefore not offered rather than left to a configuration to get right.

Each restraint's force constant rides on its own per-torsion scale, so one definition may mix strengths and both forms; the force's global parameter defaults to 1.0, so a rung is biased from its first step with no runtime call. restart.json records the definition file and every resolved restraint — the four atoms, the centre, the constant — under scientific_identity.torsion_restraints, because _protocol.py can only carry the path and a record naming a file beside an empty list understates a biased run. export-reference refuses such a ladder by name: a bundle's verify_rungs.py rebuilds rung i from rung 0 by scaling, and a restraint is not part of what that reconstructs.

Tested by arithmetic rather than by sampling: log alpha computed with and without the bias at the same configurations, equal to 1e-6 kJ/mol, plus a counter-example where one rung is restrained differently and the difference is not zero. Note what is NOT true, because it is the obvious thing to expect: a restrained and an unrestrained ladder from one seed do not accept the same exchanges — the bias changes the forces, so the trajectories diverge from the first propagation.

The .out now says what ran

Amber's mdout opens with a census of the topology and a full echo of every resolved control variable, and that is why an Amber run can very nearly be reconstructed from its own output. This package wrote the inputs, the step count and the platform. The cutoff, the PME treatment, the constraint count, the net charge, the degrees of freedom behind every reported temperature and the size of the restrained selection all existed — spread across resolved.config, the machine record and solute.yaml — so answering "what was the cutoff" meant opening three files.

A stage .out now carries three sections before Run:

System
  atoms                        7920
  residues                     2641 (HOH 2630, Cl- 4, Na+ 4, ACE 1, ALA 1, NME 1)
  net charge                   -0.000 e
  degrees of freedom           15855
  box                          4.674 x 4.963 x 4.497 nm  volume 104.323 nm^3

Method
  nonbonded                    PME, cutoff 1.0 nm, Ewald tolerance 0.0005, grid chosen per Context
  dispersion correction        on
  switching                    off
  exceptions (1-4 pairs)       7988
  constraints                  7902 bond(s)
  barostat in system           MonteCarloBarostat

Selections
  solute                       22 atom(s) (restraint and REST2 region)
  omega bonds unscaled         not applicable at tau = 0 (nothing is scaled, so nothing is exempted)

Every value is read off the SERIALISED System, never off the configuration that asked for it: a configuration claiming a 1.0 nm cutoff and a System carrying 0.8 nm are different runs, and only one of them integrates. The same facts go into the .log as structured fields, because prose is not a database.

Two details worth naming, because both were wrong in the first attempt at this. A box is reported only when the System is actually periodic — getDefaultPeriodicBoxVectors cannot answer "is there a box", since an OpenMM System defaults to a 2 nm cube, so an implicit-solvent stage would have printed 2.000 x 2.000 x 2.000 nm and a volume of 8 nm³ in the same voice as the real measurement two lines above. And the omega line distinguishes "no exclusions apply" from "none were found": at τ = 0 nothing is scaled so nothing is exempted, while the same system on a ladder excludes two amide omegas.

An rms fluctuation over a single report is now n/a (single sample) rather than 0. Over one sample sqrt(<x²> − <x>²) is exactly zero, which reads as "this did not move" when it means "there was nothing to compare it against" — and a stage shorter than state_interval_steps produces exactly one row, so this was the ordinary case rather than the corner one.

A ladder's .out names the Hamiltonian it integrated

# REST2:
#   tau ladder            0, 0.071429, …, 0.5   (8 state(s), one temperature 300.0 K, NVT)
#   scaling               solute-solute (1-tau)^2, solute-environment 1-tau
#   left unscaled         bonds unscaled, angles unscaled, ordinary amide omega unscaled
#   solute region         22 atom(s), 2 omega bond(s) excluded
#   system sha256         7521d7a01b63e5762e4650ff4a807b9d8a78ae167dda8eab5b96f4c30ed9e6f5
#   velocities on swap    never rescaled (one beta across the ladder)
# TIMINGS:
#   elapsed               2.1 s
#   throughput            32.25 ns/day per replica, 258.01 ns/day aggregate over 8 state(s)
#   per step              5.3579 ms

Amber prints the equivalent REAF block — τ, the gti_add_re scheme, the mask and its matched atom count — and cannot name the resulting Hamiltonian, because gti_add_re=6 is an index into a table in the manual and the scaled potential exists only inside the running binary. A rung here IS a serialised System, so it can be named and digested. That is the one place this package is structurally ahead, and it was not being said.

energy_components.csv decomposes on both build routes

OpenMM can only separate energies BY FORCE GROUP, and the two build routes disagree about them. ParmEd's Structure.createSystem — the implicit route — assigns bonds 0, angles 1, torsions 2, nonbonded and GB 11. OpenMM's ForceField.createSystem — the explicit route — leaves every force in group 0. So an implicit run decomposed, and an explicit run produced a single column holding the total potential energy under a joined header that promised a breakdown, with nothing in the output distinguishing the two cases. 31 such files exist under $MD_DATA.

Where the System carries no usable groups, a group-separated copy is probed instead. The run's own System is never regrouped: a force group is part of the serialised System, so changing one would alter system_sha256, invalidate the checkpoint fingerprint of every run in flight, and make registered data incomparable with new data — all for a diagnostic. Bonds, angles, torsions and nonbonded direct space come apart cleanly, and PME reciprocal space splits out for free.

EELEC from VDWAALS, and the 1-4 terms from either, do not: all 7988 1-4 pairs are exceptions inside the single NonbondedForce, which evaluates charge and dispersion in one kernel. Amber prints them separately because its energy routines are written term by term. Reaching that here needs duplicated forces, which belongs in post-hoc analysis where nothing integrates — md_tools.openmm.decomposition.

A ladder no longer recomputes the energy it already had

u[i][state_to_walker[i]] is the energy of the configuration state i's Context is already holding — _gather_configurations read it out of that very Context — so installing it again to measure it was a round trip to the device for a number already in hand. It is now taken directly, through the same reduced_potential conversion, so the two paths are the same arithmetic rather than an approximation of one another -- agreeing to 5e-11 reduced units at double precision, while at mixed precision neither is bitwise stable and the old save/restore round trip perturbed the Context by more than the difference does -- and read before any cross energy so it is measured on the Context as propagation left it.

This is exactly what Amber does: hamiltonian_exchange sets my_ene_temp%energy_1 = my_pot_ene_tot from the value the last dynamics step produced, and pays one pme_force call only for the cross term its own source calls "MY pot ene with THEIR coordinates". The partner's half arrives by MPI rather than being recomputed.

Rules may now declare which reduced potentials they will read, through required_entries. That honours a promise ExchangeContext had always made — "a callable rather than a precomputed matrix so a rule that needs only four numbers pays for four, not for N squared" — which the driver could not keep, because under MPI a rank may only evaluate rows for the states it owns and the rule runs on one rank, so a lazily evaluating callable would make a collective depend on one rank's control flow.

The driver records the declaration and deliberately still evaluates every entry. rem_log.block_rows indexes u[state, state] and u[state, mate], exchange_free_energies needs both cross terms of every proposed pair, and exchange.csv indexes u[state][walker] — two different conventions across three renderers. A sparse matrix would leave holes that some of them index, and a hole reaches the reader as nan in rem.log. The declaration is the rules' contract; the density is the driver's business.

u_evaluated is now derived from the matrix rather than written as np.ones(...). It documents "1 where u was actually computed", and a claim that cannot become false cannot catch the day it stops being true.

Output names in the documentation matched no file

remd0.nc .. remdN.nc and remd<N>.cv.csv appeared in CLAUDE.md, all four shipped configs/md/*.config, build/md.py's state_trajectory help, storage.py, preflight.py, two published docs and two example.in files. Nothing has ever written either name. A ladder writes solute_state<i>_prod<N>.nc and whole_state<i>_prod<N>.nc — state_trajectory_name is the one authority — and its CV series are cv_state<i>.csv with cv_state<i>.json sidecars, while <stage>.cv.json is the STAGE sidecar and was being quoted as though one name covered both shapes.

Corrected everywhere the text is a live instruction. Left alone in docs/history/**, docs/release-notes/** and docs/reports/**, which record what was true when written — the same convention the campaign journals follow.

One test asserted that remd0.nc.1 does not exist. Vacuously true of a name nothing writes, so it had never tested anything; it now guards against a second segment (..._prod2.nc) appearing, which is how an in-place --extend could actually break its contract.

A stale name in a contract file is not a typo. It is an instruction nobody can check.

One docstring overstated a refusal

reduced_potential_of said a cross energy is never inferred from a previously computed energy "because the scaled Hamiltonians differ by more than a single factor". That is true of rescaling a TOTAL, and the method still evaluates directly because it must serve every case. But as written it denies the exact three-term identity md_tools.ais.decomposition is built on, and would talk the next reader out of a sound optimisation.

It now separates the two claims, and names the real constraint: under PME with the long-range dispersion correction the scaler declines global switching altogether, because OpenMM computes the tail from the particles' stored epsilons and does not apply parameter offsets to it — measured at 2.6 kJ/mol at τ = 0.5 and growing with τ. There are therefore no globals to reconstruct from on an explicit-solvent ladder, which is the case that matters here.

The run directory layout

build-md used to write one flat md_script/ per run. It now writes a SYSTEM and RUNS beside it:

<system>/build/                built.xml, built.pdb, built.log -- build-top's own names
<system>/min/                  the minimisation, SHARED: one structure every run starts from
<system>/input/                min.in, eq_1.in, eq_2.in, eq_3.in, <protocol>.in -- SHARED
<system>/<method>-run<N>/      run.config, resolved.config, run.sh, the entry points,
                               eq/, remd<n>/, remd_records/, rank/, bundles/

build/, min/ and input/ belong to the system because every run on it starts from the same built System, the same minimised coordinates and the same instructions. Only what is genuinely per run is written into the run: its equilibration, its rungs, its output, its records, and run.config, which carries the seed and nothing else.

The sharing is ENFORCED, not assumed. A second configuration that resolves input/min.in or input/eq_<k>.in differently is refused by name, because the runs already beside that file read it: two methods that need different equilibration are not comparable and belong under a different <system>. min/ is first-writer-wins with the seed exempt from the comparison, since minimisation draws no velocities and keeps its own.

md_tools.layout is the single authority for where every file goes — DatasetLayout, RunLayout, record_name, ledger_name, eq_stage_key, eq_name — so the generator and the runtime read one answer instead of two.

A stage has two names, and they are different things

A stage is GENERATED as eq_nvt_posres — renamed to NVT spellings under implicit solvent or a scaled run, so a pressure-coupled name never appears on a boxless one — and it is FILED as eq_1, by position in the chain. The stage name decides the physics; the filing key decides the filename.

stage_plan stamps file_key on every plan entry, and it is the one function every surface goes through, so the layout that writes run.sh and the runtime that writes the files cannot disagree. They did: run.sh chained -c eq/eq_1.xml while the stage wrote eq/eq_nvt_posres.xml, and no cMD or REST2 chain could complete through run.sh — the documented way to run one. The plan is recomputed from resolved.config at every execution rather than persisted, so an older generated tree gets the key too.

A ladder's rungs are scaled at build time

Each rung is serialised to remd<n>/build_state<n>.xml by build-md and recorded in build_states.log beside it, so the Hamiltonian a state ran under is a file that can be read rather than a derivation that has to be trusted. build-md therefore needs build/built.xml to generate a ladder at all, and refuses before writing anything if it is absent.

A group file's -s is consequently PER LINE, and system left HOMOGENEOUS_GROUP_FIELDS. A stronger check took its place: line i must name rung i, and no two lines may name one file — so a line carrying remd2/build_state2.xml against --group-index 3 is refused rather than run.

-s or -groupfile, exactly one

A group file is the Amber-style description of a coordinated run (-rem 3, the only REMD type this build supports): the per-replica INPUT commands live on its lines, and -i, -p, -s and -c are validated THERE, not on the run-level command line, which carries only the outputs.

-s was required=True in four parsers above the runtime's own grouped exemption, so a grouped launch was impossible through every public surface. Eight gates refused it in turn: argparse in md-run and in replica_parser; _forward splicing ["-s", None] into the delegated runner; _common's particle check; the XOR itself, now refused BY NAME and read-only in both surfaces; preflight_ladder, which resolves a System — and the starting state — from the group file's first line rather than skipping the check, because skipping would have dropped the HMR timestep refusal on every ladder; the provenance record, which now names group_file and each system_state<i>; and the group file's own -i, which must be _protocol.py — the executor imports that field as Python — not the Amber-like input.

preflight_ladder reading the group file's -c is what makes rest2.equilibration_per_tau usable under run.sh: it refused a fully described launch with "no -c was given" while every line named that state. The first line is representative by construction — coordinates is in HOMOGENEOUS_GROUP_FIELDS, so a file whose lines disagree is refused earlier.

A completed ladder recorded itself as failed

replica_main built the executor's -r as args.restart or out / "restart.json", honouring what run.sh passes, but decided completion from the hardcoded default. With -r given the two disagreed, so a ladder that had just finished wrote

status: failed      failure_reason: "the executor returned 0"

while its own .out ended run_status: completed. Registration reads the record, so every run.sh-driven ladder was unregistrable, and two records of one run contradicted each other — which is what "completion is read from a machine record, never from prose" exists to prevent.

Every declaration carries the definition it names

resolved.config records the content-addressed CV and umbrella copies by BARE NAME so a directory stays movable. The copy is now written beside each declaration — the run root, the shared input/, min/ and eq/ — because the name resolves beside whichever declaration a reader started from. Written to the run root alone, input/cMD.in named a cv.<digest>.yaml that input/ did not hold, so md_tools.run.continuation could not LOAD the definition, returned None, and the read-only boundary silently did nothing: a REFUSED md-run wrote resolved.config, <stage>.out and <stage>.log into a tree it was declining to touch. Sharing one copy is safe by construction — the name carries the digest — and --overwrite reaches the new writer.

run.sh named two inputs that never existed

The AIS branch typed -i AIS.in and the all-in-one branch -i cMD.in, both bare basenames, while the inputs live in ../input/. Both died with md-run: -i …: no such run input file; the all-in-one line named cMD literally, so an all-in-one umbrella run asked for a file no generation has ever written. test_md_run_inputs pinned only a SPLIT REST2 tree, which is why both survived two green fast lanes, and it now pins the input line by protocol and by shape.

Also in this work

  • min.in carries no collective-variable cadence: minimisation produces no series, and leaving it in made two runs differing only in CV reporting collide on a shared input.
  • The seed left the shared input and became the per-run run.config; min/ keeps its own.
  • remd_records/ holds the ladder's per-segment .out, .log and restart_prod<N>.json — a ledger, not a trajectory — so an extension writes a _prod2 set beside the first rather than over it.
  • The CV series moved to the filing key (eq_1.cv.csv), as a contract edit: CLAUDE.md and docs/basics/collective-variables.md moved with it.
  • A configuration manual rendered from the schemas (md_tools.build.manual), with the shipped configs/ reduced to minimal examples.

Still open

Three items are deferred to 0.5.4 by decision, not by oversight. They are docs/backlog.md entries 13–15. Two more were filed during this cycle and are open: entry 17, the documentation reorganisation (76 files, stale copy-pasteable paths in every method page, three disagreeing version claims, and a decided direction — MkDocs Material on GitHub Pages, user-facing pages only); and entry 18, a solute.residue_name that is resolved, recorded in interpretation, and consumed by nothing, so a user who sets it gets a System whose residue is still UNL.

  • The omega exclusion is not enforced, and the case that matters is a solute whose solute.kind is peptide or peptide-like. classify_omega_bonds states the rule itself — "a non-empty unclassified list must block production" — and nothing consumes omega_unclassified_candidates. Its two routes are residue-aware (peptide) and bond-order-aware (ligand), while peptide-like is built through the whole-molecule ligand route and fits neither cleanly; that is the first thing to check. Rule out tau = 0 first, which legitimately logs not applicable.
  • The .in file's divergences from Amber's input conventions are not written down. Several are already stated and reasoned (-s avoiding -x, mdout.csv being the analogue of mden, force groups rather than Amber energy terms). What is missing is a term-by-term mapping of &cntrl, &remd and &AIS against Amber's namelist variables, so a deliberate divergence is distinguishable from an omission.
  • AIS is to become an Amber-style transformation between two topologies. A design change rather than a fix: it touches tau as the sole persisted coordinate, the three-term quadratic identity and the work convention. docs/amber-like-fix/AIS.md is the prior analysis and is explicitly unimplemented; its parameter-offset proposal is a speed change to the present design and a different piece of work.
  • --all-in-one writes its artefacts flat in the run root, while the declarations sit in eq/. Keyed correctly, placed pre-layout. Filed as docs/backlog.md entry 12 rather than changed, because changing where a runtime writes is a behaviour change for anyone already using it.
  • Two runs on one system that differ only in CV reporting cannot share a root: the cadence lives in eq_*.in. --overwrite is not the remedy, established by measurement.

build-top takes a supplied 3D structure

-i accepts a .sdf beside .pdb and .smi, for a ligand or peptide-like solute. The difference from the SMILES route is where the coordinates come from, and nothing else:

.smi   the chemistry, and the conformer is GENERATED    ETKDGv3 (seeded) -> MMFF94s -> lowest kept
.sdf   the chemistry AND the coordinates, used AS GIVEN  no embedding, no minimisation

That distinction is the point. A docked pose, a crystallographic conformer or the output of another pipeline is the structure someone chose, and silently replacing it with an MMFF minimum would be a different experiment reported under the same name. structure.etkdg and structure.mmff are therefore not read on this route — they describe a step it does not take.

initial_structure_from_sdf writes the same two files as initial_structure — solute.sdf and solute.pdb — into the same place and returns the same keys, so every step after preparation is shared between the two routes. The provenance differs where the fact differs: there is no seed and no conformer table, because nothing was sampled, and the input file's sha256 is recorded in their place. The log says coordinates: supplied by the SDF, used as given (no embedding, no minimisation), and interpretation.input_format records sdf.

Refused by name, and before anything is created: a two-dimensional conformer, missing explicit hydrogens, more than one molecule record, and a file RDKit cannot open at all. Each is a statement about the file named on the command line, so each exits 2 rather than 1.

A bundle from such a build rebuilds too. build_settings.json carries input_format, because route cannot answer the question — both molecular inputs are the ligand route, and handing an SDF to the SMILES reader parses the molfile's title line as a SMILES string and rebuilds some other molecule. test_standalone_build.py now builds an SDF case both ways and requires the same bytes.

An explicit-solvent ligand build wrote no built.sdf, and reported success

Found while adding the above, and the more serious half of it. initial_structure was called with the staging ROOT on the explicit route and with staging/structure on the implicit one, while build-top copies built.sdf out of the latter. On the explicit route the file was therefore written where nothing reads it, and none was published — with the build reporting completion.

Bond orders are not recoverable from a topology, and three consumers need them: the ligand route of classify_omega_bonds, peptide_map.map_from_sdf, and preflight._ligand_sdf_beside, which discovers <stem>.sdf beside the System at run time. An explicit-solvent REST2 ladder over a ligand or peptide-like solute therefore had nothing to perceive amides from.

It survived because the assertions and the builds were disjoint sets: every test asserting built.sdf ran under GBn2, while the suite's explicit ligand builds asserted radii and records instead. The omega item under "Still open" should be re-checked against an explicit build before its peptide-like hypothesis is pursued — a classifier with no SDF produces exactly the unclassified-candidate symptom described there.

Corrected alongside it: the kind × suffix rules were checked below the output mkdir, so every refusal of them created the output directory first. They now run above it, together with the SDF's shape, so a refused build leaves nothing behind — the rule --check already follows. tests/test_build_top_input_formats.py pins the nine-cell kind × format matrix, the SDF refusals and built.sdf under both solvents; none of those had a test before, and a repository-wide grep for the existing refusal texts matched only the lines that raise them.

Upgrading to it

git pull is not enough, and this is worth stating because it is easy to believe otherwise. pip install --no-deps . is not an editable install: the package on your PATH is a copy in site-packages, and the shipped configs/*.config are wheel DATA FILES read from <env>/share/md-tools/configs/ rather than from your checkout. A pull updates neither.

git pull && pip install --no-deps --force-reinstall .
md-openmm --version        # must match `version` in pyproject.toml

--force-reinstall because two dev commits usually carry the same version, so pip compares them, finds no change, and does nothing.

That staleness is not hypothetical: it is what hid a defect from every local test run during this cycle. md_tools.configs.example() reads the installed copies, so a suite run on a machine whose wheel was months old was checking months-old configuration files — and passed, while CI, which installs a wheel built from the tree, failed correctly.

Two environment files became one environment.yml, so a script naming environment-ci.yml or environment-cuda.yml needs updating.

Running simulations needs nothing else. If you run the TEST SUITE against an existing environment, add pytest-xdist once: pyproject.toml sets addopts = "--dist loadgroup", and an environment without the plugin exits 4 with unrecognized arguments: --dist having collected nothing.

micromamba install -y -p <env> -c conda-forge pytest-xdist

What changes for an existing run

The output work in this release alters no behaviour of a run that was already working, and no output that existed changes shape. What is new there is stated rather than inferred: three sections in a stage .out, two blocks in a ladder's, a decomposition where there was a single joined column, one energy no longer recomputed, and a set of documented file names that now match the files.

The LAYOUT work does change things. A generated directory is a different tree, and build-md refuses a run directory that already exists rather than generating into it. A run made by 0.5.2 still runs — the recorded-path resolution tries the run directory first and falls back to the bare basename, which is where that run's files sit — but a new generation uses the new layout, and docs/basics/run-layout.md section 4 carries the migration for the finished reference runs.