0.6.2 — nonpolar solvation, and three defects found while shipping it¶
This release CHANGES THE IMPLICIT-SOLVENT HAMILTONIAN, and the previous one was not what it claimed
Two separate things are true, and the second is the reason the first is not a preference.
The nonpolar term now defaults ON. implicit_solvent.nonpolar_sasa was false in 0.6.1
and is true in 0.6.2, matching OpenMM's implicit/gbn2.xml. That is worth ~16 kJ/mol
(~6 kT) on ACE-ALA-NME, so an implicit run at 0.6.2 is not the same experiment as the
same configuration at 0.6.1.
The setting did not work at all before this release. build_implicit_bundle_inputs
accepted nonpolar_sasa and never forwarded it to build_implicit_system. The argument was
declared, documented, resolved from configuration, recorded in forcefield.json — and
dropped. Every implicit System md-tools has ever built has no nonpolar term in it,
whatever its manifest says, and a configuration that asked for nonpolar_sasa: true got
exactly the same System as one that asked for false.
Traced with git log -S: the call site has never carried the keyword, going back to the
commit that introduced the parameter (1abeaa36). There is no version in which it worked.
So a manifest saying nonpolar_sasa: true from 0.6.1 or earlier describes a request, not a
Hamiltonian. One saying false is accurate, because false is what was built either way.
Records with the key absent are read as false, which is correct for the same reason.
A source-text test was watching this and could not see it. It asserted
"nonpolar_sasa=bool(" in inspect.getsource(builders) — a string in the caller, which was
there and correct the whole time, while the callee dropped the value. It has been replaced
by an AST check on the call node itself, and that check fails when the keyword is removed.
What this means for existing data. Every registered implicit dataset was built without the
nonpolar term. They remain internally consistent and their reference bundles still rebuild, because
a bundle carries the recorded value and the standalone rebuild honours it. What must not happen is
comparing an implicit result from 0.6.1 with one from 0.6.2 without checking
implicit_solvent.nonpolar in both. Explicit-solvent runs are untouched.
The nonpolar term is now a parameter set, not a flag¶
implicit_solvent gained one key:
implicit_solvent:
model: GBn2
radii: mbondi3
nonpolar_sasa: true # was false in 0.6.1, and inert before that
nonpolar_surften: null # kcal/mol/A^2; null means ACE's own 0.0054
nonpolar_surften is the surface tension σ in E_np = σ × SASA. OpenMM offers no way to set
it: customgbforces.py writes it into the ACE energy expression as the literal 28.3919551, which
is 4π × 0.0054 kcal/mol/Ų converted to kJ/mol/nm². sasaMethod="ACE" and ParmEd's
useSASA=True are booleans — on, off, and nothing between. md-tools locates that energy term in
the built CustomGBForce and substitutes a prefactor computed from the requested tension, so
surften is a real parameter here.
Measured on paracetamol (TYL, 20 atoms), GB energy against the tension:
| surften (kcal/mol/Ų) | prefactor | GB energy (kJ/mol) | vs ACE default |
|---|---|---|---|
| 0.0054 — ACE, OpenMM's own | 28.3919551 | −58.96 | reproduces exactly |
0.005 — Amber pmemd GB default |
26.2888473 | −60.18 | −1.21 |
| 0.010 | 52.5776947 | −45.01 | +13.95 |
term off (nonpolar_sasa: false) |
— | −75.34 | −16.38 |
null costs nothing. Rewriting at 0.0054 reproduces OpenMM's literal to every printed digit,
so the serialised System is byte-identical to the un-rewritten one — same digest, same
checkpoint fingerprint, and a bundle built before the rewrite still verifies against one built
after. A test asserts the bytes.
Zero is refused. nonpolar_surften: 0 would leave an energy term that always evaluates to
zero: it costs time every step, and it changes the System's serialisation and therefore the
fingerprint, so it would not be the same experiment as a run with the term off. The refusal names
nonpolar_sasa: false as the way to say that. A tension given alongside nonpolar_sasa: false is
refused too, before anything is loaded — that pairing is how an accepted-and-inert setting gets
written into a manifest, which is the defect this release exists to remove.
What is recorded¶
forcefield.json gained implicit_solvent.nonpolar, the method by name with the tension and
its units:
"nonpolar": {"method": "ACE", "applied": true, "surften": 0.0054,
"surften_units": "kcal/mol/A^2", "prefactor_kj_mol_nm2": 28.391955111258543,
"openmm_prefactor_kj_mol_nm2": 28.3919551, "energy_term_index": 1,
"is_openmm_default": true}
nonpolar_sasa stays as a bool for readers that already parse it, but it stopped identifying the
Hamiltonian the moment the tension became adjustable: 0.0054 and 0.005 differ by more than a kJ/mol
on a 20-atom ligand, which is the size of difference that silently invalidates a comparison between
two runs somebody assumed were the same.
ParmEd and OpenMM disagree about one GB fallback, in γ only¶
An element outside the GB-Neck2 fit gets a placeholder (α, β, γ). ParmEd writes (1.0, 0.8, 4.85)
and OpenMM writes (1.0, 0.8, 4.851) — a difference of 0.001 in γ, and nothing else.
It reaches only atoms the fit never covered — the halogens, P, Se, B, Si — so no protein and no
C/H/N/O/S ligand can see it. It is recorded rather than reconciled: neither value is derived
from anything, both are a library's choice of placeholder, and picking one would build a System
that is neither library's. A solute containing such an atom is already flagged by
gb_parameter_coverage as carrying parameters from outside the GB-Neck2 fit, and that warning,
not this constant, is the thing to act on.
A test reads both libraries' values and fails if either changes, so the note cannot outlive the state of affairs it describes.
amber_igb8_parity_claimed is removed¶
forcefield.json no longer carries amber_igb8_parity_claimed or amber_igb8_parity_basis.
They asserted bit-for-bit equivalence with a program this package has never run. No test, no
evidence page and no release note has ever compared an md-tools implicit energy against a pmemd
one. The value was computed as status == "supported", which comes from a route label plus an
element-coverage check — premises about the ingredients Amber would use, which say nothing about
whether the two programs produce the same energy. That is the only thing "parity" means to whoever
cites it, and forcefield.json is exactly the file somebody quotes when comparing an md-tools
number against a published Amber one.
Nothing branched on either field and no schema carried them, so removing them loses no capability.
What the record can stand behind is already beside them and stays: implicit_model, radii,
radius_assignment_method, nonpolar and parameter_coverage — all measured on the built System.
The claim was nearly entrenched instead: this release's first draft made it conditional on the nonpolar term and gave it a gating test, which would have dressed an unmeasured assertion in machinery that looks like verification. A gap invites work; a confident false entry closes the question.
The comparison has since been made, and the removed claim was wrong as worded. See
20260928-amber-igb8-cross-engine-evidence.md: one prmtop through CPU pmemd 26.0 and through
build_implicit_system, nonpolar_sasa=False so gbsa=0 means the same thing on both sides.
Bonded terms agree exactly. Totals agree to 2.6e-4 relative at worst — good agreement, but
not "bit-for-bit". And the disagreement is ten times larger under mbondi3 than under mbondi2
on the same molecule at the same coordinates with the same charges (3.1e-4 vs 3.2e-5 on EGB), so
the old field claimed parity most confidently exactly where the agreement is weakest, mbondi3
being the default. Ruled out as causes: rgbmax, system size, formal charge, and any per-atom
parameter mismatch — 0 of 124 atoms disagree in radius once GBn2's 0.195141 Å offset is applied,
and the screen factors match the GBn2 table element for element.
So the field is not merely unmeasured now; it is measured and false. What md-tools may say about Amber is a tolerance with a table behind it, not the word "parity".
The second force classifier is gone¶
md_tools.openmm.system carried its own SCALED_FORCE_CLASSES,
DELIBERATELY_UNSCALED_FORCE_CLASSES, ENERGY_FREE_FORCE_CLASSES, UnclassifiedForceError,
audit_force_classes and build_rest2_scaled_system — a whole-solute REST2 implementation
duplicating md_tools.rest2.hamiltonian. Nothing outside that file imported any of it; every
caller reaches the live ones through md_tools.rest2.scaler. 232 lines removed, and the fast lane
count did not change, which is what says they were covered by nothing.
Two classifiers is the defect, not the duplication. A force class added to one and not the other
makes audit_force_classes refuse in one code path and scale in the other, and which one runs is
decided by whichever module the caller happened to import.
Implicit AIS could not run at all¶
Under implicit solvent, AIS refused every run: "the two end states are not a parameter-only pair".
Bisected to 090be0e8 (the state renumbering, unreleased). At tau = 0 the scaler returns an
untouched clone with no rest2_scale_gb, while the scaled state has that global parameter and its
energy terms rewritten — a difference in force LAYOUT, not parameters, so the pair check correctly
refused it. Explicit solvent has no CustomGBForce, which is why every AIS tutorial passed and
this hid in the implicit path alone.
Fixed by writing the GB factor as a literal in the energy expression for AIS states
(gb_literal), rather than behind a global parameter:
- tau = 0 stays a byte-identical untouched clone — the documented invariant is preserved, not amended;
- REST2 and cMD keep the global parameter, so every saved state's bytes are unchanged;
- a DEGENERATE pair — a System against its own unscaled state — still has nothing to switch and is still refused;
- the real AIS pair is accepted, with no global parameter to collide.
The first attempt at this fix gave the tau = 0 state an inert rest2_scale_gb = 1.0 so the layouts
matched. It made the failing tests pass, and it also made a System and its own unscaled state
differ by one inert parameter — silently defeating the "nothing to switch" guard, so eight tests
then passed on a null transformation. It is recorded here because the wrong fix looked exactly like
the right one.
Never released, so no existing data is affected.
A bond another rule has already classified is not undecided¶
build-top --rest2-scaler refused builds with "1 item(s) could not be classified … RDKit found no
ordinary-amide match for this C-N bond", over a bond it had already protected.
_amide_candidates finds every C-N bond whose carbon also bears an oxygen. A C-N bond inside an
aromatic ring meets that description and can never match [CX3](=[OX1])[NX3], because SMARTS C
is an ALIPHATIC carbon and RDKit aromatises the ring. Folate's pterin 4-oxo lactam is the standard
case: the bond was in central_bonds as aromatic_ring, its torsions were never going to be
scaled — and the refusal was unanswerable, because the md-tools-torsion-exclusions/1 file only
ADDS protection, which the bond already had.
This does not weaken "a torsion nobody can classify is refused". It says a bond the aromatic or
double-bond rule placed HAS been classified. A candidate no rule placed is still refused, and a
whole residue with no bond orders is never dropped. The resolution is recorded in
amide_candidates_resolved_by_another_rule, with the rule that placed it and the amide test's own
words for why it could not.
Do not 'fix' this by widening the SMARTS
Three candidate widenings were tried and measured; all three are worse:
[#6X3](=[OX1])[#7X3]— matches the aromatic lactam, which then hits the proline-like branch (the nitrogen is in a 6-ring, inside the bound of 7) and the bond is SCALED. An aromatic ring bond that is protected today would start having its torsions scaled, silently.[CD3](=[OX1])-!@[#7]—-!@excludes ring bonds, and in a cyclic peptide every backbone amide IS a ring bond. Measured: 4 amide bonds found, 0 after the change.- Neither closes the case they were aimed at — a deprotonated amide (nitrogen with two
connections) fails on
X3, the connection count, which changing element classes does not touch.
test_relaxing_the_amide_carbon_to_any_element_would_scale_an_aromatic_ring_bond pins the
first hazard. The amidate gap remains open as of 0.6.2, and is 0.6.3 work with cyclic
peptides in the test set from the start.
Closed after 0.6.2 — and not by any of the three above
The widening taken was [CX3](=[OX1])[NX2,NX3]: the NITROGEN's connection count, and nothing
else. CX3 stays aliphatic, which is what keeps the aromatic lactam out, so the first hazard
is structurally unreachable rather than merely avoided; no !@ appears, so a cyclic peptide's
backbone amides are still ring bonds and still matched. Measured on both pinned hazards —
cyclo(Gly)4 4 → 4, folate's bond 26-28 unmatched — and every currently accepted molecule
classifies identically. Only molecules that previously REFUSED change, so no existing run's
Hamiltonian moves.
It does route a deprotonated small-ring lactam into the proline-like branch. That is the
third hazard's shape but not its defect: the NEUTRAL lactam is already proline-like today, so
the anion is being made to agree with its own neutral form, and the <= 7 bound still holds a
macrolactam as an ordinary amide.
The pattern is now ORDINARY_AMIDE_SMARTS, defined once in openmm/peptide_map.py and
imported by openmm/system.py — two literals answering one question is how they drift apart.
detector_version was deliberately NOT bumped, so a record is told apart by the pattern its
detection_method names. (After 0.6.2 the stamp was removed altogether: it named the
algorithm rather than the decision, had been a constant since 0.6.0, and sat inside the
selection digest — see the v4 identity format.)
The refusal names the atoms¶
That message printed the literal letters C and N — the ELEMENT of each end, not the atoms:
Every amide candidate in a residue rendered identically, and the letters read exactly like atom
names — actively misleading on folate, whose PDB component really does have atoms named C and
N (its benzoylglutamate amide), so the line pointed at a different bond from the one that failed.
The bond INDEX was correct throughout; only the label lied. It now names each atom:
Four CUDA-inventory sites were unclassified¶
The inventory guard is gpu-marked, so no CPU lane runs it, and it had four unclassified sites —
it would have failed at the first gate. Two were this release's; two had landed earlier and the
guard had not run since. Neither tagged release contains either function, so nothing shipped with
an unclassified site. All four are matched as derive by System/Topology accessors rather than
State accessors and create no Context; each entry says which accessor matched it and why that is
not a device read.
Also in this release¶
-
The ParmEd-free construction route was measured and reproduces the package bit-identically for a parameterised ligand: charges, σ and ε to max |Δ| = 0.00000000, and 20 bonds, 31 angles and 74 torsions identical term for term. No route change is made here — the measurement records that ParmEd is not load-bearing for that path, and the γ divergence above is its only difference.
Both ParmEd-free routes were executed, not just written down:
route result protein: ForceField("amber14-all.xml", "implicit/gbn2.xml")22 particles, 12 constraints, ACE term at index 1, −103.7465 kJ/mol unparameterised ligand: SMIRNOFFTemplateGenerator+ the same pair20 particles, ACE present, −200.2069 kJ/mol The protein figure reproduces the value already recorded in
implicit.py's docstring for the ForceField route, to every printed digit. For the ligand, OpenMM assigns GB radii itself (0.1005–0.1505 nm, none zero), sochangeRadii("mbondi3")is not what stands between that route and unassigned radii. That remains true only of the ParmEd route, where an OpenFF/Sage prmtop genuinely carries none — which is what the docstring says, and it still says it. -
Umbrella sampling moved to 0.6.3.
Compatibility¶
| explicit solvent | unchanged in every respect |
| implicit solvent, 0.6.2 defaults | new Hamiltonian — nonpolar term present for the first time |
implicit solvent, nonpolar_sasa: false |
byte-identical to 0.6.1 |
nonpolar_surften: null with the term on |
byte-identical to OpenMM's own ACE System |
| reference bundles built before 0.6.2 | rebuild unchanged; the recorded value is honoured |
manifests from 0.6.1 or earlier saying nonpolar_sasa: true |
describe a request, not what was built |
| implicit AIS | worked in no unreleased state; fixed here. Saved REST2/cMD state bytes unchanged |
| REST2 and cMD saved states | byte-identical to 0.6.1 — gb_literal is AIS-only |
| a build that 0.6.1 refused with "could not be classified" | may now succeed; what is SCALED is unchanged |
On that last row. The classifier fix changes which builds are allowed, never which torsions are scaled. A molecule that 0.6.1 refused and 0.6.2 builds gets the same Hamiltonian 0.6.1 would have produced had it not refused — the bond in question was already protected. Nothing built on 0.6.1 is invalidated by it.