Fix non-deterministic SPQR decomposition caused by set iteration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 16:23:53 +08:00
parent 709ebc76d7
commit 3a78fbd587
3 changed files with 236 additions and 1 deletions

View File

@@ -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)

View File

@@ -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}"
)

View File

@@ -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}"
)