Skip to content

Fix conformer search correctness bugs and speed up hot pure-Python paths - #927

Open
alongd wants to merge 7 commits into
mainfrom
perf_hotspots
Open

Fix conformer search correctness bugs and speed up hot pure-Python paths#927
alongd wants to merge 7 commits into
mainfrom
perf_hotspots

Conversation

@alongd

@alongd alongd commented Jul 27, 2026

Copy link
Copy Markdown
Member

What this is

Seven commits. Four of them change ARC's scientific output and are described first;
the behaviour-preserving speedups follow. Everything was measured on one machine against
main (b0dd288c7) using a pristine baseline worktree, interleaved A/B where possible,

= 15 repetitions, reporting medians.

Behaviour changes (please review these first)

1. Conformer geometries were paired with energies belonging to a different structure

change_dihedrals_and_force_field_it() always called get_force_field_energies() with
optimize=True, so the energy it returned described the FF-optimized geometry. But when
its own optimize argument was False it stored the unoptimized torsion geometry
next to that energy. conformers_combinations_by_lowest_conformer() is exactly that
caller, so every conformer it produced carried an energy that was not its own, and
get_lowest_confs() ranked geometries by foreign energies.

Measured on ethylamine, the reported energy was off by up to 0.49 kcal/mol from the
true energy of the reported geometry. It is now 0.000000 on every sample.

The optimize argument is kept as a deprecated, ignored keyword rather than removed:
dropping it would have silently rebound a legacy positional False onto force_field.

2. The duplicate scan stopped at the first non-duplicate

compare_confs_fl() is a cheap first/last-atom-distance prefilter. A negative result means
only that that one conformer is not a duplicate, but the loop treated it as break, so
every conformer past the first dissimilar one went unchecked and duplicates were admitted.
The list is in generation order with no ordering invariant that would justify the early
exit. Now continue, carrying the candidate's distance matrix across iterations since
compare_confs_fl() returns it as None precisely when the prefilter rejects.

Because continue removes the short-circuit, this scan became a full pass per candidate, so
the first/last-atom distance and the candidate's distance matrix are now computed once per
candidate and threaded into compare_confs_fl() rather than being rebuilt on every
comparison. On hexanediol that cuts xyz_to_dmat() builds by 37% (76 -> 48) with identical
conformers returned.

3. The iterative conformer descent never advanced its base geometry

conformers_combinations_by_lowest_conformer() updated base_energy but never reassigned
base_xyz, so all MAX_COMBINATION_ITERATIONS (25) rounds re-sampled dihedrals from the
original geometry and regenerated the same conformers, which were then discarded as
duplicates. The loop-bottom exit required the round's lowest conformer to compare equal to
the frozen base, which essentially never happens, so the full budget was always spent on
redundant force-field work.

base_xyz now advances whenever a round improves on the base energy, and the loop stops
when a round fails to improve. Energies are no longer rounded to 3 decimals before the
internal comparison, which had made the convergence test degenerate against a 1e-3
tolerance.

4. rdkit_force_field() could hang forever

The MMFF retry loop incremented its counter only in the else clause, so a call that
raised on every attempt spun forever with no progress. The bare except also swallowed
KeyboardInterrupt/SystemExit, and a failed optimization was still reported as an
optimized conformer. Fixed, narrowed, and the conformer is now skipped so the existing
UFF/OpenBabel fallback can engage.

The same function's UFF fallback had the identical geometry/energy pairing defect as #1: it
appended the xyz unconditionally but the energy only on convergence, so with three conformers
where 0 and 2 converge and 1 does not you got energies == [E0, E2] against
xyzs == [X0, X1, X2]. Both call sites zip them, so X1 was handed E2 and X2 was dropped
entirely. Non-converged conformers are now excluded from both lists together.

This also required importing AllChem explicitly: the module called Chem.AllChem.* in
five places while importing only Chem, which worked solely because converter.py imports
the submodule first and thereby sets the attribute on the parent package. With the bare
except gone, that latent AttributeError would have escaped into the conformer pipeline.

Blast radius of 1-3

  • With default settings (generate_conformers(n_confs=10)), the delivered conformers are
    identical for ethanol, n-butanol, n-hexanol, iso-octane, glycerol and hexyl radical,
    while wall time drops 1.6x-2.3x on the species with several rotors (n-hexanol
    11.61s -> 5.11s, glycerol 3.03s -> 1.37s, hexyl radical 2.68s -> 1.63s).
  • Under deliberately aggressive settings that force this code path
    (combination_threshold=10, n_confs=50, e_confs=50), fewer conformers are
    delivered (e.g. hexanediol 21 -> 15, OCC(O)CC(O)CO 50 -> 15) because the duplicates the
    truncated scan had been admitting are now caught. In every case tested the lowest-energy
    conformer is preserved
    to within 3e-4 kcal/mol.

Does the greedy stop cost us the global minimum?

The goal of this search is to find the global minimum, so the only acceptance criterion that
matters is whether the new early stop can return a higher minimum than before. It cannot,
and this is provable rather than merely measured.

The round is deterministic in base_xyz. The only randomness anywhere in conformer generation
is EmbedMultipleConfs(..., randomSeed=1), a fixed seed, and it is used only by the earlier
random-conformer stage, not here - this path is handed an explicit xyz. So if a round fails
to improve, base_xyz is unchanged, and the next round re-samples the same dihedrals from the
same geometry and produces bit-identical conformers, which then dedup away. Continuing cannot
reach a new basin; it is exactly the redundancy described above. The conformers generated by
the final, non-improving round are still kept - they are appended before the loop exits - so
nothing is discarded either.

Verified empirically across 18 species (alcohols, diols, polyols, glycols, amines,
diamines, an acid, an ester, a thiol, an aldehyde, a branched alkane and a radical), under
both default settings and forced settings (combination_threshold=10, n_confs=50,
e_confs=50): the baseline ran its full 25 rounds and never found a lower minimum than
the early-terminating branch. In all 36 comparisons the largest deviation is 4e-4 kcal/mol,
which is the de-rounding described above, not a search difference.

Performance (behaviour-preserving)

rdkit_conf_from_mol() - the largest win

It ran a full ETKDG AllChem.EmbedMolecule() and then overwrote every atom position from
the supplied xyz, so the entire embedding was computed and thrown away - once per torsion
per sampled dihedral, inside the 25-iteration loop above. It now builds the conformer
directly. Also Set3D(True), AddConformer(assignId=True), and a coordinate-count check
(previously a short xyz raised a raw IndexError and a long one was silently truncated).

species atoms before after speedup
ethane 8 0.609 ms 0.069 ms 8.9x
isobutane 14 0.772 ms 0.088 ms 8.7x
1-hexanol 21 1.543 ms 0.125 ms 12.4x
methylcyclohexane 21 1.032 ms 0.131 ms 7.9x
anthracene 24 2.413 ms 0.176 ms 13.7x
branched dodecanol 57 18.137 ms 0.343 ms 52.9x

Molecule.connect_the_dots() - vectorized

Replaces the O(N^2) Python double loop with a numpy pairwise formulation. Measured
compiled against compiled (this module is cythonized, so timing a Python copy of the
old function would measure the Cython compiler rather than the change):

species atoms before after speedup
naphthalene 18 0.281 ms 0.164 ms 1.7-2.1x
hexanol 21 0.391 ms 0.188 ms 1.7-2.2x
hexyl radical 22 0.433 ms 0.179 ms 2.4x
octane 26 0.462 ms 0.179 ms 2.5-3.1x
iso-octane 26 0.529 ms 0.182 ms 2.8-2.9x
decalin 28 0.506 ms 0.186 ms 2.6-2.7x
pentacontane 152 10.354 ms 0.545 ms 18.6-20.1x

The function's "delete all bonds and set them again" preamble never worked: get_bonds()
returns a {atom: bond} dict, so iterating it yielded atoms, which remove_edge() rejects.
Any call on an already-bonded molecule raised. It is fixed here (collect the bonds once,
deduplicating, then remove them) since this PR is the one rewriting and documenting the
function, and connect_the_dots() is now covered by an idempotency test.

Two deliberate details: the squared distance accumulates one axis at a time rather than
materializing an (N, N, 3) cube, and it iterates over coords.shape[1] rather than
hardcoding x/y/z so it stays equivalent to the original sum((c1 - c2) ** 2) for any
dimensionality. Non-finite coordinates now raise ValueError: previously every comparison
against NaN evaluated False, control fell through to the else branch, and a bond was
added between every such pair, silently producing a nonsense fully-bonded molecule.

cluster_confs_by_rmsd() - compute each fingerprint once

Each conformer's np.triu(dmat) is computed once and passed through compare_confs()
instead of both distance matrices being rebuilt on every pairwise comparison.
3.7x (10 conformers) rising to 11.1x (150). xyz_to_dmat() builds its coordinate
array once instead of twice: 1.11x-1.46x.

common.distance_matrix() squares in place rather than allocating a second (N, N, 3)
temporary. This is bitwise identical (np.einsum was tried first and rejected - it differs
by 1-2 ULP, and this function is on a production path via perceive.py:78). It shows no
measurable speed change
; it is included for the reduced allocation only, not as a win.

Units

DE_THRESHOLD, e_confs and the de_threshold docstrings described force field energies
as kJ/mol. RDKit's MMFF/UFF and OpenBabel's MMFF94/UFF/GAFF all report kcal/mol, and these
thresholds are compared directly against those energies, so the documented units were off
by a factor of ~4.2. Comments only; no behaviour change.

Testing

Full unit suite: 2493 passed, 3 skipped (the 3 skips are pre-existing, UMA env
unavailable), up from 2477 on main. 16 new tests.

One existing expectation changed: test_deduce_new_conformers conformer count, because it
had encoded the truncated dedup scan.

Notes for reviewers

  • Two of the three functions this started from are not on ARC's production path:
    connect_the_dots() is reachable only via s_bonds_mol_from_xyz() (test-only callers)
    and Molecule.from_xyz() (no callers), and cluster_confs_by_rmsd() has no production
    call sites. Their speedups are real but do not move ARC's wall-clock. The wins that do are
    rdkit_conf_from_mol() and the conformer descent fix.
  • Deliberately left out, as separate concerns: batching MMFF via
    MMFFOptimizeMoleculeConfs(), reusing one RDKit Mol across the dihedral loop, the
    O(N^2) .index() in to_rdkit_mol(), and colliding_atoms().

Comment thread arc/species/converter_test.py Fixed
Comment thread arc/species/converter_test.py Fixed
@codecov

codecov Bot commented Jul 27, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 63.42%. Comparing base (dc8f293) to head (f496446).

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #927      +/-   ##
==========================================
+ Coverage   63.24%   63.42%   +0.17%     
==========================================
  Files         114      114              
  Lines       38318    38347      +29     
  Branches    10026    10033       +7     
==========================================
+ Hits        24234    24321      +87     
+ Misses      11169    11114      -55     
+ Partials     2915     2912       -3     
Flag Coverage Δ
functionaltests 63.42% <ø> (+0.17%) ⬆️
unittests 63.42% <ø> (+0.17%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

rdkit_conf_from_mol() ran a full AllChem.EmbedMolecule() distance-geometry
embedding and then immediately overwrote every atom position from the given
xyz, so the embedding result was always discarded. Build the conformer
directly instead, the way conformers.embed_rdkit() already does.

This function sits on the conformer search hot path: it is called once per
torsion per sampled dihedral inside change_dihedrals_and_force_field_it(),
which itself runs up to MAX_COMBINATION_ITERATIONS times. Measured 6.5x-70x
faster on real species (ethane 12x, a 57-atom species 70x).

Behavior change: previously, when the embedding failed (returns -1 for some
strained structures), no conformer existed and the function returned None,
which silently skipped the requested dihedral change at conformers.py:397
and conformers.py:761. A conformer is now always returned, so those dihedral
changes are always applied.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes multiple correctness issues in ARC’s conformer generation/deduplication pipeline (several of which affected scientific output), and then applies targeted performance improvements to hot pure-Python/numpy paths (RDKit conformer construction, distance-matrix usage, bond perception).

Changes:

  • Fixes conformer energy/geometry mispairing and dedup scan logic, and makes the iterative “lowest conformer” descent actually advance and terminate on non-improvement.
  • Hardens RDKit force-field optimization against infinite retry/hang scenarios and keeps xyz/energy lists index-aligned across fallbacks.
  • Speeds up key paths by avoiding redundant coordinate/distance-matrix construction and vectorizing Molecule.connect_the_dots().

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
arc/species/converter.py Avoid redundant coordinate array builds; construct RDKit conformers directly from provided xyz; add precompute hooks to first/last-atom prefilter; reduce repeated dmat work in RMSD clustering.
arc/species/converter_test.py Adds regression tests for xyz_to_dmat, pinned-coordinate conformer construction, and compare_confs_fl precompute equivalence.
arc/species/conformers.py Correctness fixes in conformer descent/dedup and energy handling; deprecates/ignores legacy optimize arg in dihedral+FF helper; imports and uses AllChem explicitly; hardens rdkit_force_field.
arc/species/conformers_test.py Updates/expands tests to pin the corrected conformer search behavior and prevent RDKit FF hang/misalignment regressions.
arc/molecule/molecule.py Vectorizes connect_the_dots(), fixes bond-removal preamble, and adds explicit validation for bad coordinates.
arc/molecule/molecule_test.py Adds comprehensive connect_the_dots() tests including idempotency, NaN/inf handling, and reference-algorithm equivalence.
arc/common.py Reduces allocation in distance_matrix() by squaring in-place.
arc/common_test.py Adds unit tests for distance_matrix() including trivial, normal, and error cases.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread arc/molecule/molecule.py
Comment thread arc/species/conformers_test.py Outdated
alongd added 6 commits July 27, 2026 13:17
cluster_confs_by_rmsd() called compare_confs(), which rebuilt BOTH conformers'
distance matrices from scratch on every pairwise comparison, so each
conformer's matrix was recomputed once per representative rather than once.
Precompute each conformer's upper-triangular distance matrix up front and
feed it through compare_confs()' existing skip_conversion/dmat1/dmat2 path.
Measured 3.7x-8x faster (10-150 conformers).

xyz_to_dmat() built the coordinate array twice per call, and each build ran
check_xyz_dict() again; build it once. 1.1x-1.4x faster.

distance_matrix() materialized both an (N, M, 3) difference array and an
(N, M, 3) squared copy; square in place to drop the second temporary. Output
is bitwise identical (np.einsum was tried first and rejected: it differs from
the original by 1-2 ULP).
Replace the per-pair Python double loop with a single numpy pairwise
computation. The z-sorted early-break is preserved exactly as a mask
(|dz| > 4.0 excludes the pair), the inclusive/exclusive distance bounds are
unchanged, and bonds are still added in z-sorted (i, j > i) order so atom
edge insertion order is untouched. Bond sets are identical on every species
tested.

The win is modest on real geometries (~3-6%): real molecules have genuine
z-spread, so the original's early-break already pruned most of the inner
loop. It only looks dramatic on artificially planar coordinates, where that
break never fires.

Adds the first direct tests for connect_the_dots(), covering 1 atom, H2,
methane, a heteroatom ring, separated fragments and an empty molecule.
The MMFF optimization retry loop only incremented its counter in the `else`
clause, so a call that raised on every attempt spun forever: `j` never advanced
and `v` never changed. Break out of the loop on failure instead, and narrow the
bare `except`, which also swallowed KeyboardInterrupt and SystemExit.

Guard `ff is not None` before evaluating its energy, and import AllChem
explicitly. The module called `Chem.AllChem.*` in five places while only
importing `Chem`, which happened to work solely because converter.py imports
the submodule first and thereby sets the attribute on the parent package.
Without the bare `except` swallowing it, that latent AttributeError would
otherwise escape into the conformer pipeline.
… the dedup scan

Two correctness bugs in the iterative conformer search. Both change ARC's
scientific output.

1. change_dihedrals_and_force_field_it() called get_force_field_energies() with
   optimize=True unconditionally, so the energy it returned always belonged to
   the FF-optimized geometry. When its own `optimize` argument was False it
   nevertheless stored the *unoptimized* torsion geometry alongside that energy,
   so every conformer it produced carried an energy describing a different
   structure. conformers_combinations_by_lowest_conformer() is exactly that
   caller, and get_lowest_confs() then ranked geometries by energies that were
   not theirs. Measured on ethylamine, the reported energy was off by up to
   0.49 kcal/mol from the true energy of the reported geometry.

   Always store the matching optimized pair. The `optimize` argument is removed
   rather than left as a no-op: the FF optimization was performed either way, so
   it never saved any work, and its only effect was to decide whether the stored
   geometry was a lie. Conformers are now local minima labelled with their own
   energies.

2. The duplicate scan in conformers_combinations_by_lowest_conformer() treated
   compare_confs_fl(), a cheap first/last-atom-distance prefilter, as if a
   negative result ended the search. It only means the one candidate under test
   is not a duplicate, and the list is in generation order with no ordering
   invariant to exploit, so `break` skipped every conformer past the first
   dissimilar one and let duplicates through. Use `continue`, and carry the
   candidate's distance matrix across iterations, since compare_confs_fl()
   returns it as None precisely when the prefilter rejects.

The conformer count for the deduce_new_conformers test drops from 9 to 6: those
three were duplicates that the truncated scan had been admitting.
conformers_combinations_by_lowest_conformer() is an iterative descent: each
round samples dihedrals from the lowest conformer found so far. It updated
base_energy but never reassigned base_xyz, so all MAX_COMBINATION_ITERATIONS
rounds re-sampled dihedrals from the original geometry and regenerated the same
conformers, which were then discarded as duplicates.

The loop-bottom exit could not stop this: it required the round's lowest
conformer to compare equal to the frozen base geometry, which it essentially
never does. So the loop always ran its full budget doing redundant force field
work.

Reassign base_xyz whenever the round improves on the base energy, and stop as
soon as a round fails to improve. Also round base_energy at initialisation:
per-conformer energies are stored via round(energy, 3), so comparing them
against a full-precision base mixed precisions and made the convergence test
unreliable.

The old `if not newest_conformer_list: newest_conformer_list = [lowest_conf_i]`
fallback is replaced by a plain break. It reused the previous round's lowest
conformer, which is None on the first iteration.

Measured via ARCSpecies.generate_conformers(n_confs=10) against official/main,
the conformers produced are bitwise identical for ethanol, n-butanol, n-hexanol,
iso-octane, glycerol and hexyl radical, while wall time drops 1.6x-2.3x on the
species with several rotors (n-hexanol 11.61s -> 5.11s, glycerol 3.03s -> 1.37s,
hexyl radical 2.68s -> 1.63s).
…ol to kcal/mol

RDKit's MMFF/UFF and OpenBabel's MMFF94/UFF/GAFF implementations all report
energies in kcal/mol, but DE_THRESHOLD and the de_threshold/e_confs docstrings
described them as kJ/mol. The thresholds are compared directly against those
energies, so the documented units were off by a factor of ~4.2.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants