Compare commits
3 Commits
v0.1.2
..
ee28a225d1
| Author | SHA1 | Date | |
|---|---|---|---|
| ee28a225d1 | |||
| c3763f1768 | |||
| 14ad81ad00 |
+3
-4
@@ -1,7 +1,6 @@
|
|||||||
# The SPQR-Tree algorithm implementation.
|
# The SQPR-Tree algorithm implementation.
|
||||||
# Copyright 2026 imacat. All rights reserved.
|
# Copyright 2026 DSP, inc. All rights reserved.
|
||||||
# Authors:
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2026/3/1
|
||||||
# imacat@mail.imacat.idv.tw (imacat), 2026/3/1
|
|
||||||
|
|
||||||
*.pyc
|
*.pyc
|
||||||
__pycache__
|
__pycache__
|
||||||
|
|||||||
+1
-1
@@ -17,6 +17,6 @@
|
|||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
recursive-include docs *
|
recursive-include docs *
|
||||||
prune docs/build
|
recursive-exclude docs/build *
|
||||||
recursive-include tests *
|
recursive-include tests *
|
||||||
recursive-exclude tests *.pyc
|
recursive-exclude tests *.pyc
|
||||||
|
|||||||
@@ -2,28 +2,6 @@ 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
|
Version 0.1.0
|
||||||
-------------
|
-------------
|
||||||
|
|
||||||
|
|||||||
@@ -133,11 +133,12 @@ Using a MultiGraph
|
|||||||
~~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
For more control, build a
|
For more control, build a
|
||||||
:class:`~spqrtree.MultiGraph` directly:
|
:class:`~spqrtree._graph.MultiGraph` directly:
|
||||||
|
|
||||||
.. code-block:: python
|
.. code-block:: python
|
||||||
|
|
||||||
from spqrtree import MultiGraph, SPQRTree
|
from spqrtree._graph import MultiGraph
|
||||||
|
from spqrtree import SPQRTree
|
||||||
|
|
||||||
g = MultiGraph()
|
g = MultiGraph()
|
||||||
g.add_edge(0, 1)
|
g.add_edge(0, 1)
|
||||||
|
|||||||
+1
-8
@@ -3,7 +3,7 @@
|
|||||||
# imacat@mail.imacat.idv.tw (imacat), 2026/3/2
|
# imacat@mail.imacat.idv.tw (imacat), 2026/3/2
|
||||||
# AI assistance: Claude Code (Anthropic)
|
# AI assistance: Claude Code (Anthropic)
|
||||||
|
|
||||||
# Copyright (c) 2026 imacat.
|
# Copyright (c) 2022-2026 imacat.
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@@ -53,13 +53,6 @@ classifiers = [
|
|||||||
"Typing :: Typed",
|
"Typing :: Typed",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
|
||||||
Homepage = "https://github.com/imacat/spqrtree"
|
|
||||||
Repository = "https://github.com/imacat/spqrtree"
|
|
||||||
Documentation = "https://spqrtree.readthedocs.io"
|
|
||||||
"Change Log" = "https://spqrtree.readthedocs.io/en/latest/changelog.html"
|
|
||||||
"Bug Tracker" = "https://github.com/imacat/spqrtree/issues"
|
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["setuptools>=61"]
|
requires = ["setuptools>=61"]
|
||||||
build-backend = "setuptools.build_meta"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|||||||
@@ -25,9 +25,9 @@ Di Battista & Tamassia (1996).
|
|||||||
|
|
||||||
Public API::
|
Public API::
|
||||||
|
|
||||||
from spqrtree import Edge, MultiGraph
|
from spqrtree._graph import Edge, MultiGraph
|
||||||
from spqrtree import NodeType, SPQRNode, build_spqr_tree
|
from spqrtree._spqr import NodeType, SPQRNode, build_spqr_tree
|
||||||
from spqrtree import (
|
from spqrtree._triconnected import (
|
||||||
ComponentType,
|
ComponentType,
|
||||||
TriconnectedComponent,
|
TriconnectedComponent,
|
||||||
find_triconnected_components,
|
find_triconnected_components,
|
||||||
@@ -43,7 +43,7 @@ from spqrtree._triconnected import (
|
|||||||
find_triconnected_components,
|
find_triconnected_components,
|
||||||
)
|
)
|
||||||
|
|
||||||
VERSION: str = "0.1.2"
|
VERSION: str = "0.1.0"
|
||||||
"""The package version."""
|
"""The package version."""
|
||||||
__all__: list[str] = [
|
__all__: list[str] = [
|
||||||
"SPQRTree", "SPQRNode", "NodeType",
|
"SPQRTree", "SPQRNode", "NodeType",
|
||||||
|
|||||||
@@ -92,41 +92,6 @@ class TriconnectedComponent:
|
|||||||
"""All edges in this component (real and virtual)."""
|
"""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(
|
def find_triconnected_components(
|
||||||
graph: MultiGraph,
|
graph: MultiGraph,
|
||||||
) -> list[TriconnectedComponent]:
|
) -> list[TriconnectedComponent]:
|
||||||
@@ -143,14 +108,10 @@ def find_triconnected_components(
|
|||||||
|
|
||||||
:param graph: A biconnected multigraph.
|
:param graph: A biconnected multigraph.
|
||||||
:return: A list of TriconnectedComponent objects.
|
: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:
|
if graph.num_vertices() == 0 or graph.num_edges() == 0:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
_check_biconnected(graph)
|
|
||||||
|
|
||||||
# Work on a copy to avoid modifying the caller's graph.
|
# Work on a copy to avoid modifying the caller's graph.
|
||||||
g: MultiGraph = graph.copy()
|
g: MultiGraph = graph.copy()
|
||||||
|
|
||||||
@@ -418,11 +379,7 @@ 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.
|
||||||
# Use the first remaining edge's endpoint as start vertex
|
start: Hashable = next(iter(g.vertices))
|
||||||
# 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)
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@
|
|||||||
import unittest
|
import unittest
|
||||||
from collections.abc import Hashable
|
from collections.abc import Hashable
|
||||||
|
|
||||||
from spqrtree import Edge, MultiGraph
|
from spqrtree._graph import Edge, MultiGraph
|
||||||
|
|
||||||
|
|
||||||
class TestEdge(unittest.TestCase):
|
class TestEdge(unittest.TestCase):
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ DFS from vertex 1. Edge insertion order is specified in each test.
|
|||||||
import unittest
|
import unittest
|
||||||
from collections.abc import Hashable
|
from collections.abc import Hashable
|
||||||
|
|
||||||
from spqrtree import Edge, MultiGraph
|
from spqrtree._graph import Edge, MultiGraph
|
||||||
from spqrtree._palm_tree import PalmTree, build_palm_tree, phi_key
|
from spqrtree._palm_tree import PalmTree, build_palm_tree, phi_key
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+2
-157
@@ -21,21 +21,13 @@
|
|||||||
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
|
||||||
from collections.abc import Hashable
|
from collections.abc import Hashable
|
||||||
|
|
||||||
from spqrtree import (
|
from spqrtree._graph import Edge, MultiGraph
|
||||||
Edge,
|
from spqrtree._spqr import NodeType, SPQRNode, build_spqr_tree
|
||||||
MultiGraph,
|
|
||||||
NodeType,
|
|
||||||
SPQRNode,
|
|
||||||
build_spqr_tree,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _make_k3() -> MultiGraph:
|
def _make_k3() -> MultiGraph:
|
||||||
@@ -2803,44 +2795,6 @@ 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)
|
||||||
@@ -2897,112 +2851,3 @@ class TestSPQRRpstFig1a(unittest.TestCase):
|
|||||||
def test_no_adjacent_p_nodes(self) -> None:
|
def test_no_adjacent_p_nodes(self) -> None:
|
||||||
"""Test that no P-node is adjacent to another P-node."""
|
"""Test that no P-node is adjacent to another P-node."""
|
||||||
_assert_no_ss_pp(self, self.root, NodeType.P)
|
_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}"
|
|
||||||
)
|
|
||||||
|
|||||||
+2
-204
@@ -22,16 +22,12 @@ 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
|
||||||
|
|
||||||
from spqrtree import (
|
from spqrtree._graph import Edge, MultiGraph
|
||||||
|
from spqrtree._triconnected import (
|
||||||
ComponentType,
|
ComponentType,
|
||||||
Edge,
|
|
||||||
MultiGraph,
|
|
||||||
TriconnectedComponent,
|
TriconnectedComponent,
|
||||||
find_triconnected_components,
|
find_triconnected_components,
|
||||||
)
|
)
|
||||||
@@ -2744,49 +2740,6 @@ 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)
|
||||||
@@ -2832,158 +2785,3 @@ class TestTriconnectedRpstFig1a(unittest.TestCase):
|
|||||||
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)
|
||||||
|
|
||||||
|
|
||||||
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}"
|
|
||||||
)
|
|
||||||
|
|||||||
Reference in New Issue
Block a user