2990 lines
97 KiB
Python
2990 lines
97 KiB
Python
# Pure Python SPQR-Tree implementation.
|
|
# Authors:
|
|
# imacat@mail.imacat.idv.tw (imacat), 2026/3/2
|
|
# AI assistance: Claude Code (Anthropic)
|
|
|
|
# Copyright (c) 2026 imacat.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
"""Tests for the triconnected components algorithm (_triconnected.py).
|
|
|
|
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 import (
|
|
ComponentType,
|
|
Edge,
|
|
MultiGraph,
|
|
TriconnectedComponent,
|
|
find_triconnected_components,
|
|
)
|
|
|
|
|
|
def _make_k3() -> MultiGraph:
|
|
"""Build the triangle graph K3 (vertices 1,2,3).
|
|
|
|
:return: A MultiGraph representing K3.
|
|
"""
|
|
g: MultiGraph = MultiGraph()
|
|
g.add_edge(1, 2)
|
|
g.add_edge(2, 3)
|
|
g.add_edge(1, 3)
|
|
return g
|
|
|
|
|
|
def _make_c4() -> MultiGraph:
|
|
"""Build the 4-cycle C4 (vertices 1,2,3,4).
|
|
|
|
:return: A MultiGraph representing C4.
|
|
"""
|
|
g: MultiGraph = MultiGraph()
|
|
g.add_edge(1, 2)
|
|
g.add_edge(2, 3)
|
|
g.add_edge(3, 4)
|
|
g.add_edge(4, 1)
|
|
return g
|
|
|
|
|
|
def _make_k4() -> MultiGraph:
|
|
"""Build the complete graph K4 (6 edges among vertices 1,2,3,4).
|
|
|
|
:return: A MultiGraph representing K4.
|
|
"""
|
|
g: MultiGraph = MultiGraph()
|
|
g.add_edge(1, 2)
|
|
g.add_edge(1, 3)
|
|
g.add_edge(1, 4)
|
|
g.add_edge(2, 3)
|
|
g.add_edge(2, 4)
|
|
g.add_edge(3, 4)
|
|
return g
|
|
|
|
|
|
def _make_two_parallel() -> MultiGraph:
|
|
"""Build a graph with 2 parallel edges between vertices 1 and 2.
|
|
|
|
:return: A MultiGraph with two parallel edges.
|
|
"""
|
|
g: MultiGraph = MultiGraph()
|
|
g.add_edge(1, 2)
|
|
g.add_edge(1, 2)
|
|
return g
|
|
|
|
|
|
def _make_three_parallel() -> MultiGraph:
|
|
"""Build a graph with 3 parallel edges between vertices 1 and 2.
|
|
|
|
:return: A MultiGraph with three parallel edges.
|
|
"""
|
|
g: MultiGraph = MultiGraph()
|
|
g.add_edge(1, 2)
|
|
g.add_edge(1, 2)
|
|
g.add_edge(1, 2)
|
|
return g
|
|
|
|
|
|
def _count_real_edges(
|
|
components: list[TriconnectedComponent],
|
|
) -> int:
|
|
"""Count total real (non-virtual) edges across all components.
|
|
|
|
:param components: A list of triconnected components.
|
|
:return: Total count of real edges summed over all components.
|
|
"""
|
|
return sum(
|
|
1
|
|
for comp in components
|
|
for e in comp.edges
|
|
if not e.virtual
|
|
)
|
|
|
|
|
|
def _virtual_edge_component_count(
|
|
components: list[TriconnectedComponent],
|
|
) -> dict[int, int]:
|
|
"""Count how many components each virtual edge appears in.
|
|
|
|
:param components: A list of triconnected components.
|
|
:return: A dict mapping virtual edge ID to component count.
|
|
"""
|
|
counts: dict[int, int] = {}
|
|
for comp in components:
|
|
for e in comp.edges:
|
|
if e.virtual:
|
|
counts[e.id] = counts.get(e.id, 0) + 1
|
|
return counts
|
|
|
|
|
|
class TestTriconnectedK3(unittest.TestCase):
|
|
"""Tests for triconnected decomposition of the triangle K3."""
|
|
|
|
def setUp(self) -> None:
|
|
"""Build the components for K3."""
|
|
self.g: MultiGraph = _make_k3()
|
|
"""The K3 graph under test."""
|
|
self.comps: list[TriconnectedComponent] = \
|
|
find_triconnected_components(self.g)
|
|
"""The triconnected split components."""
|
|
|
|
def test_returns_list(self) -> None:
|
|
"""Test that find_triconnected_components returns a list."""
|
|
self.assertIsInstance(self.comps, list)
|
|
|
|
def test_single_component(self) -> None:
|
|
"""Test that K3 produces exactly 1 triconnected component."""
|
|
self.assertEqual(len(self.comps), 1)
|
|
|
|
def test_component_is_polygon(self) -> None:
|
|
"""Test that K3 yields a POLYGON component."""
|
|
self.assertEqual(self.comps[0].type, ComponentType.POLYGON)
|
|
|
|
def test_polygon_has_three_real_edges(self) -> None:
|
|
"""Test that the POLYGON from K3 contains 3 real edges."""
|
|
real: list[Edge] = [
|
|
e for e in self.comps[0].edges if not e.virtual
|
|
]
|
|
self.assertEqual(len(real), 3)
|
|
|
|
def test_total_real_edges(self) -> None:
|
|
"""Test that total real edge count equals input edge count."""
|
|
self.assertEqual(
|
|
_count_real_edges(self.comps), self.g.num_edges()
|
|
)
|
|
|
|
|
|
class TestTriconnectedC4(unittest.TestCase):
|
|
"""Tests for triconnected decomposition of the 4-cycle C4."""
|
|
|
|
def setUp(self) -> None:
|
|
"""Build the components for C4."""
|
|
self.g: MultiGraph = _make_c4()
|
|
"""The C4 graph under test."""
|
|
self.comps: list[TriconnectedComponent] = \
|
|
find_triconnected_components(self.g)
|
|
"""The triconnected split components."""
|
|
|
|
def test_single_component(self) -> None:
|
|
"""Test that C4 produces exactly 1 triconnected component."""
|
|
self.assertEqual(len(self.comps), 1)
|
|
|
|
def test_component_is_polygon(self) -> None:
|
|
"""Test that C4 yields a POLYGON component."""
|
|
self.assertEqual(self.comps[0].type, ComponentType.POLYGON)
|
|
|
|
def test_polygon_has_four_real_edges(self) -> None:
|
|
"""Test that the POLYGON from C4 contains 4 real edges."""
|
|
real: list[Edge] = [
|
|
e for e in self.comps[0].edges if not e.virtual
|
|
]
|
|
self.assertEqual(len(real), 4)
|
|
|
|
def test_total_real_edges(self) -> None:
|
|
"""Test that total real edge count equals input edge count."""
|
|
self.assertEqual(
|
|
_count_real_edges(self.comps), self.g.num_edges()
|
|
)
|
|
|
|
|
|
class TestTriconnectedK4(unittest.TestCase):
|
|
"""Tests for triconnected decomposition of the complete graph K4."""
|
|
|
|
def setUp(self) -> None:
|
|
"""Build the components for K4."""
|
|
self.g: MultiGraph = _make_k4()
|
|
"""The K4 graph under test."""
|
|
self.comps: list[TriconnectedComponent] = \
|
|
find_triconnected_components(self.g)
|
|
"""The triconnected split components."""
|
|
|
|
def test_single_component(self) -> None:
|
|
"""Test that K4 produces exactly 1 triconnected component."""
|
|
self.assertEqual(len(self.comps), 1)
|
|
|
|
def test_component_is_triconnected(self) -> None:
|
|
"""Test that K4 yields a TRICONNECTED component."""
|
|
self.assertEqual(
|
|
self.comps[0].type, ComponentType.TRICONNECTED
|
|
)
|
|
|
|
def test_triconnected_has_six_real_edges(self) -> None:
|
|
"""Test that the TRICONNECTED from K4 has 6 real edges."""
|
|
real: list[Edge] = [
|
|
e for e in self.comps[0].edges if not e.virtual
|
|
]
|
|
self.assertEqual(len(real), 6)
|
|
|
|
def test_total_real_edges(self) -> None:
|
|
"""Test that total real edge count equals input edge count."""
|
|
self.assertEqual(
|
|
_count_real_edges(self.comps), self.g.num_edges()
|
|
)
|
|
|
|
|
|
class TestTriconnectedTwoParallel(unittest.TestCase):
|
|
"""Tests for triconnected decomposition of two parallel edges."""
|
|
|
|
def setUp(self) -> None:
|
|
"""Build the components for 2 parallel edges between 1 and 2."""
|
|
self.g: MultiGraph = _make_two_parallel()
|
|
"""The two-parallel-edges graph under test."""
|
|
self.comps: list[TriconnectedComponent] = \
|
|
find_triconnected_components(self.g)
|
|
"""The triconnected split components."""
|
|
|
|
def test_single_component(self) -> None:
|
|
"""Test that 2 parallel edges produce exactly 1 component."""
|
|
self.assertEqual(len(self.comps), 1)
|
|
|
|
def test_component_is_bond(self) -> None:
|
|
"""Test that 2 parallel edges yield a BOND component."""
|
|
self.assertEqual(self.comps[0].type, ComponentType.BOND)
|
|
|
|
def test_bond_has_two_real_edges(self) -> None:
|
|
"""Test that the BOND from 2 parallel edges has 2 real edges."""
|
|
real: list[Edge] = [
|
|
e for e in self.comps[0].edges if not e.virtual
|
|
]
|
|
self.assertEqual(len(real), 2)
|
|
|
|
def test_total_real_edges(self) -> None:
|
|
"""Test that total real edge count equals input edge count."""
|
|
self.assertEqual(
|
|
_count_real_edges(self.comps), self.g.num_edges()
|
|
)
|
|
|
|
|
|
class TestTriconnectedThreeParallel(unittest.TestCase):
|
|
"""Tests for triconnected decomposition of three parallel edges."""
|
|
|
|
def setUp(self) -> None:
|
|
"""Build the components for 3 parallel edges between 1 and 2."""
|
|
self.g: MultiGraph = _make_three_parallel()
|
|
"""The three-parallel-edges graph under test."""
|
|
self.comps: list[TriconnectedComponent] = \
|
|
find_triconnected_components(self.g)
|
|
"""The triconnected split components."""
|
|
|
|
def test_single_component(self) -> None:
|
|
"""Test that 3 parallel edges produce exactly 1 component."""
|
|
self.assertEqual(len(self.comps), 1)
|
|
|
|
def test_component_is_bond(self) -> None:
|
|
"""Test that 3 parallel edges yield a BOND component."""
|
|
self.assertEqual(self.comps[0].type, ComponentType.BOND)
|
|
|
|
def test_bond_has_three_real_edges(self) -> None:
|
|
"""Test that the BOND has 3 real edges."""
|
|
real: list[Edge] = [
|
|
e for e in self.comps[0].edges if not e.virtual
|
|
]
|
|
self.assertEqual(len(real), 3)
|
|
|
|
def test_total_real_edges(self) -> None:
|
|
"""Test that total real edge count equals input edge count."""
|
|
self.assertEqual(
|
|
_count_real_edges(self.comps), self.g.num_edges()
|
|
)
|
|
|
|
|
|
class TestTriconnectedInvariants(unittest.TestCase):
|
|
"""Tests for global invariants across all graphs."""
|
|
|
|
def _check_real_edge_count(self, g: MultiGraph) -> None:
|
|
"""Check that real edge count is preserved across decomposition.
|
|
|
|
:param g: The input graph.
|
|
:return: None
|
|
"""
|
|
comps: list[TriconnectedComponent] = \
|
|
find_triconnected_components(g)
|
|
self.assertEqual(_count_real_edges(comps), g.num_edges())
|
|
|
|
def _check_virtual_edges_in_two_comps(
|
|
self, comps: list[TriconnectedComponent]
|
|
) -> None:
|
|
"""Check that each virtual edge appears in exactly 2 components.
|
|
|
|
:param comps: The list of triconnected components.
|
|
:return: None
|
|
"""
|
|
counts: dict[int, int] = \
|
|
_virtual_edge_component_count(comps)
|
|
for eid, cnt in counts.items():
|
|
self.assertEqual(
|
|
cnt,
|
|
2,
|
|
f"Virtual edge {eid} appears in {cnt} components "
|
|
f"(expected 2)",
|
|
)
|
|
|
|
def test_k3_real_edge_count(self) -> None:
|
|
"""Test real edge count invariant for K3."""
|
|
self._check_real_edge_count(_make_k3())
|
|
|
|
def test_c4_real_edge_count(self) -> None:
|
|
"""Test real edge count invariant for C4."""
|
|
self._check_real_edge_count(_make_c4())
|
|
|
|
def test_k4_real_edge_count(self) -> None:
|
|
"""Test real edge count invariant for K4."""
|
|
self._check_real_edge_count(_make_k4())
|
|
|
|
def test_two_parallel_real_edge_count(self) -> None:
|
|
"""Test real edge count invariant for 2 parallel edges."""
|
|
self._check_real_edge_count(_make_two_parallel())
|
|
|
|
def test_three_parallel_real_edge_count(self) -> None:
|
|
"""Test real edge count invariant for 3 parallel edges."""
|
|
self._check_real_edge_count(_make_three_parallel())
|
|
|
|
def test_k3_virtual_edges_in_two_comps(self) -> None:
|
|
"""Test virtual edge invariant for K3."""
|
|
comps: list[TriconnectedComponent] = \
|
|
find_triconnected_components(_make_k3())
|
|
self._check_virtual_edges_in_two_comps(comps)
|
|
|
|
def test_c4_virtual_edges_in_two_comps(self) -> None:
|
|
"""Test virtual edge invariant for C4."""
|
|
comps: list[TriconnectedComponent] = \
|
|
find_triconnected_components(_make_c4())
|
|
self._check_virtual_edges_in_two_comps(comps)
|
|
|
|
def test_k4_virtual_edges_in_two_comps(self) -> None:
|
|
"""Test virtual edge invariant for K4."""
|
|
comps: list[TriconnectedComponent] = \
|
|
find_triconnected_components(_make_k4())
|
|
self._check_virtual_edges_in_two_comps(comps)
|
|
|
|
def test_two_parallel_virtual_edges_in_two_comps(self) -> None:
|
|
"""Test virtual edge invariant for 2 parallel edges."""
|
|
comps: list[TriconnectedComponent] = \
|
|
find_triconnected_components(_make_two_parallel())
|
|
self._check_virtual_edges_in_two_comps(comps)
|
|
|
|
def test_three_parallel_virtual_edges_in_two_comps(self) -> None:
|
|
"""Test virtual edge invariant for 3 parallel edges."""
|
|
comps: list[TriconnectedComponent] = \
|
|
find_triconnected_components(_make_three_parallel())
|
|
self._check_virtual_edges_in_two_comps(comps)
|
|
|
|
def test_component_types_are_valid(self) -> None:
|
|
"""Test that all component types are valid ComponentType values."""
|
|
for g in [
|
|
_make_k3(), _make_c4(), _make_k4(),
|
|
_make_two_parallel(), _make_three_parallel(),
|
|
]:
|
|
comps: list[TriconnectedComponent] = \
|
|
find_triconnected_components(g)
|
|
for comp in comps:
|
|
self.assertIsInstance(comp.type, ComponentType)
|
|
self.assertIn(
|
|
comp.type,
|
|
[
|
|
ComponentType.BOND,
|
|
ComponentType.POLYGON,
|
|
ComponentType.TRICONNECTED,
|
|
],
|
|
)
|
|
|
|
def test_each_component_has_edges(self) -> None:
|
|
"""Test that every component has at least 2 edges."""
|
|
for g in [
|
|
_make_k3(), _make_c4(), _make_k4(),
|
|
_make_two_parallel(), _make_three_parallel(),
|
|
]:
|
|
comps: list[TriconnectedComponent] = \
|
|
find_triconnected_components(g)
|
|
for comp in comps:
|
|
self.assertGreaterEqual(
|
|
len(comp.edges),
|
|
2,
|
|
f"Component {comp.type} has fewer than 2 edges",
|
|
)
|
|
|
|
|
|
def _make_diamond() -> MultiGraph:
|
|
"""Build the diamond graph (K4 minus one edge).
|
|
|
|
Vertices 1,2,3,4; edges: 1-2, 1-3, 2-3, 2-4, 3-4.
|
|
The pair {2,3} is a separation pair.
|
|
|
|
:return: A MultiGraph representing the diamond graph.
|
|
"""
|
|
g: MultiGraph = MultiGraph()
|
|
g.add_edge(1, 2)
|
|
g.add_edge(1, 3)
|
|
g.add_edge(2, 3)
|
|
g.add_edge(2, 4)
|
|
g.add_edge(3, 4)
|
|
return g
|
|
|
|
|
|
def _make_theta() -> MultiGraph:
|
|
"""Build the theta graph: two vertices connected by 3 paths.
|
|
|
|
Vertices 1-5; edges: 1-3, 3-2, 1-4, 4-2, 1-5, 5-2.
|
|
The pair {1,2} is a separation pair.
|
|
|
|
:return: A MultiGraph representing the theta graph.
|
|
"""
|
|
g: MultiGraph = MultiGraph()
|
|
g.add_edge(1, 3)
|
|
g.add_edge(3, 2)
|
|
g.add_edge(1, 4)
|
|
g.add_edge(4, 2)
|
|
g.add_edge(1, 5)
|
|
g.add_edge(5, 2)
|
|
return g
|
|
|
|
|
|
def _make_prism() -> MultiGraph:
|
|
"""Build the triangular prism graph.
|
|
|
|
Two triangles connected by 3 edges.
|
|
Vertices 1-6; this graph is 3-connected.
|
|
|
|
:return: A MultiGraph representing the triangular prism.
|
|
"""
|
|
g: MultiGraph = MultiGraph()
|
|
# Top triangle
|
|
g.add_edge(1, 2)
|
|
g.add_edge(2, 3)
|
|
g.add_edge(1, 3)
|
|
# Bottom triangle
|
|
g.add_edge(4, 5)
|
|
g.add_edge(5, 6)
|
|
g.add_edge(4, 6)
|
|
# Connectors
|
|
g.add_edge(1, 4)
|
|
g.add_edge(2, 5)
|
|
g.add_edge(3, 6)
|
|
return g
|
|
|
|
|
|
class TestTriconnectedDiamond(unittest.TestCase):
|
|
"""Tests for triconnected decomposition of the diamond graph."""
|
|
|
|
def setUp(self) -> None:
|
|
"""Build the components for the diamond graph."""
|
|
self.g: MultiGraph = _make_diamond()
|
|
"""The diamond graph under test."""
|
|
self.comps: list[TriconnectedComponent] = \
|
|
find_triconnected_components(self.g)
|
|
"""The triconnected split components."""
|
|
|
|
def test_at_least_two_components(self) -> None:
|
|
"""Test that the diamond produces at least 2 components."""
|
|
self.assertGreaterEqual(
|
|
len(self.comps),
|
|
2,
|
|
"Diamond has separation pair {2,3}, expect >=2 components",
|
|
)
|
|
|
|
def test_total_real_edges(self) -> None:
|
|
"""Test that total real edge count equals input edge count (5)."""
|
|
self.assertEqual(
|
|
_count_real_edges(self.comps), self.g.num_edges()
|
|
)
|
|
|
|
def test_virtual_edges_in_exactly_two_comps(self) -> None:
|
|
"""Test that each virtual edge appears in exactly 2 components."""
|
|
counts: dict[int, int] = \
|
|
_virtual_edge_component_count(self.comps)
|
|
for eid, cnt in counts.items():
|
|
self.assertEqual(
|
|
cnt,
|
|
2,
|
|
f"Virtual edge {eid} appears in {cnt} components "
|
|
f"(expected 2)",
|
|
)
|
|
|
|
def test_no_ss_adjacency(self) -> None:
|
|
"""Test that no two S-type components share a virtual edge."""
|
|
_assert_no_same_type_adjacency(
|
|
self, self.comps, ComponentType.POLYGON
|
|
)
|
|
|
|
def test_no_pp_adjacency(self) -> None:
|
|
"""Test that no two P-type components share a virtual edge."""
|
|
_assert_no_same_type_adjacency(
|
|
self, self.comps, ComponentType.BOND
|
|
)
|
|
|
|
def test_each_component_has_at_least_two_edges(self) -> None:
|
|
"""Test that every component has at least 2 edges."""
|
|
for comp in self.comps:
|
|
self.assertGreaterEqual(
|
|
len(comp.edges),
|
|
2,
|
|
f"Component {comp.type} has fewer than 2 edges",
|
|
)
|
|
|
|
|
|
class TestTriconnectedTheta(unittest.TestCase):
|
|
"""Tests for triconnected decomposition of the theta graph."""
|
|
|
|
def setUp(self) -> None:
|
|
"""Build the components for the theta graph."""
|
|
self.g: MultiGraph = _make_theta()
|
|
"""The theta graph under test."""
|
|
self.comps: list[TriconnectedComponent] = \
|
|
find_triconnected_components(self.g)
|
|
"""The triconnected split components."""
|
|
|
|
def test_total_real_edges(self) -> None:
|
|
"""Test that total real edge count equals input edge count (6)."""
|
|
self.assertEqual(
|
|
_count_real_edges(self.comps), self.g.num_edges()
|
|
)
|
|
|
|
def test_virtual_edges_in_exactly_two_comps(self) -> None:
|
|
"""Test that each virtual edge appears in exactly 2 components."""
|
|
counts: dict[int, int] = \
|
|
_virtual_edge_component_count(self.comps)
|
|
for eid, cnt in counts.items():
|
|
self.assertEqual(
|
|
cnt,
|
|
2,
|
|
f"Virtual edge {eid} appears in {cnt} components "
|
|
f"(expected 2)",
|
|
)
|
|
|
|
def test_no_ss_adjacency(self) -> None:
|
|
"""Test that no two S-type components share a virtual edge."""
|
|
_assert_no_same_type_adjacency(
|
|
self, self.comps, ComponentType.POLYGON
|
|
)
|
|
|
|
def test_no_pp_adjacency(self) -> None:
|
|
"""Test that no two P-type components share a virtual edge."""
|
|
_assert_no_same_type_adjacency(
|
|
self, self.comps, ComponentType.BOND
|
|
)
|
|
|
|
def test_each_component_has_at_least_two_edges(self) -> None:
|
|
"""Test that every component has at least 2 edges."""
|
|
for comp in self.comps:
|
|
self.assertGreaterEqual(
|
|
len(comp.edges),
|
|
2,
|
|
f"Component {comp.type} has fewer than 2 edges",
|
|
)
|
|
|
|
|
|
class TestTriconnectedPrism(unittest.TestCase):
|
|
"""Tests for triconnected decomposition of the triangular prism."""
|
|
|
|
def setUp(self) -> None:
|
|
"""Build the components for the triangular prism."""
|
|
self.g: MultiGraph = _make_prism()
|
|
"""The triangular prism graph under test."""
|
|
self.comps: list[TriconnectedComponent] = \
|
|
find_triconnected_components(self.g)
|
|
"""The triconnected split components."""
|
|
|
|
def test_single_triconnected_component(self) -> None:
|
|
"""Test that the prism (3-connected) yields 1 component."""
|
|
self.assertEqual(len(self.comps), 1)
|
|
|
|
def test_component_is_triconnected(self) -> None:
|
|
"""Test that the single component is TRICONNECTED."""
|
|
self.assertEqual(
|
|
self.comps[0].type, ComponentType.TRICONNECTED
|
|
)
|
|
|
|
def test_total_real_edges(self) -> None:
|
|
"""Test that total real edge count equals input edge count (9)."""
|
|
self.assertEqual(
|
|
_count_real_edges(self.comps), self.g.num_edges()
|
|
)
|
|
|
|
def test_nine_real_edges(self) -> None:
|
|
"""Test that the TRICONNECTED component contains all 9 edges."""
|
|
real: list[Edge] = [
|
|
e for e in self.comps[0].edges if not e.virtual
|
|
]
|
|
self.assertEqual(len(real), 9)
|
|
|
|
|
|
def _assert_no_same_type_adjacency(
|
|
tc: unittest.TestCase,
|
|
comps: list[TriconnectedComponent],
|
|
ctype: ComponentType,
|
|
) -> None:
|
|
"""Assert that no two components of *ctype* share a virtual edge.
|
|
|
|
Checks the SPQR-tree invariant that adjacent components in the
|
|
decomposition have different types (no S-S or P-P pairs after
|
|
merging).
|
|
|
|
:param tc: The TestCase instance for assertions.
|
|
:param comps: The list of triconnected components.
|
|
:param ctype: The component type to check (BOND or POLYGON).
|
|
:return: None
|
|
"""
|
|
# Map: virtual edge ID -> list of component indices containing it.
|
|
ve_to_comps: dict[int, list[int]] = {}
|
|
for i, comp in enumerate(comps):
|
|
for e in comp.edges:
|
|
if e.virtual:
|
|
ve_to_comps.setdefault(e.id, []).append(i)
|
|
|
|
for eid, idxs in ve_to_comps.items():
|
|
if len(idxs) == 2:
|
|
i, j = idxs
|
|
both_same: bool = (
|
|
comps[i].type == ctype
|
|
and comps[j].type == ctype
|
|
)
|
|
tc.assertFalse(
|
|
both_same,
|
|
f"Virtual edge {eid} shared by two {ctype.name} "
|
|
f"components (S-S or P-P adjacency not allowed)",
|
|
)
|
|
|
|
|
|
class TestTriconnectedNoSSPP(unittest.TestCase):
|
|
"""Tests that no S-S or P-P adjacency occurs for any graph."""
|
|
|
|
def _check_no_ss(self, g: MultiGraph) -> None:
|
|
"""Check no S-S adjacency for graph g.
|
|
|
|
:param g: The input multigraph.
|
|
:return: None
|
|
"""
|
|
comps: list[TriconnectedComponent] = \
|
|
find_triconnected_components(g)
|
|
_assert_no_same_type_adjacency(
|
|
self, comps, ComponentType.POLYGON
|
|
)
|
|
|
|
def _check_no_pp(self, g: MultiGraph) -> None:
|
|
"""Check no P-P adjacency for graph g.
|
|
|
|
:param g: The input multigraph.
|
|
:return: None
|
|
"""
|
|
comps: list[TriconnectedComponent] = \
|
|
find_triconnected_components(g)
|
|
_assert_no_same_type_adjacency(
|
|
self, comps, ComponentType.BOND
|
|
)
|
|
|
|
def test_k3_no_ss(self) -> None:
|
|
"""Test no S-S adjacency for K3."""
|
|
self._check_no_ss(_make_k3())
|
|
|
|
def test_c4_no_ss(self) -> None:
|
|
"""Test no S-S adjacency for C4."""
|
|
self._check_no_ss(_make_c4())
|
|
|
|
def test_k4_no_ss(self) -> None:
|
|
"""Test no S-S adjacency for K4."""
|
|
self._check_no_ss(_make_k4())
|
|
|
|
def test_diamond_no_ss(self) -> None:
|
|
"""Test no S-S adjacency for the diamond graph."""
|
|
self._check_no_ss(_make_diamond())
|
|
|
|
def test_theta_no_ss(self) -> None:
|
|
"""Test no S-S adjacency for the theta graph."""
|
|
self._check_no_ss(_make_theta())
|
|
|
|
def test_prism_no_ss(self) -> None:
|
|
"""Test no S-S adjacency for the triangular prism."""
|
|
self._check_no_ss(_make_prism())
|
|
|
|
def test_k3_no_pp(self) -> None:
|
|
"""Test no P-P adjacency for K3."""
|
|
self._check_no_pp(_make_k3())
|
|
|
|
def test_c4_no_pp(self) -> None:
|
|
"""Test no P-P adjacency for C4."""
|
|
self._check_no_pp(_make_c4())
|
|
|
|
def test_k4_no_pp(self) -> None:
|
|
"""Test no P-P adjacency for K4."""
|
|
self._check_no_pp(_make_k4())
|
|
|
|
def test_diamond_no_pp(self) -> None:
|
|
"""Test no P-P adjacency for the diamond graph."""
|
|
self._check_no_pp(_make_diamond())
|
|
|
|
def test_theta_no_pp(self) -> None:
|
|
"""Test no P-P adjacency for the theta graph."""
|
|
self._check_no_pp(_make_theta())
|
|
|
|
def test_prism_no_pp(self) -> None:
|
|
"""Test no P-P adjacency for the triangular prism."""
|
|
self._check_no_pp(_make_prism())
|
|
|
|
|
|
def _check_all_invariants(
|
|
tc: unittest.TestCase,
|
|
g: MultiGraph,
|
|
comps: list[TriconnectedComponent],
|
|
) -> None:
|
|
"""Check all decomposition invariants for a given graph.
|
|
|
|
Verifies: real edge count preserved, virtual edges in exactly 2
|
|
components, no S-S or P-P adjacency, each component has >= 2 edges,
|
|
and reconstruction (real edges) matches the original graph.
|
|
|
|
:param tc: The TestCase instance for assertions.
|
|
:param g: The original input graph.
|
|
:param comps: The triconnected components to check.
|
|
:return: None
|
|
"""
|
|
# 1. Real edge count.
|
|
tc.assertEqual(
|
|
_count_real_edges(comps),
|
|
g.num_edges(),
|
|
"Real edge count mismatch",
|
|
)
|
|
# 2. Virtual edges in exactly 2 components.
|
|
counts: dict[int, int] = _virtual_edge_component_count(comps)
|
|
for eid, cnt in counts.items():
|
|
tc.assertEqual(
|
|
cnt, 2,
|
|
f"Virtual edge {eid} in {cnt} components (expected 2)",
|
|
)
|
|
# 3. No S-S adjacency.
|
|
_assert_no_same_type_adjacency(tc, comps, ComponentType.POLYGON)
|
|
# 4. No P-P adjacency.
|
|
_assert_no_same_type_adjacency(tc, comps, ComponentType.BOND)
|
|
# 5. Each component has at least 2 edges.
|
|
for comp in comps:
|
|
tc.assertGreaterEqual(
|
|
len(comp.edges), 2,
|
|
f"Component {comp.type} has fewer than 2 edges",
|
|
)
|
|
# 6. Reconstruction: real edges across all components == original.
|
|
orig_edge_ids: set[int] = {e.id for e in g.edges}
|
|
decomp_real_ids: set[int] = set()
|
|
for comp in comps:
|
|
for e in comp.edges:
|
|
if not e.virtual:
|
|
decomp_real_ids.add(e.id)
|
|
tc.assertEqual(
|
|
decomp_real_ids,
|
|
orig_edge_ids,
|
|
"Reconstructed real-edge set does not match original graph",
|
|
)
|
|
|
|
|
|
def _make_wikipedia_example() -> MultiGraph:
|
|
"""Build the Wikipedia SPQR-tree example graph.
|
|
|
|
21 edges, 13 vertices. Used in the Wikipedia SPQR_tree article.
|
|
|
|
:return: A MultiGraph representing the Wikipedia example.
|
|
"""
|
|
g: MultiGraph = MultiGraph()
|
|
edges: list[tuple[int, int]] = [
|
|
(1, 2), (1, 4), (1, 8), (1, 12),
|
|
(3, 4), (2, 3), (2, 13), (3, 13),
|
|
(4, 5), (4, 7), (5, 6), (5, 8), (5, 7), (6, 7),
|
|
(8, 11), (8, 9), (8, 12), (9, 10), (9, 11),
|
|
(9, 12), (10, 12),
|
|
]
|
|
for u, v in edges:
|
|
g.add_edge(u, v)
|
|
return g
|
|
|
|
|
|
def _make_ht_example() -> MultiGraph:
|
|
"""Build the Hopcroft-Tarjan (1973) example graph.
|
|
|
|
23 edges, 13 vertices. Used in [HT1973].
|
|
|
|
:return: A MultiGraph representing the HT1973 example.
|
|
"""
|
|
g: MultiGraph = MultiGraph()
|
|
edges: list[tuple[int, int]] = [
|
|
(1, 2), (1, 4), (1, 8), (1, 12), (1, 13),
|
|
(2, 3), (2, 13), (3, 4), (3, 13),
|
|
(4, 5), (4, 7), (5, 6), (5, 7), (5, 8), (6, 7),
|
|
(8, 9), (8, 11), (8, 12), (9, 10), (9, 11),
|
|
(9, 12), (10, 11), (10, 12),
|
|
]
|
|
for u, v in edges:
|
|
g.add_edge(u, v)
|
|
return g
|
|
|
|
|
|
def _make_gm_example() -> MultiGraph:
|
|
"""Build the Gutwenger-Mutzel (2001) example graph.
|
|
|
|
28 edges, 16 vertices. Used in [GM2001] as the running example.
|
|
|
|
:return: A MultiGraph representing the GM2001 example.
|
|
"""
|
|
g: MultiGraph = MultiGraph()
|
|
edges: list[tuple[int, int]] = [
|
|
(1, 2), (1, 4), (2, 3), (2, 5), (3, 4), (3, 5),
|
|
(4, 5), (4, 6), (5, 7), (5, 8), (5, 14), (6, 8),
|
|
(7, 14), (8, 9), (8, 10), (8, 11), (8, 12),
|
|
(9, 10), (10, 13), (10, 14), (10, 15), (10, 16),
|
|
(11, 12), (11, 13), (12, 13),
|
|
(14, 15), (14, 16), (15, 16),
|
|
]
|
|
for u, v in edges:
|
|
g.add_edge(u, v)
|
|
return g
|
|
|
|
|
|
def _make_multiedge_complex() -> MultiGraph:
|
|
"""Build a complex graph with multi-edges embedded in a larger graph.
|
|
|
|
5 vertices, 7 edges; two pairs of parallel edges (1-5 and 2-3)
|
|
embedded in a cycle. Expected: 2 BOND + 1 POLYGON.
|
|
|
|
:return: A MultiGraph with embedded parallel edges.
|
|
"""
|
|
g: MultiGraph = MultiGraph()
|
|
# Cycle backbone: 1-2, 3-4, 4-5
|
|
g.add_edge(1, 2)
|
|
# Double edge 2-3
|
|
g.add_edge(2, 3)
|
|
g.add_edge(2, 3)
|
|
# Continue cycle: 3-4, 4-5
|
|
g.add_edge(3, 4)
|
|
g.add_edge(4, 5)
|
|
# Double edge 1-5
|
|
g.add_edge(1, 5)
|
|
g.add_edge(1, 5)
|
|
return g
|
|
|
|
|
|
class TestTriconnectedWikipediaExample(unittest.TestCase):
|
|
"""Tests for triconnected decomposition of the Wikipedia example."""
|
|
|
|
def setUp(self) -> None:
|
|
"""Build the components for the Wikipedia SPQR-tree example."""
|
|
self.g: MultiGraph = _make_wikipedia_example()
|
|
"""The Wikipedia example graph under test."""
|
|
self.comps: list[TriconnectedComponent] = \
|
|
find_triconnected_components(self.g)
|
|
"""The triconnected split components."""
|
|
|
|
def test_all_invariants(self) -> None:
|
|
"""Test all decomposition invariants for the Wikipedia example."""
|
|
_check_all_invariants(self, self.g, self.comps)
|
|
|
|
def test_at_least_two_components(self) -> None:
|
|
"""Test that the Wikipedia example produces multiple components."""
|
|
self.assertGreaterEqual(
|
|
len(self.comps),
|
|
2,
|
|
"Wikipedia example has separation pairs, expect >=2 comps",
|
|
)
|
|
|
|
|
|
class TestTriconnectedHTExample(unittest.TestCase):
|
|
"""Tests for triconnected decomposition of the HT1973 example."""
|
|
|
|
def setUp(self) -> None:
|
|
"""Build the components for the Hopcroft-Tarjan 1973 example."""
|
|
self.g: MultiGraph = _make_ht_example()
|
|
"""The Hopcroft-Tarjan 1973 example graph under test."""
|
|
self.comps: list[TriconnectedComponent] = \
|
|
find_triconnected_components(self.g)
|
|
"""The triconnected split components."""
|
|
|
|
def test_all_invariants(self) -> None:
|
|
"""Test all decomposition invariants for the HT1973 example."""
|
|
_check_all_invariants(self, self.g, self.comps)
|
|
|
|
def test_at_least_two_components(self) -> None:
|
|
"""Test that the HT1973 example produces multiple components."""
|
|
self.assertGreaterEqual(
|
|
len(self.comps),
|
|
2,
|
|
"HT1973 example has separation pairs, expect >=2 comps",
|
|
)
|
|
|
|
|
|
class TestTriconnectedGMExample(unittest.TestCase):
|
|
"""Tests for triconnected decomposition of the GM2001 example."""
|
|
|
|
def setUp(self) -> None:
|
|
"""Build the components for the Gutwenger-Mutzel 2001 example."""
|
|
self.g: MultiGraph = _make_gm_example()
|
|
"""The Gutwenger-Mutzel 2001 example graph under test."""
|
|
self.comps: list[TriconnectedComponent] = \
|
|
find_triconnected_components(self.g)
|
|
"""The triconnected split components."""
|
|
|
|
def test_all_invariants(self) -> None:
|
|
"""Test all decomposition invariants for the GM2001 example."""
|
|
_check_all_invariants(self, self.g, self.comps)
|
|
|
|
def test_at_least_two_components(self) -> None:
|
|
"""Test that the GM2001 example produces multiple components."""
|
|
self.assertGreaterEqual(
|
|
len(self.comps),
|
|
2,
|
|
"GM2001 example has separation pairs, expect >=2 comps",
|
|
)
|
|
|
|
|
|
class TestTriconnectedMultiEdgeComplex(unittest.TestCase):
|
|
"""Tests for decomposition of a complex multi-edge graph.
|
|
|
|
Inspired by the SageMath ``spqr_tree`` test suite.
|
|
"""
|
|
|
|
def setUp(self) -> None:
|
|
"""Build the components for the complex multi-edge graph."""
|
|
self.g: MultiGraph = _make_multiedge_complex()
|
|
"""The multi-edge complex graph under test."""
|
|
self.comps: list[TriconnectedComponent] = \
|
|
find_triconnected_components(self.g)
|
|
"""The triconnected split components."""
|
|
|
|
def test_all_invariants(self) -> None:
|
|
"""Test all decomposition invariants for the multi-edge graph."""
|
|
_check_all_invariants(self, self.g, self.comps)
|
|
|
|
def test_has_bond_components(self) -> None:
|
|
"""Test that multi-edges produce BOND components."""
|
|
bond_types: list[TriconnectedComponent] = [
|
|
c for c in self.comps
|
|
if c.type == ComponentType.BOND
|
|
]
|
|
self.assertGreaterEqual(
|
|
len(bond_types), 1,
|
|
"Multi-edge graph should have at least one BOND "
|
|
"component",
|
|
)
|
|
|
|
def test_has_polygon_component(self) -> None:
|
|
"""Test that the backbone cycle produces a POLYGON component."""
|
|
poly_types: list[TriconnectedComponent] = [
|
|
c for c in self.comps
|
|
if c.type == ComponentType.POLYGON
|
|
]
|
|
self.assertGreaterEqual(
|
|
len(poly_types), 1,
|
|
"Multi-edge graph should have at least one POLYGON",
|
|
)
|
|
|
|
def test_exact_component_structure(self) -> None:
|
|
"""Test exact component counts: 2 BONDs and 1 POLYGON.
|
|
|
|
The graph (1,2),(1,5)x2,(2,3)x2,(3,4),(4,5) has two
|
|
parallel pairs -> 2 BOND, and a backbone cycle -> 1 POLYGON.
|
|
"""
|
|
bond_count: int = sum(
|
|
1 for c in self.comps
|
|
if c.type == ComponentType.BOND
|
|
)
|
|
poly_count: int = sum(
|
|
1 for c in self.comps
|
|
if c.type == ComponentType.POLYGON
|
|
)
|
|
self.assertEqual(
|
|
bond_count, 2,
|
|
f"Expected 2 BOND components, got {bond_count}",
|
|
)
|
|
self.assertEqual(
|
|
poly_count, 1,
|
|
f"Expected 1 POLYGON component, got {poly_count}",
|
|
)
|
|
|
|
|
|
def _make_c5() -> MultiGraph:
|
|
"""Build the 5-cycle C5 (vertices 0-4).
|
|
|
|
:return: A MultiGraph representing C5.
|
|
"""
|
|
g: MultiGraph = MultiGraph()
|
|
for i in range(5):
|
|
g.add_edge(i, (i + 1) % 5)
|
|
return g
|
|
|
|
|
|
def _make_c6() -> MultiGraph:
|
|
"""Build the 6-cycle C6 (vertices 0-5).
|
|
|
|
:return: A MultiGraph representing C6.
|
|
"""
|
|
g: MultiGraph = MultiGraph()
|
|
for i in range(6):
|
|
g.add_edge(i, (i + 1) % 6)
|
|
return g
|
|
|
|
|
|
def _make_c6_with_chord() -> MultiGraph:
|
|
"""Build C6 plus chord (0,3): 7 edges, 6 vertices.
|
|
|
|
The chord (0,3) creates a separation pair {0,3} splitting the
|
|
graph into two 4-cycles and a degenerate bond.
|
|
|
|
:return: A MultiGraph representing C6 plus a chord.
|
|
"""
|
|
g: MultiGraph = MultiGraph()
|
|
for i in range(6):
|
|
g.add_edge(i, (i + 1) % 6)
|
|
g.add_edge(0, 3)
|
|
return g
|
|
|
|
|
|
def _make_k5() -> MultiGraph:
|
|
"""Build the complete graph K5 (10 edges, vertices 0-4).
|
|
|
|
K5 is 4-connected, hence triconnected.
|
|
|
|
:return: A MultiGraph representing K5.
|
|
"""
|
|
g: MultiGraph = MultiGraph()
|
|
for i in range(5):
|
|
for j in range(i + 1, 5):
|
|
g.add_edge(i, j)
|
|
return g
|
|
|
|
|
|
def _make_petersen() -> MultiGraph:
|
|
"""Build the Petersen graph (10 vertices, 15 edges).
|
|
|
|
The Petersen graph is 3-connected (triconnected).
|
|
Outer 5-cycle: 0-1-2-3-4-0.
|
|
Spokes: 0-5, 1-6, 2-7, 3-8, 4-9.
|
|
Inner pentagram: 5-7, 7-9, 9-6, 6-8, 8-5.
|
|
|
|
:return: A MultiGraph representing the Petersen graph.
|
|
"""
|
|
g: MultiGraph = MultiGraph()
|
|
for i in range(5):
|
|
g.add_edge(i, (i + 1) % 5)
|
|
for i in range(5):
|
|
g.add_edge(i, i + 5)
|
|
for u, v in [(5, 7), (7, 9), (9, 6), (6, 8), (8, 5)]:
|
|
g.add_edge(u, v)
|
|
return g
|
|
|
|
|
|
def _make_three_k4_cliques() -> MultiGraph:
|
|
"""Build graph: 3 K4 cliques sharing poles {0, 1}.
|
|
|
|
Vertices 0-7; poles are 0 and 1; each clique K4(0,1,a,b) adds
|
|
the 6 edges among {0,1,a,b}. The edge (0,1) appears 3 times.
|
|
|
|
:return: A MultiGraph with three K4 cliques sharing poles.
|
|
"""
|
|
g: MultiGraph = MultiGraph()
|
|
for a, b in [(2, 3), (4, 5), (6, 7)]:
|
|
for u, v in [
|
|
(0, 1), (0, a), (0, b), (1, a), (1, b), (a, b)
|
|
]:
|
|
g.add_edge(u, v)
|
|
return g
|
|
|
|
|
|
def _make_three_long_paths() -> MultiGraph:
|
|
"""Build graph: 3 paths of length 3 between vertices 0 and 1.
|
|
|
|
Vertices 0-7; paths: 0-2-3-1, 0-4-5-1, 0-6-7-1.
|
|
Expected: 4 components (1 BOND + 3 POLYGON).
|
|
|
|
:return: A MultiGraph with three length-3 paths.
|
|
"""
|
|
g: MultiGraph = MultiGraph()
|
|
for a, b in [(2, 3), (4, 5), (6, 7)]:
|
|
g.add_edge(0, a)
|
|
g.add_edge(a, b)
|
|
g.add_edge(b, 1)
|
|
return g
|
|
|
|
|
|
class TestTriconnectedC5(unittest.TestCase):
|
|
"""Tests for triconnected decomposition of the 5-cycle C5."""
|
|
|
|
def setUp(self) -> None:
|
|
"""Build the components for C5."""
|
|
self.g: MultiGraph = _make_c5()
|
|
"""The C5 graph under test."""
|
|
self.comps: list[TriconnectedComponent] = \
|
|
find_triconnected_components(self.g)
|
|
"""The triconnected split components."""
|
|
|
|
def test_single_polygon_component(self) -> None:
|
|
"""Test that C5 yields exactly 1 POLYGON component."""
|
|
self.assertEqual(len(self.comps), 1)
|
|
self.assertEqual(self.comps[0].type, ComponentType.POLYGON)
|
|
|
|
def test_five_real_edges(self) -> None:
|
|
"""Test that the POLYGON from C5 contains 5 real edges."""
|
|
real: list[Edge] = [
|
|
e for e in self.comps[0].edges if not e.virtual
|
|
]
|
|
self.assertEqual(len(real), 5)
|
|
|
|
def test_total_real_edges(self) -> None:
|
|
"""Test that total real edge count equals input edge count."""
|
|
self.assertEqual(
|
|
_count_real_edges(self.comps), self.g.num_edges()
|
|
)
|
|
|
|
|
|
class TestTriconnectedC6(unittest.TestCase):
|
|
"""Tests for triconnected decomposition of the 6-cycle C6.
|
|
|
|
Expected: 1 POLYGON component (the entire cycle).
|
|
|
|
Inspired by the SageMath ``spqr_tree`` test suite.
|
|
"""
|
|
|
|
def setUp(self) -> None:
|
|
"""Build the components for C6."""
|
|
self.g: MultiGraph = _make_c6()
|
|
"""The C6 graph under test."""
|
|
self.comps: list[TriconnectedComponent] = \
|
|
find_triconnected_components(self.g)
|
|
"""The triconnected split components."""
|
|
|
|
def test_single_polygon_component(self) -> None:
|
|
"""Test that C6 yields exactly 1 POLYGON component."""
|
|
self.assertEqual(len(self.comps), 1)
|
|
self.assertEqual(self.comps[0].type, ComponentType.POLYGON)
|
|
|
|
def test_six_real_edges(self) -> None:
|
|
"""Test that the POLYGON from C6 contains 6 real edges."""
|
|
real: list[Edge] = [
|
|
e for e in self.comps[0].edges if not e.virtual
|
|
]
|
|
self.assertEqual(len(real), 6)
|
|
|
|
def test_total_real_edges(self) -> None:
|
|
"""Test that total real edge count equals input edge count."""
|
|
self.assertEqual(
|
|
_count_real_edges(self.comps), self.g.num_edges()
|
|
)
|
|
|
|
|
|
class TestTriconnectedC6Chord(unittest.TestCase):
|
|
"""Tests for triconnected decomposition of C6 plus chord (0,3).
|
|
|
|
The chord (0,3) creates separation pair {0,3} yielding 3
|
|
components: 2 POLYGON and 1 BOND.
|
|
|
|
Inspired by the SageMath ``spqr_tree`` test suite.
|
|
"""
|
|
|
|
def setUp(self) -> None:
|
|
"""Build the components for C6 with chord."""
|
|
self.g: MultiGraph = _make_c6_with_chord()
|
|
"""The C6-with-chord graph under test."""
|
|
self.comps: list[TriconnectedComponent] = \
|
|
find_triconnected_components(self.g)
|
|
"""The triconnected split components."""
|
|
|
|
def test_all_invariants(self) -> None:
|
|
"""Test all decomposition invariants for C6 plus chord."""
|
|
_check_all_invariants(self, self.g, self.comps)
|
|
|
|
def test_three_components(self) -> None:
|
|
"""Test that C6 plus chord produces exactly 3 components.
|
|
|
|
The chord (0,3) creates separation pair {0,3} yielding
|
|
2 POLYGON components and 1 BOND.
|
|
"""
|
|
self.assertEqual(
|
|
len(self.comps),
|
|
3,
|
|
f"C6+chord should have 3 components, "
|
|
f"got {len(self.comps)}",
|
|
)
|
|
|
|
def test_two_polygon_components(self) -> None:
|
|
"""Test that C6 plus chord has 2 POLYGON components."""
|
|
poly: list[TriconnectedComponent] = [
|
|
c for c in self.comps
|
|
if c.type == ComponentType.POLYGON
|
|
]
|
|
self.assertEqual(
|
|
len(poly), 2,
|
|
f"Expected 2 POLYGON components, got {len(poly)}",
|
|
)
|
|
|
|
def test_one_bond_component(self) -> None:
|
|
"""Test that C6 plus chord has 1 BOND component."""
|
|
bond: list[TriconnectedComponent] = [
|
|
c for c in self.comps
|
|
if c.type == ComponentType.BOND
|
|
]
|
|
self.assertEqual(
|
|
len(bond), 1,
|
|
f"Expected 1 BOND component, got {len(bond)}",
|
|
)
|
|
|
|
|
|
class TestTriconnectedK5(unittest.TestCase):
|
|
"""Tests for triconnected decomposition of the complete graph K5.
|
|
|
|
K5 is 4-connected, so the entire graph is one TRICONNECTED
|
|
component.
|
|
"""
|
|
|
|
def setUp(self) -> None:
|
|
"""Build the components for K5."""
|
|
self.g: MultiGraph = _make_k5()
|
|
"""The K5 graph under test."""
|
|
self.comps: list[TriconnectedComponent] = \
|
|
find_triconnected_components(self.g)
|
|
"""The triconnected split components."""
|
|
|
|
def test_single_triconnected_component(self) -> None:
|
|
"""Test that K5 yields exactly 1 TRICONNECTED component."""
|
|
self.assertEqual(len(self.comps), 1)
|
|
self.assertEqual(
|
|
self.comps[0].type, ComponentType.TRICONNECTED
|
|
)
|
|
|
|
def test_ten_real_edges(self) -> None:
|
|
"""Test that the TRICONNECTED component has 10 real edges."""
|
|
real: list[Edge] = [
|
|
e for e in self.comps[0].edges if not e.virtual
|
|
]
|
|
self.assertEqual(len(real), 10)
|
|
|
|
def test_total_real_edges(self) -> None:
|
|
"""Test that total real edge count equals input edge count."""
|
|
self.assertEqual(
|
|
_count_real_edges(self.comps), self.g.num_edges()
|
|
)
|
|
|
|
|
|
class TestTriconnectedPetersen(unittest.TestCase):
|
|
"""Tests for triconnected decomposition of the Petersen graph.
|
|
|
|
The Petersen graph is 3-connected, so it yields a single
|
|
TRICONNECTED component with all 15 edges.
|
|
|
|
Inspired by the SageMath ``spqr_tree`` test suite.
|
|
"""
|
|
|
|
def setUp(self) -> None:
|
|
"""Build the components for the Petersen graph."""
|
|
self.g: MultiGraph = _make_petersen()
|
|
"""The Petersen graph under test."""
|
|
self.comps: list[TriconnectedComponent] = \
|
|
find_triconnected_components(self.g)
|
|
"""The triconnected split components."""
|
|
|
|
def test_single_triconnected_component(self) -> None:
|
|
"""Test that the Petersen graph yields 1 TRICONNECTED."""
|
|
self.assertEqual(len(self.comps), 1)
|
|
self.assertEqual(
|
|
self.comps[0].type, ComponentType.TRICONNECTED
|
|
)
|
|
|
|
def test_fifteen_real_edges(self) -> None:
|
|
"""Test that the TRICONNECTED component has 15 real edges."""
|
|
real: list[Edge] = [
|
|
e for e in self.comps[0].edges if not e.virtual
|
|
]
|
|
self.assertEqual(len(real), 15)
|
|
|
|
def test_total_real_edges(self) -> None:
|
|
"""Test that total real edge count equals input edge count."""
|
|
self.assertEqual(
|
|
_count_real_edges(self.comps), self.g.num_edges()
|
|
)
|
|
|
|
|
|
class TestTriconnectedThreeK4Cliques(unittest.TestCase):
|
|
"""Tests for decomposition of 3 K4 cliques sharing poles {0,1}.
|
|
|
|
Expected: 4 components: 1 BOND (3-way parallel at {0,1}) and
|
|
3 TRICONNECTED components (one per K4 clique).
|
|
|
|
Inspired by the SageMath ``spqr_tree`` test suite.
|
|
"""
|
|
|
|
def setUp(self) -> None:
|
|
"""Build the components for the three-K4-cliques graph."""
|
|
self.g: MultiGraph = _make_three_k4_cliques()
|
|
"""The three-K4-cliques graph under test."""
|
|
self.comps: list[TriconnectedComponent] = \
|
|
find_triconnected_components(self.g)
|
|
"""The triconnected split components."""
|
|
|
|
def test_all_invariants(self) -> None:
|
|
"""Test all decomposition invariants for three K4 cliques."""
|
|
_check_all_invariants(self, self.g, self.comps)
|
|
|
|
def test_four_components_total(self) -> None:
|
|
"""Test that the graph produces exactly 4 components.
|
|
|
|
Expected: 1 BOND + 3 TRICONNECTED = 4 total.
|
|
"""
|
|
self.assertEqual(
|
|
len(self.comps),
|
|
4,
|
|
f"Expected 4 components, got {len(self.comps)}",
|
|
)
|
|
|
|
def test_three_triconnected_components(self) -> None:
|
|
"""Test that there are exactly 3 TRICONNECTED components."""
|
|
tc: list[TriconnectedComponent] = [
|
|
c for c in self.comps
|
|
if c.type == ComponentType.TRICONNECTED
|
|
]
|
|
self.assertEqual(
|
|
len(tc), 3,
|
|
f"Expected 3 TRICONNECTED components, "
|
|
f"got {len(tc)}",
|
|
)
|
|
|
|
def test_one_bond_component(self) -> None:
|
|
"""Test that there is exactly 1 BOND component."""
|
|
bond: list[TriconnectedComponent] = [
|
|
c for c in self.comps
|
|
if c.type == ComponentType.BOND
|
|
]
|
|
self.assertEqual(
|
|
len(bond), 1,
|
|
f"Expected 1 BOND component, got {len(bond)}",
|
|
)
|
|
|
|
|
|
class TestTriconnectedThreeLongPaths(unittest.TestCase):
|
|
"""Tests for decomposition of 3 length-3 paths between 0 and 1.
|
|
|
|
Expected: 4 components: 1 BOND (3-way parallel at {0,1}) and
|
|
3 POLYGON components (one per length-3 path).
|
|
|
|
Inspired by the SageMath ``spqr_tree`` test suite.
|
|
"""
|
|
|
|
def setUp(self) -> None:
|
|
"""Build the components for the three-long-paths graph."""
|
|
self.g: MultiGraph = _make_three_long_paths()
|
|
"""The three-long-paths graph under test."""
|
|
self.comps: list[TriconnectedComponent] = \
|
|
find_triconnected_components(self.g)
|
|
"""The triconnected split components."""
|
|
|
|
def test_all_invariants(self) -> None:
|
|
"""Test all decomposition invariants for three long paths."""
|
|
_check_all_invariants(self, self.g, self.comps)
|
|
|
|
def test_four_components_total(self) -> None:
|
|
"""Test that the graph produces exactly 4 components.
|
|
|
|
Expected: 1 BOND + 3 POLYGON = 4 total.
|
|
"""
|
|
self.assertEqual(
|
|
len(self.comps),
|
|
4,
|
|
f"Expected 4 components, got {len(self.comps)}",
|
|
)
|
|
|
|
def test_three_polygon_components(self) -> None:
|
|
"""Test that there are exactly 3 POLYGON components."""
|
|
poly: list[TriconnectedComponent] = [
|
|
c for c in self.comps
|
|
if c.type == ComponentType.POLYGON
|
|
]
|
|
self.assertEqual(
|
|
len(poly), 3,
|
|
f"Expected 3 POLYGON components, "
|
|
f"got {len(poly)}",
|
|
)
|
|
|
|
def test_one_bond_component(self) -> None:
|
|
"""Test that there is exactly 1 BOND component."""
|
|
bond: list[TriconnectedComponent] = [
|
|
c for c in self.comps
|
|
if c.type == ComponentType.BOND
|
|
]
|
|
self.assertEqual(
|
|
len(bond), 1,
|
|
f"Expected 1 BOND component, got {len(bond)}",
|
|
)
|
|
|
|
|
|
def _make_k33() -> MultiGraph:
|
|
"""Build the complete bipartite graph K_{3,3} (9 edges).
|
|
|
|
Vertices 0,1,2 on one side and 3,4,5 on the other. K_{3,3}
|
|
is 3-connected (triconnected) and non-planar.
|
|
|
|
:return: A MultiGraph representing K_{3,3}.
|
|
"""
|
|
g: MultiGraph = MultiGraph()
|
|
for i in range(3):
|
|
for j in range(3, 6):
|
|
g.add_edge(i, j)
|
|
return g
|
|
|
|
|
|
def _make_w4() -> MultiGraph:
|
|
"""Build the wheel graph W4: hub vertex 0, rim vertices 1-4.
|
|
|
|
Edges: hub spokes (0,1),(0,2),(0,3),(0,4) and rim cycle
|
|
(1,2),(2,3),(3,4),(4,1). W4 is 3-connected.
|
|
|
|
:return: A MultiGraph representing W4.
|
|
"""
|
|
g: MultiGraph = MultiGraph()
|
|
for i in range(1, 5):
|
|
g.add_edge(0, i)
|
|
g.add_edge(i, i % 4 + 1)
|
|
return g
|
|
|
|
|
|
def _make_k3_doubled() -> MultiGraph:
|
|
"""Build K3 with each edge doubled (6 edges total).
|
|
|
|
Each of the 3 triangle edges appears twice. Expected: 4
|
|
components (1 POLYGON + 3 BOND).
|
|
|
|
:return: A MultiGraph representing K3 with doubled edges.
|
|
"""
|
|
g: MultiGraph = MultiGraph()
|
|
for u, v in [(1, 2), (2, 3), (1, 3)]:
|
|
g.add_edge(u, v)
|
|
g.add_edge(u, v)
|
|
return g
|
|
|
|
|
|
def _make_four_parallel() -> MultiGraph:
|
|
"""Build a graph with 4 parallel edges between vertices 1 and 2.
|
|
|
|
:return: A MultiGraph with four parallel edges.
|
|
"""
|
|
g: MultiGraph = MultiGraph()
|
|
for _ in range(4):
|
|
g.add_edge(1, 2)
|
|
return g
|
|
|
|
|
|
def _make_five_parallel() -> MultiGraph:
|
|
"""Build a graph with 5 parallel edges between vertices 1 and 2.
|
|
|
|
:return: A MultiGraph with five parallel edges.
|
|
"""
|
|
g: MultiGraph = MultiGraph()
|
|
for _ in range(5):
|
|
g.add_edge(1, 2)
|
|
return g
|
|
|
|
|
|
def _make_three_long_paths_doubled() -> MultiGraph:
|
|
"""Build 3 length-3 paths between 0 and 1 with each edge doubled.
|
|
|
|
All 9 edges of _make_three_long_paths() appear twice. Expected:
|
|
13 components (3 POLYGON + 10 BOND).
|
|
|
|
:return: A MultiGraph with doubled length-3 paths.
|
|
"""
|
|
g: MultiGraph = MultiGraph()
|
|
for a, b in [(2, 3), (4, 5), (6, 7)]:
|
|
for u, v in [(0, a), (a, b), (b, 1)]:
|
|
g.add_edge(u, v)
|
|
g.add_edge(u, v)
|
|
return g
|
|
|
|
|
|
def _make_graph6_sage_docstring() -> MultiGraph:
|
|
"""Build the 13-vertex, 23-edge graph from graph6 'LlCG{O@?GBoMw?'.
|
|
|
|
This biconnected graph has separation pairs yielding 12 split
|
|
components (5 BOND + 2 TRICONNECTED + 5 POLYGON).
|
|
|
|
:return: A MultiGraph with 13 vertices and 23 edges.
|
|
"""
|
|
g: MultiGraph = MultiGraph()
|
|
edges: list[tuple[int, int]] = [
|
|
(0, 1), (1, 2), (0, 3), (2, 3), (3, 4), (4, 5),
|
|
(3, 6), (4, 6), (5, 6), (0, 7), (4, 7), (7, 8),
|
|
(8, 9), (7, 10), (8, 10), (9, 10), (0, 11),
|
|
(7, 11), (8, 11), (9, 11), (0, 12), (1, 12),
|
|
(2, 12),
|
|
]
|
|
for u, v in edges:
|
|
g.add_edge(u, v)
|
|
return g
|
|
|
|
|
|
def _make_petersen_augmented_twice() -> MultiGraph:
|
|
"""Build Petersen graph with two rounds of path augmentation.
|
|
|
|
Round 1: for each of the 15 Petersen edges (u,v), add path
|
|
u-w1-w2-v alongside. Round 2: for each of the 60 edges from
|
|
round 1, add path alongside. Result: 160 vertices, 240 edges.
|
|
Expected: 136 components (60 BOND + 75 POLYGON + 1 TRICONNECTED).
|
|
|
|
:return: The doubly-augmented Petersen multigraph.
|
|
"""
|
|
g: MultiGraph = MultiGraph()
|
|
for i in range(5):
|
|
g.add_edge(i, (i + 1) % 5)
|
|
for i in range(5):
|
|
g.add_edge(i, i + 5)
|
|
for u, v in [(5, 7), (7, 9), (9, 6), (6, 8), (8, 5)]:
|
|
g.add_edge(u, v)
|
|
petersen_edges: list[tuple[int, int]] = [
|
|
(0, 1), (1, 2), (2, 3), (3, 4), (4, 0),
|
|
(0, 5), (1, 6), (2, 7), (3, 8), (4, 9),
|
|
(5, 7), (7, 9), (9, 6), (6, 8), (8, 5),
|
|
]
|
|
next_v: int = 10
|
|
for u, v in petersen_edges:
|
|
g.add_edge(u, next_v)
|
|
g.add_edge(next_v, next_v + 1)
|
|
g.add_edge(next_v + 1, v)
|
|
next_v += 2
|
|
# Round 2: augment the 60 edges from round 1.
|
|
round1_edges: list[tuple[Hashable, Hashable]] = [
|
|
(e.u, e.v) for e in g.edges
|
|
]
|
|
for u, v in round1_edges:
|
|
g.add_edge(u, next_v)
|
|
g.add_edge(next_v, next_v + 1)
|
|
g.add_edge(next_v + 1, v)
|
|
next_v += 2
|
|
return g
|
|
|
|
|
|
class TestTriconnectedK33(unittest.TestCase):
|
|
"""Tests for triconnected decomposition of K_{3,3}.
|
|
|
|
K_{3,3} is 3-connected, so it yields a single TRICONNECTED
|
|
component.
|
|
|
|
Inspired by the SageMath ``spqr_tree`` test suite.
|
|
"""
|
|
|
|
def setUp(self) -> None:
|
|
"""Build the components for K_{3,3}."""
|
|
self.g: MultiGraph = _make_k33()
|
|
"""The K_{3,3} graph under test."""
|
|
self.comps: list[TriconnectedComponent] = \
|
|
find_triconnected_components(self.g)
|
|
"""The triconnected split components."""
|
|
|
|
def test_all_invariants(self) -> None:
|
|
"""Test all decomposition invariants for K_{3,3}."""
|
|
_check_all_invariants(self, self.g, self.comps)
|
|
|
|
def test_single_triconnected_component(self) -> None:
|
|
"""Test that K_{3,3} yields exactly 1 TRICONNECTED."""
|
|
self.assertEqual(len(self.comps), 1)
|
|
self.assertEqual(
|
|
self.comps[0].type, ComponentType.TRICONNECTED
|
|
)
|
|
|
|
def test_nine_real_edges(self) -> None:
|
|
"""Test that the TRICONNECTED component has 9 real edges."""
|
|
real: list[Edge] = [
|
|
e for e in self.comps[0].edges if not e.virtual
|
|
]
|
|
self.assertEqual(len(real), 9)
|
|
|
|
def test_total_real_edges(self) -> None:
|
|
"""Test that total real edge count equals input edge count."""
|
|
self.assertEqual(
|
|
_count_real_edges(self.comps), self.g.num_edges()
|
|
)
|
|
|
|
|
|
class TestTriconnectedW4(unittest.TestCase):
|
|
"""Tests for triconnected decomposition of the wheel graph W4.
|
|
|
|
W4 (hub + 4-rim) is 3-connected, yielding 1 TRICONNECTED.
|
|
|
|
Inspired by the SageMath ``spqr_tree`` test suite.
|
|
"""
|
|
|
|
def setUp(self) -> None:
|
|
"""Build the components for W4."""
|
|
self.g: MultiGraph = _make_w4()
|
|
"""The W4 wheel graph under test."""
|
|
self.comps: list[TriconnectedComponent] = \
|
|
find_triconnected_components(self.g)
|
|
"""The triconnected split components."""
|
|
|
|
def test_all_invariants(self) -> None:
|
|
"""Test all decomposition invariants for W4."""
|
|
_check_all_invariants(self, self.g, self.comps)
|
|
|
|
def test_single_triconnected_component(self) -> None:
|
|
"""Test that W4 yields exactly 1 TRICONNECTED component."""
|
|
self.assertEqual(len(self.comps), 1)
|
|
self.assertEqual(
|
|
self.comps[0].type, ComponentType.TRICONNECTED
|
|
)
|
|
|
|
def test_eight_real_edges(self) -> None:
|
|
"""Test that the TRICONNECTED component has 8 real edges."""
|
|
real: list[Edge] = [
|
|
e for e in self.comps[0].edges if not e.virtual
|
|
]
|
|
self.assertEqual(len(real), 8)
|
|
|
|
def test_total_real_edges(self) -> None:
|
|
"""Test that total real edge count equals input edge count."""
|
|
self.assertEqual(
|
|
_count_real_edges(self.comps), self.g.num_edges()
|
|
)
|
|
|
|
|
|
class TestTriconnectedK3Doubled(unittest.TestCase):
|
|
"""Tests for triconnected decomposition of K3 with doubled edges.
|
|
|
|
Each K3 edge appears twice. Expected: 1 POLYGON (backbone) and
|
|
3 BOND components (one per parallel pair).
|
|
|
|
Inspired by the SageMath ``spqr_tree`` test suite.
|
|
"""
|
|
|
|
def setUp(self) -> None:
|
|
"""Build the components for K3 with doubled edges."""
|
|
self.g: MultiGraph = _make_k3_doubled()
|
|
"""The K3-doubled graph under test."""
|
|
self.comps: list[TriconnectedComponent] = \
|
|
find_triconnected_components(self.g)
|
|
"""The triconnected split components."""
|
|
|
|
def test_all_invariants(self) -> None:
|
|
"""Test all decomposition invariants for K3 doubled."""
|
|
_check_all_invariants(self, self.g, self.comps)
|
|
|
|
def test_four_components(self) -> None:
|
|
"""Test exactly 4 components: 1 POLYGON + 3 BOND."""
|
|
self.assertEqual(
|
|
len(self.comps), 4,
|
|
f"Expected 4 components, got {len(self.comps)}",
|
|
)
|
|
|
|
def test_one_polygon_three_bonds(self) -> None:
|
|
"""Test 1 POLYGON and 3 BOND components."""
|
|
poly: list[TriconnectedComponent] = [
|
|
c for c in self.comps
|
|
if c.type == ComponentType.POLYGON
|
|
]
|
|
bond: list[TriconnectedComponent] = [
|
|
c for c in self.comps
|
|
if c.type == ComponentType.BOND
|
|
]
|
|
self.assertEqual(len(poly), 1, "Expected 1 POLYGON")
|
|
self.assertEqual(len(bond), 3, "Expected 3 BOND")
|
|
|
|
|
|
class TestTriconnectedFourParallel(unittest.TestCase):
|
|
"""Tests for triconnected decomposition of 4 parallel edges."""
|
|
|
|
def setUp(self) -> None:
|
|
"""Build the components for 4 parallel edges."""
|
|
self.g: MultiGraph = _make_four_parallel()
|
|
"""The four-parallel-edges graph under test."""
|
|
self.comps: list[TriconnectedComponent] = \
|
|
find_triconnected_components(self.g)
|
|
"""The triconnected split components."""
|
|
|
|
def test_single_bond_component(self) -> None:
|
|
"""Test that 4 parallel edges yield exactly 1 BOND."""
|
|
self.assertEqual(len(self.comps), 1)
|
|
self.assertEqual(
|
|
self.comps[0].type, ComponentType.BOND
|
|
)
|
|
|
|
def test_four_real_edges(self) -> None:
|
|
"""Test that the BOND has 4 real edges."""
|
|
real: list[Edge] = [
|
|
e for e in self.comps[0].edges if not e.virtual
|
|
]
|
|
self.assertEqual(len(real), 4)
|
|
|
|
def test_total_real_edges(self) -> None:
|
|
"""Test that total real edge count equals input edge count."""
|
|
self.assertEqual(
|
|
_count_real_edges(self.comps), self.g.num_edges()
|
|
)
|
|
|
|
|
|
class TestTriconnectedFiveParallel(unittest.TestCase):
|
|
"""Tests for triconnected decomposition of 5 parallel edges."""
|
|
|
|
def setUp(self) -> None:
|
|
"""Build the components for 5 parallel edges."""
|
|
self.g: MultiGraph = _make_five_parallel()
|
|
"""The five-parallel-edges graph under test."""
|
|
self.comps: list[TriconnectedComponent] = \
|
|
find_triconnected_components(self.g)
|
|
"""The triconnected split components."""
|
|
|
|
def test_single_bond_component(self) -> None:
|
|
"""Test that 5 parallel edges yield exactly 1 BOND."""
|
|
self.assertEqual(len(self.comps), 1)
|
|
self.assertEqual(
|
|
self.comps[0].type, ComponentType.BOND
|
|
)
|
|
|
|
def test_five_real_edges(self) -> None:
|
|
"""Test that the BOND has 5 real edges."""
|
|
real: list[Edge] = [
|
|
e for e in self.comps[0].edges if not e.virtual
|
|
]
|
|
self.assertEqual(len(real), 5)
|
|
|
|
|
|
class TestTriconnectedThreeLongPathsDoubled(unittest.TestCase):
|
|
"""Tests for 3 length-3 paths with all edges doubled.
|
|
|
|
Each of the 9 edges appears twice, yielding 13 components
|
|
(3 POLYGON + 10 BOND).
|
|
|
|
Inspired by the SageMath ``spqr_tree`` test suite.
|
|
"""
|
|
|
|
def setUp(self) -> None:
|
|
"""Build the components for the doubled 3-paths graph."""
|
|
self.g: MultiGraph = \
|
|
_make_three_long_paths_doubled()
|
|
"""The doubled three-long-paths graph under test."""
|
|
self.comps: list[TriconnectedComponent] = \
|
|
find_triconnected_components(self.g)
|
|
"""The triconnected split components."""
|
|
|
|
def test_all_invariants(self) -> None:
|
|
"""Test all decomposition invariants for doubled 3-paths."""
|
|
_check_all_invariants(self, self.g, self.comps)
|
|
|
|
def test_at_least_four_components(self) -> None:
|
|
"""Test that doubled 3-paths produces many components."""
|
|
self.assertGreaterEqual(
|
|
len(self.comps), 4,
|
|
"Doubled 3-paths should have many components",
|
|
)
|
|
|
|
def test_has_polygon_and_bond(self) -> None:
|
|
"""Test that both POLYGON and BOND components exist."""
|
|
poly: list[TriconnectedComponent] = [
|
|
c for c in self.comps
|
|
if c.type == ComponentType.POLYGON
|
|
]
|
|
bond: list[TriconnectedComponent] = [
|
|
c for c in self.comps
|
|
if c.type == ComponentType.BOND
|
|
]
|
|
self.assertGreaterEqual(
|
|
len(poly), 1, "Need >= 1 POLYGON"
|
|
)
|
|
self.assertGreaterEqual(
|
|
len(bond), 1, "Need >= 1 BOND"
|
|
)
|
|
|
|
|
|
class TestTriconnectedSageDocstringGraph(unittest.TestCase):
|
|
"""Tests for the 13-vertex, 23-edge graph (graph6 'LlCG{O@?GBoMw?').
|
|
|
|
This biconnected graph has multiple separation pairs and yields
|
|
12 split components (5 BOND + 2 TRICONNECTED + 5 POLYGON).
|
|
|
|
Inspired by the SageMath ``spqr_tree`` test suite.
|
|
"""
|
|
|
|
def setUp(self) -> None:
|
|
"""Build the components for the 13-vertex docstring graph."""
|
|
self.g: MultiGraph = _make_graph6_sage_docstring()
|
|
"""The SageMath docstring graph under test."""
|
|
self.comps: list[TriconnectedComponent] = \
|
|
find_triconnected_components(self.g)
|
|
"""The triconnected split components."""
|
|
|
|
def test_all_invariants(self) -> None:
|
|
"""Test all decomposition invariants for the 13V/23E graph."""
|
|
_check_all_invariants(self, self.g, self.comps)
|
|
|
|
def test_at_least_two_components(self) -> None:
|
|
"""Test that the 13V/23E graph produces multiple components."""
|
|
self.assertGreaterEqual(
|
|
len(self.comps),
|
|
2,
|
|
"Graph has separation pairs and must have > 1 component",
|
|
)
|
|
|
|
|
|
class TestTriconnectedPetersenAugmentedTwice(unittest.TestCase):
|
|
"""Tests for the doubly-augmented Petersen graph.
|
|
|
|
Round 1: for each of the 15 Petersen edges (u,v), a parallel path
|
|
u-w1-w2-v is added alongside. Round 2: for each of the 60 edges
|
|
from round 1, another parallel path is added.
|
|
Result: 160 vertices, 240 edges, 136 components.
|
|
|
|
Inspired by the SageMath ``spqr_tree`` test suite.
|
|
"""
|
|
|
|
def setUp(self) -> None:
|
|
"""Build the components for the doubly-augmented Petersen."""
|
|
self.g: MultiGraph = \
|
|
_make_petersen_augmented_twice()
|
|
"""The doubly-augmented Petersen graph under test."""
|
|
self.comps: list[TriconnectedComponent] = \
|
|
find_triconnected_components(self.g)
|
|
"""The triconnected split components."""
|
|
|
|
def test_all_invariants(self) -> None:
|
|
"""Test all decomposition invariants for doubly-aug. Petersen."""
|
|
_check_all_invariants(self, self.g, self.comps)
|
|
|
|
def test_136_total_components(self) -> None:
|
|
"""Test that the doubly-augmented Petersen yields 136 comps.
|
|
|
|
Expected: 60 BOND + 75 POLYGON + 1 TRICONNECTED = 136 total.
|
|
"""
|
|
self.assertEqual(
|
|
len(self.comps),
|
|
136,
|
|
f"Doubly-augmented Petersen should have 136 components, "
|
|
f"got {len(self.comps)}",
|
|
)
|
|
|
|
def test_one_triconnected(self) -> None:
|
|
"""Test that there is exactly 1 TRICONNECTED component."""
|
|
tc: list[TriconnectedComponent] = [
|
|
c for c in self.comps
|
|
if c.type == ComponentType.TRICONNECTED
|
|
]
|
|
self.assertEqual(
|
|
len(tc), 1,
|
|
f"Expected 1 TRICONNECTED, got {len(tc)}",
|
|
)
|
|
|
|
def test_sixty_bonds(self) -> None:
|
|
"""Test that there are exactly 60 BOND components."""
|
|
bonds: list[TriconnectedComponent] = [
|
|
c for c in self.comps
|
|
if c.type == ComponentType.BOND
|
|
]
|
|
self.assertEqual(
|
|
len(bonds), 60,
|
|
f"Expected 60 BOND, got {len(bonds)}",
|
|
)
|
|
|
|
def test_seventy_five_polygons(self) -> None:
|
|
"""Test that there are exactly 75 POLYGON components."""
|
|
polys: list[TriconnectedComponent] = [
|
|
c for c in self.comps
|
|
if c.type == ComponentType.POLYGON
|
|
]
|
|
self.assertEqual(
|
|
len(polys), 75,
|
|
f"Expected 75 POLYGON, got {len(polys)}",
|
|
)
|
|
|
|
|
|
class TestTriconnectedDiamondExact(unittest.TestCase):
|
|
"""Exact component-type tests for the diamond graph.
|
|
|
|
The diamond has separation pair {2,3}. Expected: exactly 3
|
|
components: 2 POLYGON (the two triangular halves) and 1 BOND
|
|
(the direct edge (2,3) with 2 virtual edges = P-node).
|
|
"""
|
|
|
|
def setUp(self) -> None:
|
|
"""Build the components for the diamond graph."""
|
|
self.g: MultiGraph = _make_diamond()
|
|
"""The diamond graph under test."""
|
|
self.comps: list[TriconnectedComponent] = \
|
|
find_triconnected_components(self.g)
|
|
"""The triconnected split components."""
|
|
|
|
def test_exactly_three_components(self) -> None:
|
|
"""Test that the diamond produces exactly 3 components."""
|
|
self.assertEqual(
|
|
len(self.comps),
|
|
3,
|
|
f"Diamond should have 3 components, "
|
|
f"got {len(self.comps)}",
|
|
)
|
|
|
|
def test_two_polygons_one_bond(self) -> None:
|
|
"""Test that diamond has 2 POLYGON and 1 BOND components."""
|
|
poly: list[TriconnectedComponent] = [
|
|
c for c in self.comps
|
|
if c.type == ComponentType.POLYGON
|
|
]
|
|
bond: list[TriconnectedComponent] = [
|
|
c for c in self.comps
|
|
if c.type == ComponentType.BOND
|
|
]
|
|
self.assertEqual(len(poly), 2, "Expected 2 POLYGON")
|
|
self.assertEqual(len(bond), 1, "Expected 1 BOND")
|
|
|
|
|
|
def _make_ladder() -> MultiGraph:
|
|
"""Build the 3-rung ladder graph (2x4 grid).
|
|
|
|
Vertices 0-7. Top row: 0-1-2-3, bottom row: 4-5-6-7,
|
|
rungs: (0,4), (1,5), (2,6), (3,7). Separation pairs
|
|
{1,5} and {2,6} yield 5 components: 3 POLYGON + 2 BOND.
|
|
|
|
Inspired by the SageMath ``spqr_tree`` test suite.
|
|
|
|
:return: A MultiGraph representing the 3-rung ladder graph.
|
|
"""
|
|
g: MultiGraph = MultiGraph()
|
|
g.add_edge(0, 1)
|
|
g.add_edge(1, 2)
|
|
g.add_edge(2, 3)
|
|
g.add_edge(4, 5)
|
|
g.add_edge(5, 6)
|
|
g.add_edge(6, 7)
|
|
g.add_edge(0, 4)
|
|
g.add_edge(1, 5)
|
|
g.add_edge(2, 6)
|
|
g.add_edge(3, 7)
|
|
return g
|
|
|
|
|
|
class TestTriconnectedLadder(unittest.TestCase):
|
|
"""Tests for triconnected decomposition of the 3-rung ladder graph.
|
|
|
|
The ladder (2x4 grid) has separation pairs {1,5} and {2,6}.
|
|
Expected: 5 components (3 POLYGON + 2 BOND).
|
|
|
|
Inspired by the SageMath ``spqr_tree`` test suite.
|
|
"""
|
|
|
|
def setUp(self) -> None:
|
|
"""Build the components for the ladder graph."""
|
|
self.g: MultiGraph = _make_ladder()
|
|
"""The 3-rung ladder graph under test."""
|
|
self.comps: list[TriconnectedComponent] = \
|
|
find_triconnected_components(self.g)
|
|
"""The triconnected split components."""
|
|
|
|
def test_all_invariants(self) -> None:
|
|
"""Test all decomposition invariants for the ladder graph."""
|
|
_check_all_invariants(self, self.g, self.comps)
|
|
|
|
def test_five_components(self) -> None:
|
|
"""Test that the ladder graph yields exactly 5 components."""
|
|
self.assertEqual(
|
|
len(self.comps),
|
|
5,
|
|
f"Expected 5 components, got {len(self.comps)}",
|
|
)
|
|
|
|
def test_three_polygons_two_bonds(self) -> None:
|
|
"""Test that the ladder has 3 POLYGON and 2 BOND components."""
|
|
poly: list[TriconnectedComponent] = [
|
|
c for c in self.comps
|
|
if c.type == ComponentType.POLYGON
|
|
]
|
|
bond: list[TriconnectedComponent] = [
|
|
c for c in self.comps
|
|
if c.type == ComponentType.BOND
|
|
]
|
|
self.assertEqual(len(poly), 3, "Expected 3 POLYGON")
|
|
self.assertEqual(len(bond), 2, "Expected 2 BOND")
|
|
|
|
def test_real_edge_count(self) -> None:
|
|
"""Test that total real edge count equals 10."""
|
|
self.assertEqual(
|
|
_count_real_edges(self.comps),
|
|
self.g.num_edges(),
|
|
)
|
|
|
|
|
|
def _make_c7() -> MultiGraph:
|
|
"""Build the 7-cycle C7 (vertices 0-6).
|
|
|
|
:return: A MultiGraph representing C7.
|
|
"""
|
|
g: MultiGraph = MultiGraph()
|
|
for i in range(7):
|
|
g.add_edge(i, (i + 1) % 7)
|
|
return g
|
|
|
|
|
|
class TestTriconnectedC7(unittest.TestCase):
|
|
"""Tests for triconnected decomposition of the 7-cycle C7.
|
|
|
|
C7 is biconnected but not triconnected. It yields a single
|
|
POLYGON component with 7 real edges.
|
|
"""
|
|
|
|
def setUp(self) -> None:
|
|
"""Build the components for C7."""
|
|
self.g: MultiGraph = _make_c7()
|
|
"""The C7 graph under test."""
|
|
self.comps: list[TriconnectedComponent] = \
|
|
find_triconnected_components(self.g)
|
|
"""The triconnected split components."""
|
|
|
|
def test_all_invariants(self) -> None:
|
|
"""Test all decomposition invariants for C7."""
|
|
_check_all_invariants(self, self.g, self.comps)
|
|
|
|
def test_single_component(self) -> None:
|
|
"""Test that C7 produces exactly 1 component."""
|
|
self.assertEqual(len(self.comps), 1)
|
|
|
|
def test_component_is_polygon(self) -> None:
|
|
"""Test that C7 yields a POLYGON component."""
|
|
self.assertEqual(self.comps[0].type, ComponentType.POLYGON)
|
|
|
|
def test_seven_real_edges(self) -> None:
|
|
"""Test that the POLYGON has 7 real edges."""
|
|
real: list[Edge] = [
|
|
e for e in self.comps[0].edges if not e.virtual
|
|
]
|
|
self.assertEqual(len(real), 7)
|
|
|
|
|
|
def _make_c8() -> MultiGraph:
|
|
"""Build the 8-cycle C8 (vertices 0-7).
|
|
|
|
:return: A MultiGraph representing C8.
|
|
"""
|
|
g: MultiGraph = MultiGraph()
|
|
for i in range(8):
|
|
g.add_edge(i, (i + 1) % 8)
|
|
return g
|
|
|
|
|
|
class TestTriconnectedC8(unittest.TestCase):
|
|
"""Tests for triconnected decomposition of the 8-cycle C8.
|
|
|
|
C8 is biconnected but not triconnected. It yields a single
|
|
POLYGON component with 8 real edges.
|
|
"""
|
|
|
|
def setUp(self) -> None:
|
|
"""Build the components for C8."""
|
|
self.g: MultiGraph = _make_c8()
|
|
"""The C8 graph under test."""
|
|
self.comps: list[TriconnectedComponent] = \
|
|
find_triconnected_components(self.g)
|
|
"""The triconnected split components."""
|
|
|
|
def test_all_invariants(self) -> None:
|
|
"""Test all decomposition invariants for C8."""
|
|
_check_all_invariants(self, self.g, self.comps)
|
|
|
|
def test_single_component(self) -> None:
|
|
"""Test that C8 produces exactly 1 component."""
|
|
self.assertEqual(len(self.comps), 1)
|
|
|
|
def test_component_is_polygon(self) -> None:
|
|
"""Test that C8 yields a POLYGON component."""
|
|
self.assertEqual(self.comps[0].type, ComponentType.POLYGON)
|
|
|
|
def test_eight_real_edges(self) -> None:
|
|
"""Test that the POLYGON has 8 real edges."""
|
|
real: list[Edge] = [
|
|
e for e in self.comps[0].edges if not e.virtual
|
|
]
|
|
self.assertEqual(len(real), 8)
|
|
|
|
|
|
def _make_k23() -> MultiGraph:
|
|
"""Build the complete bipartite graph K_{2,3}.
|
|
|
|
Vertices 0,1 (part A) and 2,3,4 (part B).
|
|
Edges: all 6 pairs between parts. K_{2,3} has vertex
|
|
connectivity 2: removing {0,1} disconnects the graph.
|
|
Each internal vertex x in {2,3,4} creates a path 0-x-1,
|
|
yielding 4 components: 3 POLYGON + 1 BOND.
|
|
|
|
Inspired by the SageMath ``spqr_tree`` test suite.
|
|
|
|
:return: A MultiGraph representing K_{2,3}.
|
|
"""
|
|
g: MultiGraph = MultiGraph()
|
|
for a in [0, 1]:
|
|
for b in [2, 3, 4]:
|
|
g.add_edge(a, b)
|
|
return g
|
|
|
|
|
|
class TestTriconnectedK23(unittest.TestCase):
|
|
"""Tests for triconnected decomposition of K_{2,3}.
|
|
|
|
K_{2,3} has 5 vertices and 6 edges. It has vertex
|
|
connectivity 2 (separation pair {0,1}), yielding
|
|
4 components: 3 POLYGON + 1 BOND.
|
|
|
|
Inspired by the SageMath ``spqr_tree`` test suite.
|
|
"""
|
|
|
|
def setUp(self) -> None:
|
|
"""Build the components for K_{2,3}."""
|
|
self.g: MultiGraph = _make_k23()
|
|
"""The K_{2,3} graph under test."""
|
|
self.comps: list[TriconnectedComponent] = \
|
|
find_triconnected_components(self.g)
|
|
"""The triconnected split components."""
|
|
|
|
def test_all_invariants(self) -> None:
|
|
"""Test all decomposition invariants for K_{2,3}."""
|
|
_check_all_invariants(self, self.g, self.comps)
|
|
|
|
def test_four_components(self) -> None:
|
|
"""Test that K_{2,3} produces exactly 4 components."""
|
|
self.assertEqual(
|
|
len(self.comps), 4,
|
|
f"Expected 4 components, got {len(self.comps)}",
|
|
)
|
|
|
|
def test_three_polygons_one_bond(self) -> None:
|
|
"""Test that K_{2,3} has 3 POLYGON and 1 BOND."""
|
|
poly: list[TriconnectedComponent] = [
|
|
c for c in self.comps
|
|
if c.type == ComponentType.POLYGON
|
|
]
|
|
bond: list[TriconnectedComponent] = [
|
|
c for c in self.comps
|
|
if c.type == ComponentType.BOND
|
|
]
|
|
self.assertEqual(len(poly), 3, "Expected 3 POLYGON")
|
|
self.assertEqual(len(bond), 1, "Expected 1 BOND")
|
|
|
|
def test_real_edge_count(self) -> None:
|
|
"""Test that total real edge count equals 6."""
|
|
self.assertEqual(
|
|
_count_real_edges(self.comps),
|
|
self.g.num_edges(),
|
|
)
|
|
|
|
|
|
def _make_w5() -> MultiGraph:
|
|
"""Build the wheel graph W5 (hub + 5-cycle, 6 vertices).
|
|
|
|
Hub vertex 0 connected to rim vertices 1-5.
|
|
W5 is 3-connected, yielding 1 TRICONNECTED with 10 edges.
|
|
|
|
Inspired by the SageMath ``spqr_tree`` test suite.
|
|
|
|
:return: A MultiGraph representing W5.
|
|
"""
|
|
g: MultiGraph = MultiGraph()
|
|
for i in range(1, 6):
|
|
g.add_edge(i, i % 5 + 1)
|
|
for i in range(1, 6):
|
|
g.add_edge(0, i)
|
|
return g
|
|
|
|
|
|
class TestTriconnectedW5(unittest.TestCase):
|
|
"""Tests for triconnected decomposition of the wheel W5.
|
|
|
|
W5 has 6 vertices and 10 edges. It is triconnected,
|
|
so it yields a single TRICONNECTED component.
|
|
|
|
Inspired by the SageMath ``spqr_tree`` test suite.
|
|
"""
|
|
|
|
def setUp(self) -> None:
|
|
"""Build the components for W5."""
|
|
self.g: MultiGraph = _make_w5()
|
|
"""The W5 wheel graph under test."""
|
|
self.comps: list[TriconnectedComponent] = \
|
|
find_triconnected_components(self.g)
|
|
"""The triconnected split components."""
|
|
|
|
def test_all_invariants(self) -> None:
|
|
"""Test all decomposition invariants for W5."""
|
|
_check_all_invariants(self, self.g, self.comps)
|
|
|
|
def test_single_component(self) -> None:
|
|
"""Test that W5 produces exactly 1 component."""
|
|
self.assertEqual(len(self.comps), 1)
|
|
|
|
def test_component_is_triconnected(self) -> None:
|
|
"""Test that W5 yields a TRICONNECTED component."""
|
|
self.assertEqual(
|
|
self.comps[0].type, ComponentType.TRICONNECTED,
|
|
)
|
|
|
|
def test_ten_real_edges(self) -> None:
|
|
"""Test that the TRICONNECTED has 10 real edges."""
|
|
real: list[Edge] = [
|
|
e for e in self.comps[0].edges if not e.virtual
|
|
]
|
|
self.assertEqual(len(real), 10)
|
|
|
|
|
|
def _make_w6() -> MultiGraph:
|
|
"""Build the wheel graph W6 (hub + 6-cycle, 7 vertices).
|
|
|
|
Hub vertex 0 connected to rim vertices 1-6.
|
|
W6 is 3-connected, yielding 1 TRICONNECTED with 12 edges.
|
|
|
|
Inspired by the SageMath ``spqr_tree`` test suite.
|
|
|
|
:return: A MultiGraph representing W6.
|
|
"""
|
|
g: MultiGraph = MultiGraph()
|
|
for i in range(1, 7):
|
|
g.add_edge(i, i % 6 + 1)
|
|
for i in range(1, 7):
|
|
g.add_edge(0, i)
|
|
return g
|
|
|
|
|
|
class TestTriconnectedW6(unittest.TestCase):
|
|
"""Tests for triconnected decomposition of the wheel W6.
|
|
|
|
W6 has 7 vertices and 12 edges. It is triconnected,
|
|
so it yields a single TRICONNECTED component.
|
|
|
|
Inspired by the SageMath ``spqr_tree`` test suite.
|
|
"""
|
|
|
|
def setUp(self) -> None:
|
|
"""Build the components for W6."""
|
|
self.g: MultiGraph = _make_w6()
|
|
"""The W6 wheel graph under test."""
|
|
self.comps: list[TriconnectedComponent] = \
|
|
find_triconnected_components(self.g)
|
|
"""The triconnected split components."""
|
|
|
|
def test_all_invariants(self) -> None:
|
|
"""Test all decomposition invariants for W6."""
|
|
_check_all_invariants(self, self.g, self.comps)
|
|
|
|
def test_single_component(self) -> None:
|
|
"""Test that W6 produces exactly 1 component."""
|
|
self.assertEqual(len(self.comps), 1)
|
|
|
|
def test_component_is_triconnected(self) -> None:
|
|
"""Test that W6 yields a TRICONNECTED component."""
|
|
self.assertEqual(
|
|
self.comps[0].type, ComponentType.TRICONNECTED,
|
|
)
|
|
|
|
def test_twelve_real_edges(self) -> None:
|
|
"""Test that the TRICONNECTED has 12 real edges."""
|
|
real: list[Edge] = [
|
|
e for e in self.comps[0].edges if not e.virtual
|
|
]
|
|
self.assertEqual(len(real), 12)
|
|
|
|
|
|
def _make_q3_cube() -> MultiGraph:
|
|
"""Build the Q3 cube graph (3-dimensional hypercube).
|
|
|
|
8 vertices (0-7), 12 edges. The cube graph is 3-connected,
|
|
yielding 1 TRICONNECTED component with 12 edges.
|
|
|
|
Inspired by the SageMath ``spqr_tree`` test suite.
|
|
|
|
:return: A MultiGraph representing the Q3 cube.
|
|
"""
|
|
g: MultiGraph = MultiGraph()
|
|
# Bottom face: 0-1-2-3
|
|
g.add_edge(0, 1)
|
|
g.add_edge(1, 2)
|
|
g.add_edge(2, 3)
|
|
g.add_edge(3, 0)
|
|
# Top face: 4-5-6-7
|
|
g.add_edge(4, 5)
|
|
g.add_edge(5, 6)
|
|
g.add_edge(6, 7)
|
|
g.add_edge(7, 4)
|
|
# Vertical edges
|
|
g.add_edge(0, 4)
|
|
g.add_edge(1, 5)
|
|
g.add_edge(2, 6)
|
|
g.add_edge(3, 7)
|
|
return g
|
|
|
|
|
|
class TestTriconnectedQ3Cube(unittest.TestCase):
|
|
"""Tests for triconnected decomposition of the Q3 cube graph.
|
|
|
|
The cube (Q3) has 8 vertices and 12 edges. It is
|
|
3-connected, so it yields a single TRICONNECTED component.
|
|
|
|
Inspired by the SageMath ``spqr_tree`` test suite.
|
|
"""
|
|
|
|
def setUp(self) -> None:
|
|
"""Build the components for the Q3 cube."""
|
|
self.g: MultiGraph = _make_q3_cube()
|
|
"""The Q3 cube graph under test."""
|
|
self.comps: list[TriconnectedComponent] = \
|
|
find_triconnected_components(self.g)
|
|
"""The triconnected split components."""
|
|
|
|
def test_all_invariants(self) -> None:
|
|
"""Test all decomposition invariants for Q3 cube."""
|
|
_check_all_invariants(self, self.g, self.comps)
|
|
|
|
def test_single_component(self) -> None:
|
|
"""Test that Q3 cube produces exactly 1 component."""
|
|
self.assertEqual(len(self.comps), 1)
|
|
|
|
def test_component_is_triconnected(self) -> None:
|
|
"""Test that Q3 cube yields a TRICONNECTED component."""
|
|
self.assertEqual(
|
|
self.comps[0].type, ComponentType.TRICONNECTED,
|
|
)
|
|
|
|
def test_twelve_real_edges(self) -> None:
|
|
"""Test that the TRICONNECTED has 12 real edges."""
|
|
real: list[Edge] = [
|
|
e for e in self.comps[0].edges if not e.virtual
|
|
]
|
|
self.assertEqual(len(real), 12)
|
|
|
|
|
|
def _make_octahedron() -> MultiGraph:
|
|
"""Build the octahedron graph (6 vertices, 12 edges).
|
|
|
|
The octahedron is the complete tripartite graph K_{2,2,2}.
|
|
It is 4-connected, yielding 1 TRICONNECTED component.
|
|
|
|
Inspired by the SageMath ``spqr_tree`` test suite.
|
|
|
|
:return: A MultiGraph representing the octahedron.
|
|
"""
|
|
g: MultiGraph = MultiGraph()
|
|
# All pairs except (0,5), (1,3), (2,4)
|
|
pairs: list[tuple[int, int]] = [
|
|
(0, 1), (0, 2), (0, 3), (0, 4),
|
|
(1, 2), (1, 4), (1, 5),
|
|
(2, 3), (2, 5),
|
|
(3, 4), (3, 5),
|
|
(4, 5),
|
|
]
|
|
for u, v in pairs:
|
|
g.add_edge(u, v)
|
|
return g
|
|
|
|
|
|
class TestTriconnectedOctahedron(unittest.TestCase):
|
|
"""Tests for triconnected decomposition of the octahedron.
|
|
|
|
The octahedron has 6 vertices and 12 edges. It is 4-connected,
|
|
so it yields a single TRICONNECTED component.
|
|
|
|
Inspired by the SageMath ``spqr_tree`` test suite.
|
|
"""
|
|
|
|
def setUp(self) -> None:
|
|
"""Build the components for the octahedron."""
|
|
self.g: MultiGraph = _make_octahedron()
|
|
"""The octahedron graph under test."""
|
|
self.comps: list[TriconnectedComponent] = \
|
|
find_triconnected_components(self.g)
|
|
"""The triconnected split components."""
|
|
|
|
def test_all_invariants(self) -> None:
|
|
"""Test all decomposition invariants for octahedron."""
|
|
_check_all_invariants(self, self.g, self.comps)
|
|
|
|
def test_single_component(self) -> None:
|
|
"""Test that octahedron produces exactly 1 component."""
|
|
self.assertEqual(len(self.comps), 1)
|
|
|
|
def test_component_is_triconnected(self) -> None:
|
|
"""Test that octahedron yields a TRICONNECTED component."""
|
|
self.assertEqual(
|
|
self.comps[0].type, ComponentType.TRICONNECTED,
|
|
)
|
|
|
|
def test_twelve_real_edges(self) -> None:
|
|
"""Test that the TRICONNECTED has 12 real edges."""
|
|
real: list[Edge] = [
|
|
e for e in self.comps[0].edges if not e.virtual
|
|
]
|
|
self.assertEqual(len(real), 12)
|
|
|
|
|
|
def _make_k4_one_doubled() -> MultiGraph:
|
|
"""Build K4 with one edge doubled.
|
|
|
|
K4 has 6 edges. One edge (say 1-2) is doubled, giving 7
|
|
edges total. The doubled edge creates a separation pair
|
|
{1,2}, yielding 2 components: 1 BOND (the doubled edge) and
|
|
1 TRICONNECTED (the K4 skeleton with a virtual edge).
|
|
|
|
Inspired by the SageMath ``spqr_tree`` test suite.
|
|
|
|
:return: A MultiGraph representing K4 with one doubled edge.
|
|
"""
|
|
g: MultiGraph = MultiGraph()
|
|
g.add_edge(1, 2)
|
|
g.add_edge(1, 3)
|
|
g.add_edge(1, 4)
|
|
g.add_edge(2, 3)
|
|
g.add_edge(2, 4)
|
|
g.add_edge(3, 4)
|
|
g.add_edge(1, 2) # duplicate
|
|
return g
|
|
|
|
|
|
class TestTriconnectedK4OneDoubled(unittest.TestCase):
|
|
"""Tests for triconnected decomposition of K4 + 1 doubled edge.
|
|
|
|
K4 with one edge doubled has 7 edges. The doubled edge
|
|
creates separation pair {1,2}, yielding 2 components:
|
|
1 BOND and 1 TRICONNECTED.
|
|
|
|
Inspired by the SageMath ``spqr_tree`` test suite.
|
|
"""
|
|
|
|
def setUp(self) -> None:
|
|
"""Build the components for K4 + 1 doubled edge."""
|
|
self.g: MultiGraph = _make_k4_one_doubled()
|
|
"""The K4-one-doubled graph under test."""
|
|
self.comps: list[TriconnectedComponent] = \
|
|
find_triconnected_components(self.g)
|
|
"""The triconnected split components."""
|
|
|
|
def test_all_invariants(self) -> None:
|
|
"""Test all decomposition invariants for K4+doubled."""
|
|
_check_all_invariants(self, self.g, self.comps)
|
|
|
|
def test_two_components(self) -> None:
|
|
"""Test that K4+doubled produces exactly 2 components."""
|
|
self.assertEqual(
|
|
len(self.comps), 2,
|
|
f"Expected 2 components, got {len(self.comps)}",
|
|
)
|
|
|
|
def test_one_bond_one_triconnected(self) -> None:
|
|
"""Test that K4+doubled has 1 BOND and 1 TRICONNECTED."""
|
|
bond: list[TriconnectedComponent] = [
|
|
c for c in self.comps
|
|
if c.type == ComponentType.BOND
|
|
]
|
|
tri: list[TriconnectedComponent] = [
|
|
c for c in self.comps
|
|
if c.type == ComponentType.TRICONNECTED
|
|
]
|
|
self.assertEqual(len(bond), 1, "Expected 1 BOND")
|
|
self.assertEqual(
|
|
len(tri), 1, "Expected 1 TRICONNECTED",
|
|
)
|
|
|
|
def test_real_edge_count(self) -> None:
|
|
"""Test that total real edge count equals 7."""
|
|
self.assertEqual(
|
|
_count_real_edges(self.comps),
|
|
self.g.num_edges(),
|
|
)
|
|
|
|
|
|
def _make_mobius_kantor() -> MultiGraph:
|
|
"""Build the Mobius-Kantor graph GP(8,3) (16 verts, 24 edges).
|
|
|
|
The Mobius-Kantor graph is the generalized Petersen graph
|
|
GP(8,3). It is 3-regular and 3-connected, yielding 1
|
|
TRICONNECTED component with 24 real edges.
|
|
|
|
Inspired by the SageMath ``spqr_tree`` test suite.
|
|
|
|
:return: A MultiGraph representing the Mobius-Kantor graph.
|
|
"""
|
|
g: MultiGraph = MultiGraph()
|
|
# Outer 8-cycle: 0-1-2-3-4-5-6-7-0
|
|
for i in range(8):
|
|
g.add_edge(i, (i + 1) % 8)
|
|
# Spokes: i -> 8+i
|
|
for i in range(8):
|
|
g.add_edge(i, 8 + i)
|
|
# Inner star (step 3): 8+i -> 8+(i+3)%8
|
|
for i in range(8):
|
|
g.add_edge(8 + i, 8 + (i + 3) % 8)
|
|
return g
|
|
|
|
|
|
class TestTriconnectedMobiusKantor(unittest.TestCase):
|
|
"""Tests for triconnected decomposition of Mobius-Kantor graph.
|
|
|
|
The Mobius-Kantor graph has 16 vertices and 24 edges. It is
|
|
3-connected, so it yields a single TRICONNECTED component.
|
|
|
|
Inspired by the SageMath ``spqr_tree`` test suite.
|
|
"""
|
|
|
|
def setUp(self) -> None:
|
|
"""Build the components for the Mobius-Kantor graph."""
|
|
self.g: MultiGraph = _make_mobius_kantor()
|
|
"""The Mobius-Kantor graph under test."""
|
|
self.comps: list[TriconnectedComponent] = \
|
|
find_triconnected_components(self.g)
|
|
"""The triconnected split components."""
|
|
|
|
def test_all_invariants(self) -> None:
|
|
"""Test all decomposition invariants for Mobius-Kantor."""
|
|
_check_all_invariants(self, self.g, self.comps)
|
|
|
|
def test_single_component(self) -> None:
|
|
"""Test that Mobius-Kantor produces exactly 1 component."""
|
|
self.assertEqual(len(self.comps), 1)
|
|
|
|
def test_component_is_triconnected(self) -> None:
|
|
"""Test that Mobius-Kantor yields TRICONNECTED."""
|
|
self.assertEqual(
|
|
self.comps[0].type, ComponentType.TRICONNECTED,
|
|
)
|
|
|
|
def test_twenty_four_real_edges(self) -> None:
|
|
"""Test that the TRICONNECTED has 24 real edges."""
|
|
real: list[Edge] = [
|
|
e for e in self.comps[0].edges if not e.virtual
|
|
]
|
|
self.assertEqual(len(real), 24)
|
|
|
|
|
|
def _make_petersen_augmented() -> MultiGraph:
|
|
"""Build the Petersen graph with each edge augmented by a path.
|
|
|
|
For each of the 15 Petersen edges (u,v), two intermediate
|
|
vertices w1 and w2 are added and a path u-w1-w2-v is inserted
|
|
alongside the original edge. Result: 40 vertices, 60 edges.
|
|
Expected: 31 components (15 BOND + 15 POLYGON + 1 TRICONNECTED).
|
|
|
|
Inspired by the SageMath ``spqr_tree`` test suite.
|
|
|
|
:return: The augmented Petersen multigraph.
|
|
"""
|
|
g: MultiGraph = MultiGraph()
|
|
for i in range(5):
|
|
g.add_edge(i, (i + 1) % 5)
|
|
for i in range(5):
|
|
g.add_edge(i, i + 5)
|
|
for u, v in [(5, 7), (7, 9), (9, 6), (6, 8), (8, 5)]:
|
|
g.add_edge(u, v)
|
|
petersen_edges: list[tuple[int, int]] = [
|
|
(0, 1), (1, 2), (2, 3), (3, 4), (4, 0),
|
|
(0, 5), (1, 6), (2, 7), (3, 8), (4, 9),
|
|
(5, 7), (7, 9), (9, 6), (6, 8), (8, 5),
|
|
]
|
|
next_v: int = 10
|
|
for u, v in petersen_edges:
|
|
w1: int = next_v
|
|
w2: int = next_v + 1
|
|
next_v += 2
|
|
g.add_edge(u, w1)
|
|
g.add_edge(w1, w2)
|
|
g.add_edge(w2, v)
|
|
return g
|
|
|
|
|
|
class TestTriconnectedPetersenAugmented(unittest.TestCase):
|
|
"""Tests for triconnected decomposition of augmented Petersen.
|
|
|
|
Each Petersen edge (u,v) gets a parallel path u-w1-w2-v.
|
|
Expected: 31 components (15 BOND + 15 POLYGON + 1 TRICONN).
|
|
|
|
Inspired by the SageMath ``spqr_tree`` test suite.
|
|
"""
|
|
|
|
def setUp(self) -> None:
|
|
"""Build the components for the augmented Petersen."""
|
|
self.g: MultiGraph = _make_petersen_augmented()
|
|
"""The augmented Petersen graph under test."""
|
|
self.comps: list[TriconnectedComponent] = \
|
|
find_triconnected_components(self.g)
|
|
"""The triconnected split components."""
|
|
|
|
def test_all_invariants(self) -> None:
|
|
"""Test all decomposition invariants for aug. Petersen."""
|
|
_check_all_invariants(self, self.g, self.comps)
|
|
|
|
def test_thirty_one_components(self) -> None:
|
|
"""Test that augmented Petersen has 31 components."""
|
|
self.assertEqual(
|
|
len(self.comps), 31,
|
|
f"Expected 31 components, got {len(self.comps)}",
|
|
)
|
|
|
|
def test_one_triconnected(self) -> None:
|
|
"""Test that there is exactly 1 TRICONNECTED component."""
|
|
tri: list[TriconnectedComponent] = [
|
|
c for c in self.comps
|
|
if c.type == ComponentType.TRICONNECTED
|
|
]
|
|
self.assertEqual(
|
|
len(tri), 1,
|
|
f"Expected 1 TRICONNECTED, got {len(tri)}",
|
|
)
|
|
|
|
def test_fifteen_bonds(self) -> None:
|
|
"""Test that there are exactly 15 BOND components."""
|
|
bonds: list[TriconnectedComponent] = [
|
|
c for c in self.comps
|
|
if c.type == ComponentType.BOND
|
|
]
|
|
self.assertEqual(
|
|
len(bonds), 15,
|
|
f"Expected 15 BOND, got {len(bonds)}",
|
|
)
|
|
|
|
def test_fifteen_polygons(self) -> None:
|
|
"""Test that there are exactly 15 POLYGON components."""
|
|
polys: list[TriconnectedComponent] = [
|
|
c for c in self.comps
|
|
if c.type == ComponentType.POLYGON
|
|
]
|
|
self.assertEqual(
|
|
len(polys), 15,
|
|
f"Expected 15 POLYGON, got {len(polys)}",
|
|
)
|
|
|
|
def test_real_edge_count(self) -> None:
|
|
"""Test that total real edge count equals 60."""
|
|
self.assertEqual(
|
|
_count_real_edges(self.comps),
|
|
self.g.num_edges(),
|
|
)
|
|
|
|
|
|
def _make_wikimedia_spqr() -> MultiGraph:
|
|
"""Build the Wikimedia Commons SPQR-tree example graph.
|
|
|
|
16 vertices (a-p), 26 edges. From File:SPQR_tree_2.svg.
|
|
|
|
:return: A MultiGraph representing the Wikimedia SPQR example.
|
|
"""
|
|
g: MultiGraph = MultiGraph()
|
|
edges: list[tuple[str, str]] = [
|
|
('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)
|
|
return g
|
|
|
|
|
|
def _make_rpst_fig1a() -> MultiGraph:
|
|
"""Build the RPST paper Figure 1(a) graph.
|
|
|
|
15 vertices, 19 edges. From Polyvyanyy, Vanhatalo &
|
|
Voelzer (2011), Figure 1(a).
|
|
|
|
:return: A MultiGraph representing RPST Fig 1(a).
|
|
"""
|
|
g: MultiGraph = MultiGraph()
|
|
edges: list[tuple[str, str]] = [
|
|
('s', 'u'), ('a1', 'u'), ('a4', 'u'),
|
|
('a1', 'v'), ('a4', 'w'), ('a3', 'v'),
|
|
('a3', 'w'), ('a2', 'v'), ('a5', 'w'),
|
|
('a2', 'x'), ('a5', 'x'), ('x', 'y'),
|
|
('a6', 'y'), ('y', 'z'), ('a7', 'y'),
|
|
('a6', 'z'), ('a7', 'z'), ('t', 'z'),
|
|
('s', 't'),
|
|
]
|
|
for u, v in edges:
|
|
g.add_edge(u, v)
|
|
return g
|
|
|
|
|
|
class TestTriconnectedWikimediaSpqr(unittest.TestCase):
|
|
"""Tests for triconnected decomposition of the Wikimedia
|
|
SPQR example."""
|
|
|
|
def setUp(self) -> None:
|
|
"""Set up graph and triconnected components."""
|
|
self.g: MultiGraph = _make_wikimedia_spqr()
|
|
self.comps: list[TriconnectedComponent] = \
|
|
find_triconnected_components(self.g)
|
|
|
|
def test_component_count(self) -> None:
|
|
"""Test that there are exactly 5 components."""
|
|
self.assertEqual(len(self.comps), 5)
|
|
|
|
def test_triconnected_count(self) -> None:
|
|
"""Test that there are exactly 3 TRICONNECTED."""
|
|
n: int = sum(
|
|
1 for c in self.comps
|
|
if c.type == ComponentType.TRICONNECTED
|
|
)
|
|
self.assertEqual(n, 3)
|
|
|
|
def test_polygon_count(self) -> None:
|
|
"""Test that there is exactly 1 POLYGON."""
|
|
n: int = sum(
|
|
1 for c in self.comps
|
|
if c.type == ComponentType.POLYGON
|
|
)
|
|
self.assertEqual(n, 1)
|
|
|
|
def test_bond_count(self) -> None:
|
|
"""Test that there is exactly 1 BOND."""
|
|
n: int = sum(
|
|
1 for c in self.comps
|
|
if c.type == ComponentType.BOND
|
|
)
|
|
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)
|
|
|
|
|
|
class TestTriconnectedRpstFig1a(unittest.TestCase):
|
|
"""Tests for triconnected decomposition of RPST Fig 1(a)."""
|
|
|
|
def setUp(self) -> None:
|
|
"""Set up graph and triconnected components."""
|
|
self.g: MultiGraph = _make_rpst_fig1a()
|
|
self.comps: list[TriconnectedComponent] = \
|
|
find_triconnected_components(self.g)
|
|
|
|
def test_component_count(self) -> None:
|
|
"""Test that there are exactly 10 components."""
|
|
self.assertEqual(len(self.comps), 10)
|
|
|
|
def test_triconnected_count(self) -> None:
|
|
"""Test that there is exactly 1 TRICONNECTED."""
|
|
n: int = sum(
|
|
1 for c in self.comps
|
|
if c.type == ComponentType.TRICONNECTED
|
|
)
|
|
self.assertEqual(n, 1)
|
|
|
|
def test_polygon_count(self) -> None:
|
|
"""Test that there are exactly 8 POLYGON."""
|
|
n: int = sum(
|
|
1 for c in self.comps
|
|
if c.type == ComponentType.POLYGON
|
|
)
|
|
self.assertEqual(n, 8)
|
|
|
|
def test_bond_count(self) -> None:
|
|
"""Test that there is exactly 1 BOND."""
|
|
n: int = sum(
|
|
1 for c in self.comps
|
|
if c.type == ComponentType.BOND
|
|
)
|
|
self.assertEqual(n, 1)
|
|
|
|
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}"
|
|
)
|