Make cycle order, and therefore symmetry numbers, independent of PYTHONHASHSEED - #991
Make cycle order, and therefore symmetry numbers, independent of PYTHONHASHSEED#991calvinp0 wants to merge 2 commits into
Conversation
get_disparate_cycles(), get_polycycles() and get_all_cycles_of_size() all build
their cycles as sets of vertices and hand them back as list(cycle_set), so the
order of the atoms within a cycle came out of the set's iteration. Atom hashes
on ('Atom', symbol) and compares by identity, so every carbon in a molecule
lands in one hash bucket and the order of a cycle follows Python's randomised
string hash. The same molecule therefore yields differently ordered cycles in
different processes.
calculate_cyclic_symmetry_number() consumes that order. For a polycyclic
cluster it calls get_largest_ring(ring[0]), seeding a largest-ring search from
whichever atom the set happened to list first. In a bridged polycycle the
largest ring through the bridge atom is smaller than the largest ring through
any other atom, so the ring handed to the symmetry search changes size with the
hash seed and the symmetry number changes with it: bicyclo[2.2.1]heptane
returned 4 for 163 of the hash seeds 0-200 and 2 for the other 38, and
7-oxabicyclo[2.2.1]heptane returned 4 for 128 and 2 for 73. A symmetry number
enters the entropy as -R ln(sigma), so a factor of two is 1.377 cal/mol/K in S
and a factor of two in every rate and equilibrium constant for the species.
kekulize() consumes the same order through get_all_cycles_of_size(6), which
seeds its ring-by-ring resolution with the ring list.
Return the vertices of every cycle in the graph's vertex order instead, so the
order is the same in every process.
Cover both halves of the contract: order_vertex_set() puts a set of vertices back into the graph's vertex order, and two processes started with different hash seeds list the cycles, and the symmetry numbers derived from them, identically. The symmetry test asserts only that the processes agree, not what they agree on. What calculate_cyclic_symmetry_number() should return for a bridged polycycle is a separate question from whether it returns the same thing twice. PYTHONPATH is pinned to the repository so the child process imports the tree under test rather than an installed ARC.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #991 +/- ##
==========================================
- Coverage 64.18% 64.10% -0.09%
==========================================
Files 119 119
Lines 39564 39564
Branches 10265 10265
==========================================
- Hits 25395 25361 -34
- Misses 11198 11228 +30
- Partials 2971 2975 +4
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
Superseded by #992, which combines this PR with #989. Merged alone, this PR's headline would have been false: |
Molecule.calculate_symmetry_number()returns different values for the same molecule in different processes. Over hash seeds 0–200:C1CC2CCC1C2C1CC2CCC1O2C1CC2CCC3CCC1C23All 27 species tested — bridged, fused, spiro, cage, aromatic, monocyclic, acyclic — are single-valued after. No species that was already stable changed.
Cause
graph.pyxconverted merged cycles from sets back to lists with[list(cycle_set) for cycle_set in ...].Atom.__hash__ishash(('Atom', symbol))with an identity__eq__, so every carbon collides into one bucket and the resulting order is the set's probe order — which follows Python's per-process randomized string hash.symmetry.py:373then callsget_largest_ring(ring[0]), seeding a largest-ring search from a hash-order-chosen atom. Instrumented on norbornane:Atom 6 is C7, the one-carbon bridge. The largest cycle through the bridge is a 5-ring; through any other atom it is a 6-ring.
The existing suite already contained a fossil of this:
graph_test.py:874reads "Try two different items since one might contain the vertex 8."Fix
A
Graph.order_vertex_set()helper returning vertices in the graph's own vertex order, applied at all threelist(set)sites —get_disparate_cycles,get_polycycles,get_all_cycles_of_size. Producer-level, because there is a second consumer:kekulize.pyx:54seeds ring-by-ring resolution fromget_all_cycles_of_size(6), so hash order could select a different Kekulé structure for an ambiguous polycyclic aromatic.Cost is negligible — 15 ms for 50 calls on a 36-atom PAH.
This is the same idiom as
get_all_edgesin #989, at a different site.get_all_edgesitself is untouched here.What this PR does NOT fix — please read
The value it makes deterministic is chemically wrong for cages, and that is not a consequence of this change. ARC's polycyclic symmetry branch is structurally incorrect regardless of hash seed:
12 of 18 polycyclics tested are wrong.
calculate_cyclic_symmetry_numberreduces a polycyclic cluster to one largest cycle, discards the bonds that make a cage a cage, and applies planar-monocycle logic — in which reversing the atom sequence is a proper in-plane C2. In a rigid 3D cage that reversal is a reflection, which must not enter σ. Errors are erratic in sign (prismane 2× high, adamantane 1.5× low), so they do not cancel across a reaction.A function returning 4 for both norbornane and bicyclo[2.2.2]octane is not one ordering away from correct.
Note also that the nondeterminism was masking this flatteringly: seeding on the bridge gave norbornane the right answer for the wrong reason, and after this change
ring[0]is essentially never the bridge.The new test asserts only that processes agree — never a value — so it does not enshrine the wrong number. Correcting cage symmetry is left to a separate change;
Graph.find_isomorphism(self, self)already enumerates the automorphism group (norbornane 128 = 4 × 2⁵, adamantane 1536 = 24 × 2⁶), so the machinery exists and only the chirality decision is missing — which a 2D graph cannot supply.This PR is offered because one wrong answer everywhere is strictly better than two different wrong answers depending on which process you happen to run in.
Blast radius
ARC's thermo does not consume this value.
external_symmetryis parsed back out of Arkane's own geometry-based point-group determination (arc/statmech/arkane.py:1363).get_symmetry_number()only populatesARCSpecies.symmetry_number, which reaches theoutput.yml/TCKDB export and any RMG-side group-additivity use.Separately:
arc/species/species.py:310documents that attribute as "The external symmetry number", contradicting the method's own docstring — a doc bug that invites exactly the wrong use of this value. Not changed here.Upstream
Both defects exist in RMG-Py: the
list(cycle_set)conversion atrmgpy/molecule/molecule.py:2892and theget_largest_ring(ring[0])call atrmgpy/molecule/symmetry.py:404.Checks
arc/molecule/: 582 passed.arc/species/: 327 passed + 180 subtests.order_vertex_setunit test, a cross-seed cycle-order test, and a cross-seed symmetry-number test. Both subprocess tests pinPYTHONPATHandcwd, so the child validates the tree under test rather than whateverimport arcresolves to.Touches
graph_test.py, as does #989 — they will conflict on the import block only, a union resolve.