diff --git a/src/spqrtree/_triconnected.py b/src/spqrtree/_triconnected.py index b5508d7..498e89d 100644 --- a/src/spqrtree/_triconnected.py +++ b/src/spqrtree/_triconnected.py @@ -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() diff --git a/tests/test_spqrtree.py b/tests/test_spqrtree.py index bc73689..1ade940 100644 --- a/tests/test_spqrtree.py +++ b/tests/test_spqrtree.py @@ -2851,3 +2851,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)) diff --git a/tests/test_triconnected.py b/tests/test_triconnected.py index e345865..4a9de9d 100644 --- a/tests/test_triconnected.py +++ b/tests/test_triconnected.py @@ -2785,3 +2785,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)