Skip to content

Commit 9d7828a

Browse files
committed
Test that a graph's edge and cycle order is stable across processes
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.
1 parent 3dba9dd commit 9d7828a

2 files changed

Lines changed: 155 additions & 0 deletions

File tree

arc/molecule/graph_test.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,38 @@
11
#!/usr/bin/env python3
22
# encoding: utf-8
33

4+
import os
5+
import subprocess
6+
import sys
47
import unittest
58

69
from arc.molecule.graph import Edge, Graph, Vertex
710

811

12+
REPOSITORY_DIRECTORY = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
13+
14+
15+
def outputs_at_hash_seeds(script, seeds):
16+
"""
17+
Run `script` in a subprocess once per hash seed in `seeds`, and return the stripped standard
18+
output of each run. The subprocesses are given the repository as their working directory and at
19+
the front of PYTHONPATH, so they import the tree under test rather than an installed ARC while
20+
keeping whatever else the environment already put on the path.
21+
22+
Raises a RuntimeError if any of the subprocesses exits with a non-zero return code.
23+
"""
24+
python_path = os.pathsep.join(path for path in (REPOSITORY_DIRECTORY, os.environ.get('PYTHONPATH', '')) if path)
25+
outputs = list()
26+
for seed in seeds:
27+
environment = dict(os.environ, PYTHONHASHSEED=seed, PYTHONPATH=python_path)
28+
result = subprocess.run([sys.executable, '-c', script], capture_output=True, text=True,
29+
env=environment, cwd=REPOSITORY_DIRECTORY, timeout=600)
30+
if result.returncode:
31+
raise RuntimeError(f'The subprocess run with PYTHONHASHSEED={seed} failed:\n{result.stderr}')
32+
outputs.append(result.stdout.strip())
33+
return outputs
34+
35+
936
class TestGraph(unittest.TestCase):
1037
"""
1138
Contains unit tests of the Vertex, Edge, and Graph classes. Most of the
@@ -108,6 +135,86 @@ def test_get_all_edges(self):
108135
self.assertIsInstance(edges, list)
109136
self.assertEqual(len(edges), 5)
110137

138+
def test_get_all_edges_orders_the_edges_by_vertex(self):
139+
"""
140+
Test that Graph.get_all_edges() returns the edges in vertex order, each edge once.
141+
"""
142+
expected = []
143+
for vertex in self.graph.vertices:
144+
for edge in vertex.edges.values():
145+
if not any(edge is seen for seen in expected):
146+
expected.append(edge)
147+
self.assertEqual(self.graph.get_all_edges(), expected)
148+
149+
def test_get_all_edges_order_does_not_depend_on_the_hash_seed(self):
150+
"""
151+
Test that Graph.get_all_edges() returns the same order in processes with different hash seeds.
152+
"""
153+
script = ('from arc.molecule.molecule import Molecule\n'
154+
'mol = Molecule(smiles="c1ccccc1Cc1ccccc1")\n'
155+
'atoms = mol.atoms\n'
156+
'print([(atoms.index(edge.vertex1), atoms.index(edge.vertex2)) '
157+
'for edge in mol.get_all_edges()])\n')
158+
outputs = outputs_at_hash_seeds(script, ('1', '35'))
159+
self.assertTrue(outputs[0])
160+
self.assertEqual(len(set(outputs)), 1, f'The edge order differs between hash seeds: {outputs}')
161+
162+
def test_order_vertex_set(self):
163+
"""
164+
Test that Graph.order_vertex_set() returns the vertices in the graph's vertex order.
165+
"""
166+
vertices = self.graph.vertices
167+
self.assertEqual(self.graph.order_vertex_set({vertices[4], vertices[1], vertices[3]}),
168+
[vertices[1], vertices[3], vertices[4]])
169+
self.assertEqual(self.graph.order_vertex_set(set()), [])
170+
self.assertEqual(self.graph.order_vertex_set(set(vertices)), vertices)
171+
172+
def test_order_vertex_set_rejects_a_vertex_that_is_not_in_the_graph(self):
173+
"""
174+
Test that Graph.order_vertex_set() raises a ValueError instead of dropping a foreign vertex.
175+
"""
176+
vertices = self.graph.vertices
177+
with self.assertRaises(ValueError):
178+
self.graph.order_vertex_set({Vertex()})
179+
with self.assertRaises(ValueError):
180+
self.graph.order_vertex_set({vertices[0], vertices[2], Vertex()})
181+
copied = self.graph.copy(deep=True)
182+
with self.assertRaises(ValueError):
183+
self.graph.order_vertex_set(set(copied.vertices))
184+
185+
def test_order_vertex_set_counts_a_repeated_vertex_once(self):
186+
"""
187+
Test that Graph.order_vertex_set() returns a vertex once and still rejects a foreign vertex
188+
when the graph's vertex list holds the same vertex twice.
189+
"""
190+
vertices = self.graph.vertices
191+
repeated = Graph(vertices=[vertices[0], vertices[0], vertices[1]])
192+
self.assertEqual(repeated.order_vertex_set({vertices[0], vertices[1]}),
193+
[vertices[0], vertices[1]])
194+
with self.assertRaises(ValueError):
195+
repeated.order_vertex_set({vertices[0], Vertex()})
196+
197+
def test_cycle_order_does_not_depend_on_the_hash_seed(self):
198+
"""
199+
Test that the cycles a graph returns are ordered identically in processes with different hash seeds.
200+
201+
The comparison covers the order of the vertices within one cycle and the order of the cycles
202+
within the returned list.
203+
"""
204+
script = ('from arc.molecule.molecule import Molecule\n'
205+
'for smiles in ("C1CC2CCC1C2", "c1ccccc1Cc1ccccc1", "C1CC2CCC3CCC1C23"):\n'
206+
' mol = Molecule(smiles=smiles)\n'
207+
' atoms = mol.atoms\n'
208+
' index = lambda cycle: [atoms.index(atom) for atom in cycle]\n'
209+
' monocyclic, polycyclic = mol.get_disparate_cycles()\n'
210+
' print(smiles, [index(cycle) for cycle in monocyclic + polycyclic])\n'
211+
' print(smiles, [index(cycle) for cycle in mol.get_polycycles()])\n'
212+
' print(smiles, [index(cycle) for cycle in mol.get_all_cycles_of_size(5)])\n'
213+
' print(smiles, [index(cycle) for cycle in mol.get_smallest_set_of_smallest_rings()])\n')
214+
outputs = outputs_at_hash_seeds(script, ('1', '5', '87'))
215+
self.assertTrue(outputs[0])
216+
self.assertEqual(len(set(outputs)), 1, f'The cycle order differs between hash seeds: {outputs}')
217+
111218
def test_has_vertex(self):
112219
"""
113220
Test the Graph.has_vertex() method.

arc/molecule/symmetry_test.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,28 @@
33

44
import unittest
55

6+
from arc.molecule.graph_test import outputs_at_hash_seeds
67
from arc.molecule.molecule import Molecule
78
from arc.molecule.resonance import generate_optimal_aromatic_resonance_structures
89
from arc.molecule.symmetry import (calculate_atom_symmetry_number, calculate_axis_symmetry_number,
910
calculate_bond_symmetry_number, calculate_cyclic_symmetry_number, _indistinguishable)
1011
from arc.species.species import ARCSpecies
1112

1213

14+
HASH_SEED_SPECIES = ('C1CC2CCC1C2',
15+
'C1CC2CCC1CC2',
16+
'C1CC2CCC3CCC1C23',
17+
'C1CC2CCC1O2',
18+
'C1=CC2C=CC1C2',
19+
'c1cc2ccc3cccc4ccc(c1)c2c34',
20+
'c1ccc2c(c1)-c1cccc3cccc2c13',
21+
'c1cc2cccc3c4cccc5cccc(c(c1)c23)c54',
22+
'c1cc2ccc3ccc4ccc5ccc6ccc1c1c2c3c4c5c61',
23+
'[CH2]c1ccc2ccccc2c1')
24+
25+
HASH_SEEDS = ('1', '3', '5', '13')
26+
27+
1328
class TestMoleculeSymmetry(unittest.TestCase):
1429
"""
1530
Contains unit tests of the methods for computing symmetry numbers for a
@@ -676,6 +691,39 @@ def test_indistinguishable_2(self):
676691
# O is different from H
677692
self.assertFalse(_indistinguishable(mol.atoms[6], mol.atoms[7]))
678693

694+
def test_symmetry_number_does_not_depend_on_the_hash_seed(self):
695+
"""
696+
Test that calculate_symmetry_number() returns the same value in processes with different hash seeds.
697+
698+
The bridged polycyclics and fused aromatics of HASH_SEED_SPECIES reach
699+
calculate_cyclic_symmetry_number() through get_disparate_cycles(). Only agreement between the
700+
processes is asserted, not the value they agree on.
701+
"""
702+
script = ('from arc.molecule.molecule import Molecule\n'
703+
'from arc.molecule.symmetry import calculate_symmetry_number\n'
704+
f'print([calculate_symmetry_number(Molecule(smiles=smiles)) for smiles in {HASH_SEED_SPECIES}])\n')
705+
symmetry_numbers = outputs_at_hash_seeds(script, HASH_SEEDS)
706+
self.assertTrue(symmetry_numbers[0])
707+
self.assertEqual(len(set(symmetry_numbers)), 1,
708+
f'The symmetry numbers differ between hash seeds: {symmetry_numbers}')
709+
710+
def test_species_symmetry_number_does_not_depend_on_the_hash_seed(self):
711+
"""
712+
Test that ARCSpecies.get_symmetry_number() returns the same value in processes with different hash seeds.
713+
714+
This is the entry point production uses. It reaches the symmetry code through
715+
get_resonance_hybrid() rather than through the molecule it was given, so the resonance layer
716+
lies between the caller and the cycles. Only agreement between the processes is asserted, not
717+
the value they agree on.
718+
"""
719+
script = ('from arc.species.species import ARCSpecies\n'
720+
'print([ARCSpecies(label=f"species{index}", smiles=smiles).get_symmetry_number() '
721+
f'for index, smiles in enumerate({HASH_SEED_SPECIES})])\n')
722+
symmetry_numbers = outputs_at_hash_seeds(script, HASH_SEEDS)
723+
self.assertTrue(symmetry_numbers[0])
724+
self.assertEqual(len(set(symmetry_numbers)), 1,
725+
f'The symmetry numbers differ between hash seeds: {symmetry_numbers}')
726+
679727

680728
if __name__ == '__main__':
681729
unittest.main(testRunner=unittest.TextTestRunner(verbosity=2))

0 commit comments

Comments
 (0)