Skip to content

0.6.3 — what a refusal means

0.6.2 shipped a torsion classifier that refused whenever it could not name a bond. This release narrows that to the one case a user can act on, decides the rest, shows its working, and lets a person overrule it.

No existing Hamiltonian moves. Every classification change affects only molecules that previously REFUSED, and a refusal has no run.

A refusal now means one thing: give me the bond orders

The refusal used to mix two situations that look alike and are not.

A residue whose bond orders were never supplied is a missing input. With no SDF the classifier does not see an unnameable bond, it sees nothing at all — paracetamol goes from 7 protected bonds (1 amide + 6 aromatic ring) to 0. Defaulting that to scaled would put a whole benzene ring on the λ path because somebody forgot a file, with no refusal raised and nothing red in the picture to notice. That still refuses, and names the file to supply.

A bond the evidence could not name is the other kind — a carbon with two carbonyl oxygens, one also carrying a hydroxyl or ester oxygen, or an SDF that simply answers that this C–N is not an ordinary amide. Nothing is absent, so nothing the user could supply would change it, and refusing it refuses forever. Those are scaled, and recorded under unnamed_scaled_bonds so that "seen and not named" cannot read as "never looked at". A carbamate builds now; it did not before.

Two categories, and the rule kept as evidence

amide_omega and non_rotatable. amide_omega stays its own category because it is protected by CONVENTION rather than by having nowhere to go: cis-peptide is a real state, just one the reference ensemble does not visit. Which rule fired is kept per bond as evidence, so a record still reads benzene ring [non_rotatable].

PROTEIN_UNSCALED_BONDS and rest2.sidechains key their tables by the RULE; the classifier maps them onto the category.

The principle the classes follow

Written into the guide, because the next case should be reasoned from the physics rather than pattern-matched against a list:

Scale a torsion when it has real alternative states behind a barrier the cold run cannot cross. Leave it unscaled only when there is no other state to reach and scaling would buy distortion instead of sampling.

ΔG‡ reachable?
aromatic ring bond — no conformer exists unscaled
amide omega ~20 kcal/mol, ~10 ms no unscaled (convention v3)
urea C–N ~11 kcal/mol, ~10 µs yes scaled
saturated ring (chair↔twist-boat) ~10.4 kcal/mol, ~µs yes scaled

Ring MEMBERSHIP is not the criterion, π character is. RDKit's NumRotatableBonds calls every ring bond rigid and would leave a cyclic peptide with nothing to scale — measured: cyclo(Gly)₄ has 16 ring bonds and it calls all 16 non-rotatable.

A urea used to be an abstention — "which of these two C–N bonds is the omega?" — and that was the wrong question, because both nitrogens donate into the same carbonyl and both bonds are partially double. It is recognised, recorded under urea_scaled_bonds, and left scalable: a real syn/anti change behind a barrier the cold run does not cross is what REST2 exists to accelerate. This unblocks phenytoin and phenobarbital, which refused while neutral.

The amidate gap, closed

[CX3](=[OX1])[NX2,NX3] — the NITROGEN's connection count, and nothing else. A deprotonated amide nitrogen has two connections, so an acylsulfonamide (pKa ~4–5, deprotonated at pH 7.4) lost the match its own neutral form had and would not build at all.

CX3 stays aliphatic, which is what keeps folate's aromatic pterin lactam out of the pattern, so 0.6.2's first rejected widening is structurally unreachable rather than avoided by luck. No !@, so cyclo(Gly)₄ keeps all four backbone amides, where the second rejected widening scored 0. The pattern lives once, as ORDINARY_AMIDE_SMARTS.

The refusal now carries what answers it

The message gained the local environment of each undecided bond:

bond 2-1: URE0 C -> URE0 N: carbon 2 is bonded to 2 nitrogens (urea-like)
    C [2] bonded to N [1], O [3], N [4]
    N [1] bonded to C [0], C [2], H [9]
      already classified here: N1-C3 aromatic_ring; N1-C5 aromatic_ring

Atoms carry their INDEX as well as their name, because a small molecule's atoms are routinely named after their element and C bonded to N, O, N identifies nothing.

The depiction is drawn on a refusal too, beside the System, and never overwrites an earlier one (URE-unscaled.png, then URE-unscaled.1.png). Previously it was drawn only on success, 121 lines after the refusal was raised, so the one artefact that says WHERE a named bond sits did not exist precisely when it was needed.

unscaled_list and scaled_list

The person's answer on top of the rules, in scaler.config, as [i, j] topology index pairs — the indices the refusal quotes and the picture annotates.

unscaled_list: [[6, 8]]
scaled_list:   [[26, 28]]

Applied BEFORE the selection is built, so a declared bond is part of the identity and two runs differing only in a list are two Hamiltonians with two digests. Three refusals: a bond that is the central bond of no proper torsion (this is what makes an index safe to write down — after a rebuild the same number is a different atom); a bond in both lists, rather than resolving by precedence; and a four-atom entry, because a torsion is named by its central bond and every torsion across it moves together.

Hamiltonian identity v3 → v4

detector_policy_version left hamiltonian_selection_projection, whose contract is that it hashes what determines the Hamiltonian and NOT its provenance. It named the algorithm rather than the decision, and had been a constant since 0.6.0, so it distinguished nothing while making every selection digest depend on an implementation detail.

It remains in solute.yaml as provenance. A compatibility branch accepts a v3 identity, so in-flight 0.6.1/0.6.2 ladders and AIS source ensembles still resume.

Every <method>-run<N>/ now carries build -> ../build, input -> ../input, min -> ../min, relative. The generated scripts already reach these by relative path, so a run does not need them — they make a run directory self-describing to anything that walks the tree without reading run.sh, which is what a dataset manifest declaring components BY PATH does.

Never absolute; never replaces a real directory or re-points an existing link; a target that does not exist yet is fine, because min/ appears only once a minimisation has run.

data-register accepts them. A dataset still holds its own bytes: the permission is the PROPERTY that makes a link safe — relative AND resolving inside the dataset — so a link called build that escapes the root is still refused, and a permitted link is never descended into.

Also

  • CLAUDE.md said four public commands; export-reference has been a fifth since it landed.
  • Two byte-identical paracetamol figures collapsed into one.

Evidence

Gate lane on b3ea95e2, cards 1–6, no MPS: 563 passed, 10 skipped, 0 failed, reconciling to 573 collected. Fast lane 2439 passed.

It took three gate lanes. The first came back 11 failed — nine commits had landed on fast-lane evidence alone, which is not evidence. What it found is recorded in the commits: a contract conflict where rule 2's links made every new dataset unregisterable; an import that would have broken verify_rungs.py inside a published bundle, silently, only on a machine without md_tools; five tests asserting a literal version string; and a dictionary key whose POSITION is part of a content-addressed record's identity.