Molecules
Two kinds — MoleculeSet and MoleculeRanking — and one axis,
representation, which is what admits them. The kinds, their loader, their
canonical forms and the checking are all in crates/ligase-core, with no
chemistry dependency of any sort. RDKit lives in one Python module.
The measurement, first
§4.4 of the v0.2 design asked for InChIKey stability to be measured before committing to it as a canonical form. It was measured before any engine code was written.
Method. Six RDKit releases spanning four years — 2022.09.5, 2023.09.6,
2024.09.6, 2025.09.6, 2026.03.6, 2026.09.1 — each in its own throwaway Python
3.11 environment. Thirty input SMILES: marketed drugs, four salt forms, three
tautomer pairs, a zwitterion, a charged/neutral pair, a pentavalent-nitro
spelling, a metal complex, and both ibuprofen enantiomers. Six standardisation
states each: raw, Cleanup only, salt-stripped, tautomer-normalised,
charge-neutralised, and all three together. 1,080 keys.
The probe is scripts/measure_inchikey_stability.py, with the cross-version
runner in its own docstring.
InChIKey survived
Zero disagreements in 1,080 cells. Every (state, molecule) pair produced the same key on all six releases, tautomer normalisation included — which was the state most likely to drift. InChIKey is a sound thing to compare by, and the design rests on that rather than on assuming it.
Canonical SMILES did not
In the same span, RDKit’s canonical SMILES for cisplatin changed:
canonical SMILES InChIKey
2022.09.5-2024.09.6 Cl[Pt]Cl.N.N LXZZYRPGZAFOLE-UHFFFAOYSA-L
2025.09.6-2026.09.1 N.N.[Cl][Pt][Cl] LXZZYRPGZAFOLE-UHFFFAOYSA-LOne compound in thirty, and it is the whole argument for §4.4’s “InChIKeys, not SMILES strings”. A canonical form over SMILES would have changed the content hash of every file holding that compound on an upgrade that changed no chemistry, and rerun everything below it. The key did not move.
This is why representation records the identity as part of its value: a
set named by structure strings has an identity its own toolkit can change
under it, and must not compare equal to one named by keys.
The standardisation state is what moves the key
Twelve of thirty molecules, within a single RDKit version. Aspirin, arriving
as its sodium salt, has three keys — JZLOKWGVGHYBKD-…-M raw,
BSYNRYMUTXBXSQ-…-M salt-stripped, BSYNRYMUTXBXSQ-…-N salt-stripped and
neutralised, which is the key plain aspirin gets. Warfarin’s keto and enol
forms are two keys raw and one after tautomer normalisation. Tetracycline’s
first block changes, so the two keys look like unrelated substances.
That is the dimension, demonstrated rather than argued for. The values the
design quotes are asserted on one interpreter by
tests/test_inchikey_stability.py; if a future release changes one, that is
where it surfaces, and it would be a finding about InChIKey rather than a bug
in Ligase.
Where §4.4 was wrong
It describes the state as “salt-stripped, tautomer-normalised,
charge-neutralised, stereo kept”. Stereo is not kept. RDKit’s tautomer
canonicaliser removes stereochemistry at an sp3 centre inside a tautomeric
system, and a carboxylic acid’s alpha-carbon is inside one, so ibuprofen’s
two enantiomers both arrive as HEFNNWSXXWATRW-UHFFFAOYSA-N.
So the axis records stereo-discarded as an operation that was applied,
observed rather than requested, and lg.chem.standardize checks its own work
asymmetrically — see below.
The value type
dim/representation.rs. An Identity (InChIKey or Smiles) and a closed
set of Standardisation operations, held in one canonical order with repeats
collapsed, so set equality is a slice comparison and one state renders as the
same sentence every time. That matters because the sentence appears in three
places that must agree: an error message, a file’s own representation line,
and a conversion’s target.
The operations are a closed enum on CoordinateSystem’s grounds: a value
the engine cannot render is a value it cannot explain, so a file naming an
operation the engine has never heard of is a format error naming the four it
knows rather than a state carried along unread.
Representation::parse and Representation::spell are the two directions of
one spelling. A unit test round-trips every state through both, because a
step that wrote a line its own loader read as something else would be the
worst kind of bug this design could have.
The file shape, and what is not read
A tab-separated table read by the existing load::table reader, optionally
preceded by # lines of which one may be the representation. The identity
column is inchikey where there is one and smiles otherwise; neither is a
format error naming both and listing the columns the file does have.
No chemistry format is read. No SDF, no MOL, no .smi, no .cdx. That
is D35’s first clause applied rather than argued around: chemistry has
standard formats and RDKit reads them properly, so a kind admitted because
one of them was convenient would be a parser. The axis is the grounds; the
TSV is the cheapest envelope that can carry a key column and a representation
line.
Table::read_after_comments is new, and lives with the reader rather than in
either caller, because the loader and the canonical form must agree about
where the header starts. One of them disagreeing would hash a file the engine
had described differently.
Where the representation comes from
| Source | When |
|---|---|
Source::Declared | the user stated it, and it parses |
Source::Inferred | read from the file’s #ligase representation: line |
Source::Inferred | the table names its molecules by structure, so the names are the structures |
| unknown, with a reason | the table names them by InChIKey and nothing said what produced them |
The last row is the one with a decision in it. A table of keys with no line
could be a ChEMBL download, which is standardised, or a vendor list, which
is not. Assuming as given would be the engine inventing the most dangerous
of the possible answers, and it would be a known value, so two such tables
would agree. So the axis is a gap, and because representation is demanded
wherever two of these kinds are joined, the table cannot quietly join
anything until somebody says.
A declared representation is recorded, not verified. That is D38’s position on a declared namespace, reached by a shorter road: the engine has no chemistry, so it could not check the claim if it wanted to.
Except for charge state, which the keys record
One operation leaves a trace in the identifier, and a consumer found out what
it costs to ignore that. An InChIKey’s third block is the protonation layer —
N for a proton balance of zero, M, L, K for one, two, three protons
removed, O, P, Q for as many added — so a table declared
charge-neutralised whose keys are not neutral is a claim the file itself
refutes, and ChargeState in load/molecule_table.rs refuses it with
LoadError::Representation.
Nothing chemical happens to see this. The rows are already being read to the end so that a row the header cannot account for is refused here rather than by the canonical form, and the key column is a column of strings whose third block is one character. D49 is untouched: no structure is parsed, no ring is perceived.
The check is one-sided, and the reason is in NEUTRAL’s own comment. A
molecule’s net charge is not always in the protonation layer — a quaternary
ammonium carries its charge in the InChI’s /q layer and keys N
regardless — so a neutral block proves nothing, while a non-neutral one is a
molecule the charge-neutraliser would have neutralised. Hence:
charge-neutraliseddeclared, a key that is not → an error, naming the key, its row, its block and the state those keys are in.salt-strippeddeclared alone, every key neutral → a note (keys-look-neutralised), because a set of compounds with nothing to neutralise looks exactly the same.
Which is the stereo asymmetry below, arrived at from the other side: there, the step knows what it did and compares it against what was asked; here, the file states what was done and the keys are what disagree (D65).
stated is the one place the precedence between a declaration and the file’s
own line lives, and both the dimension and this check read it — so the state
that is checked is the state that is reported, which it would not be if each
had resolved the precedence for itself.
The canonical forms
canon/molecule_set.rs records rows unordered; canon/molecule_ranking.rs
writes them in order. They share the reader and the fields above the rows,
and differ in one call — record against ordered_record — which is where
D41 requires the difference to live. The ranking’s module comment says why it
does not sort, at the top where it cannot be missed, and its test asserts the
contrast over the same two files rather than a bare inequality.
The representation is hashed as a field, and that is load-bearing rather
than tidy. A step key is hash(function code, parameters, input content
hashes, tool versions) — dimensions are not in it. Two tables with a
byte-identical InChIKey column and different standardisation lines mean
different things, so without the line in the content hash the cache would
serve one when the pipeline asked for the other. Everywhere else in canon a
field is hashed because it is part of what a file says; here, leaving it out
would be a correctness bug.
Nothing is dropped. The other # lines are hashed too, in order: unlike a
served JSON document, a table nobody fetched carries no serving system’s
signature, so there is no volatile set to appeal to.
One check entity_set deliberately does not make
canon::entity_set does not check which column holds the identifiers,
because it is not told — a canonical form is computed from a path and a kind,
and the identifier column is a loader argument. Here the column’s name is
fixed by the kind, so this can check and therefore must: without it any
one-column text file parses as a molecule table and hashes as the canonical
form of something it is not. A JSON document is one such file, since every
line is a row of one field. That was a real bug, caught by the test for it.
Why the chemistry is in Python, and how that is proved
Computing an InChIKey needs RDKit. RDKit is a Python package, so an extra can
reach it — unlike the remote store’s cargo feature, which is why that extra
is empty. The alternative was linking an InChI library into the core, which
would have ended the promise tests/default_build.rs defends: that
pip install ligase-bio needs no toolchain beyond Rust.
So the division is:
| Needs RDKit? | |
|---|---|
| loading a molecule table, reading its representation | no |
| hashing it, early cutoff, the cache | no |
| refusing to join two sets that disagree | no |
| propagating the axis through a step | no |
| producing a set whose standardisation is known | yes |
A claim about what an install needs is checked against what the install does, not asserted in prose. Four tests:
default_build.rs::a_default_build_does_the_whole_of_the_chemistry_checkingloads two tables and asserts the refusal, in a build with no features.test_default_install.py::test_importing_ligase_imports_no_chemistryruns a subprocess and assertsrdkitis in no entry ofsys.modules. That is what notices the day somebody moves the import to the top ofchem.py— which is how an install story is lost: not by a decision, by a convenience.test_a_molecule_set_loads_and_checks_with_no_toolkit_at_alldoes the Python half of the first, from inside a process that has RDKit, by makingimport rdkitraise.test_standardising_without_the_toolkit_names_the_extraasserts the error namespip install 'ligase-bio[chem]'and says what works without it. The precedent isligase store status, which tells a user which build they have rather than leaving them to the release notes.
And the docs site builds with no chemistry toolkit: the guide’s examples are
over committed fixtures and the capture_diagnostics.py scenarios need no
RDKit. A site that could not build without the optional extra would have made
it mandatory in everything but name.
Standardisation is a conversion
convert.rs gains a second entry. standardize crosses representation,
reads its destination from the same to= every conversion reads, and can
fail for some inputs — which is what keeps it a conversion rather than a
rename, in convert’s own words.
It is the first entry whose body is Python, and nothing in the registry
had to change for that: an entry is metadata about an axis, and who runs the
crossing is the caller’s business. That is the first real evidence that the
registry takes a row rather than a redesign, which is what D5 wanted of it
before liftover and rename_contigs arrive.
Its failures follow map_identifiers’ contract: twenty molecules named
individually, the rest carried by a count, and nothing dropped in silence.
The version capture, which is now an ordinary tool=
The step declares tool="rdkit" like any other step declares its tool, and
both halves of D42 then work: the version joins the cache key, and the
citation resolves from the engine’s curated table.
That needed a change to the machinery, because tool= meant a program the
engine execs as well as a name a citation resolves from, and RDKit is only
the second. D46
added a third kind of tool, after a program the engine runs and a built-in it
carries: a library, whose version the host supplies because the library
lives in the interpreter rather than on PATH. exec::LIBRARY names them and
exec::register_library_version is how one arrives.
ligase.chem.toolkit_version registers it, and is called by the wrapper that
builds the step — not by the step’s body, because a graph is built from
decorated functions without executing them, so a body runs far too late. A
library nothing registered is an error naming the library and the extra,
never an empty version: that is the bwa lesson kept intact, since a
non-answer is the same text for every release and upgrading the library would
move no key at all.
The engine also refuses to run a library even where a program shares its name, which the tests pin. What the step calls is the library, so a program of that name would be a version of the wrong thing.
The asymmetric stereo check
Stereo loss is observed, not requested, so the step compares what it did against what it was asked for — and the two directions are not equally dangerous, so they are not treated alike:
- stereo discarded, not declared → an error, naming the count. Two sets would otherwise agree while one of them has lost the difference between an active enantiomer and an inactive one.
- declared, nothing discarded → a note. It can only cause a refusal a user fixes by declaring one thing consistently.
That asymmetry is the one M10a settled for case folding, applied to a second axis: a false agreement is unrecoverable, and a loud refusal is not.
Gaps, named
source_setis not claimed, though a compound library merged from three vendors is genuinely assembled, and the axis exists. It is declined because nothing here can read it: which vendor a compound came from is a column, an attribute, not a fact about the set. An axis the loader cannot fill is the unexercised value type M7’s standing requirement exists to prevent. Closing it means deciding whether a per-row source column can be read as a set, which is a different question from a graph’s per-edge provenance and is not answered here.- A declared representation is still mostly unverifiable. Charge state is checked against the keys, above; salt stripping and tautomer normalisation leave no trace in a key, so a wrong declaration about either still passes. The step checks its own work; nothing checks a user’s claim about what somebody else’s standardiser did.
standardizeis not checked against this rule before it writes. If RDKit’sUnchargerever leaves a key charged in a run that asked forcharge-neutralised, the step writes a line the loader will then refuse — which is the right outcome for a false declaration and a poor place to discover it. The uncharger neutralising what the protonation layer records is what makes this latent rather than open, and a self-check inside the step is the honest closing of it.- One state, one toolkit.
representationrecords the operations, not which toolkit applied them. Two sets standardised to the same state by RDKit and by another toolkit compare equal, and the measurement says nothing about whether they should: it measured RDKit against itself across time, not RDKit against OpenEye. Recording the toolkit would be a wider claim than the evidence supports, and measuring it is the next experiment rather than a line in this milestone. - The whole table is read into memory by the canonical form, as a gene list’s is and for the same reason. A library larger than memory is not readable today and nothing pretends otherwise.