Skip to content

Make the molecular graph's edge and cycle order independent of the hash seed - #992

Open
calvinp0 wants to merge 2 commits into
mainfrom
fix_hash_seed_dependent_graph_order
Open

Make the molecular graph's edge and cycle order independent of the hash seed#992
calvinp0 wants to merge 2 commits into
mainfrom
fix_hash_seed_dependent_graph_order

Conversation

@calvinp0

@calvinp0 calvinp0 commented Aug 17, 2026

Copy link
Copy Markdown
Member

Make the molecular graph's edge and cycle order independent of the hash seed

Combines #989 and #991. They cannot land separately, and this was measured in both directions: on a build of #991 alone, get_smallest_set_of_smallest_rings still gave 14–19 distinct orders per molecule over 20 seeds, biphenylmethane's get_disparate_cycles 2 and a steroid skeleton's get_polycycles 3, because #991 orders the vertices within a cycle while the list of cycles still follows the hash-ordered edge list that only #989 fixes. On a build with #989's fix alone, 3 of the 7 new tests still fail and σ stays seed-dependent for norbornane, pyrene, fluoranthene and the triquinacene skeleton. Combined: 1 distinct result for every probe over 200 seeds.

The defect. get_all_edges() de-duplicated through a set of Edge and returned list(edge_set); the three cycle methods returned list(cycle_set). Atom and Bond hash on symbol and bond order but compare by identity, and those hashes derive from string hashes, which Python randomises per process. Every atom of one element therefore lands in a single bucket and the iteration order follows the seed.

Why this matters most: bond orders feed bond additivity corrections. generate_lewis_structure() explores equal-cost A* states in bond-list order. For COC1=C(CN[C@H]2C3CCN(CC3)[C@H]2C(C2=CC=CC=C2)C2=CC=CC=C2)C=C(C=C1)C(C)C (C31H38N2O, three aromatic rings) the same xyz was perceived differently in 2 of 60 seeds, and the two perceptions do not merely differ cosmetically:

neutral (correct):  {'C-C': 13, 'C-H': 37, 'C-N': 5, 'C-O': 2, 'C:C': 18, 'H-N': 1}
charge-separated :  {'C-C': 17, 'C-H': 37, 'C-N': 5, 'C-O': 1, 'C:C': 12, 'C=C': 2, 'C=O': 1, 'H-N': 1}

Six aromatic bonds become four C–C plus two C=C, and a C–O becomes a C=O. arc/species/species.py computes bond_corrections from exactly this perceived molecule, and those feed Arkane's Petersson/Melius BACs. So a species built from xyz could receive a materially different energy correction depending on which process perceived it — including between a run and its own restart. The alternative structure is a methyl oxocarbenium (O⁺) paired with a ring carbanion (three σ bonds, one lone pair, formal −1) — the standard anisole lone-pair-donation resonance set, whose members sit at the ortho and para positions. It is a real but minor contributor; the neutral aromatic form is the ground-state description, and it is the one now pinned. In the worst case RDKit then refused the charge-separated structure outright with Explicit valence for atom # 5 C greater than permitted, which is how this was first noticed.

The same ordering also reached ring perception (py_rdl SSSR and relevant cycles), atom mapping, and TS-guess construction — all of which become reproducible here. For a kinetics code that is worth more than the symmetry-number story below.

Symmetry numbers. calculate_cyclic_symmetry_number() calls get_largest_ring(ring[0]), seeding from whichever atom the set listed first. Bicyclo[2.2.1]heptane returned 4 for 163 of seeds 0–200 and 2 for the other 38. σ enters entropy as −R ln σ, so a factor of two is 1.377 cal/mol/K in S.

The fix. Return edges in the graph's vertex order de-duplicated on edge identity, and route every cycle through a new Graph.order_vertex_set(). It matches on vertex identity — Atom.__hash__ is content-derived while __eq__ is identity, so in was O(V) per lookup — and raises rather than dropping a vertex that is not in the graph, because a silently short cycle feeds size = len(ring) and yields a wrong σ with no signal. get_all_edges also loses a quadratic term: 0.300 → 0.036 ms at 302 atoms and 24.257 → 0.356 ms at 3002, i.e. quadratic to linear. (The cycle methods pay a small cost instead — order_vertex_set is O(V) per cycle where list(set) was O(k) — measured at 9% of get_disparate_cycles on a 72-atom polyacene.)

This deliberately does not fix σ, and the tests assert only that processes agree

ARC's polycyclic branch replaces a fused cluster with one largest circuit and applies planar-monocycle logic. Grading the values this PR freezes — 5 of the 10 pinned species are wrong:

species pinned true σ_ext
triquinacene skeleton 3 3 correct
pyrene 4 4 correct
fluoranthene 2 2 correct
perylene 4 4 correct only by coincidence — it flips to 1 under reordering
norbornane 4 2 wrong
7-oxanorbornane 4 2 wrong
norbornadiene 4 2 wrong
bicyclo[2.2.2]octane 4 6 wrong
adamantane 8 12 wrong
cubane 16 24 wrong

Across a wider sweep, 12 of 18 polycyclics tested return a wrong value. For bicyclo[2.2.2]octane, adamantane and cubane the returned value does not even divide the true σ_ext — a subgroup order would. The function is not selecting the wrong subgroup; it is not computing a group order at all.

There are two distinct failure modes. For small bridged bicyclics the result is an overcount of at most 2, because a mirror plane is counted as a rotation — verified directly on norbornane, where the ring "flip" automorphism has determinant −1. For larger cages and peri-fused PAHs the result is an undercount from the polycycle-to-single-circuit reduction, with no improper operation involved at all.

"Wrong for cages" understates it. Peri-fused planar PAHs take the same branch — pyrene reduces to a 14-circuit over 16 carbons, perylene 18 over 20, and coronene returns 2 against a true 12, its chosen "ring" being a 22-membered circuit over 24 carbons that detours through the inner ring and omits two rim carbons. It is not a chemical ring. Had the 18-carbon rim been used, the correct 12 would follow. PAHs are core combustion species — pyrene dimerisation is the canonical soot-inception step — so this is not an exotic corner.

Because several pinned values are known-wrong, no test asserts a σ value; every new determinism test asserts only that separate processes produce identical output. Nothing here obstructs a later correctness fix.

Honest limitations

  • This removes hash-seed dependence, not order dependence. Two SMILES for the same species (identical InChIKey) still disagree — norbornane C1CC2CCC1C2 → 4.0 but C1C2CCC1CC2 → 2.0. On the motivating molecule, 30 random xyz atom orderings still yield the wrong perception for exactly 1 ordering, at every seed, where main produced it for 8.1% of (ordering, seed) pairs. The defect is pinned, not eliminated.
  • This is not a no-op relative to main for tie cases. py_rdl's node indexing follows the edge order, so where the SSSR is not unique the selected ring set — and hence σ — is now frozen to one choice. Any σ cached from a previous ARC run can disagree with the new value by a factor of two. In every case measured the pinned value equals main's modal value, so this affects the minority branch only.

Also found, not fixed here

ARCSpecies.get_symmetry_number() and calculate_symmetry_number() diverge for benzylic radicals: benzyl [CH2]c1ccccc1 gives 4.0 direct vs 2.0 through the production path, and 2-naphthylmethyl 2.0 vs 1.0. The resonance hybrid's fractional exocyclic bond order (1.75 and 1.25 respectively) matches none of calculate_atom_symmetry_number's single/double/triple/benzene branches, so the CH₂ factor of 2 is silently dropped. That method's docstring also promises "the highest symmetry number amongst its resonance isomers and the resonance hybrid" while the code only ever uses the hybrid.

Blast radius

ARCSpecies.get_symmetry_number() has no production consumer: spc.external_symmetry, which is what reaches output.yml, is set by parsing Arkane's own geometry-based NonlinearRotor(symmetry=N) block, and ARC passes Arkane no external symmetry at all. So freezing wrong polycyclic σ values changes no number ARC reports today. The reproducibility that does matter is the bond-order and ring-perception path described above.

Reuse check

Searched for an existing vertex-ordering or set-ordering helper before adding order_vertex_setgit grep "def order_", def sort_, sorted(.*vertices, and a search by behaviour for "put a set of atoms back into the molecule's atom order". sort_vertices sorts by connectivity and mutates; sort_cyclic_vertices orders a cycle by adjacency; neither maps a vertex set onto graph position. For the tests, no test-utility module exists and PYTHONHASHSEED appeared nowhere else, so outputs_at_hash_seeds is new; symmetry_test.py imports it from graph_test.py, matching the existing species_test.pyperceive_test.py precedent.

Verification

All 7 new tests fail on main; 3 also fail on a build carrying only the get_all_edges half, and each of the three order_vertex_set call sites is independently covered. The two symmetry-number tests are not redundant: at the seeds they pin, pyrene is invariant through calculate_symmetry_number but varies through ARCSpecies.get_symmetry_number(), so each catches species the other misses. arc/molecule/ + arc/species/ 913 passed under -n 6 --dist worksteal; full suite 2752 passed with the 5 pre-existing torch_ani_test.py failures unchanged.

@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.51%. Comparing base (45d73a0) to head (a687d59).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #992      +/-   ##
==========================================
- Coverage   64.60%   64.51%   -0.09%     
==========================================
  Files         119      119              
  Lines       39785    39785              
  Branches    10307    10307              
==========================================
- Hits        25703    25669      -34     
- Misses      11105    11132      +27     
- Partials     2977     2984       +7     
Flag Coverage Δ
functionaltests 64.51% <ø> (-0.09%) ⬇️
unittests 64.51% <ø> (-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
calvinp0 force-pushed the fix_hash_seed_dependent_graph_order branch 2 times, most recently from 9d7828a to 1752cf1 Compare August 22, 2026 06:45
@calvinp0
calvinp0 marked this pull request as ready for review August 22, 2026 08:53
Copilot AI lite review requested due to automatic review settings August 22, 2026 08:53

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@calvinp0
calvinp0 force-pushed the fix_hash_seed_dependent_graph_order branch from 1752cf1 to abfbdb6 Compare August 23, 2026 08:56
Graph.get_all_edges() de-duplicated the edges through a set of Edge objects and
returned list(edge_set), and get_disparate_cycles(), get_polycycles() and
get_all_cycles_of_size() each built their cycles as sets of vertices and handed
them back as list(cycle_set). In both cases the order came out of the set's
iteration. Atom and Bond hash on their symbols and bond order rather than on
identity, and those hashes are derived from string hashes, which Python
randomises per process. Every carbon in a molecule therefore lands in one hash
bucket, and both the edge order and the vertex order within a cycle differ from
one process to the next.

Molecule perception consumes the edge order: generate_lewis_structure() walks
the bond list in an A* search whose equal-cost states are explored in the order
the bonds are listed, so a molecule with several equal-cost Lewis structures
could be perceived differently in different processes. Diphenylprolinol methyl
ether was perceived with a methoxy C=O double bond and a carbene ring carbon in
about 2% of hash seeds, which then failed RDKit's valence check.

calculate_cyclic_symmetry_number() consumes the cycle 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.

The two are one defect and one fix: the cycles are derived from the edges, so
ordering the cycles alone leaves get_disparate_cycles() and get_polycycles()
hash-seed-dependent through the edge list they are built from.

Return the edges in the graph's vertex order, de-duplicating on the edge
identities, and return the vertices of every cycle in the graph's vertex order
through the new Graph.order_vertex_set(), so both orders are the same in every
process. order_vertex_set() matches on vertex identity, which keeps it linear in
the number of vertices, and raises rather than dropping a vertex that does not
belong to the graph.

Both replacements also drop a quadratic term. The old set of Edge objects and
the `vertex in vertex_set` membership test both hash on content while comparing
by identity, so every bond of one order and every atom of one element shared a
single hash bucket. Matching on identity instead takes get_all_edges() on a
carbon macrocycle from 0.589 ms to 0.033 ms at 302 atoms and from 46.5 ms to
0.338 ms at 3002 atoms, and the vertex ordering from 13.5 ms to 0.212 ms at
2402 atoms.
The edge order returned by get_all_edges() and the vertex order within the
cycles returned by get_disparate_cycles(), get_polycycles() and
get_all_cycles_of_size() used to follow the iteration order of a set of Edge or
Vertex objects, which is governed by the per-process randomized string hash, so
this asserts the property directly: subprocesses started at different
PYTHONHASHSEED values must report the same order, and the same symmetry numbers.

The symmetry numbers are checked at both entry points. calculate_symmetry_number()
is the one that reads the cycles, and ARCSpecies.get_symmetry_number() is the one
production calls, which reaches the symmetry code through get_resonance_hybrid()
rather than through the molecule it was given, so the resonance layer is covered
too. Both assert only that the processes agree, not what they agree on. What
calculate_cyclic_symmetry_number() should return for a bridged polycycle or a
peri-fused aromatic is a separate question from whether it returns the same thing
twice, and asserting a value here would fix the wrong one in place.

order_vertex_set() is covered for the ordering itself and for its rejection of a
vertex that does not belong to the graph, including the vertices of a copy of the
graph, which compare unequal to the originals and would otherwise be dropped.

The child processes are given PYTHONPATH and a working directory explicitly. A
subprocess inherits the parent's working directory but not pytest's sys.path, so
without it the child imports whichever ARC `import arc` resolves to -- which,
with an editable install present, is not necessarily the tree under test. The
test then either fails spuriously when run from outside the repository root, or
passes while having validated a different checkout.
@calvinp0
calvinp0 force-pushed the fix_hash_seed_dependent_graph_order branch from abfbdb6 to a687d59 Compare August 23, 2026 12:14
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.

2 participants