Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 709ebc76d7 | |||
| 8e81879654 | |||
| 88d337bb88 |
@@ -2,6 +2,18 @@ Change Log
|
||||
==========
|
||||
|
||||
|
||||
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
|
||||
-------------
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -43,7 +43,7 @@ from spqrtree._triconnected import (
|
||||
find_triconnected_components,
|
||||
)
|
||||
|
||||
VERSION: str = "0.1.0"
|
||||
VERSION: str = "0.1.1"
|
||||
"""The package version."""
|
||||
__all__: list[str] = [
|
||||
"SPQRTree", "SPQRNode", "NodeType",
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -26,8 +26,13 @@ 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:
|
||||
@@ -2851,3 +2856,38 @@ 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))
|
||||
|
||||
@@ -25,9 +25,10 @@ edge appearance count.
|
||||
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,
|
||||
)
|
||||
@@ -2785,3 +2786,88 @@ 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)
|
||||
|
||||
Reference in New Issue
Block a user