diff --git a/src/spqrtree/_triconnected.py b/src/spqrtree/_triconnected.py index 498e89d..6ba5d5c 100644 --- a/src/spqrtree/_triconnected.py +++ b/src/spqrtree/_triconnected.py @@ -418,7 +418,11 @@ class _PathSearcher: """Set of virtual edge IDs.""" # Build palm tree and sort adjacency lists. - start: Hashable = next(iter(g.vertices)) + # Use the first remaining edge's endpoint as start vertex + # for deterministic DFS ordering. Edge IDs are sequential + # integers, so g.edges[0] is always the lowest-ID edge, + # regardless of Python hash seed. + start: Hashable = g.edges[0].u pt: PalmTree = build_palm_tree(g, start) sort_adjacency_lists(g, pt) pt = build_palm_tree(g, start) diff --git a/tests/test_spqrtree.py b/tests/test_spqrtree.py index 0b44ae5..6164cb0 100644 --- a/tests/test_spqrtree.py +++ b/tests/test_spqrtree.py @@ -21,6 +21,9 @@ Tests cover: triangle K3 (S-node), K4 (R-node), C4 (S-node), two parallel edges (Q-node), and three parallel edges (P-node). """ +import os +import subprocess +import sys import time import unittest from collections import deque @@ -2800,6 +2803,44 @@ class TestSPQRWikimediaSpqr(unittest.TestCase): ) self.assertEqual(n, 1) + def test_s_node_vertices(self) -> None: + """Test that the S-node has vertices {g, h, l, m}.""" + s_nodes: list[SPQRNode] = [ + nd for nd in self.all_nodes + if nd.type == NodeType.S + ] + self.assertEqual(len(s_nodes), 1) + self.assertEqual( + set(s_nodes[0].skeleton.vertices), + {'g', 'h', 'l', 'm'}, + ) + + def test_p_node_vertices(self) -> None: + """Test that the P-node has vertices {l, m}.""" + p_nodes: list[SPQRNode] = [ + nd for nd in self.all_nodes + if nd.type == NodeType.P + ] + self.assertEqual(len(p_nodes), 1) + self.assertEqual( + set(p_nodes[0].skeleton.vertices), + {'l', 'm'}, + ) + + def test_r_node_vertex_sets(self) -> None: + """Test exact vertex sets of R-nodes.""" + r_verts: list[frozenset[Hashable]] = [ + frozenset(nd.skeleton.vertices) + for nd in self.all_nodes + if nd.type == NodeType.R + ] + expected: set[frozenset[Hashable]] = { + frozenset({'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'}), + frozenset({'h', 'i', 'j', 'k', 'm', 'n'}), + frozenset({'l', 'm', 'o', 'p'}), + } + self.assertEqual(set(r_verts), expected) + def test_no_adjacent_s_nodes(self) -> None: """Test that no S-node is adjacent to another S-node.""" _assert_no_ss_pp(self, self.root, NodeType.S) @@ -2891,3 +2932,77 @@ class TestBuildSpqrTreeBiconnectivity(unittest.TestCase): with self.assertRaises(ValueError) as ctx: build_spqr_tree(g) self.assertIn("cut vertex", str(ctx.exception)) + + +# Script used by TestSPQRTreeDeterminism to run SPQR-tree +# construction in a subprocess with a specific PYTHONHASHSEED. +_SUBPROCESS_SCRIPT: str = """ +import json, sys +from collections import deque +sys.path.insert(0, "src") +from spqrtree import MultiGraph, NodeType, SPQRNode, build_spqr_tree + +g = MultiGraph() +edges = [ + ('a', 'b'), ('a', 'c'), ('a', 'g'), + ('b', 'd'), ('b', 'h'), ('c', 'd'), + ('c', 'e'), ('d', 'f'), ('e', 'f'), + ('e', 'g'), ('f', 'h'), ('h', 'i'), + ('h', 'j'), ('i', 'j'), ('i', 'n'), + ('j', 'k'), ('k', 'm'), ('k', 'n'), + ('m', 'n'), ('l', 'm'), ('l', 'o'), + ('l', 'p'), ('m', 'o'), ('m', 'p'), + ('o', 'p'), ('g', 'l'), +] +for u, v in edges: + g.add_edge(u, v) +root = build_spqr_tree(g) +nodes = [] +queue = deque([root]) +while queue: + nd = queue.popleft() + verts = sorted(nd.skeleton.vertices) + nodes.append({"type": nd.type.value, "vertices": verts}) + for ch in nd.children: + queue.append(ch) +nodes.sort(key=lambda x: (x["type"], x["vertices"])) +print(json.dumps(nodes)) +""" + + +class TestSPQRTreeDeterminism(unittest.TestCase): + """Test that SPQR-tree construction is deterministic. + + Runs SPQR-tree construction of the Wikimedia SPQR example + graph in subprocesses with different PYTHONHASHSEED values + and verifies that all runs produce identical results. + """ + + def test_deterministic_across_hash_seeds(self) -> None: + """Test consistent results with 20 different hash seeds.""" + results: list[str] = [] + env: dict[str, str] = os.environ.copy() + cwd: str = os.path.join( + os.path.dirname(__file__), os.pardir + ) + for seed in range(20): + env["PYTHONHASHSEED"] = str(seed) + proc: subprocess.CompletedProcess[str] = \ + subprocess.run( + [sys.executable, "-c", _SUBPROCESS_SCRIPT], + capture_output=True, text=True, + env=env, cwd=cwd, + ) + self.assertEqual( + proc.returncode, 0, + f"seed={seed} failed:\n{proc.stderr}" + ) + results.append(proc.stdout.strip()) + # All runs must produce the same result. + for i, r in enumerate(results): + self.assertEqual( + r, results[0], + f"seed={i} differs from seed=0:\n" + f" seed=0: {results[0]}\n" + f" seed={i}: {r}" + ) diff --git a/tests/test_triconnected.py b/tests/test_triconnected.py index b9f149f..7c9042a 100644 --- a/tests/test_triconnected.py +++ b/tests/test_triconnected.py @@ -22,6 +22,9 @@ Tests cover: triangle K3, 4-cycle C4, complete graph K4, two parallel edges, three parallel edges, real-edge count invariant, and virtual edge appearance count. """ +import os +import subprocess +import sys import unittest from collections.abc import Hashable @@ -2741,6 +2744,49 @@ class TestTriconnectedWikimediaSpqr(unittest.TestCase): ) self.assertEqual(n, 1) + def test_polygon_vertices(self) -> None: + """Test that the POLYGON has vertices {g, h, l, m}.""" + polygons: list[TriconnectedComponent] = [ + c for c in self.comps + if c.type == ComponentType.POLYGON + ] + self.assertEqual(len(polygons), 1) + verts: set[Hashable] = set() + for e in polygons[0].edges: + verts.add(e.u) + verts.add(e.v) + self.assertEqual(verts, {'g', 'h', 'l', 'm'}) + + def test_bond_vertices(self) -> None: + """Test that the BOND has vertices {l, m}.""" + bonds: list[TriconnectedComponent] = [ + c for c in self.comps + if c.type == ComponentType.BOND + ] + self.assertEqual(len(bonds), 1) + verts: set[Hashable] = set() + for e in bonds[0].edges: + verts.add(e.u) + verts.add(e.v) + self.assertEqual(verts, {'l', 'm'}) + + def test_triconnected_vertex_sets(self) -> None: + """Test exact vertex sets of TRICONNECTED components.""" + tri_verts: list[frozenset[Hashable]] = [] + for c in self.comps: + if c.type == ComponentType.TRICONNECTED: + verts: set[Hashable] = set() + for e in c.edges: + verts.add(e.u) + verts.add(e.v) + tri_verts.append(frozenset(verts)) + expected: set[frozenset[Hashable]] = { + frozenset({'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'}), + frozenset({'h', 'i', 'j', 'k', 'm', 'n'}), + frozenset({'l', 'm', 'o', 'p'}), + } + self.assertEqual(set(tri_verts), expected) + def test_all_invariants(self) -> None: """Test all decomposition invariants.""" _check_all_invariants(self, self.g, self.comps) @@ -2871,3 +2917,73 @@ class TestBiconnectivityCheck(unittest.TestCase): comps: list[TriconnectedComponent] = \ find_triconnected_components(g) self.assertEqual(len(comps), 1) + + +# Script used by TestTriconnectedDeterminism to run decomposition +# in a subprocess with a specific PYTHONHASHSEED. +_SUBPROCESS_SCRIPT: str = """ +import json, sys +sys.path.insert(0, "src") +from spqrtree import ( + ComponentType, MultiGraph, find_triconnected_components, +) + +g = MultiGraph() +edges = [ + ('a', 'b'), ('a', 'c'), ('a', 'g'), + ('b', 'd'), ('b', 'h'), ('c', 'd'), + ('c', 'e'), ('d', 'f'), ('e', 'f'), + ('e', 'g'), ('f', 'h'), ('h', 'i'), + ('h', 'j'), ('i', 'j'), ('i', 'n'), + ('j', 'k'), ('k', 'm'), ('k', 'n'), + ('m', 'n'), ('l', 'm'), ('l', 'o'), + ('l', 'p'), ('m', 'o'), ('m', 'p'), + ('o', 'p'), ('g', 'l'), +] +for u, v in edges: + g.add_edge(u, v) +comps = find_triconnected_components(g) +result = [] +for c in comps: + verts = sorted({ep for e in c.edges for ep in (e.u, e.v)}) + result.append({"type": c.type.value, "vertices": verts}) +result.sort(key=lambda x: (x["type"], x["vertices"])) +print(json.dumps(result)) +""" + + +class TestTriconnectedDeterminism(unittest.TestCase): + """Test that triconnected decomposition is deterministic. + + Runs decomposition of the Wikimedia SPQR example graph in + subprocesses with different PYTHONHASHSEED values and verifies + that all runs produce identical results. + """ + + def test_deterministic_across_hash_seeds(self) -> None: + """Test consistent results with 20 different hash seeds.""" + results: list[str] = [] + env: dict[str, str] = os.environ.copy() + for seed in range(20): + env["PYTHONHASHSEED"] = str(seed) + proc: subprocess.CompletedProcess[str] = \ + subprocess.run( + [sys.executable, "-c", _SUBPROCESS_SCRIPT], + capture_output=True, text=True, env=env, + cwd=os.path.join( + os.path.dirname(__file__), os.pardir + ), + ) + self.assertEqual( + proc.returncode, 0, + f"seed={seed} failed:\n{proc.stderr}" + ) + results.append(proc.stdout.strip()) + # All runs must produce the same result. + for i, r in enumerate(results): + self.assertEqual( + r, results[0], + f"seed={i} differs from seed=0:\n" + f" seed=0: {results[0]}\n" + f" seed={i}: {r}" + )