5 Commits

Author SHA1 Message Date
a8005babec Advance to version 0.1.2
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 16:39:20 +08:00
3a78fbd587 Fix non-deterministic SPQR decomposition caused by set iteration
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 16:39:10 +08:00
709ebc76d7 Advance to version 0.1.1
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 12:27:31 +08:00
8e81879654 Use public API imports in tests and docs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 12:12:17 +08:00
88d337bb88 Add biconnectivity check to find_triconnected_components
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 11:52:27 +08:00
8 changed files with 432 additions and 11 deletions

View File

@@ -2,6 +2,28 @@ Change Log
==========
Version 0.1.2
-------------
Released 2026/3/11
- Fix non-deterministic SPQR decomposition caused by Python hash
seed randomization. ``PathSearcher`` now uses a deterministic
DFS start vertex instead of picking from a ``set``.
Version 0.1.1
-------------
Released 2026/3/11
- ``find_triconnected_components()`` now raises ``ValueError`` when
the input graph is not biconnected (disconnected or has a cut
vertex). Previously, non-biconnected input was silently accepted
and produced incorrect results.
- Use public API imports in documentation examples.
Version 0.1.0
-------------

View File

@@ -133,12 +133,11 @@ Using a MultiGraph
~~~~~~~~~~~~~~~~~~
For more control, build a
:class:`~spqrtree._graph.MultiGraph` directly:
:class:`~spqrtree.MultiGraph` directly:
.. code-block:: python
from spqrtree._graph import MultiGraph
from spqrtree import SPQRTree
from spqrtree import MultiGraph, SPQRTree
g = MultiGraph()
g.add_edge(0, 1)

View File

@@ -43,7 +43,7 @@ from spqrtree._triconnected import (
find_triconnected_components,
)
VERSION: str = "0.1.0"
VERSION: str = "0.1.2"
"""The package version."""
__all__: list[str] = [
"SPQRTree", "SPQRNode", "NodeType",

View File

@@ -92,6 +92,41 @@ class TriconnectedComponent:
"""All edges in this component (real and virtual)."""
def _check_biconnected(graph: MultiGraph) -> None:
"""Check that a graph is biconnected (connected with no cut vertex).
Uses a single DFS pass to verify connectivity and detect cut
vertices via Tarjan's algorithm. A non-root vertex v is a cut
vertex if it has a child w such that lowpt1[w] >= dfs_num[v].
The DFS root is a cut vertex if it has two or more children.
:param graph: The multigraph to check.
:raises ValueError: If the graph is not connected or has a cut
vertex.
"""
start: Hashable = next(iter(graph.vertices))
pt: PalmTree = build_palm_tree(graph, start)
# Check connectivity: DFS must visit all vertices.
if len(pt.dfs_num) < graph.num_vertices():
raise ValueError("graph is not connected")
# Check for cut vertices.
for v in graph.vertices:
if pt.parent[v] is None:
# Root: cut vertex if it has 2+ children.
if len(pt.children[v]) >= 2:
raise ValueError("graph has a cut vertex")
else:
# Non-root: cut vertex if any child w has
# lowpt1[w] >= dfs_num[v].
v_num: int = pt.dfs_num[v]
for w in pt.children[v]:
if pt.lowpt1[w] >= v_num:
raise ValueError(
"graph has a cut vertex")
def find_triconnected_components(
graph: MultiGraph,
) -> list[TriconnectedComponent]:
@@ -108,10 +143,14 @@ def find_triconnected_components(
:param graph: A biconnected multigraph.
:return: A list of TriconnectedComponent objects.
:raises ValueError: If the graph is not connected or has a
cut vertex.
"""
if graph.num_vertices() == 0 or graph.num_edges() == 0:
return []
_check_biconnected(graph)
# Work on a copy to avoid modifying the caller's graph.
g: MultiGraph = graph.copy()
@@ -379,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

@@ -20,7 +20,7 @@
import unittest
from collections.abc import Hashable
from spqrtree._graph import Edge, MultiGraph
from spqrtree import Edge, MultiGraph
class TestEdge(unittest.TestCase):

View File

@@ -24,7 +24,7 @@ DFS from vertex 1. Edge insertion order is specified in each test.
import unittest
from collections.abc import Hashable
from spqrtree._graph import Edge, MultiGraph
from spqrtree import Edge, MultiGraph
from spqrtree._palm_tree import PalmTree, build_palm_tree, phi_key

View File

@@ -21,13 +21,21 @@
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
from collections.abc import Hashable
from spqrtree._graph import Edge, MultiGraph
from spqrtree._spqr import NodeType, SPQRNode, build_spqr_tree
from spqrtree import (
Edge,
MultiGraph,
NodeType,
SPQRNode,
build_spqr_tree,
)
def _make_k3() -> MultiGraph:
@@ -2795,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)
@@ -2851,3 +2897,112 @@ class TestSPQRRpstFig1a(unittest.TestCase):
def test_no_adjacent_p_nodes(self) -> None:
"""Test that no P-node is adjacent to another P-node."""
_assert_no_ss_pp(self, self.root, NodeType.P)
class TestBuildSpqrTreeBiconnectivity(unittest.TestCase):
"""Tests that build_spqr_tree rejects non-biconnected graphs."""
def test_cut_vertex_raises(self) -> None:
"""Test that a graph with a cut vertex raises ValueError."""
g: MultiGraph = MultiGraph()
g.add_edge(1, 2)
g.add_edge(2, 3)
g.add_edge(1, 3)
g.add_edge(3, 4)
g.add_edge(4, 5)
g.add_edge(3, 5)
with self.assertRaises(ValueError) as ctx:
build_spqr_tree(g)
self.assertIn("cut vertex", str(ctx.exception))
def test_disconnected_raises(self) -> None:
"""Test that a disconnected graph raises ValueError."""
g: MultiGraph = MultiGraph()
g.add_edge(1, 2)
g.add_edge(3, 4)
with self.assertRaises(ValueError) as ctx:
build_spqr_tree(g)
self.assertIn("not connected", str(ctx.exception))
def test_path_raises(self) -> None:
"""Test that a path graph raises ValueError."""
g: MultiGraph = MultiGraph()
g.add_edge(1, 2)
g.add_edge(2, 3)
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,12 +22,16 @@ 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
from spqrtree._graph import Edge, MultiGraph
from spqrtree._triconnected import (
from spqrtree import (
ComponentType,
Edge,
MultiGraph,
TriconnectedComponent,
find_triconnected_components,
)
@@ -2740,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)
@@ -2785,3 +2832,158 @@ class TestTriconnectedRpstFig1a(unittest.TestCase):
def test_all_invariants(self) -> None:
"""Test all decomposition invariants."""
_check_all_invariants(self, self.g, self.comps)
def _make_cut_vertex_graph() -> MultiGraph:
"""Build a graph with a cut vertex.
Graph: 1-2-3 triangle connected to 3-4-5 triangle via
shared vertex 3 (the cut vertex).
:return: A MultiGraph with a cut vertex at vertex 3.
"""
g: MultiGraph = MultiGraph()
g.add_edge(1, 2)
g.add_edge(2, 3)
g.add_edge(1, 3)
g.add_edge(3, 4)
g.add_edge(4, 5)
g.add_edge(3, 5)
return g
def _make_disconnected_graph() -> MultiGraph:
"""Build a disconnected graph with two components.
Component 1: edge 1-2.
Component 2: edge 3-4.
:return: A disconnected MultiGraph.
"""
g: MultiGraph = MultiGraph()
g.add_edge(1, 2)
g.add_edge(3, 4)
return g
def _make_path_graph() -> MultiGraph:
"""Build a path graph 1-2-3 (not biconnected).
Vertex 2 is a cut vertex (removing it disconnects 1 and 3).
:return: A MultiGraph representing a path.
"""
g: MultiGraph = MultiGraph()
g.add_edge(1, 2)
g.add_edge(2, 3)
return g
class TestBiconnectivityCheck(unittest.TestCase):
"""Tests that non-biconnected graphs raise ValueError."""
def test_cut_vertex_raises(self) -> None:
"""Test that a graph with a cut vertex raises ValueError."""
g: MultiGraph = _make_cut_vertex_graph()
with self.assertRaises(ValueError) as ctx:
find_triconnected_components(g)
self.assertIn("cut vertex", str(ctx.exception))
def test_disconnected_raises(self) -> None:
"""Test that a disconnected graph raises ValueError."""
g: MultiGraph = _make_disconnected_graph()
with self.assertRaises(ValueError) as ctx:
find_triconnected_components(g)
self.assertIn("not connected", str(ctx.exception))
def test_path_raises(self) -> None:
"""Test that a path graph (has cut vertex) raises ValueError."""
g: MultiGraph = _make_path_graph()
with self.assertRaises(ValueError) as ctx:
find_triconnected_components(g)
self.assertIn("cut vertex", str(ctx.exception))
def test_single_vertex_no_edges(self) -> None:
"""Test that a single vertex with no edges returns empty."""
g: MultiGraph = MultiGraph()
g.add_vertex(1)
comps: list[TriconnectedComponent] = \
find_triconnected_components(g)
self.assertEqual(comps, [])
def test_biconnected_graph_ok(self) -> None:
"""Test that a biconnected graph does not raise."""
g: MultiGraph = _make_k3()
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}"
)