Fix non-deterministic SPQR decomposition caused by set iteration
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -418,7 +418,11 @@ class _PathSearcher:
|
|||||||
"""Set of virtual edge IDs."""
|
"""Set of virtual edge IDs."""
|
||||||
|
|
||||||
# Build palm tree and sort adjacency lists.
|
# 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)
|
pt: PalmTree = build_palm_tree(g, start)
|
||||||
sort_adjacency_lists(g, pt)
|
sort_adjacency_lists(g, pt)
|
||||||
pt = build_palm_tree(g, start)
|
pt = build_palm_tree(g, start)
|
||||||
|
|||||||
@@ -21,6 +21,9 @@
|
|||||||
Tests cover: triangle K3 (S-node), K4 (R-node), C4 (S-node),
|
Tests cover: triangle K3 (S-node), K4 (R-node), C4 (S-node),
|
||||||
two parallel edges (Q-node), and three parallel edges (P-node).
|
two parallel edges (Q-node), and three parallel edges (P-node).
|
||||||
"""
|
"""
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
import time
|
import time
|
||||||
import unittest
|
import unittest
|
||||||
from collections import deque
|
from collections import deque
|
||||||
@@ -2800,6 +2803,44 @@ class TestSPQRWikimediaSpqr(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(n, 1)
|
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:
|
def test_no_adjacent_s_nodes(self) -> None:
|
||||||
"""Test that no S-node is adjacent to another S-node."""
|
"""Test that no S-node is adjacent to another S-node."""
|
||||||
_assert_no_ss_pp(self, self.root, NodeType.S)
|
_assert_no_ss_pp(self, self.root, NodeType.S)
|
||||||
@@ -2891,3 +2932,77 @@ class TestBuildSpqrTreeBiconnectivity(unittest.TestCase):
|
|||||||
with self.assertRaises(ValueError) as ctx:
|
with self.assertRaises(ValueError) as ctx:
|
||||||
build_spqr_tree(g)
|
build_spqr_tree(g)
|
||||||
self.assertIn("cut vertex", str(ctx.exception))
|
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}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
|
edges, three parallel edges, real-edge count invariant, and virtual
|
||||||
edge appearance count.
|
edge appearance count.
|
||||||
"""
|
"""
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
import unittest
|
import unittest
|
||||||
from collections.abc import Hashable
|
from collections.abc import Hashable
|
||||||
|
|
||||||
@@ -2741,6 +2744,49 @@ class TestTriconnectedWikimediaSpqr(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(n, 1)
|
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:
|
def test_all_invariants(self) -> None:
|
||||||
"""Test all decomposition invariants."""
|
"""Test all decomposition invariants."""
|
||||||
_check_all_invariants(self, self.g, self.comps)
|
_check_all_invariants(self, self.g, self.comps)
|
||||||
@@ -2871,3 +2917,73 @@ class TestBiconnectivityCheck(unittest.TestCase):
|
|||||||
comps: list[TriconnectedComponent] = \
|
comps: list[TriconnectedComponent] = \
|
||||||
find_triconnected_components(g)
|
find_triconnected_components(g)
|
||||||
self.assertEqual(len(comps), 1)
|
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}"
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user