Make the molecular graph's edge and cycle order independent of the hash seed - #992
Open
calvinp0 wants to merge 2 commits into
Open
Make the molecular graph's edge and cycle order independent of the hash seed#992calvinp0 wants to merge 2 commits into
calvinp0 wants to merge 2 commits into
Conversation
This was referenced Aug 17, 2026
Codecov Report✅ All modified and coverable lines are covered by tests. 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
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:
|
calvinp0
force-pushed
the
fix_hash_seed_dependent_graph_order
branch
2 times, most recently
from
August 22, 2026 06:45
9d7828a to
1752cf1
Compare
calvinp0
marked this pull request as ready for review
August 22, 2026 08:53
calvinp0
force-pushed
the
fix_hash_seed_dependent_graph_order
branch
from
August 23, 2026 08:56
1752cf1 to
abfbdb6
Compare
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
force-pushed
the
fix_hash_seed_dependent_graph_order
branch
from
August 23, 2026 12:14
abfbdb6 to
a687d59
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_ringsstill gave 14–19 distinct orders per molecule over 20 seeds, biphenylmethane'sget_disparate_cycles2 and a steroid skeleton'sget_polycycles3, 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 asetofEdgeand returnedlist(edge_set); the three cycle methods returnedlist(cycle_set).AtomandBondhash 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. ForCOC1=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:Six aromatic bonds become four C–C plus two C=C, and a C–O becomes a C=O.
arc/species/species.pycomputesbond_correctionsfrom 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 withExplicit valence for atom # 5 C greater than permitted, which is how this was first noticed.The same ordering also reached ring perception (
py_rdlSSSR 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()callsget_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, soinwas O(V) per lookup — and raises rather than dropping a vertex that is not in the graph, because a silently short cycle feedssize = len(ring)and yields a wrong σ with no signal.get_all_edgesalso 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_setis O(V) per cycle wherelist(set)was O(k) — measured at 9% ofget_disparate_cycleson 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:
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
C1CC2CCC1C2→ 4.0 butC1C2CCC1CC2→ 2.0. On the motivating molecule, 30 random xyz atom orderings still yield the wrong perception for exactly 1 ordering, at every seed, wheremainproduced it for 8.1% of (ordering, seed) pairs. The defect is pinned, not eliminated.mainfor 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 equalsmain's modal value, so this affects the minority branch only.Also found, not fixed here
ARCSpecies.get_symmetry_number()andcalculate_symmetry_number()diverge for benzylic radicals: benzyl[CH2]c1ccccc1gives 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 ofcalculate_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 reachesoutput.yml, is set by parsing Arkane's own geometry-basedNonlinearRotor(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_set—git 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_verticessorts by connectivity and mutates;sort_cyclic_verticesorders a cycle by adjacency; neither maps a vertex set onto graph position. For the tests, no test-utility module exists andPYTHONHASHSEEDappeared nowhere else, sooutputs_at_hash_seedsis new;symmetry_test.pyimports it fromgraph_test.py, matching the existingspecies_test.py→perceive_test.pyprecedent.Verification
All 7 new tests fail on
main; 3 also fail on a build carrying only theget_all_edgeshalf, and each of the threeorder_vertex_setcall sites is independently covered. The two symmetry-number tests are not redundant: at the seeds they pin, pyrene is invariant throughcalculate_symmetry_numberbut varies throughARCSpecies.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-existingtorch_ani_test.pyfailures unchanged.