Skip to content

Make cycle order, and therefore symmetry numbers, independent of PYTHONHASHSEED - #991

Closed
calvinp0 wants to merge 2 commits into
mainfrom
fix_hash_seed_dependent_symmetry
Closed

Make cycle order, and therefore symmetry numbers, independent of PYTHONHASHSEED#991
calvinp0 wants to merge 2 commits into
mainfrom
fix_hash_seed_dependent_symmetry

Conversation

@calvinp0

Copy link
Copy Markdown
Member

Molecule.calculate_symmetry_number() returns different values for the same molecule in different processes. Over hash seeds 0–200:

species before after
norbornane C1CC2CCC1C2 4.0 ×163, 2.0 ×38 4.0 ×201
7-oxanorbornane C1CC2CCC1O2 4.0 ×128, 2.0 ×73 4.0 ×201
triquinacane skeleton C1CC2CCC3CCC1C23 3.0 ×166, 2.0 ×35 3.0 ×201

All 27 species tested — bridged, fused, spiro, cage, aromatic, monocyclic, acyclic — are single-valued after. No species that was already stable changed.

Cause

graph.pyx converted merged cycles from sets back to lists with [list(cycle_set) for cycle_set in ...]. Atom.__hash__ is hash(('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:373 then calls get_largest_ring(ring[0]), seeding a largest-ring search from a hash-order-chosen atom. Instrumented on norbornane:

seed=0  order=[3,6,4,5,2,1,0]  ring0=3  largest_ring=6  -> sigma 4.0
seed=5  order=[6,2,0,1,4,3,5]  ring0=6  largest_ring=5  -> sigma 2.0

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:874 reads "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 three list(set) sites — get_disparate_cycles, get_polycycles, get_all_cycles_of_size. Producer-level, because there is a second consumer: kekulize.pyx:54 seeds ring-by-ring resolution from get_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_edges in #989, at a different site. get_all_edges itself 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:

            norbornane  ARC=4.0   true=2
  bicyclo[2.2.2]octane  ARC=4.0   true=6      <- same answer, different molecules
            adamantane  ARC=8.0   true=12
                cubane  ARC=16.0  true=24

12 of 18 polycyclics tested are wrong. calculate_cyclic_symmetry_number reduces 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_symmetry is parsed back out of Arkane's own geometry-based point-group determination (arc/statmech/arkane.py:1363). get_symmetry_number() only populates ARCSpecies.symmetry_number, which reaches the output.yml/TCKDB export and any RMG-side group-additivity use.

Separately: arc/species/species.py:310 documents 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 at rmgpy/molecule/molecule.py:2892 and the get_largest_ring(ring[0]) call at rmgpy/molecule/symmetry.py:404.

Checks

  • Seed sweep 0–200 across 27 species, before and after.
  • arc/molecule/: 582 passed. arc/species/: 327 passed + 180 subtests.
  • New tests: an order_vertex_set unit test, a cross-seed cycle-order test, and a cross-seed symmetry-number test. Both subprocess tests pin PYTHONPATH and cwd, so the child validates the tree under test rather than whatever import arc resolves to.
  • Test species were rebuilt from explicit von Baeyer bond lists after three initial SMILES (cuneane, quadricyclane, twistane) parsed cleanly but produced the wrong isomers; formula, degree sequence and SSSR sizes were verified before any number was trusted.

Touches graph_test.py, as does #989 — they will conflict on the import block only, a union resolve.

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

codecov Bot commented Aug 17, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 64.10%. Comparing base (adf58ad) to head (bc7d45d).

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     
Flag Coverage Δ
functionaltests 64.10% <ø> (-0.09%) ⬇️
unittests 64.10% <ø> (-0.09%) ⬇️

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.

@calvinp0

Copy link
Copy Markdown
Member Author

Superseded by #992, which combines this PR with #989.

Merged alone, this PR's headline would have been false: get_polycycles and get_disparate_cycles order the vertices within a cycle, but the list of cycles still followed the hash-ordered edge list that only #989 fixes — measured as 2 and 3 distinct orders across 20 seeds on a build of this branch alone. #992 carries both changes, and folds in this PR's three review findings: coverage at the ARCSpecies.get_symmetry_number() entry point, order_vertex_set raising rather than silently dropping a foreign vertex (and made O(V)), and the PAH case — coronene returns 2 against a true 12 — which "wrong for cages" understated.

@calvinp0 calvinp0 closed this Aug 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant