diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..5ecc46d --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,40 @@ +# Pure Python SPQR-Tree implementation. +# Authors: +# imacat@mail.imacat.idv.tw (imacat), 2026/3/5 + +# 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. + +# Read the Docs configuration file +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +# Required +version: 2 + +# Set the OS, Python version, and other tools you might need +build: + os: ubuntu-24.04 + tools: + python: "3.13" + +# Build documentation in the "docs/" directory with Sphinx +sphinx: + configuration: docs/source/conf.py + +# Optionally, but recommended, +# declare the Python requirements required to build your documentation +# See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html +python: + install: + - requirements: docs/requirements.txt diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..224bdeb --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,22 @@ +# Pure Python SPQR-Tree implementation. +# Authors: +# imacat@mail.imacat.idv.tw (imacat), 2026/3/4 + +# 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. + +recursive-include docs * +prune docs/build +recursive-include tests * +recursive-exclude tests *.pyc diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..026a9ab --- /dev/null +++ b/README.rst @@ -0,0 +1,138 @@ +======== +spqrtree +======== + + +Description +=========== + +**spqrtree** is a pure Python implementation of the SPQR-tree data +structure for biconnected graphs. It decomposes a biconnected graph +into its triconnected components (S, P, Q, and R nodes) and organizes +them as a tree. + +SPQR-trees are a classical tool in graph theory, widely used for +planarity testing, graph drawing, and network analysis. + +The implementation is based on the Gutwenger & Mutzel (2001) linear-time +algorithm, with corrections to Hopcroft & Tarjan (1973), and follows the +SPQR-tree data structure defined by Di Battista & Tamassia (1996). + +Features: + +- Pure Python --- no compiled extensions or external dependencies. +- Handles multigraphs (parallel edges between the same vertex pair). +- Simple ``dict``-based input for quick prototyping. +- Typed package with PEP 561 support. +- Requires Python 3.10 or later. + + +Installation +============ + +You can install spqrtree with ``pip``: + +:: + + pip install spqrtree + +You may also install the latest source from the +`spqrtree GitHub repository`_. + +:: + + pip install git+https://github.com/imacat/spqrtree.git + + +Quick Start +=========== + +.. code-block:: python + + from spqrtree import SPQRTree, NodeType + + # K4 complete graph + graph = { + 1: [2, 3, 4], + 2: [1, 3, 4], + 3: [1, 2, 4], + 4: [1, 2, 3], + } + tree = SPQRTree(graph) + print(tree.root.type) # NodeType.R + print(len(tree.nodes())) # number of SPQR-tree nodes + + +Documentation +============= + +Refer to the `documentation on Read the Docs`_. + + +Change Log +========== + +Refer to the `change log`_. + + +References +========== + +- C. Gutwenger and P. Mutzel, "A Linear Time Implementation of + SPQR-Trees," *Graph Drawing (GD 2000)*, LNCS 1984, pp. 77--90, + 2001. `doi:10.1007/3-540-44541-2_8`_ + +- J. E. Hopcroft and R. E. Tarjan, "Dividing a Graph into + Triconnected Components," *SIAM Journal on Computing*, 2(3), + pp. 135--158, 1973. `doi:10.1137/0202012`_ + +- G. Di Battista and R. Tamassia, "On-Line Planarity Testing," + *SIAM Journal on Computing*, 25(5), pp. 956--997, 1996. + `doi:10.1137/S0097539794280736`_ + + +Acknowledgments +=============== + +This project was written from scratch in pure Python but drew +inspiration from the `SageMath`_ project. The SPQR-tree +implementation in SageMath served as a valuable reference for +both its implementation approach and its comprehensive test +cases. + +Development was assisted by `Claude Code`_ (Anthropic). + + +Copyright +========= + + 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. + + +Authors +======= + +| imacat +| imacat@mail.imacat.idv.tw +| 2026/3/4 + +.. _spqrtree GitHub repository: https://github.com/imacat/spqrtree +.. _documentation on Read the Docs: https://spqrtree.readthedocs.io +.. _change log: https://spqrtree.readthedocs.io/en/latest/changelog.html +.. _doi\:10.1007/3-540-44541-2_8: https://doi.org/10.1007/3-540-44541-2_8 +.. _doi\:10.1137/0202012: https://doi.org/10.1137/0202012 +.. _doi\:10.1137/S0097539794280736: https://doi.org/10.1137/S0097539794280736 +.. _SageMath: https://www.sagemath.org/ +.. _Claude Code: https://claude.com/claude-code diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d0c3cbf --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..dc1312a --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 0000000..52b04f2 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1 @@ +sphinx_rtd_theme \ No newline at end of file diff --git a/docs/source/_static/.keep b/docs/source/_static/.keep new file mode 100644 index 0000000..e69de29 diff --git a/docs/source/_templates/.keep b/docs/source/_templates/.keep new file mode 100644 index 0000000..e69de29 diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst new file mode 100644 index 0000000..b2e22f2 --- /dev/null +++ b/docs/source/changelog.rst @@ -0,0 +1,3 @@ +Change Log +========== + diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..25df3c9 --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,33 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html +import os +import sys + +sys.path.insert(0, os.path.abspath('../../src/')) +import spqrtree + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information + +project = 'Pure Python SPQR-Tree Implementation' +copyright = '2026, imacat' +author = 'imacat' +release = spqrtree.VERSION + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = ["sphinx.ext.autodoc"] + +templates_path = ['_templates'] +exclude_patterns = [] + + + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +html_theme = 'sphinx_rtd_theme' +html_static_path = ['_static'] diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..37e3b0c --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,25 @@ +spqrtree +======== + +A pure Python SPQR-tree implementation for biconnected graphs. + +**spqrtree** decomposes a biconnected graph into its triconnected +components and organizes them as an SPQR-tree, a classical data +structure in graph theory used for planarity testing, graph drawing, +and network analysis. + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + intro + spqrtree + changelog + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/docs/source/intro.rst b/docs/source/intro.rst new file mode 100644 index 0000000..40a6e8e --- /dev/null +++ b/docs/source/intro.rst @@ -0,0 +1,180 @@ +Introduction +============ + +What is an SPQR-Tree? +--------------------- + +An SPQR-tree is a tree data structure that represents the decomposition +of a biconnected graph into its triconnected components. Each node of +the tree corresponds to one of four types: + +- **S-node** (series): a simple cycle. +- **P-node** (parallel): a bundle of parallel edges between two poles. +- **Q-node**: a single real edge (degenerate case). +- **R-node** (rigid): a 3-connected subgraph that cannot be further + decomposed. + +SPQR-trees are widely used in graph drawing, planarity testing, +and network reliability analysis. + + +Features +-------- + +- Pure Python --- no compiled extensions or external dependencies. +- Handles multigraphs (parallel edges between the same vertex pair). +- Implements the Gutwenger & Mutzel (2001) algorithm with corrections + to Hopcroft & Tarjan (1973). +- Simple ``dict``-based input for quick prototyping. +- Typed package with :pep:`561` support. + + +Installation +------------ + +Install from `PyPI `_ with pip: + +.. code-block:: bash + + pip install spqrtree + +Or install the latest development version from GitHub: + +.. code-block:: bash + + pip install git+https://github.com/imacat/spqrtree.git + +Requires Python 3.10 or later. + + +Quick Start +----------- + +Build an SPQR-tree from an adjacency-list dictionary: + +.. code-block:: python + + from spqrtree import SPQRTree + + # A simple diamond graph (K4 minus one edge) + graph = { + 1: [2, 3, 4], + 2: [1, 3, 4], + 3: [1, 2, 4], + 4: [1, 2, 3], + } + tree = SPQRTree(graph) + print(tree.root.type) # NodeType.R + print(len(tree.nodes())) # number of SPQR-tree nodes + +The input dictionary maps each vertex to its list of neighbors. For +each pair ``(u, v)`` where ``u < v``, one edge is added. + + +Usage Guide +----------- + +Inspecting the Tree +~~~~~~~~~~~~~~~~~~~ + +The :class:`~spqrtree.SPQRTree` object exposes two main attributes: + +- :attr:`~spqrtree.SPQRTree.root` --- the root + :class:`~spqrtree.SPQRNode` of the tree. +- :meth:`~spqrtree.SPQRTree.nodes` --- all nodes in BFS order. + +.. code-block:: python + + from spqrtree import SPQRTree, NodeType + + graph = { + 0: [1, 2], + 1: [0, 2, 3], + 2: [0, 1, 3], + 3: [1, 2], + } + tree = SPQRTree(graph) + + for node in tree.nodes(): + print(node.type, node.poles) + +Each :class:`~spqrtree.SPQRNode` has the following attributes: + +- ``type`` --- a :class:`~spqrtree.NodeType` enum (``S``, ``P``, + ``Q``, or ``R``). +- ``skeleton`` --- the skeleton graph containing the real and virtual + edges of the component. +- ``poles`` --- the two vertices shared with the parent component. +- ``parent`` --- the parent node (``None`` for the root). +- ``children`` --- the list of child nodes. + + +Understanding Node Types +~~~~~~~~~~~~~~~~~~~~~~~~ + +**S-node (series):** +Represents a cycle. The skeleton is a simple polygon whose edges +alternate between real edges and virtual edges leading to children. + +**P-node (parallel):** +Represents parallel edges between two pole vertices. The skeleton +contains three or more edges (real and/or virtual) between the same +pair of poles. + +**R-node (rigid):** +Represents a 3-connected component that cannot be further decomposed +by any separation pair. The skeleton is a 3-connected graph. + +**Q-node:** +Represents a single real edge. Q-nodes appear as leaves of the tree. + + +Using a MultiGraph +~~~~~~~~~~~~~~~~~~ + +For more control, build a +:class:`~spqrtree._graph.MultiGraph` directly: + +.. code-block:: python + + from spqrtree._graph import MultiGraph + from spqrtree import SPQRTree + + g = MultiGraph() + g.add_edge(0, 1) + g.add_edge(1, 2) + g.add_edge(2, 0) + g.add_edge(1, 3) + g.add_edge(3, 2) + + tree = SPQRTree(g) + + +References +---------- + +The implementation is based on the following papers: + +- J. Hopcroft and R. Tarjan, "Dividing a graph into triconnected + components," *SIAM Journal on Computing*, vol. 2, no. 3, + pp. 135--158, 1973. + `doi:10.1137/0202012 `_ + +- G. Di Battista and R. Tamassia, "On-line planarity testing," + *SIAM Journal on Computing*, vol. 25, no. 5, pp. 956--997, 1996. + `doi:10.1137/S0097539794280736 + `_ + +- C. Gutwenger and P. Mutzel, "A linear time implementation of + SPQR-trees," *Proc. 8th International Symposium on Graph Drawing + (GD 2000)*, LNCS 1984, pp. 77--90, Springer, 2001. + `doi:10.1007/3-540-44541-2_8 + `_ + + +Acknowledgments +--------------- + +The test suite was validated against the SPQR-tree implementation in +`SageMath `_, which served as the reference +for verifying correctness of the decomposition results. diff --git a/docs/source/modules.rst b/docs/source/modules.rst new file mode 100644 index 0000000..af7d4d7 --- /dev/null +++ b/docs/source/modules.rst @@ -0,0 +1,7 @@ +src +=== + +.. toctree:: + :maxdepth: 4 + + spqrtree diff --git a/docs/source/spqrtree.rst b/docs/source/spqrtree.rst new file mode 100644 index 0000000..cdbc3cf --- /dev/null +++ b/docs/source/spqrtree.rst @@ -0,0 +1,10 @@ +spqrtree package +================ + +Module contents +--------------- + +.. automodule:: spqrtree + :members: + :show-inheritance: + :undoc-members: diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2db73a5 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,68 @@ +# 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. + +[project] +name = "spqrtree" +dynamic = ["version"] +description = "Pure Python SPQR-Tree implementation" +readme = "README.rst" +requires-python = ">=3.10" +license = { text = "Apache-2.0" } +authors = [ + { name = "imacat", email = "imacat@mail.imacat.idv.tw" }, +] +keywords = [ + "spqr", + "spqr-tree", + "graph", + "triconnected", + "triconnectivity", + "graph-decomposition", + "biconnected", + "separation-pair", +] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Scientific/Engineering :: Mathematics", + "Topic :: Software Development :: Libraries :: Python Modules", + "Typing :: Typed", +] + +[project.urls] +Homepage = "https://github.com/imacat/spqrtree" +Repository = "https://github.com/imacat/spqrtree" +Documentation = "https://spqrtree.readthedocs.io" +"Change Log" = "https://spqrtree.readthedocs.io/en/latest/changelog.html" +"Bug Tracker" = "https://github.com/imacat/spqrtree/issues" + +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.dynamic] +version = {attr = "spqrtree.VERSION"} diff --git a/src/spqrtree/__init__.py b/src/spqrtree/__init__.py new file mode 100644 index 0000000..6d198d9 --- /dev/null +++ b/src/spqrtree/__init__.py @@ -0,0 +1,105 @@ +# Pure Python SPQR-Tree implementation. +# Authors: +# imacat@mail.imacat.idv.tw (imacat), 2026/3/1 +# 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. +"""SPQR-Tree: a pure Python implementation. + +This package provides an SPQR-Tree data structure for biconnected graphs, +based on the algorithm by Gutwenger & Mutzel (2001) with corrections to +Hopcroft-Tarjan (1973), and data structure definitions from +Di Battista & Tamassia (1996). + +Public API:: + + from spqrtree import Edge, MultiGraph + from spqrtree import NodeType, SPQRNode, build_spqr_tree + from spqrtree import ( + ComponentType, + TriconnectedComponent, + find_triconnected_components, + ) +""" +from collections import deque + +from spqrtree._graph import Edge, MultiGraph +from spqrtree._spqr import NodeType, SPQRNode, build_spqr_tree +from spqrtree._triconnected import ( + ComponentType, + TriconnectedComponent, + find_triconnected_components, +) + +VERSION: str = "0.0.0" +"""The package version.""" +__all__: list[str] = [ + "SPQRTree", "SPQRNode", "NodeType", + "Edge", "MultiGraph", + "build_spqr_tree", + "ComponentType", "TriconnectedComponent", + "find_triconnected_components", +] + + +class SPQRTree: + """An SPQR-Tree for a biconnected graph. + + Constructs the SPQR-Tree from a biconnected multigraph, + using the Gutwenger-Mutzel triconnected components algorithm. + """ + + def __init__(self, graph: MultiGraph | dict) -> None: + """Initialize the SPQR-Tree from a graph. + + :param graph: A MultiGraph or dict representing the input graph. + :return: None + """ + if isinstance(graph, dict): + g: MultiGraph = MultiGraph() + seen: set[frozenset] = set() + for u, neighbors in graph.items(): + g.add_vertex(u) + for v in neighbors: + pair: frozenset = frozenset((u, v)) + if pair not in seen: + seen.add(pair) + g.add_edge(u, v) + else: + g = graph + self._root: SPQRNode = build_spqr_tree(g) + """The root node of the SPQR-Tree.""" + + @property + def root(self) -> SPQRNode: + """Return the root node of the SPQR-Tree. + + :return: The root SPQRNode. + """ + return self._root + + def nodes(self) -> list[SPQRNode]: + """Return all nodes of the SPQR-Tree in BFS order. + + :return: A list of all SPQRNode objects. + """ + result: list[SPQRNode] = [] + bfs_queue: deque[SPQRNode] = deque([self._root]) + while bfs_queue: + node: SPQRNode = bfs_queue.popleft() + result.append(node) + for child in node.children: + bfs_queue.append(child) + return result diff --git a/src/spqrtree/_graph.py b/src/spqrtree/_graph.py new file mode 100644 index 0000000..80be251 --- /dev/null +++ b/src/spqrtree/_graph.py @@ -0,0 +1,306 @@ +# Pure Python SPQR-Tree implementation. +# Authors: +# imacat@mail.imacat.idv.tw (imacat), 2026/3/1 +# 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. +"""Internal multigraph data structure for the SPQR-Tree algorithm. + +Provides Edge and MultiGraph classes supporting parallel edges and +virtual edges, identified by integer IDs. +""" +from __future__ import annotations + +from collections.abc import Hashable +from dataclasses import dataclass + + +@dataclass +class Edge: + """An edge in a multigraph, identified by a unique integer ID. + + Supports parallel edges (multiple edges between the same pair of + vertices) and virtual edges used internally by the SPQR-Tree + algorithm. + """ + + id: int + """Unique integer identifier for this edge.""" + + u: Hashable + """One endpoint of the edge.""" + + v: Hashable + """The other endpoint of the edge.""" + + virtual: bool = False + """Whether this is a virtual edge (used in SPQR skeletons).""" + + def endpoints(self) -> tuple[Hashable, Hashable]: + """Return both endpoints as a tuple. + + :return: A tuple (u, v) of the two endpoints. + """ + return self.u, self.v + + def other(self, vertex: Hashable) -> Hashable: + """Return the endpoint opposite to the given vertex. + + :param vertex: One endpoint of this edge. + :return: The other endpoint. + :raises ValueError: If vertex is not an endpoint of this edge. + """ + if vertex == self.u: + return self.v + if vertex == self.v: + return self.u + raise ValueError( + f"Vertex {vertex} is not an endpoint of edge {self.id}" + ) + + +class MultiGraph: + """An undirected multigraph supporting parallel edges and virtual edges. + + Vertices are arbitrary hashable values. Edges are identified by + unique integer IDs. Supports parallel edges (multiple edges between + the same vertex pair). + """ + + def __init__(self) -> None: + """Initialize an empty multigraph. + + :return: None + """ + self._vertices: set[Hashable] = set() + """The set of vertices in this graph.""" + + self._edges: dict[int, Edge] = {} + """Map from edge ID to Edge object.""" + + self._adj: dict[Hashable, list[int]] = {} + """Adjacency list: vertex -> list of edge IDs.""" + + self._next_edge_id: int = 0 + """Counter for assigning unique edge IDs.""" + + @property + def vertices(self) -> set[Hashable]: + """Return the set of all vertices. + + :return: The set of vertices. + """ + return self._vertices + + @property + def edges(self) -> list[Edge]: + """Return a list of all edges in the graph. + + :return: List of Edge objects. + """ + return list(self._edges.values()) + + def add_vertex(self, v: Hashable) -> None: + """Add a vertex to the graph. No-op if already present. + + :param v: The vertex to add. + :return: None + """ + if v not in self._vertices: + self._vertices.add(v) + self._adj[v] = [] + + def remove_vertex(self, v: Hashable) -> None: + """Remove a vertex and all its incident edges from the graph. + + :param v: The vertex to remove. + :return: None + :raises KeyError: If the vertex does not exist. + """ + if v not in self._vertices: + raise KeyError(f"Vertex {v} not in graph") + edge_ids: list[int] = list(self._adj[v]) + for eid in edge_ids: + self.remove_edge(eid) + self._vertices.remove(v) + del self._adj[v] + + def add_edge( + self, u: Hashable, v: Hashable, virtual: bool = False + ) -> Edge: + """Add an edge between two vertices and return it. + + Automatically adds vertices u and v if not already present. + + :param u: One endpoint vertex. + :param v: The other endpoint vertex. + :param virtual: Whether this is a virtual edge. + :return: The newly created Edge object. + """ + self.add_vertex(u) + self.add_vertex(v) + eid: int = self._next_edge_id + self._next_edge_id += 1 + e: Edge = Edge(id=eid, u=u, v=v, virtual=virtual) + self._edges[eid] = e + self._adj[u].append(eid) + if u != v: + self._adj[v].append(eid) + return e + + def remove_edge(self, edge_id: int) -> None: + """Remove an edge from the graph by its ID. + + :param edge_id: The integer ID of the edge to remove. + :return: None + :raises KeyError: If the edge ID does not exist. + """ + if edge_id not in self._edges: + raise KeyError(f"Edge {edge_id} not in graph") + e: Edge = self._edges.pop(edge_id) + self._adj[e.u].remove(edge_id) + if e.u != e.v: + self._adj[e.v].remove(edge_id) + + def get_edge(self, edge_id: int) -> Edge | None: + """Return the edge with the given ID, or None if not found. + + :param edge_id: The integer ID of the edge to look up. + :return: The Edge object, or None if no such edge exists. + """ + return self._edges.get(edge_id) + + def has_edge(self, edge_id: int) -> bool: + """Check whether an edge with the given ID exists. + + :param edge_id: The integer ID to check. + :return: True if the edge exists, False otherwise. + """ + return edge_id in self._edges + + def neighbors(self, v: Hashable) -> list[Hashable]: + """Return a list of distinct vertices adjacent to v. + + :param v: A vertex. + :return: List of adjacent vertices (no duplicates). + :raises KeyError: If vertex v does not exist. + """ + if v not in self._vertices: + raise KeyError(f"Vertex {v} not in graph") + seen: set[Hashable] = set() + result: list[Hashable] = [] + for eid in self._adj[v]: + nbr: Hashable = self._edges[eid].other(v) + if nbr not in seen: + seen.add(nbr) + result.append(nbr) + return result + + def incident_edges(self, v: Hashable) -> list[Edge]: + """Return all edges incident to vertex v. + + :param v: A vertex. + :return: List of Edge objects incident to v. + :raises KeyError: If vertex v does not exist. + """ + if v not in self._vertices: + raise KeyError(f"Vertex {v} not in graph") + return [self._edges[eid] for eid in self._adj[v]] + + def edges_between(self, u: Hashable, v: Hashable) -> list[Edge]: + """Return all edges between vertices u and v. + + :param u: One vertex. + :param v: The other vertex. + :return: List of Edge objects between u and v. + """ + return [ + self._edges[eid] + for eid in self._adj.get(u, []) + if self._edges[eid].other(u) == v + ] + + def degree(self, v: Hashable) -> int: + """Return the degree of vertex v (number of incident edges). + + :param v: A vertex. + :return: The number of edges incident to v. + :raises KeyError: If vertex v does not exist. + """ + if v not in self._vertices: + raise KeyError(f"Vertex {v} not in graph") + return len(self._adj[v]) + + def num_vertices(self) -> int: + """Return the number of vertices in the graph. + + :return: Vertex count. + """ + return len(self._vertices) + + def num_edges(self) -> int: + """Return the number of edges in the graph. + + :return: Edge count. + """ + return len(self._edges) + + def copy(self) -> MultiGraph: + """Return a shallow copy of this graph with independent structure. + + Vertices and edge IDs are preserved. Edge objects are copied. + + :return: A new MultiGraph with the same structure. + """ + g: MultiGraph = MultiGraph() + g._next_edge_id = self._next_edge_id + for v in self._vertices: + g._vertices.add(v) + g._adj[v] = list(self._adj[v]) + for eid, e in self._edges.items(): + g._edges[eid] = Edge( + id=e.id, u=e.u, v=e.v, virtual=e.virtual + ) + return g + + def adj_edge_ids(self, v: Hashable) -> list[int]: + """Return the list of edge IDs in the adjacency list of v. + + The order reflects the current adjacency list ordering, which + may be modified by the palm tree construction for algorithmic + correctness. + + :param v: A vertex. + :return: List of edge IDs adjacent to v, in adjacency list order. + :raises KeyError: If vertex v does not exist. + """ + if v not in self._vertices: + raise KeyError(f"Vertex {v} not in graph") + return list(self._adj[v]) + + def set_adj_order(self, v: Hashable, edge_ids: list[int]) -> None: + """Set the adjacency list order for vertex v. + + Used by the palm tree algorithm to reorder edges for correct + DFS traversal order per Gutwenger-Mutzel Section 4.2. + + :param v: A vertex. + :param edge_ids: The ordered list of edge IDs for v's adjacency. + :return: None + :raises KeyError: If vertex v does not exist. + """ + if v not in self._vertices: + raise KeyError(f"Vertex {v} not in graph") + self._adj[v] = list(edge_ids) diff --git a/src/spqrtree/_palm_tree.py b/src/spqrtree/_palm_tree.py new file mode 100644 index 0000000..2a668d1 --- /dev/null +++ b/src/spqrtree/_palm_tree.py @@ -0,0 +1,363 @@ +# Pure Python SPQR-Tree implementation. +# Authors: +# imacat@mail.imacat.idv.tw (imacat), 2026/3/1 +# 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. +"""Palm tree construction for the SPQR-Tree algorithm. + +Implements a DFS-based palm tree computation as required by the +Gutwenger-Mutzel (2001) triconnected components algorithm. + +A palm tree is a DFS spanning tree together with all back edges (fronds). +For each vertex v, we compute: + +- ``dfs_num[v]``: DFS discovery order number (1-indexed). +- ``parent[v]``: parent vertex in the DFS tree (None for root). +- ``parent_edge[v]``: edge ID of the parent edge (None for root). +- ``tree_edges``: set of tree edge IDs. +- ``fronds``: set of frond (back edge) IDs. +- ``lowpt1[v]``: minimum DFS number reachable from the subtree of v + (including v itself) via at most one frond. +- ``lowpt2[v]``: second minimum DFS number in the same candidate set, + i.e., the smallest value strictly greater than lowpt1[v]. + Set to ``LOWPT_INF`` if no such value exists. +- ``nd[v]``: number of vertices in the subtree rooted at v (including v). +- ``first_child[v]``: first DFS tree child of v (None if v is a leaf). +- ``high[v]``: highest DFS number of a descendant of v (inclusive) that + has a frond to a proper ancestor of v. Zero if no such frond exists. +""" +from collections.abc import Hashable +from dataclasses import dataclass, field + +from spqrtree._graph import Edge, MultiGraph + +LOWPT_INF: int = 10 ** 9 +"""Sentinel value representing positive infinity for lowpt computations.""" + + +@dataclass +class PalmTree: + """A DFS palm tree with low-point values for a connected multigraph. + + All vertex-keyed dictionaries are defined for every vertex in the + graph. Edge-ID sets cover every edge exactly once. + """ + + dfs_num: dict[Hashable, int] + """DFS discovery number for each vertex (1-indexed).""" + + parent: dict[Hashable, Hashable | None] + """Parent vertex in the DFS tree; None for the root.""" + + parent_edge: dict[Hashable, int | None] + """Edge ID of the tree edge to the parent; None for the root.""" + + tree_edges: set[int] + """Set of edge IDs classified as DFS tree edges.""" + + fronds: set[int] + """Set of edge IDs classified as fronds (back edges).""" + + lowpt1: dict[Hashable, int] + """Minimum DFS number reachable from the subtree of each vertex.""" + + lowpt2: dict[Hashable, int] + """Second minimum DFS number reachable; LOWPT_INF if none.""" + + nd: dict[Hashable, int] + """Number of vertices in the subtree rooted at each vertex.""" + + first_child: dict[Hashable, Hashable | None] + """First DFS tree child of each vertex; None if the vertex is a leaf.""" + + high: dict[Hashable, int] + """Highest descendant DFS number with a frond to a proper ancestor.""" + + children: dict[Hashable, list[Hashable]] = field( + default_factory=dict) + """Ordered list of DFS tree children for each vertex.""" + + dfs_order: list[Hashable] = field(default_factory=list) + """Vertices in DFS discovery order (root first).""" + + +def _is_frond_edge( + w: Hashable, + v: Hashable, + eid: int, + dfs_num: dict[Hashable, int], + parent_edge: dict[Hashable, int | None], +) -> bool: + """Check if edge *eid* from *v* to *w* is a frond (back edge). + + A frond goes from v to a proper ancestor w (lower DFS number) + and is not the tree edge connecting v to its parent. + + :param w: The other endpoint of the edge. + :param v: The current vertex. + :param eid: The edge ID. + :param dfs_num: DFS discovery numbers. + :param parent_edge: Parent edge IDs. + :return: True if the edge is a frond. + """ + return dfs_num[w] < dfs_num[v] and eid != parent_edge[v] + + +def build_palm_tree(graph: MultiGraph, start: Hashable) -> PalmTree: + """Build a DFS palm tree for the given graph starting at *start*. + + Performs an iterative DFS in adjacency-list order (edge insertion + order). Computes DFS numbers, parent links, tree/frond + classification, lowpt1/lowpt2, nd, first_child, and high values. + + :param graph: The multigraph to traverse. + :param start: The starting vertex for the DFS. + :return: A fully populated PalmTree. + :raises KeyError: If *start* is not in the graph. + """ + dfs_num: dict[Hashable, int] = {} + parent: dict[Hashable, Hashable | None] = {start: None} + parent_edge: dict[Hashable, int | None] = {start: None} + tree_edges: set[int] = set() + fronds: set[int] = set() + lowpt1: dict[Hashable, int] = {} + lowpt2: dict[Hashable, int] = {} + nd: dict[Hashable, int] = {} + first_child: dict[Hashable, Hashable | None] = {} + high: dict[Hashable, int] = {} + children: dict[Hashable, list[Hashable]] = { + v: [] for v in graph.vertices} + dfs_order: list[Hashable] = [] + + counter: int = 0 + + # Stack entries: (vertex, iterator-index into adjacency list) + stack: list[tuple[Hashable, int]] = [(start, 0)] + visited: set[Hashable] = set() + adj_lists: dict[Hashable, list[int]] = { + v: graph.adj_edge_ids(v) for v in graph.vertices + } + + while stack: + v, idx = stack[-1] + + if v not in visited: + visited.add(v) + counter += 1 + dfs_num[v] = counter + dfs_order.append(v) + first_child[v] = None + + adj: list[int] = adj_lists[v] + advanced: bool = False + + while idx < len(adj): + eid: int = adj[idx] + idx += 1 + e: Edge | None = graph.get_edge(eid) + assert e is not None + w: Hashable = e.other(v) + + if w not in visited: + # Tree edge v -> w + tree_edges.add(eid) + parent[w] = v + parent_edge[w] = eid + children[v].append(w) + if first_child[v] is None: + first_child[v] = w + stack[-1] = (v, idx) + stack.append((w, 0)) + advanced = True + break + elif _is_frond_edge(w, v, eid, dfs_num, parent_edge): + # Frond v -> w (w is a proper ancestor of v) + fronds.add(eid) + # else: w is a descendant or parent edge (already handled) + + if not advanced: + # All adjacencies of v processed; compute bottom-up values + stack.pop() + _compute_lowpt( + v, dfs_num, children, fronds, + graph, lowpt1, lowpt2, nd, high + ) + + return PalmTree( + dfs_num=dfs_num, + parent=parent, + parent_edge=parent_edge, + tree_edges=tree_edges, + fronds=fronds, + lowpt1=lowpt1, + lowpt2=lowpt2, + nd=nd, + first_child=first_child, + high=high, + children=children, + dfs_order=dfs_order, + ) + + +def _compute_lowpt( + v: Hashable, + dfs_num: dict[Hashable, int], + children: dict[Hashable, list[Hashable]], + fronds: set[int], + graph: MultiGraph, + lowpt1: dict[Hashable, int], + lowpt2: dict[Hashable, int], + nd: dict[Hashable, int], + high: dict[Hashable, int], +) -> None: + """Compute lowpt1, lowpt2, nd, and high for vertex v. + + Called in post-order (after all children of v are processed). + Updates the dictionaries in place. + + :param v: The vertex being processed. + :param dfs_num: DFS discovery numbers for all vertices. + :param children: DFS tree children for all vertices. + :param fronds: Set of frond edge IDs. + :param graph: The multigraph. + :param lowpt1: Output dictionary for lowpt1 values. + :param lowpt2: Output dictionary for lowpt2 values. + :param nd: Output dictionary for subtree sizes. + :param high: Output dictionary for high values. + :return: None + """ + # Candidate multiset for low-point computation. + # We only need to track the two smallest distinct values. + lp1: int = dfs_num[v] + lp2: int = LOWPT_INF + + def _update(val: int) -> None: + """Update (lp1, lp2) with a new candidate value. + + :param val: A new candidate DFS number. + :return: None + """ + nonlocal lp1, lp2 + if val < lp1: + lp2 = lp1 + lp1 = val + elif lp1 < val < lp2: + lp2 = val + + # Fronds from v directly + for eid in graph.adj_edge_ids(v): + if eid in fronds: + e: Edge | None = graph.get_edge(eid) + assert e is not None + w: Hashable = e.other(v) + _update(dfs_num[w]) + + # Propagate from children + nd_sum: int = 1 + high_max: int = 0 + for c in children[v]: + _update(lowpt1[c]) + if lowpt2[c] != LOWPT_INF: + _update(lowpt2[c]) + nd_sum += nd[c] + if high[c] > high_max: + high_max = high[c] + + lowpt1[v] = lp1 + lowpt2[v] = lp2 + nd[v] = nd_sum + + # high[v]: the highest DFS number of a descendant (or v) that has + # a frond to a proper ancestor of v. + # A frond (v, w) goes to a proper ancestor if dfs_num[w] < dfs_num[v]. + if any(eid in fronds for eid in graph.adj_edge_ids(v)): + high_max = max(high_max, dfs_num[v]) + high[v] = high_max + + +def phi_key( + v: Hashable, + eid: int, + pt: PalmTree, + graph: MultiGraph, +) -> int: + """Compute the sort key φ(e) for edge eid as defined by Gutwenger-Mutzel. + + The φ ordering governs the adjacency list order used during the main + DFS in Algorithm 3 (PathSearch). Smaller φ values come first. + + From Gutwenger-Mutzel (2001), p. 83: + + For a tree edge e = v→w (w is a child of v): + + - φ(e) = 3 * lowpt1[w] if lowpt2[w] < dfs_num[v] + - φ(e) = 3 * lowpt1[w] + 2 if lowpt2[w] >= dfs_num[v] + + For a frond e = v↪w (w is a proper ancestor of v): + + - φ(e) = 3 * dfs_num[w] + 1 + + :param v: The vertex whose adjacency list is being sorted. + :param eid: The edge ID to compute the key for. + :param pt: The palm tree with DFS data. + :param graph: The multigraph. + :return: The integer sort key φ(e). + """ + e: Edge | None = graph.get_edge(eid) + assert e is not None + w: Hashable = e.other(v) + w_num: int = pt.dfs_num.get(w, 0) + v_num: int = pt.dfs_num.get(v, 0) + + if eid in pt.tree_edges and pt.parent_edge.get(w) == eid: + # Tree edge v→w where w is the child of v. + if pt.lowpt2[w] < v_num: + # Case 1: lowpt2(w) < dfs_num(v). + return 3 * pt.lowpt1[w] + # Case 3: lowpt2(w) >= dfs_num(v). + return 3 * pt.lowpt1[w] + 2 + + if eid in pt.fronds and w_num < v_num: + # Frond from v to proper ancestor w (w has smaller DFS number). + # Case 2: phi = 3 * dfs_num(w) + 1. + return 3 * w_num + 1 + + # Parent edge or edge to descendant (frond from the other direction): + # assign a large key so it is sorted last. + return 3 * (w_num + 1) * 3 + 3 + + +def sort_adjacency_lists( + graph: MultiGraph, + pt: PalmTree, +) -> None: + """Re-order each vertex's adjacency list by φ values in place. + + This must be called before running PathSearch (Algorithm 3) to + ensure the DFS visits edges in the order required for the + Gutwenger-Mutzel triconnected components algorithm. + + :param graph: The multigraph whose adjacency lists will be sorted. + :param pt: A fully computed palm tree for the graph. + :return: None + """ + for v in graph.vertices: + adj: list[int] = graph.adj_edge_ids(v) + adj_sorted: list[int] = sorted( + adj, + key=lambda eid, _v=v: phi_key(_v, eid, pt, graph) + ) + graph.set_adj_order(v, adj_sorted) diff --git a/src/spqrtree/_spqr.py b/src/spqrtree/_spqr.py new file mode 100644 index 0000000..99f86df --- /dev/null +++ b/src/spqrtree/_spqr.py @@ -0,0 +1,340 @@ +# 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. +"""SPQR-Tree construction from triconnected split components. + +Builds an SPQR-tree from the triconnected split components of a +biconnected multigraph, following Di Battista & Tamassia (1996). + +Each triconnected split component maps to an SPQR-tree node: +- BOND with 2 total edges -> Q-node (degenerate: a single edge). +- BOND with 3+ total edges -> P-node (parallel edges). +- POLYGON -> S-node (simple cycle). +- TRICONNECTED -> R-node (3-connected subgraph). + +Adjacent components (sharing a virtual edge) are linked as +parent-child pairs in the tree. + +Public API:: + + from spqrtree._spqr import NodeType, SPQRNode, build_spqr_tree +""" +from __future__ import annotations + +from collections import deque +from collections.abc import Hashable +from dataclasses import dataclass, field +from enum import Enum + +from spqrtree._graph import MultiGraph +from spqrtree._triconnected import ( + ComponentType, + TriconnectedComponent, + find_triconnected_components, +) + + +class NodeType(Enum): + """SPQR-tree node types. + + :cvar Q: A Q-node represents a single edge (degenerate bond). + :cvar S: An S-node represents a simple cycle (series). + :cvar P: A P-node represents parallel edges (parallel). + :cvar R: An R-node represents a 3-connected subgraph (rigid). + """ + + Q = "Q" + """Q-node: a single real edge.""" + + S = "S" + """S-node: a simple cycle (polygon).""" + + P = "P" + """P-node: parallel edges (bond with 3+ real edges).""" + + R = "R" + """R-node: a 3-connected subgraph.""" + + +@dataclass +class SPQRNode: + """A node in the SPQR-tree. + + :param type: The node type (Q, S, P, or R). + :param skeleton: The skeleton graph for this node, containing + the real and virtual edges of the component. + :param poles: The pair of poles (u, v) for this node, i.e., the + two vertices of the virtual edge connecting this node to its + parent (or the two endpoints for the root). + :param parent: The parent SPQRNode, or None if this is the root. + :param children: Ordered list of child SPQRNodes. + """ + + type: NodeType + """The SPQR-tree node type.""" + + skeleton: MultiGraph + """The skeleton graph of this node.""" + + poles: tuple[Hashable, Hashable] + """The two pole vertices of this node.""" + + parent: SPQRNode | None + """Parent node, or None if this is the root.""" + + children: list[SPQRNode] = field(default_factory=list) + """Child nodes in the SPQR-tree.""" + + +def build_spqr_tree(graph: MultiGraph) -> SPQRNode: + """Build an SPQR-tree for a biconnected multigraph. + + Decomposes the graph into triconnected split components, then + assembles them into an SPQR-tree. Each component becomes a node + with type Q (degenerate bond of 2 edges), P (bond of 3+ edges), + S (polygon), or R (triconnected). + + :param graph: A biconnected multigraph. + :return: The root SPQRNode of the SPQR-tree. + """ + comps: list[TriconnectedComponent] = find_triconnected_components( + graph) + + if not comps: + # Degenerate: single edge graph. + skel: MultiGraph = MultiGraph() + for e in graph.edges: + skel.add_edge(e.u, e.v, virtual=e.virtual) + verts: list[Hashable] = list(graph.vertices) + poles: tuple[Hashable, Hashable] = ( + verts[0], + verts[1] if len(verts) > 1 else verts[0]) + return SPQRNode( + type=NodeType.Q, + skeleton=skel, + poles=poles, + parent=None, + ) + + if len(comps) == 1: + return _make_single_node(comps[0], None) + + # Multiple components: build tree from adjacency. + return _build_tree_from_components(comps) + + +def _comp_to_node_type(comp: TriconnectedComponent) -> NodeType: + """Convert a TriconnectedComponent type to an SPQRNode NodeType. + + A BOND with exactly 2 total edges (one real + one virtual) is a + Q-node (degenerate: a single edge). A BOND with 3 or more total + edges is a P-node (parallel class). + + :param comp: The triconnected component. + :return: The corresponding NodeType. + """ + if comp.type == ComponentType.BOND: + if len(comp.edges) <= 2: + return NodeType.Q + return NodeType.P + if comp.type == ComponentType.POLYGON: + return NodeType.S + return NodeType.R + + +def _make_skeleton(comp: TriconnectedComponent) -> MultiGraph: + """Build a skeleton MultiGraph for a triconnected component. + + :param comp: The triconnected component. + :return: A MultiGraph containing all edges of the component. + """ + skel: MultiGraph = MultiGraph() + for e in comp.edges: + skel.add_edge(e.u, e.v, virtual=e.virtual) + return skel + + +def _get_poles( + comp: TriconnectedComponent, +) -> tuple[Hashable, Hashable]: + """Determine the poles of a component. + + The poles are the two endpoints of the virtual edge in the + component (if any), or the two endpoints of the first real edge. + + :param comp: The triconnected component. + :return: A (u, v) pair of pole vertices. + :raises RuntimeError: If the component has no edges. + """ + for e in comp.edges: + if e.virtual: + return e.u, e.v + # No virtual edge: use first edge endpoints. + if comp.edges: + return comp.edges[0].u, comp.edges[0].v + raise RuntimeError("Component has no edges") + + +def _make_single_node( + comp: TriconnectedComponent, + parent_node: SPQRNode | None, +) -> SPQRNode: + """Create a single SPQRNode from one TriconnectedComponent. + + :param comp: The triconnected component. + :param parent_node: The parent SPQRNode, or None for root. + :return: A new SPQRNode. + """ + ntype: NodeType = _comp_to_node_type(comp) + skel: MultiGraph = _make_skeleton(comp) + poles: tuple[Hashable, Hashable] = _get_poles(comp) + node: SPQRNode = SPQRNode( + type=ntype, + skeleton=skel, + poles=poles, + parent=parent_node, + ) + return node + + +def _collect_ve_to_comps( + comps: list[TriconnectedComponent], +) -> dict[int, list[int]]: + """Map virtual edge IDs to component indices containing them. + + Scans all components for virtual edges, then builds a mapping + from each virtual edge ID to the list of component indices + that include that edge. + + :param comps: List of triconnected components. + :return: Mapping from virtual edge ID to component indices. + """ + virtual_edge_ids: set[int] = set() + for comp in comps: + for e in comp.edges: + if e.virtual: + virtual_edge_ids.add(e.id) + + ve_to_comps: dict[int, list[int]] = {} + for i, comp in enumerate(comps): + for e in comp.edges: + if e.id in virtual_edge_ids: + ve_to_comps.setdefault(e.id, []).append(i) + return ve_to_comps + + +def _build_adj_and_root( + comps: list[TriconnectedComponent], + ve_to_comps: dict[int, list[int]], +) -> tuple[dict[int, list[tuple[int, int]]], int]: + """Build component adjacency and choose the root component. + + Two components are adjacent if they share a virtual edge. The + root is the component with the most virtual-edge adjacencies + (the most central node in the tree). + + :param comps: List of triconnected components. + :param ve_to_comps: Virtual edge to component indices mapping. + :return: A (adj, root_ci) tuple where adj maps component index + to list of (neighbor_index, shared_ve_id) pairs, and + root_ci is the chosen root component index. + """ + adj: dict[int, list[tuple[int, int]]] = { + i: [] for i in range(len(comps)) + } + for veid, idxs in ve_to_comps.items(): + if len(idxs) == 2: + i, j = idxs + adj[i].append((j, veid)) + adj[j].append((i, veid)) + + adj_count: list[int] = [0] * len(comps) + for idxs in ve_to_comps.values(): + if len(idxs) == 2: + adj_count[idxs[0]] += 1 + adj_count[idxs[1]] += 1 + root_ci = max(range(len(comps)), key=lambda i: adj_count[i]) + return adj, root_ci + + +def _build_tree_from_components( + comps: list[TriconnectedComponent], +) -> SPQRNode: + """Build an SPQR-tree from a list of triconnected components. + + Components sharing a virtual edge are adjacent in the tree. + The component with the most virtual-edge adjacencies is the root. + + :param comps: List of triconnected components. + :return: The root SPQRNode. + """ + ve_to_comps: dict[int, list[int]] = _collect_ve_to_comps(comps) + adj: dict[int, list[tuple[int, int]]] + root_ci: int + adj, root_ci = _build_adj_and_root( + comps, ve_to_comps) + + # Build the tree by BFS from the chosen root component. + nodes: list[SPQRNode | None] = [None] * len(comps) + visited: set[int] = set() + bfs_queue: deque[tuple[int, SPQRNode | None]] = deque([ + (root_ci, None)]) + + while bfs_queue: + ci, parent_node = bfs_queue.popleft() + if ci in visited: + continue + visited.add(ci) + + comp: TriconnectedComponent = comps[ci] + ntype: NodeType = _comp_to_node_type(comp) + skel: MultiGraph = _make_skeleton(comp) + poles: tuple[Hashable, Hashable] = _get_poles(comp) + node: SPQRNode = SPQRNode( + type=ntype, + skeleton=skel, + poles=poles, + parent=parent_node, + ) + if parent_node is not None: + parent_node.children.append(node) + nodes[ci] = node + + for cj, _ in adj[ci]: + if cj not in visited: + bfs_queue.append((cj, node)) + + # Handle any disconnected components (shouldn't happen for + # biconnected graphs, but be defensive). + for i in range(len(comps)): + if nodes[i] is None: + comp = comps[i] + ntype = _comp_to_node_type(comp) + skel = _make_skeleton(comp) + poles = _get_poles(comp) + nodes[i] = SPQRNode( + type=ntype, + skeleton=skel, + poles=poles, + parent=None, + ) + + # Return the chosen root node. + root: SPQRNode | None = nodes[root_ci] + assert root is not None + return root diff --git a/src/spqrtree/_triconnected.py b/src/spqrtree/_triconnected.py new file mode 100644 index 0000000..b5508d7 --- /dev/null +++ b/src/spqrtree/_triconnected.py @@ -0,0 +1,1495 @@ +# 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. +"""Triconnected components for the SPQR-Tree algorithm. + +Implements decomposition of a biconnected multigraph into its +triconnected split components: BONDs (parallel-edge groups), +POLYGONs (simple cycles), and TRICONNECTED subgraphs. + +The algorithm proceeds in three phases: + +1. **Multi-edge phase**: Each group of parallel edges becomes a BOND + split component; they are replaced by a single virtual edge. +2. **PathSearch phase**: An iterative DFS using the Gutwenger-Mutzel + (2001) algorithm detects separation pairs and creates split + components using an edge stack (ESTACK) and triple stack (TSTACK). +3. **Merge phase**: Adjacent split components of the same type that + share a virtual edge are merged (Algorithm 2). + +Public API:: + + from spqrtree._triconnected import ( + ComponentType, TriconnectedComponent, + find_triconnected_components, + ) +""" +import bisect +from collections.abc import Hashable +from dataclasses import dataclass +from enum import Enum + +from spqrtree._graph import Edge, MultiGraph +from spqrtree._palm_tree import ( + LOWPT_INF, + PalmTree, + build_palm_tree, + sort_adjacency_lists, +) + +# Sentinel triple marking the end of a path segment on the TSTACK. +_EOS: tuple[int, int, int] = (-1, -1, -1) + + +class ComponentType(Enum): + """Classification of a triconnected split component. + + :cvar BOND: A bond - two vertices connected by multiple parallel + edges. + :cvar POLYGON: A simple cycle - every vertex has degree 2 in the + component. + :cvar TRICONNECTED: A 3-connected subgraph. + """ + + BOND = "bond" + """A bond component: two vertices connected by parallel edges.""" + + POLYGON = "polygon" + """A polygon component: a simple cycle.""" + + TRICONNECTED = "triconnected" + """A triconnected component: a 3-connected subgraph.""" + + +@dataclass +class TriconnectedComponent: + """A single triconnected split component. + + :param type: Classification as BOND, POLYGON, or TRICONNECTED. + :param edges: All edges in this component (real and virtual). + Virtual edges are shared with exactly one other component. + """ + + type: ComponentType + """Classification of this component.""" + + edges: list[Edge] + """All edges in this component (real and virtual).""" + + +def find_triconnected_components( + graph: MultiGraph, +) -> list[TriconnectedComponent]: + """Find all triconnected split components of a biconnected multigraph. + + The input must be a biconnected multigraph (possibly with parallel + edges). Returns a list of TriconnectedComponent objects, each + classified as BOND, POLYGON, or TRICONNECTED. + + Each real (non-virtual) edge of the input appears in exactly one + component. Each virtual edge (added by the algorithm) appears in + exactly two components, representing the two sides of a separation + pair. + + :param graph: A biconnected multigraph. + :return: A list of TriconnectedComponent objects. + """ + if graph.num_vertices() == 0 or graph.num_edges() == 0: + return [] + + # Work on a copy to avoid modifying the caller's graph. + g: MultiGraph = graph.copy() + + # Accumulated split components (each is a list of edge IDs). + raw_comps: list[list[int]] = [] + + # Set of virtual edge IDs added during decomposition. + virtual_ids: set[int] = set() + + # Phase 1: Split off multi-edges. + _phase_multiedge(g, raw_comps, virtual_ids) + + # If only 2 vertices remain (or fewer), skip PathSearch. + if g.num_vertices() >= 2 and g.num_edges() >= 1: + # Phase 2: PathSearch. + _phase_pathsearch(g, raw_comps, virtual_ids) + + # Phase 3: Classify and merge. + return _phase_classify_merge(raw_comps, virtual_ids, graph, g) + + +# --------------------------------------------------------------------------- +# Phase 1: Multi-edge splitting (Algorithm 1) +# --------------------------------------------------------------------------- + +def _phase_multiedge( + g: MultiGraph, + raw_comps: list[list[int]], + virtual_ids: set[int], +) -> None: + """Replace groups of parallel edges with virtual edges (Algorithm 1). + + For each pair of vertices (u, v) with k >= 2 parallel edges, + creates a BOND split component {e_1, ..., e_k, e'} where e' is a + new virtual edge, then removes e_1, ..., e_k from g, leaving e'. + + :param g: The working multigraph (modified in place). + :param raw_comps: List to append new split components to. + :param virtual_ids: Set to add new virtual edge IDs to. + :return: None + """ + # Collect all unordered vertex pairs. + seen_pairs: set[frozenset[Hashable]] = set() + pairs: list[tuple[Hashable, Hashable]] = [] + for e in g.edges: + key: frozenset[Hashable] = frozenset((e.u, e.v)) + if key not in seen_pairs: + seen_pairs.add(key) + pairs.append((e.u, e.v)) + + for u, v in pairs: + parallel: list[Edge] = g.edges_between(u, v) + if len(parallel) < 2: + continue + # Only create a virtual edge if there are other edges in g + # (i.e., the bond is embedded in a larger graph). + if g.num_edges() > len(parallel): + ve: Edge = g.add_edge(u, v, virtual=True) + virtual_ids.add(ve.id) + comp_eids: list[int] = [e.id for e in parallel] + [ve.id] + else: + # Entire graph is this bond: no virtual edge needed. + comp_eids = [e.id for e in parallel] + raw_comps.append(comp_eids) + # Remove original parallel edges. + for e in parallel: + g.remove_edge(e.id) + + +def _classify_start_edge( + eid: int, + w: Hashable, + v: Hashable, + v_num: int, + w_num: int, + tree_edges: set[int], + fronds: set[int], + parent_edge: dict[Hashable, int | None], +) -> tuple[bool, bool]: + """Classify an edge for the start-set computation. + + :param eid: Edge ID to classify. + :param w: The other endpoint. + :param v: The current vertex. + :param v_num: DFS number of v. + :param w_num: DFS number of w. + :param tree_edges: Set of tree edge IDs. + :param fronds: Set of frond edge IDs. + :param parent_edge: Parent edge map. + :return: (is_tree_arc, is_frond) tuple. + """ + is_tree_arc: bool = (eid in tree_edges + and parent_edge.get(w) == eid) + is_frond: bool = (eid in fronds + and w_num < v_num + and eid != parent_edge.get(v)) + return is_tree_arc, is_frond + + +def _compute_start_set(g: MultiGraph, pt: PalmTree) -> set[int]: + """Compute start edges using the path-finder traversal. + + An edge starts a new path if ``new_path`` is True when the edge is + first traversed in DFS order. ``new_path`` starts True, is set to + False after the first outgoing arc (tree edge or frond) is marked, + and is set back to True after any frond is processed. Backtracking + never changes ``new_path``. + + This corresponds to the ``starts_path`` computation in the + Gutwenger-Mutzel (2001) path_finder subroutine and determines which + tree-edge arcs push a new EOS sentinel onto TSTACK during PathSearch. + + :param g: The multigraph with phi-sorted adjacency lists. + :param pt: The palm tree computed for g. + :return: Set of edge IDs that start a new path segment. + """ + start_set: set[int] = set() + if not g.vertices: + return start_set + + dfs_num: dict[Hashable, int] = pt.dfs_num + tree_edges: set[int] = pt.tree_edges + fronds: set[int] = pt.fronds + parent_edge: dict[Hashable, int | None] = pt.parent_edge + root: Hashable = min( + g.vertices, key=lambda v: dfs_num[v]) + + adj_lists: dict[Hashable, list[int]] = { + v: g.adj_edge_ids(v) for v in g.vertices} + + new_path: bool = True + # Stack entries: (vertex, adj_index) + stack: list[tuple[Hashable, int]] = [(root, 0)] + + while stack: + v, idx = stack[-1] + adj: list[int] = adj_lists[v] + + if idx >= len(adj): + stack.pop() + continue + + eid: int = adj[idx] + stack[-1] = (v, idx + 1) + + if not g.has_edge(eid): + continue + + e: Edge | None = g.get_edge(eid) + assert e is not None + w: Hashable = e.other(v) + v_num: int = dfs_num[v] + w_num: int = dfs_num.get(w, 0) + is_tree_arc, is_frond = _classify_start_edge( + eid, w, v, v_num, w_num, + tree_edges, fronds, parent_edge) + + if is_tree_arc: + # Tree edge v -> w (w is a child of v). + if new_path: + start_set.add(eid) + new_path = False + stack.append((w, 0)) + elif is_frond: + # Frond v -> w (w is a proper ancestor of v). + if new_path: + start_set.add(eid) + new_path = True + # Parent edge or reverse-direction edge: skip. + + return start_set + + +def _renumber_palm_tree(pt: PalmTree) -> None: + """Renumber a palm tree using the Gutwenger-Mutzel scheme. + + Assigns ``newnum[v] = counter - nd[v] + 1`` where *counter* + starts at *n* and decrements on backtrack. Within a vertex's + children, the first child (in phi-sorted order) receives the + **highest** DFS numbers and the last child the **lowest**. This + matches the numbering in Gutwenger-Mutzel (2001) §3.3 and is + required for correct TSTACK triple ranges in PathSearch. + + Updates ``dfs_num``, ``lowpt1``, and ``lowpt2`` in place. + + :param pt: The palm tree to renumber. + :return: None + """ + n: int = len(pt.dfs_num) + old_to_new: dict[int, int] = {} + counter: int = n + + # Iterative DFS following children order. + root: Hashable = next( + v for v, p in pt.parent.items() if p is None) + stack: list[tuple[Hashable, bool]] = [(root, False)] + visited: set[Hashable] = set() + + while stack: + v, returning = stack.pop() + if returning: + counter -= 1 + continue + if v in visited: + continue + visited.add(v) + old_num: int = pt.dfs_num[v] + new_num: int = counter - pt.nd[v] + 1 + old_to_new[old_num] = new_num + + # Push backtrack marker. + stack.append((v, True)) + # Push children in reverse order so first child is + # processed first (popped last from reversed push). + for child in reversed(pt.children.get(v, [])): + stack.append((child, False)) + + # Update dfs_num. + for v in pt.dfs_num: + pt.dfs_num[v] = old_to_new[pt.dfs_num[v]] + # Update lowpt1 and lowpt2. + for v in pt.lowpt1: + pt.lowpt1[v] = old_to_new[pt.lowpt1[v]] + for v in pt.lowpt2: + old_val: int = pt.lowpt2[v] + if old_val == LOWPT_INF: + continue + pt.lowpt2[v] = old_to_new.get(old_val, old_val) + + +# --------------------------------------------------------------------------- +# Phase 2: PathSearch (Algorithms 3 - 6) +# --------------------------------------------------------------------------- + + +class _PathSearcher: + """PathSearch algorithm state and methods (Algorithms 3-6). + + Encapsulates the mutable state and sub-algorithms for the + Gutwenger-Mutzel (2001) PathSearch. Splitting the logic into + methods keeps cognitive complexity manageable. + """ + + def __init__( + self, + g: MultiGraph, + raw_comps: list[list[int]], + virtual_ids: set[int], + ) -> None: + """Initialize the PathSearch state. + + Builds the palm tree, sorts adjacency lists, and sets up + all mutable data structures needed by the algorithm. + + :param g: The working multigraph. + :param raw_comps: Accumulator for split components. + :param virtual_ids: Accumulator for virtual edge IDs. + :return: None + """ + self.g: MultiGraph = g + """The working multigraph.""" + self.raw_comps: list[list[int]] = raw_comps + """Accumulated raw split components.""" + self.virtual_ids: set[int] = virtual_ids + """Set of virtual edge IDs.""" + + # Build palm tree and sort adjacency lists. + start: Hashable = next(iter(g.vertices)) + pt: PalmTree = build_palm_tree(g, start) + sort_adjacency_lists(g, pt) + pt = build_palm_tree(g, start) + _renumber_palm_tree(pt) + + self.dfs_num: dict[Hashable, int] = pt.dfs_num + """DFS discovery numbers.""" + self.parent: dict[Hashable, Hashable | None] = pt.parent + """DFS tree parent for each vertex.""" + self.lowpt1: dict[Hashable, int] = pt.lowpt1 + """Lowpt1 values.""" + self.lowpt2: dict[Hashable, int] = pt.lowpt2 + """Lowpt2 values.""" + self.nd: dict[Hashable, int] = pt.nd + """Subtree sizes.""" + self.inv_dfs: dict[int, Hashable] = { + n: v for v, n in pt.dfs_num.items()} + """Inverse DFS map: DFS number to vertex.""" + self.cur_parent_edge: dict[Hashable, int | None] = dict( + pt.parent_edge) + """Current parent edge for each vertex.""" + self.cur_tree: set[int] = set(pt.tree_edges) + """Current set of tree edge IDs.""" + self.cur_deg: dict[Hashable, int] = { + v: g.degree(v) for v in g.vertices} + """Current degree of each vertex.""" + self.cur_children: dict[Hashable, list[Hashable]] = { + v: list(pt.children[v]) + for v in g.vertices} + """Current DFS children for each vertex.""" + self.fronds: set[int] = pt.fronds + """Set of frond edge IDs from palm tree.""" + + # Build frond sources for _high(). + self.frond_srcs: dict[int, list[int]] = { + pt.dfs_num[v]: [] for v in g.vertices} + """Frond source DFS numbers by target.""" + self.in_high: dict[int, tuple[int, int]] = {} + """Maps edge ID to (target_dfs, source_dfs) for + highpt tracking.""" + for eid in pt.fronds: + e: Edge | None = g.get_edge(eid) + if e is None: + continue + if pt.dfs_num[e.u] > pt.dfs_num[e.v]: + s: int = pt.dfs_num[e.u] + d: int = pt.dfs_num[e.v] + else: + s = pt.dfs_num[e.v] + d = pt.dfs_num[e.u] + self.frond_srcs[d].append(s) + self.in_high[eid] = (d, s) + for lst in self.frond_srcs.values(): + lst.sort() + + self.start_set: set[int] = _compute_start_set(g, pt) + """Edge IDs that start a new path segment.""" + self.estack: list[int] = [] + """Edge stack (ESTACK).""" + self.tstack: list[tuple[int, int, int]] = [] + """Triple stack (TSTACK).""" + self.adj_cache: dict[Hashable, list[int]] = { + v: list(g.adj_edge_ids(v)) + for v in g.vertices} + """Cached adjacency lists.""" + self.consumed: set[int] = set() + """Edge IDs consumed by split components.""" + self.adj_len: dict[Hashable, int] = {} + """Original adjacency list length per vertex for DFS + iteration bounds.""" + self.y_accum: dict[Hashable, int] = {} + """Accumulated TSTACK h-value per vertex, persisting + across children (matching SageMath's y_dict).""" + self.call_stack: list[tuple] = [] + """DFS call stack.""" + + def run(self) -> None: + """Execute the main PathSearch DFS loop. + + Processes visit and post-return frames iteratively. + Any remaining edges on ESTACK form a final component. + + :return: None + """ + root: Hashable = self.inv_dfs[1] + self.adj_len[root] = len( + self.adj_cache.get(root, [])) + self.y_accum[root] = 0 + self.call_stack = [('visit', root, 0)] + + while self.call_stack: + frame: tuple = self.call_stack[-1] + + if frame[0] == 'post': + self.call_stack.pop() + self._process_post_frame( + frame[1], frame[2], frame[3], frame[4]) + continue + + _, v, idx = frame + adj: list[int] = self.adj_cache.get(v, []) + bound: int = self.adj_len.get(v, len(adj)) + + if idx >= bound: + self.call_stack.pop() + continue + + eid: int = adj[idx] + self.call_stack[-1] = ('visit', v, idx + 1) + + if not self.g.has_edge(eid): + continue + + e: Edge | None = self.g.get_edge(eid) + assert e is not None + w: Hashable = e.other(v) + v_num: int = self.dfs_num[v] + w_num: int = self.dfs_num.get(w, -1) + if w_num < 0: + continue + + is_start: bool = eid in self.start_set + + is_tree_arc: bool = ( + eid in self.cur_tree + and self.cur_parent_edge.get(w) == eid) + is_out_frond: bool = ( + eid in self.fronds and w_num < v_num + and eid != self.cur_parent_edge.get(v)) + + if is_tree_arc: + self._process_tree_edge( + v, eid, w, v_num, w_num, is_start) + elif is_out_frond: + self._process_frond( + v, eid, w, v_num, w_num, is_start) + + if self.estack: + self.raw_comps.append(list(self.estack)) + + def _high(self, w_num: int) -> int: + """Return the largest frond-source DFS number. + + :param w_num: DFS number of vertex w. + :return: Largest frond-source DFS number, or 0. + """ + fl: list[int] = self.frond_srcs.get(w_num, []) + return fl[-1] if fl else 0 + + def _del_high(self, eid: int) -> None: + """Remove the highpt entry for edge *eid*. + + If *eid* has a recorded ``in_high`` entry, removes + the corresponding source from ``frond_srcs``. + + :param eid: The edge ID whose highpt entry to remove. + :return: None + """ + entry: tuple[int, int] | None = self.in_high.pop( + eid, None) + if entry is None: + return + target, source = entry + fl: list[int] = self.frond_srcs.get(target, []) + idx: int = bisect.bisect_left(fl, source) + if idx < len(fl) and fl[idx] == source: + fl.pop(idx) + + def _first_child_num(self, v: Hashable) -> int: + """Return DFS number of v's first child, or 0. + + :param v: A vertex. + :return: DFS number of the first child, or 0. + """ + ch: list[Hashable] = self.cur_children.get(v, []) + return self.dfs_num[ch[0]] if ch else 0 + + def _remaining_deg(self, w: Hashable) -> int: + """Return the number of non-consumed edges of *w*. + + Counts edges in the adjacency cache that have not + been consumed by split components and still exist + in the graph. + + :param w: A vertex. + :return: Count of remaining incident edges. + """ + count: int = 0 + for eid in self.adj_cache.get(w, []): + if eid in self.consumed: + continue + if self.g.get_edge(eid) is None: + continue + count += 1 + return count + + def _temp_target_num(self, w: Hashable) -> int: + """Return DFS number of the first remaining outgoing + edge target from *w*. + + Scans the adjacency cache for the first edge that + has not been consumed and is not the parent edge, + returning the target DFS number for outgoing tree + arcs (to children) or outgoing fronds (to ancestors). + + :param w: A vertex. + :return: DFS number of the target, or 0. + """ + w_num: int = self.dfs_num[w] + peid: int | None = self.cur_parent_edge.get(w) + for eid in self.adj_cache.get(w, []): + if eid in self.consumed or eid == peid: + continue + ed: Edge | None = self.g.get_edge(eid) + if ed is None: + continue + other: Hashable = ed.other(w) + o_num: int = self.dfs_num.get(other, -1) + if o_num < 0: + continue + is_tree: bool = eid in self.cur_tree + if is_tree and o_num > w_num: + return o_num + if not is_tree and o_num < w_num: + return o_num + return 0 + + def _add_virt(self, u: Hashable, v: Hashable) -> Edge: + """Add a new virtual edge between u and v. + + Records the virtual edge ID. Does NOT modify + cur_deg. + + :param u: One endpoint. + :param v: Other endpoint. + :return: The new virtual Edge. + """ + e: Edge = self.g.add_edge(u, v, virtual=True) + self.virtual_ids.add(e.id) + return e + + def _delete_tstack_above( + self, threshold: int, + ) -> list[tuple[int, int, int]]: + """Pop TSTACK triples with *a* above *threshold*. + + Pops triples from TSTACK top while they are not EOS + and their second element (a) exceeds *threshold*. + + :param threshold: The low-point threshold value. + :return: List of deleted triples. + """ + deleted: list[tuple[int, int, int]] = [] + while ( + self.tstack + and self.tstack[-1] != _EOS + and self.tstack[-1][1] > threshold + ): + deleted.append(self.tstack.pop()) + return deleted + + def _process_post_frame( + self, + v: Hashable, + eid: int, + w_orig: Hashable, + is_start: bool, + ) -> None: + """Handle a post-return frame after recursing. + + Pushes the tree edge onto ESTACK, runs type-2 and + type-1 checks, cleans up EOS triples, and pops + stale triples. + + :param v: The parent vertex. + :param eid: The tree edge ID. + :param w_orig: The original child vertex. + :param is_start: Whether this edge starts a path. + :return: None + """ + ed: Edge | None = self.g.get_edge(eid) + if ed is not None: + w: Hashable = ed.other(v) + else: + w = w_orig + + peid_w: int | None = self.cur_parent_edge.get(w) + if peid_w is not None: + self.estack.append(peid_w) + + w = self._check_type2(v, w) + self._check_type1(v, w) + + if is_start: + while (self.tstack + and self.tstack[-1] != _EOS): + self.tstack.pop() + if (self.tstack + and self.tstack[-1] == _EOS): + self.tstack.pop() + + v_num: int = self.dfs_num[v] + while ( + self.tstack + and self.tstack[-1] != _EOS + and self.tstack[-1][2] != v_num + and self._high(v_num) + > self.tstack[-1][0] + ): + self.tstack.pop() + + def _process_tree_edge( + self, + v: Hashable, + eid: int, + w: Hashable, + v_num: int, + w_num: int, + is_start: bool, + ) -> None: + """Handle a tree edge v -> w in the visit frame. + + Updates TSTACK with a new triple if this is a start + edge, then pushes post-return and visit frames. + + :param v: The current vertex. + :param eid: The tree edge ID. + :param w: The child vertex. + :param v_num: DFS number of v. + :param w_num: DFS number of w. + :param is_start: Whether this edge starts a path. + :return: None + """ + if is_start: + lp1w: int = self.lowpt1[w] + y_acc: int = self.y_accum.get(v, 0) + deleted: list[tuple[int, int, int]] = \ + self._delete_tstack_above(lp1w) + if not deleted: + self.tstack.append( + (w_num + self.nd[w] - 1, lp1w, v_num)) + else: + y: int = y_acc + for t in deleted: + y = max(y, t[0]) + last_b: int = deleted[-1][2] + self.tstack.append((y, lp1w, last_b)) + self.y_accum[v] = y + self.tstack.append(_EOS) + + self.call_stack.append(('post', v, eid, w, is_start)) + self.adj_cache[w] = list(self.g.adj_edge_ids(w)) + self.adj_len[w] = len(self.adj_cache[w]) + self.y_accum[w] = 0 + self.call_stack.append(('visit', w, 0)) + + def _process_frond( + self, + v: Hashable, + eid: int, + w: Hashable, + v_num: int, + w_num: int, + is_start: bool, + ) -> None: + """Handle a frond v -> w in the visit frame. + + Updates TSTACK if this is a start edge. If the frond + goes to the parent, creates a 2-component and replaces + the parent edge with a virtual edge. + + :param v: The current vertex. + :param eid: The frond edge ID. + :param w: The ancestor vertex. + :param v_num: DFS number of v. + :param w_num: DFS number of w. + :param is_start: Whether this edge starts a path. + :return: None + """ + if is_start: + y_acc: int = self.y_accum.get(v, 0) + deleted: list[tuple[int, int, int]] = \ + self._delete_tstack_above(w_num) + if not deleted: + self.tstack.append((v_num, w_num, v_num)) + else: + y: int = y_acc + for t in deleted: + y = max(y, t[0]) + last_b: int = deleted[-1][2] + self.tstack.append((y, w_num, last_b)) + + p_v: Hashable | None = self.parent.get(v) + if p_v is not None and w == p_v: + peid: int | None = self.cur_parent_edge.get(v) + if peid is not None and self.g.has_edge(peid): + self.raw_comps.append([eid, peid]) + self.consumed.add(eid) + self.consumed.add(peid) + ve: Edge = self._add_virt(v, w) + self.cur_tree.add(ve.id) + self.cur_parent_edge[v] = ve.id + self.adj_cache[v] = list( + self.g.adj_edge_ids(v)) + self.adj_cache[w] = list( + self.g.adj_edge_ids(w)) + else: + self.estack.append(eid) + else: + self.estack.append(eid) + + def _eval_type2_conds( + self, v_num: int, w: Hashable, + ) -> tuple[bool, bool]: + """Evaluate the two type-2 loop conditions. + + Separates the boolean ``and``/``or`` sequences + from ``_check_type2`` to keep cognitive complexity + within limits. + + :param v_num: DFS number of the current vertex. + :param w: The child vertex. + :return: (top_cond, deg2_cond) tuple. + """ + top_cond: bool = ( + self.tstack + and self.tstack[-1] != _EOS + and self.tstack[-1][1] == v_num) + deg2_cond: bool = ( + self.cur_deg.get(w, 0) == 2 + and self._temp_target_num(w) + > self.dfs_num[w]) + return top_cond, deg2_cond + + def _type2_try_parent_pop( + self, v: Hashable, top_cond: bool, + ) -> bool: + """Try to pop a TSTACK triple whose parent is v. + + If the top-of-stack condition holds and the parent of + b is v, pops the triple and returns True. Otherwise + returns False without modifying state. + + :param v: The current vertex. + :param top_cond: Whether the TSTACK top condition holds. + :return: True if a triple was popped, False otherwise. + """ + if not top_cond: + return False + b: int = self.tstack[-1][2] + b_v: Hashable = self.inv_dfs[b] + if self.parent.get(b_v) != v: + return False + self.tstack.pop() + return True + + def _type2_apply( + self, v: Hashable, w: Hashable, + deg2_cond: bool, + ) -> Hashable | None: + """Apply a type-2 split (deg2 or top variant). + + Delegates to ``_check_type2_deg2`` when *deg2_cond* + is true, otherwise to ``_check_type2_top``. + + :param v: The current vertex. + :param w: The current child vertex. + :param deg2_cond: Whether the degree-2 condition holds. + :return: New w vertex, or None. + """ + if deg2_cond: + return self._check_type2_deg2(v, w) + return self._check_type2_top(v) + + def _check_type2( + self, v: Hashable, w: Hashable, + ) -> Hashable: + """Check for type-2 separation pairs (Algorithm 5). + + Implements Algorithm 5 from Gutwenger-Mutzel (2001). + May update w if the algorithm creates new virtual edges. + + Priority follows SageMath: (1) if top_cond and parent + of b_v is v, pop TSTACK and continue; (2) if deg2_cond, + do deg2 split; (3) otherwise do top split. + + :param v: The current vertex label. + :param w: The child vertex (just processed). + :return: Updated w (may change if edges merge). + """ + v_num: int = self.dfs_num[v] + if v_num == 1: + return w + + while True: + top_cond, deg2_cond = self._eval_type2_conds( + v_num, w) + if not top_cond and not deg2_cond: + break + if self._type2_try_parent_pop(v, top_cond): + continue + new_w: Hashable | None = self._type2_apply( + v, w, deg2_cond) + if new_w is not None: + w = new_w + elif deg2_cond: + break + + return w + + def _check_type2_top( + self, v: Hashable, + ) -> Hashable | None: + """Handle TSTACK-based type-2 separation pair. + + Pops the top triple and splits edges in the range + [a, h]. The parent-check pop is handled by the + caller. Returns the new w vertex, or None to signal + that the caller should continue (no-op pop). + + :param v: The current vertex. + :return: New w vertex, or None for continue. + """ + h, a, b = self.tstack[-1] + self.tstack.pop() + a_v: Hashable = self.inv_dfs[a] + b_v = self.inv_dfs[b] + comp_eids, e_ab = self._pop_range_edges(a, b, h) + if not comp_eids: + if e_ab is not None: + self.estack.append(e_ab) + return None + if e_ab is not None: + e_ab_ed: Edge | None = self.g.get_edge(e_ab) + self._del_high(e_ab) + self.consumed.add(e_ab) + if e_ab_ed is not None: + self.cur_deg[e_ab_ed.u] -= 1 + self.cur_deg[e_ab_ed.v] -= 1 + ve: Edge = self._add_virt(a_v, b_v) + comp_eids.append(ve.id) + self.raw_comps.append(comp_eids) + if e_ab is not None: + ve2: Edge = self._add_virt(a_v, b_v) + self.raw_comps.append([e_ab, ve.id, ve2.id]) + ve = ve2 + self.estack.append(ve.id) + self.adj_cache.setdefault( + a_v, []).append(ve.id) + self.cur_deg[a_v] += 1 + self.cur_deg[b_v] += 1 + self.cur_tree.add(ve.id) + self.parent[b_v] = v + self.cur_parent_edge[b_v] = ve.id + return b_v + + def _pop_range_edges( + self, a: int, b: int, h: int, + ) -> tuple[list[int], int | None]: + """Pop ESTACK edges within DFS range [a, h]. + + Pops edges where both endpoints have DFS numbers in + [a, h]. The edge connecting vertices a and b (if + found) is separated out as *e_ab* and not consumed. + + :param a: Lower bound of DFS range. + :param b: DFS number of the second separation vertex. + :param h: Upper bound of DFS range. + :return: (comp_eids, e_ab) — component edge IDs and + the optional {a, b} edge ID. + """ + comp_eids: list[int] = [] + e_ab: int | None = None + while self.estack: + eid: int = self.estack[-1] + ed: Edge | None = self.g.get_edge(eid) + if ed is None: + self.estack.pop() + continue + eu: int = self.dfs_num.get(ed.u, -1) + ev_: int = self.dfs_num.get(ed.v, -1) + if not (a <= eu <= h and a <= ev_ <= h): + break + self.estack.pop() + if {eu, ev_} == {a, b}: + e_ab = eid + else: + self._del_high(eid) + self.consumed.add(eid) + self.cur_deg[ed.u] -= 1 + self.cur_deg[ed.v] -= 1 + comp_eids.append(eid) + return comp_eids, e_ab + + def _check_type2_deg2( + self, v: Hashable, w: Hashable, + ) -> Hashable | None: + """Handle degree-2 based type-2 separation pair. + + Pops two edges from ESTACK and creates a split + component. Returns the new w vertex, or None to + signal that the caller should break. + + :param v: The current vertex. + :param w: The current child vertex. + :return: New w vertex, or None for break. + """ + if len(self.estack) < 2: + return None + e1id: int = self.estack.pop() + e2id: int = self.estack.pop() + ed1: Edge | None = self.g.get_edge(e1id) + ed2: Edge | None = self.g.get_edge(e2id) + if ed1 is None or ed2 is None: + if ed2 is not None: + self.estack.append(e2id) + if ed1 is not None: + self.estack.append(e1id) + return None + verts: set[Hashable] = ( + {ed1.u, ed1.v, ed2.u, ed2.v} - {v, w}) + if not verts: + self.estack.append(e2id) + self.estack.append(e1id) + return None + b_v: Hashable = min( + verts, key=lambda x: self.dfs_num.get(x, 0)) + self.consumed.add(e1id) + self.consumed.add(e2id) + ve: Edge = self._add_virt(v, b_v) + self.cur_deg[v] -= 1 + self.cur_deg[b_v] -= 1 + comp_eids: list[int] = [e1id, e2id, ve.id] + self.raw_comps.append(comp_eids) + e_ab: int | None = None + if self.estack: + top_e: Edge | None = self.g.get_edge( + self.estack[-1]) + if (top_e is not None + and {top_e.u, top_e.v} == {v, b_v}): + e_ab = self.estack.pop() + self._del_high(e_ab) + self.consumed.add(e_ab) + if e_ab is not None: + ve2: Edge = self._add_virt(v, b_v) + self.raw_comps.append([e_ab, ve.id, ve2.id]) + self.cur_deg[v] -= 1 + self.cur_deg[b_v] -= 1 + ve = ve2 + self.estack.append(ve.id) + self.adj_cache.setdefault( + v, []).append(ve.id) + self.cur_deg[v] += 1 + self.cur_deg[b_v] += 1 + self.cur_tree.add(ve.id) + self.parent[b_v] = v + self.cur_parent_edge[b_v] = ve.id + return b_v + + def _pop_subtree_edges( + self, w_lo: int, w_hi: int, + ) -> list[int]: + """Pop ESTACK edges within a subtree DFS range. + + Pops edges whose at least one endpoint has a DFS + number in [w_lo, w_hi]. Decrements degrees for + consumed edges. + + :param w_lo: Lower bound of subtree DFS range. + :param w_hi: Upper bound of subtree DFS range. + :return: List of popped edge IDs. + """ + comp_eids: list[int] = [] + while self.estack: + eid: int = self.estack[-1] + ed: Edge | None = self.g.get_edge(eid) + if ed is None: + self.estack.pop() + continue + eu: int = self.dfs_num.get(ed.u, -1) + ev_: int = self.dfs_num.get(ed.v, -1) + in_range: bool = ((w_lo <= eu <= w_hi) + or (w_lo <= ev_ <= w_hi)) + if not in_range: + break + self.estack.pop() + self._del_high(eid) + self.consumed.add(eid) + comp_eids.append(eid) + self.cur_deg[ed.u] -= 1 + self.cur_deg[ed.v] -= 1 + return comp_eids + + def _type1_try_combine( + self, v: Hashable, lp1w_v: Hashable, + ve: Edge, + ) -> Edge: + """Try to combine ESTACK top into a bond. + + If the ESTACK top edge connects {v, lp1w_v}, pops it + and creates a bond component. Returns the (possibly + updated) virtual edge. + + :param v: The current vertex. + :param lp1w_v: The lowpt1(w) vertex. + :param ve: The current virtual edge. + :return: Updated virtual edge (may be a new one). + """ + if not self.estack: + return ve + top_e: Edge | None = self.g.get_edge( + self.estack[-1]) + if (top_e is None + or {top_e.u, top_e.v} != {v, lp1w_v}): + return ve + e_top: int = self.estack.pop() + self.consumed.add(e_top) + ve2: Edge = self._add_virt(v, lp1w_v) + self.raw_comps.append( + [e_top, ve.id, ve2.id]) + self.cur_deg[v] -= 1 + self.cur_deg[lp1w_v] -= 1 + if e_top in self.in_high: + self.in_high[ve2.id] = \ + self.in_high.pop(e_top) + return ve2 + + def _type1_parent_bond( + self, v: Hashable, lp1w_v: Hashable, + ve: Edge, + ) -> None: + """Handle the lp1w == pv_num branch of type-1. + + Creates a new virtual edge replacing the parent edge, + and appends a bond component. + + :param v: The current vertex. + :param lp1w_v: The lowpt1(w) vertex. + :param ve: The virtual edge for the split component. + :return: None + """ + peid: int | None = self.cur_parent_edge.get(v) + ve2: Edge = self._add_virt(lp1w_v, v) + self.cur_tree.add(ve2.id) + self.cur_parent_edge[v] = ve2.id + if peid is not None and self.g.has_edge(peid): + self.consumed.add(peid) + if peid in self.in_high: + self.in_high[ve2.id] = \ + self.in_high.pop(peid) + self.raw_comps.append( + [ve.id, peid, ve2.id]) + else: + self.raw_comps.append([ve.id, ve2.id]) + + def _check_type1( + self, v: Hashable, w: Hashable, + ) -> None: + """Check for type-1 separation pair (Algorithm 6). + + Implements Algorithm 6 from Gutwenger-Mutzel (2001). + + :param v: The current vertex label. + :param w: The child vertex (just processed). + :return: None + """ + v_num: int = self.dfs_num[v] + w_num: int = self.dfs_num[w] + lp1w: int = self.lowpt1[w] + lp2w: int = self.lowpt2[w] + + if not (lp1w < v_num <= lp2w): + return + pv: Hashable | None = self.parent.get(v) + pv_num: int = (self.dfs_num.get(pv, 0) + if pv is not None else 0) + has_more: bool = _has_unvisited_arc( + v, w_num, self.cur_children, self.dfs_num) + if not (pv_num != 1 or has_more): + return + + w_lo: int = w_num + w_hi: int = w_num + self.nd[w] - 1 + comp_eids: list[int] = self._pop_subtree_edges( + w_lo, w_hi) + + lp1w_v: Hashable = self.inv_dfs[lp1w] + ve: Edge = self._add_virt(v, lp1w_v) + comp_eids.append(ve.id) + self.raw_comps.append(comp_eids) + + ve = self._type1_try_combine(v, lp1w_v, ve) + + if lp1w != pv_num: + self.estack.append(ve.id) + self.adj_cache.setdefault( + v, []).append(ve.id) + self.cur_deg[v] += 1 + self.cur_deg[lp1w_v] += 1 + self.cur_tree.add(ve.id) + if (ve.id not in self.in_high + and self._high(lp1w) < v_num): + bisect.insort( + self.frond_srcs[lp1w], v_num) + self.in_high[ve.id] = (lp1w, v_num) + else: + self._type1_parent_bond(v, lp1w_v, ve) + + ch: list[Hashable] = self.cur_children.get(v, []) + if w in ch: + ch.remove(w) + + +def _phase_pathsearch( + g: MultiGraph, + raw_comps: list[list[int]], + virtual_ids: set[int], +) -> None: + """Detect separation pairs and split components via PathSearch. + + Implements Algorithms 3-6 from Gutwenger-Mutzel (2001). Runs an + iterative DFS over the phi-sorted adjacency lists, maintaining an + edge stack (ESTACK) and a triple stack (TSTACK) to detect type-1 + and type-2 separation pairs. + + :param g: The working multigraph (modified in place). + :param raw_comps: List to append new split components to. + :param virtual_ids: Set to add new virtual edge IDs to. + :return: None + """ + searcher: _PathSearcher = _PathSearcher( + g, raw_comps, virtual_ids) + searcher.run() + + +def _has_unvisited_arc( + v: Hashable, + w_num: int, + cur_children: dict[Hashable, list[Hashable]], + dfs_num: dict[Hashable, int], +) -> bool: + """Check if v has a tree child with DFS number > w_num. + + Used in Algorithm 6 to determine whether v is adjacent to a + not-yet-visited tree arc (i.e., has another child after w). + + :param v: The vertex to check. + :param w_num: DFS number of the current child w. + :param cur_children: Current DFS children for each vertex. + :param dfs_num: DFS discovery numbers. + :return: True if v has another child with dfs_num > w_num. + """ + for c in cur_children.get(v, []): + if dfs_num.get(c, 0) > w_num: + return True + return False + + +# --------------------------------------------------------------------------- +# Phase 3: Classify and merge split components +# --------------------------------------------------------------------------- + +def _make_edge_list( + eids: list[int], + all_edges: dict[int, Edge], +) -> list[Edge]: + """Resolve edge IDs to Edge objects (skipping unknowns). + + :param eids: List of edge IDs. + :param all_edges: Mapping from edge ID to Edge object. + :return: List of Edge objects. + """ + result: list[Edge] = [] + for eid in eids: + if eid in all_edges: + result.append(all_edges[eid]) + return result + + +def _classify_component( + edges: list[Edge], +) -> ComponentType: + """Classify a set of edges as BOND, POLYGON, or TRICONNECTED. + + :param edges: The edges of the component. + :return: The ComponentType. + """ + verts: set[Hashable] = set() + for e in edges: + verts.add(e.u) + verts.add(e.v) + if len(verts) == 2: + return ComponentType.BOND + # Polygon: every vertex has degree 2. + deg_map: dict[Hashable, int] = dict.fromkeys(verts, 0) + for e in edges: + deg_map[e.u] += 1 + deg_map[e.v] += 1 + if all(d == 2 for d in deg_map.values()): + return ComponentType.POLYGON + return ComponentType.TRICONNECTED + + +def _phase_classify_merge( + raw_comps: list[list[int]], + virtual_ids: set[int], + orig_graph: MultiGraph, + work_graph: MultiGraph, +) -> list[TriconnectedComponent]: + """Classify raw split components and merge same-type adjacent ones. + + Classifies each split component as BOND, POLYGON, or TRICONNECTED. + Then merges adjacent components of the same type that share a virtual + edge (Algorithm 2). + + :param raw_comps: Raw split components from Phases 1 and 2. + :param virtual_ids: Set of virtual edge IDs. + :param orig_graph: The original input graph. + :param work_graph: The working graph after PathSearch. + :return: Final list of TriconnectedComponent objects. + """ + # Build a unified edge dictionary. + all_edges: dict[int, Edge] = {} + for e in orig_graph.edges: + all_edges[e.id] = e + for e in work_graph.edges: + if e.id not in all_edges: + all_edges[e.id] = e + + # Build classified components. + comps: list[TriconnectedComponent] = [] + for eids in raw_comps: + edges: list[Edge] = _make_edge_list(eids, all_edges) + if len(edges) < 2: + continue + ctype: ComponentType = _classify_component(edges) + comps.append(TriconnectedComponent( + type=ctype, edges=edges)) + + if not comps: + return comps + + # Merge adjacent same-type components (Algorithm 2). + return _merge_components(comps, virtual_ids) + + +def _uf_find(uf: list[int], x: int) -> int: + """Find root of x's set in the union-find structure. + + Uses path splitting for amortised near-constant time. + + :param uf: The union-find parent array. + :param x: Index to find. + :return: Root index. + """ + while uf[x] != x: + uf[x] = uf[uf[x]] + x = uf[x] + return x + + +def _uf_union(uf: list[int], x: int, y: int) -> None: + """Union the sets containing x and y. + + :param uf: The union-find parent array. + :param x: First element. + :param y: Second element. + :return: None + """ + rx: int = _uf_find(uf, x) + ry: int = _uf_find(uf, y) + if rx != ry: + uf[rx] = ry + + +def _collect_merge_groups( + comps: list[TriconnectedComponent], + virtual_ids: set[int], +) -> tuple[dict[int, list[int]], set[int]]: + """Identify groups of same-type components to merge. + + Uses union-find to group components that share virtual edges + and have the same type. + + :param comps: Initial classified components. + :param virtual_ids: Set of virtual edge IDs. + :return: A (groups, internal_ves) tuple where groups maps + representative index to list of member indices, and + internal_ves is the set of virtual edge IDs consumed + by merging. + """ + n: int = len(comps) + + # Map: virtual edge ID -> list of component indices. + ve_to_comps: dict[int, list[int]] = {} + for i, comp in enumerate(comps): + for e in comp.edges: + if e.id in virtual_ids: + ve_to_comps.setdefault(e.id, []).append(i) + + # Union-Find for merging groups. + uf: list[int] = list(range(n)) + + # Virtual edge IDs consumed by merging. + internal_ves: set[int] = set() + + for veid, idxs in ve_to_comps.items(): + if len(idxs) < 2: + continue + types_set: set[ComponentType] = { + comps[i].type for i in idxs} + if len(types_set) == 1: + for k in range(1, len(idxs)): + _uf_union(uf, idxs[0], idxs[k]) + internal_ves.add(veid) + + # Group component indices by representative. + groups: dict[int, list[int]] = {} + for i in range(n): + r: int = _uf_find(uf, i) + groups.setdefault(r, []).append(i) + + return groups, internal_ves + + +def _merge_group_edges( + comps: list[TriconnectedComponent], + group_idxs: list[int], + internal_ves: set[int], +) -> TriconnectedComponent | None: + """Merge edges from multiple same-type components. + + Combines all edges from the group, removing internal virtual + edges that were shared between the merged components. + + :param comps: All classified components. + :param group_idxs: Indices of components in this group. + :param internal_ves: Virtual edge IDs to exclude. + :return: A merged TriconnectedComponent, or None if fewer + than 2 edges remain. + """ + merged_type: ComponentType = comps[group_idxs[0]].type + seen_eids: set[int] = set() + merged_edges: list[Edge] = [] + for idx in group_idxs: + for e in comps[idx].edges: + if e.id in seen_eids: + continue + seen_eids.add(e.id) + if e.id in internal_ves: + continue + merged_edges.append(e) + if len(merged_edges) >= 2: + return TriconnectedComponent( + type=merged_type, edges=merged_edges) + return None + + +def _build_merged_result( + comps: list[TriconnectedComponent], + groups: dict[int, list[int]], + internal_ves: set[int], +) -> list[TriconnectedComponent]: + """Build the final merged component list from groups. + + :param comps: All classified components. + :param groups: Mapping from representative to member indices. + :param internal_ves: Virtual edge IDs to exclude. + :return: Merged list of TriconnectedComponent objects. + """ + result: list[TriconnectedComponent] = [] + for group_idxs in groups.values(): + if len(group_idxs) == 1: + idx: int = group_idxs[0] + edges: list[Edge] = [ + e for e in comps[idx].edges + if e.id not in internal_ves + ] + result.append(TriconnectedComponent( + type=comps[idx].type, edges=edges)) + continue + merged: TriconnectedComponent | None = \ + _merge_group_edges( + comps, group_idxs, internal_ves) + if merged is not None: + result.append(merged) + return result + + +def _merge_components( + comps: list[TriconnectedComponent], + virtual_ids: set[int], +) -> list[TriconnectedComponent]: + """Merge adjacent same-type components sharing a virtual edge. + + Two or more components are adjacent if they share a virtual edge. + When all components sharing a virtual edge are the same type + (BOND+BOND or POLYGON+POLYGON), they are merged transitively: the + shared virtual edge is removed and the remaining edges are combined. + + :param comps: Initial classified components. + :param virtual_ids: Set of virtual edge IDs. + :return: Merged list of TriconnectedComponent objects. + """ + if len(comps) == 0: + return comps + + groups: dict[int, list[int]] + internal_ves: set[int] + groups, internal_ves = _collect_merge_groups( + comps, virtual_ids) + + return _build_merged_result(comps, groups, internal_ves) diff --git a/src/spqrtree/py.typed b/src/spqrtree/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_graph.py b/tests/test_graph.py new file mode 100644 index 0000000..f5769e5 --- /dev/null +++ b/tests/test_graph.py @@ -0,0 +1,228 @@ +# Pure Python SPQR-Tree implementation. +# Authors: +# imacat@mail.imacat.idv.tw (imacat), 2026/3/1 +# 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 MultiGraph data structure (_graph.py).""" +import unittest +from collections.abc import Hashable + +from spqrtree._graph import Edge, MultiGraph + + +class TestEdge(unittest.TestCase): + """Tests for the Edge dataclass.""" + + def test_edge_creation(self) -> None: + """Test basic edge creation with required attributes.""" + e: Edge = Edge(id=0, u=1, v=2) + self.assertEqual(e.id, 0) + self.assertEqual(e.u, 1) + self.assertEqual(e.v, 2) + self.assertFalse(e.virtual) + + def test_edge_virtual(self) -> None: + """Test creating a virtual edge.""" + e: Edge = Edge(id=1, u=3, v=4, virtual=True) + self.assertTrue(e.virtual) + + def test_edge_endpoints(self) -> None: + """Test that endpoints method returns both endpoints.""" + e: Edge = Edge(id=0, u=1, v=2) + self.assertEqual(e.endpoints(), (1, 2)) + + def test_edge_other(self) -> None: + """Test that other() returns the opposite endpoint.""" + e: Edge = Edge(id=0, u=1, v=2) + self.assertEqual(e.other(1), 2) + self.assertEqual(e.other(2), 1) + + +class TestMultiGraphVertices(unittest.TestCase): + """Tests for vertex operations on MultiGraph.""" + + def setUp(self) -> None: + """Set up a fresh MultiGraph for each test.""" + self.g: MultiGraph = MultiGraph() + """The graph under test.""" + + def test_add_vertex(self) -> None: + """Test adding a single vertex.""" + self.g.add_vertex(1) + self.assertIn(1, self.g.vertices) + + def test_add_multiple_vertices(self) -> None: + """Test adding multiple vertices.""" + for v in [1, 2, 3, 4]: + self.g.add_vertex(v) + self.assertEqual(set(self.g.vertices), {1, 2, 3, 4}) + + def test_add_duplicate_vertex(self) -> None: + """Test that adding a duplicate vertex is a no-op.""" + self.g.add_vertex(1) + self.g.add_vertex(1) + self.assertEqual(len(self.g.vertices), 1) + + def test_remove_vertex(self) -> None: + """Test removing a vertex also removes its edges.""" + self.g.add_vertex(1) + self.g.add_vertex(2) + self.g.add_edge(1, 2) + self.g.remove_vertex(1) + self.assertNotIn(1, self.g.vertices) + self.assertEqual(len(self.g.edges), 0) + + def test_vertex_count(self) -> None: + """Test vertex count after additions.""" + for v in range(5): + self.g.add_vertex(v) + self.assertEqual(self.g.num_vertices(), 5) + + +class TestMultiGraphEdges(unittest.TestCase): + """Tests for edge operations on MultiGraph.""" + + def setUp(self) -> None: + """Set up a fresh MultiGraph with two vertices.""" + self.g: MultiGraph = MultiGraph() + """The graph under test.""" + self.g.add_vertex(1) + self.g.add_vertex(2) + self.g.add_vertex(3) + + def test_add_edge(self) -> None: + """Test adding an edge between two vertices.""" + e: Edge = self.g.add_edge(1, 2) + self.assertIn(e.id, {e2.id for e2 in self.g.edges}) + self.assertEqual(e.u, 1) + self.assertEqual(e.v, 2) + + def test_add_edge_returns_edge(self) -> None: + """Test that add_edge returns an Edge object.""" + e: Edge = self.g.add_edge(1, 2) + self.assertIsInstance(e, Edge) + + def test_add_parallel_edges(self) -> None: + """Test adding parallel edges between the same pair.""" + e1: Edge = self.g.add_edge(1, 2) + e2: Edge = self.g.add_edge(1, 2) + self.assertNotEqual(e1.id, e2.id) + self.assertEqual(len(self.g.edges_between(1, 2)), 2) + + def test_remove_edge(self) -> None: + """Test removing a specific edge by ID.""" + e: Edge = self.g.add_edge(1, 2) + self.g.remove_edge(e.id) + self.assertEqual( + len(self.g.edges_between(1, 2)), 0) + + def test_remove_one_parallel_edge(self) -> None: + """Test removing one of several parallel edges.""" + e1: Edge = self.g.add_edge(1, 2) + e2: Edge = self.g.add_edge(1, 2) + self.g.remove_edge(e1.id) + remaining: list[Edge] = ( + self.g.edges_between(1, 2)) + self.assertEqual(len(remaining), 1) + self.assertEqual(remaining[0].id, e2.id) + + def test_edge_count(self) -> None: + """Test total edge count.""" + self.g.add_edge(1, 2) + self.g.add_edge(2, 3) + self.g.add_edge(1, 2) + self.assertEqual(self.g.num_edges(), 3) + + def test_add_virtual_edge(self) -> None: + """Test adding a virtual edge.""" + e: Edge = self.g.add_edge(1, 2, virtual=True) + self.assertTrue(e.virtual) + + def test_edges_property(self) -> None: + """Test that edges property returns all edges.""" + e1: Edge = self.g.add_edge(1, 2) + e2: Edge = self.g.add_edge(2, 3) + ids: set[int] = {e.id for e in self.g.edges} + self.assertIn(e1.id, ids) + self.assertIn(e2.id, ids) + + +class TestMultiGraphNeighbors(unittest.TestCase): + """Tests for neighbor/adjacency operations on MultiGraph.""" + + def setUp(self) -> None: + """Set up a triangle graph (K3).""" + self.g: MultiGraph = MultiGraph() + """The graph under test.""" + for v in [1, 2, 3]: + self.g.add_vertex(v) + self.g.add_edge(1, 2) + self.g.add_edge(2, 3) + self.g.add_edge(1, 3) + + def test_neighbors(self) -> None: + """Test neighbors returns all adjacent vertices.""" + nbrs: list[Hashable] = self.g.neighbors(1) + self.assertEqual(set(nbrs), {2, 3}) + + def test_neighbors_with_parallel_edges(self) -> None: + """Test neighbors returns unique vertices with parallel edges.""" + self.g.add_edge(1, 2) + nbrs: list[Hashable] = self.g.neighbors(1) + self.assertEqual(set(nbrs), {2, 3}) + + def test_incident_edges(self) -> None: + """Test incident_edges returns edges incident to a vertex.""" + edges: list[Edge] = self.g.incident_edges(1) + self.assertEqual(len(edges), 2) + + def test_edges_between(self) -> None: + """Test edges_between returns edges between two vertices.""" + edges: list[Edge] = self.g.edges_between(1, 2) + self.assertEqual(len(edges), 1) + self.assertEqual(edges[0].u, 1) + self.assertEqual(edges[0].v, 2) + + def test_degree(self) -> None: + """Test degree counts incident edges (with multiplicity).""" + self.assertEqual(self.g.degree(1), 2) + self.g.add_edge(1, 2) + self.assertEqual(self.g.degree(1), 3) + + +class TestMultiGraphCopy(unittest.TestCase): + """Tests for copying operations on MultiGraph.""" + + def test_copy_is_independent(self) -> None: + """Test that a copy is independent from the original.""" + g: MultiGraph = MultiGraph() + g.add_vertex(1) + g.add_vertex(2) + g.add_edge(1, 2) + g2: MultiGraph = g.copy() + g2.add_vertex(3) + self.assertNotIn(3, g.vertices) + + def test_copy_has_same_structure(self) -> None: + """Test that a copy has the same vertices and edges.""" + g: MultiGraph = MultiGraph() + for v in [1, 2, 3]: + g.add_vertex(v) + g.add_edge(1, 2) + g.add_edge(2, 3) + g2: MultiGraph = g.copy() + self.assertEqual(set(g2.vertices), {1, 2, 3}) + self.assertEqual(g2.num_edges(), 2) diff --git a/tests/test_palm_tree.py b/tests/test_palm_tree.py new file mode 100644 index 0000000..9aadd09 --- /dev/null +++ b/tests/test_palm_tree.py @@ -0,0 +1,471 @@ +# Pure Python SPQR-Tree implementation. +# Authors: +# imacat@mail.imacat.idv.tw (imacat), 2026/3/1 +# 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 palm tree construction (_palm_tree.py). + +All DFS results are based on insertion-order adjacency traversal and +DFS from vertex 1. Edge insertion order is specified in each test. +""" +import unittest +from collections.abc import Hashable + +from spqrtree._graph import Edge, MultiGraph +from spqrtree._palm_tree import PalmTree, build_palm_tree, phi_key + + +def _make_k3() -> tuple[MultiGraph, list[int]]: + """Build the triangle graph K3 (vertices 1,2,3; edges 1-2, 2-3, 1-3). + + :return: A tuple (graph, edge_ids) where edge_ids is + [e0.id, e1.id, e2.id]. + """ + g: MultiGraph = MultiGraph() + for v in [1, 2, 3]: + g.add_vertex(v) + e0: Edge = g.add_edge(1, 2) + e1: Edge = g.add_edge(2, 3) + e2: Edge = g.add_edge(1, 3) + return g, [e0.id, e1.id, e2.id] + + +def _make_p3() -> tuple[MultiGraph, list[int]]: + """Build the path graph P3 (vertices 1,2,3; edges 1-2, 2-3). + + :return: A tuple (graph, edge_ids) where edge_ids is [e0.id, e1.id]. + """ + g: MultiGraph = MultiGraph() + for v in [1, 2, 3]: + g.add_vertex(v) + e0: Edge = g.add_edge(1, 2) + e1: Edge = g.add_edge(2, 3) + return g, [e0.id, e1.id] + + +def _make_c4() -> tuple[MultiGraph, list[int]]: + """Build the 4-cycle C4 (vertices 1,2,3,4; edges 1-2,2-3,3-4,4-1). + + :return: A tuple (graph, edge_ids) in insertion order. + """ + g: MultiGraph = MultiGraph() + for v in [1, 2, 3, 4]: + g.add_vertex(v) + e0: Edge = g.add_edge(1, 2) + e1: Edge = g.add_edge(2, 3) + e2: Edge = g.add_edge(3, 4) + e3: Edge = g.add_edge(4, 1) + return g, [e0.id, e1.id, e2.id, e3.id] + + +class TestPalmTreeType(unittest.TestCase): + """Tests that build_palm_tree returns a PalmTree instance.""" + + def test_returns_palm_tree(self) -> None: + """Test that build_palm_tree returns a PalmTree object.""" + g: MultiGraph + g, _ = _make_k3() + pt: PalmTree = build_palm_tree(g, 1) + self.assertIsInstance(pt, PalmTree) + + +class TestPalmTreePath(unittest.TestCase): + """Tests for palm tree on a path graph P3 (no back edges).""" + + def setUp(self) -> None: + """Build palm tree for P3 starting at vertex 1.""" + g: MultiGraph + g, eids = _make_p3() + self.eids: list[int] = eids + """The edge IDs of the P3 graph.""" + self.pt: PalmTree = build_palm_tree(g, 1) + """The palm tree for the graph.""" + + def test_dfs_num_root(self) -> None: + """Test that the start vertex has DFS number 1.""" + self.assertEqual(self.pt.dfs_num[1], 1) + + def test_dfs_num_order(self) -> None: + """Test that DFS numbers are assigned 1, 2, 3 in traversal order.""" + nums: list[int] = sorted(self.pt.dfs_num.values()) + self.assertEqual(nums, [1, 2, 3]) + + def test_tree_edges_count(self) -> None: + """Test that there are n-1 = 2 tree edges.""" + self.assertEqual(len(self.pt.tree_edges), 2) + + def test_no_fronds(self) -> None: + """Test that there are no fronds on a tree path.""" + self.assertEqual(len(self.pt.fronds), 0) + + def test_all_edges_are_tree_edges(self) -> None: + """Test that both edges are classified as tree edges.""" + e0, e1 = self.eids + self.assertIn(e0, self.pt.tree_edges) + self.assertIn(e1, self.pt.tree_edges) + + def test_parent_root(self) -> None: + """Test that the root has no parent (None).""" + self.assertIsNone(self.pt.parent.get(1)) + + def test_nd_values(self) -> None: + """Test that ND values are correct for P3.""" + # DFS from 1: 1→2→3 (since 2 is first in adj[1]) + self.assertEqual(self.pt.nd[1], 3) + + def test_nd_leaf(self) -> None: + """Test that a leaf vertex has ND = 1.""" + # Vertex 3 is a leaf in P3 + self.assertEqual(self.pt.nd[3], 1) + + def test_lowpt1_values(self) -> None: + """Test lowpt1 values for P3 (all vertices reach only themselves).""" + for v in [1, 2, 3]: + self.assertLessEqual( + self.pt.lowpt1[v], self.pt.dfs_num[v] + ) + + def test_lowpt1_no_fronds(self) -> None: + """Test that lowpt1[v] == dfs_num[v] when no fronds exist.""" + for v in [1, 2, 3]: + self.assertEqual(self.pt.lowpt1[v], self.pt.dfs_num[v]) + + +class TestPalmTreeTriangle(unittest.TestCase): + """Tests for palm tree on the triangle graph K3.""" + + def setUp(self) -> None: + """Build palm tree for K3 starting at vertex 1.""" + g: MultiGraph + g, eids = _make_k3() + self.eids: list[int] = eids + """The edge IDs of the K3 graph.""" + self.pt: PalmTree = build_palm_tree(g, 1) + """The palm tree for the graph.""" + + def test_dfs_num_root(self) -> None: + """Test that vertex 1 has DFS number 1.""" + self.assertEqual(self.pt.dfs_num[1], 1) + + def test_dfs_num_all_assigned(self) -> None: + """Test that all vertices get a DFS number.""" + self.assertEqual(set(self.pt.dfs_num.keys()), {1, 2, 3}) + + def test_tree_edge_count(self) -> None: + """Test that there are n-1 = 2 tree edges.""" + self.assertEqual(len(self.pt.tree_edges), 2) + + def test_frond_count(self) -> None: + """Test that there is exactly 1 frond.""" + self.assertEqual(len(self.pt.fronds), 1) + + def test_frond_is_back_edge(self) -> None: + """Test that the frond e2 (1-3) is classified as a frond.""" + # e2 = edges[2] = the third edge added (1-3) + e0, e1, e2 = self.eids + self.assertIn(e2, self.pt.fronds) + self.assertIn(e0, self.pt.tree_edges) + self.assertIn(e1, self.pt.tree_edges) + + def test_lowpt1_all_reach_root(self) -> None: + """Test that all vertices have lowpt1 = 1 via frond.""" + for v in [1, 2, 3]: + self.assertEqual(self.pt.lowpt1[v], 1) + + def test_nd_root(self) -> None: + """Test that the root has nd = 3.""" + self.assertEqual(self.pt.nd[1], 3) + + def test_nd_leaf(self) -> None: + """Test that the DFS leaf (vertex 3) has nd = 1.""" + # Vertex 3 is visited last in K3 with DFS from 1 + leaf: Hashable = next( + v for v, n in self.pt.nd.items() if n == 1 + ) + self.assertEqual(self.pt.nd[leaf], 1) + + def test_first_child_of_root(self) -> None: + """Test that root vertex 1 has a first child.""" + self.assertIsNotNone(self.pt.first_child.get(1)) + + def test_first_child_of_leaf(self) -> None: + """Test that the DFS leaf has no first child.""" + leaf: Hashable = next( + v for v, n in self.pt.nd.items() if n == 1 + ) + self.assertIsNone(self.pt.first_child.get(leaf)) + + def test_lowpt1_le_dfs_num(self) -> None: + """Test that lowpt1[v] <= dfs_num[v] for all v.""" + for v in [1, 2, 3]: + self.assertLessEqual(self.pt.lowpt1[v], self.pt.dfs_num[v]) + + def test_lowpt1_le_lowpt2(self) -> None: + """Test that lowpt1[v] <= lowpt2[v] for all v.""" + for v in [1, 2, 3]: + self.assertLessEqual(self.pt.lowpt1[v], self.pt.lowpt2[v]) + + +class TestPalmTreeC4(unittest.TestCase): + """Tests for palm tree on the 4-cycle C4.""" + + def setUp(self) -> None: + """Build palm tree for C4 starting at vertex 1.""" + g: MultiGraph + g, eids = _make_c4() + self.eids: list[int] = eids + """The edge IDs of the C4 graph.""" + self.pt: PalmTree = build_palm_tree(g, 1) + """The palm tree for the graph.""" + + def test_dfs_num_root(self) -> None: + """Test that vertex 1 has DFS number 1.""" + self.assertEqual(self.pt.dfs_num[1], 1) + + def test_dfs_num_all_assigned(self) -> None: + """Test that all 4 vertices get DFS numbers.""" + self.assertEqual(set(self.pt.dfs_num.keys()), {1, 2, 3, 4}) + + def test_tree_edge_count(self) -> None: + """Test that there are n-1 = 3 tree edges.""" + self.assertEqual(len(self.pt.tree_edges), 3) + + def test_frond_count(self) -> None: + """Test that there is exactly 1 frond.""" + self.assertEqual(len(self.pt.fronds), 1) + + def test_frond_classification(self) -> None: + """Test that e3 (4-1) is a frond and e0,e1,e2 are tree edges.""" + e0, e1, e2, e3 = self.eids + self.assertIn(e0, self.pt.tree_edges) + self.assertIn(e1, self.pt.tree_edges) + self.assertIn(e2, self.pt.tree_edges) + self.assertIn(e3, self.pt.fronds) + + def test_nd_root(self) -> None: + """Test that root vertex 1 has nd = 4.""" + self.assertEqual(self.pt.nd[1], 4) + + def test_nd_leaf(self) -> None: + """Test that the DFS leaf has nd = 1.""" + leaf: Hashable = next( + v for v, n in self.pt.nd.items() if n == 1 + ) + self.assertEqual(self.pt.nd[leaf], 1) + + def test_lowpt1_all_reach_root(self) -> None: + """Test that all vertices have lowpt1 = 1 due to the frond.""" + for v in [1, 2, 3, 4]: + self.assertEqual(self.pt.lowpt1[v], 1) + + def test_lowpt2_intermediate(self) -> None: + """Test that lowpt2 values are consistent (lowpt1 <= lowpt2).""" + for v in [1, 2, 3, 4]: + self.assertLessEqual(self.pt.lowpt1[v], self.pt.lowpt2[v]) + + def test_parent_structure(self) -> None: + """Test that the parent structure is a valid tree rooted at 1.""" + # Root has no parent + self.assertIsNone(self.pt.parent.get(1)) + # All other vertices have a parent + for v in [2, 3, 4]: + self.assertIsNotNone(self.pt.parent.get(v)) + + +class TestPalmTreeLowptSpecific(unittest.TestCase): + """Tests for specific lowpt1/lowpt2 values on P3.""" + + def setUp(self) -> None: + """Build palm tree for P3 (path 1-2-3) starting at 1.""" + g: MultiGraph + g, _ = _make_p3() + self.pt: PalmTree = build_palm_tree(g, 1) + """The palm tree for the graph.""" + + def test_exact_dfs_nums(self) -> None: + """Test exact DFS numbers for the path graph.""" + self.assertEqual(self.pt.dfs_num[1], 1) + self.assertEqual(self.pt.dfs_num[2], 2) + self.assertEqual(self.pt.dfs_num[3], 3) + + def test_exact_lowpt1(self) -> None: + """Test exact lowpt1 values for P3 (no back edges).""" + # Without fronds, lowpt1[v] = dfs_num[v] + self.assertEqual(self.pt.lowpt1[3], 3) + self.assertEqual(self.pt.lowpt1[2], 2) + self.assertEqual(self.pt.lowpt1[1], 1) + + def test_exact_nd(self) -> None: + """Test exact nd values for P3.""" + self.assertEqual(self.pt.nd[3], 1) + self.assertEqual(self.pt.nd[2], 2) + self.assertEqual(self.pt.nd[1], 3) + + def test_exact_parent(self) -> None: + """Test exact parent mapping for P3.""" + self.assertIsNone(self.pt.parent.get(1)) + self.assertEqual(self.pt.parent[2], 1) + self.assertEqual(self.pt.parent[3], 2) + + def test_exact_first_child(self) -> None: + """Test exact first_child mapping for P3.""" + self.assertEqual(self.pt.first_child[1], 2) + self.assertEqual(self.pt.first_child[2], 3) + self.assertIsNone(self.pt.first_child.get(3)) + + +class TestPhiKeyP3(unittest.TestCase): + """Tests for phi_key correctness on the path graph P3. + + P3: vertices 1,2,3; edges 1-2 (tree), 2-3 (tree). + DFS from 1: dfs_num = {1:1, 2:2, 3:3}. + No fronds; lowpt1[v] = dfs_num[v] for all v. + """ + + def setUp(self) -> None: + """Build palm tree for P3 and gather data for phi_key tests.""" + g: MultiGraph + g, eids = _make_p3() + self.g: MultiGraph = g + """The P3 graph under test.""" + self.eids: list[int] = eids + """The edge IDs of the P3 graph.""" + self.pt: PalmTree = build_palm_tree(g, 1) + """The palm tree for the graph.""" + + def test_tree_edge_1_2_case3_formula(self) -> None: + """Test phi_key for tree edge 1-2 uses case 3 formula. + + For tree edge v=1, w=2: + lowpt1[2]=2, lowpt2[2]=INF, dfs_num[1]=1. + lowpt2[2] >= dfs_num[1] -> case 3 -> phi = 3*lowpt1[2]+2 = 8. + """ + e0: int = self.eids[0] + key: int = phi_key( + v=1, eid=e0, pt=self.pt, graph=self.g, + ) + # lowpt1[2]=2, case 3: 3*2+2 = 8 + self.assertEqual(key, 3 * 2 + 2) + + def test_tree_edge_2_3_case3_formula(self) -> None: + """Test phi_key for tree edge 2-3 uses case 3 formula. + + For tree edge v=2, w=3: + lowpt1[3]=3, lowpt2[3]=INF, dfs_num[2]=2. + lowpt2[3] >= dfs_num[2] -> case 3 -> phi = 3*lowpt1[3]+2 = 11. + """ + e1: int = self.eids[1] + key: int = phi_key( + v=2, eid=e1, pt=self.pt, graph=self.g, + ) + # lowpt1[3]=3, case 3: 3*3+2 = 11 + self.assertEqual(key, 3 * 3 + 2) + + def test_tree_edge_case3_greater_than_case1(self) -> None: + """Test that case-3 phi > case-1 phi for ordering. + + Case 1: phi = 3*lowpt1[w] + Case 3: phi = 3*lowpt1[w]+2 + Case 3 must be strictly greater than case 1 for same lowpt1. + """ + e0: int = self.eids[0] + key_v1: int = phi_key( + v=1, eid=e0, pt=self.pt, graph=self.g, + ) + # Case 3 value (8) > case 1 value (6) for lowpt1[2]=2 + self.assertGreater(key_v1, 3 * 2) + + +class TestPhiKeyK3Frond(unittest.TestCase): + """Tests for phi_key correctness on the triangle K3. + + K3: vertices 1,2,3; edges 1-2 (tree), 2-3 (tree), 1-3 (frond). + DFS from 1: dfs_num = {1:1, 2:2, 3:3}. + Frond: 1-3 (from vertex 3 to ancestor 1). + """ + + def setUp(self) -> None: + """Build palm tree for K3 and gather data for phi_key tests.""" + g: MultiGraph + g, eids = _make_k3() + self.g: MultiGraph = g + """The K3 graph under test.""" + self.eids: list[int] = eids + """The edge IDs of the K3 graph.""" + self.pt: PalmTree = build_palm_tree(g, 1) + """The palm tree for the graph.""" + + def test_frond_phi_uses_w_dfs_num(self) -> None: + """Test phi_key for frond e2 (1-3) from v=3 uses dfs_num[w=1]. + + Frond from v=3 to ancestor w=1: dfs_num[w]=1. + phi = 3 * dfs_num[w] + 1 = 3 * 1 + 1 = 4. + """ + e2: int = self.eids[2] # edge 1-3 (frond) + # The frond is traversed from vertex 3 toward ancestor 1. + # We need to identify which end is the frond source. + # In the palm tree, frond is from v=3 (dfs_num=3) to w=1 + # (dfs_num=1). + # phi_key called with v=3, w=1 (ancestor), eid=e2. + key: int = phi_key( + v=3, eid=e2, pt=self.pt, graph=self.g, + ) + # Correct formula: 3 * dfs_num[w=1] + 1 = 3*1+1 = 4 + self.assertEqual(key, 3 * 1 + 1) + + def test_frond_phi_different_from_v_formula(self) -> None: + """Test that frond phi uses w (not v) DFS number. + + The buggy formula uses dfs_num[v] (= 3) giving 3*3+2 = 11. + The correct formula uses dfs_num[w] (= 1) giving 3*1+1 = 4. + These must differ. + """ + e2: int = self.eids[2] + key: int = phi_key( + v=3, eid=e2, pt=self.pt, graph=self.g, + ) + # The buggy value would be 3*dfs_num[v=3]+2 = 3*3+2 = 11. + # The correct value is 3*dfs_num[w=1]+1 = 4. + self.assertNotEqual(key, 11) + self.assertEqual(key, 4) + + def test_frond_phi_less_than_tree_edge_case3(self) -> None: + """Test ordering: frond phi < tree-edge case-3 phi. + + The frond phi (4) should be less than a case-3 tree-edge phi + with the same lowpt1 (3*1+2=5), ensuring correct DFS order. + """ + e2: int = self.eids[2] + frond_key: int = phi_key( + v=3, eid=e2, pt=self.pt, graph=self.g, + ) + # Frond phi = 3*1+1=4; case-3 tree-edge phi at lowpt1=1 = 3*1+2=5 + self.assertLess(frond_key, 3 * 1 + 2) + + def test_tree_edge_case1_condition(self) -> None: + """Test phi_key for tree edge where lowpt2[w] < dfs_num[v]. + + For K3, after sorting: tree edge v=1, w=2: + lowpt1[2]=1, lowpt2[2]=INF, dfs_num[1]=1. + lowpt2[2]=INF >= dfs_num[1]=1 -> case 3 -> phi = 3*1+2 = 5. + """ + e0: int = self.eids[0] # edge 1-2 (tree edge) + key: int = phi_key( + v=1, eid=e0, pt=self.pt, graph=self.g, + ) + # lowpt1[2]=1, lowpt2[2]=INF >= dfs_num[1]=1 -> case 3: + # phi = 3*1+2 = 5 + self.assertEqual(key, 3 * 1 + 2) diff --git a/tests/test_spqrtree.py b/tests/test_spqrtree.py new file mode 100644 index 0000000..bc73689 --- /dev/null +++ b/tests/test_spqrtree.py @@ -0,0 +1,2853 @@ +# 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 SPQR-tree (_spqr.py). + +Tests cover: triangle K3 (S-node), K4 (R-node), C4 (S-node), +two parallel edges (Q-node), and three parallel edges (P-node). +""" +import time +import unittest +from collections import deque +from collections.abc import Hashable + +from spqrtree._graph import Edge, MultiGraph +from spqrtree._spqr import NodeType, SPQRNode, build_spqr_tree + + +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 _collect_all_nodes(root: SPQRNode) -> list[SPQRNode]: + """Collect all nodes in the SPQR-tree by BFS. + + :param root: The root SPQRNode. + :return: List of all SPQRNode objects in BFS order. + """ + result: list[SPQRNode] = [] + bfs_queue: deque[SPQRNode] = deque([root]) + while bfs_queue: + node: SPQRNode = bfs_queue.popleft() + result.append(node) + for child in node.children: + bfs_queue.append(child) + return result + + +class TestSPQRNodeStructure(unittest.TestCase): + """Tests for SPQRNode structure and attributes.""" + + def test_spqrnode_has_type(self) -> None: + """Test that SPQRNode has a type attribute.""" + root: SPQRNode = build_spqr_tree(_make_k3()) + self.assertIsInstance(root.type, NodeType) + + def test_spqrnode_has_skeleton(self) -> None: + """Test that SPQRNode has a skeleton graph.""" + root: SPQRNode = build_spqr_tree(_make_k3()) + self.assertIsInstance(root.skeleton, MultiGraph) + + def test_spqrnode_has_poles(self) -> None: + """Test that SPQRNode has a poles attribute.""" + root: SPQRNode = build_spqr_tree(_make_k3()) + self.assertIsInstance(root.poles, tuple) + self.assertEqual(len(root.poles), 2) + + def test_spqrnode_has_children(self) -> None: + """Test that SPQRNode has a children list.""" + root: SPQRNode = build_spqr_tree(_make_k3()) + self.assertIsInstance(root.children, list) + + def test_spqrnode_parent_is_none_for_root(self) -> None: + """Test that the root SPQRNode has parent = None.""" + root: SPQRNode = build_spqr_tree(_make_k3()) + self.assertIsNone(root.parent) + + def test_children_parent_links(self) -> None: + """Test that children have correct parent links.""" + root: SPQRNode = build_spqr_tree(_make_k3()) + for child in root.children: + self.assertIs(child.parent, root) + + +class TestSPQRK3(unittest.TestCase): + """Tests for the SPQR-tree of the triangle K3.""" + + def setUp(self) -> None: + """Build the SPQR-tree for K3.""" + self.root: SPQRNode = build_spqr_tree(_make_k3()) + """The root node of the SPQR tree.""" + self.all_nodes: list[SPQRNode] = \ + _collect_all_nodes(self.root) + """All SPQR tree nodes.""" + + def test_returns_spqrnode(self) -> None: + """Test that build_spqr_tree returns an SPQRNode.""" + self.assertIsInstance(self.root, SPQRNode) + + def test_root_is_s_node(self) -> None: + """Test that K3 produces an S-node (POLYGON) as root.""" + self.assertEqual(self.root.type, NodeType.S) + + def test_node_types_are_valid(self) -> None: + """Test that all node types are valid NodeType values.""" + for node in self.all_nodes: + self.assertIsInstance(node.type, NodeType) + self.assertIn(node.type, list(NodeType)) + + +class TestSPQRK4(unittest.TestCase): + """Tests for the SPQR-tree of the complete graph K4.""" + + def setUp(self) -> None: + """Build the SPQR-tree for K4.""" + self.root: SPQRNode = build_spqr_tree(_make_k4()) + """The root node of the SPQR tree.""" + self.all_nodes: list[SPQRNode] = \ + _collect_all_nodes(self.root) + """All SPQR tree nodes.""" + + def test_root_is_r_node(self) -> None: + """Test that K4 produces a single R-node.""" + self.assertEqual(self.root.type, NodeType.R) + + def test_skeleton_has_vertices(self) -> None: + """Test that the R-node skeleton has vertices.""" + self.assertGreater(self.root.skeleton.num_vertices(), 0) + + +class TestSPQRC4(unittest.TestCase): + """Tests for the SPQR-tree of the 4-cycle C4.""" + + def setUp(self) -> None: + """Build the SPQR-tree for C4.""" + self.root: SPQRNode = build_spqr_tree(_make_c4()) + """The root node of the SPQR tree.""" + self.all_nodes: list[SPQRNode] = \ + _collect_all_nodes(self.root) + """All SPQR tree nodes.""" + + def test_root_is_s_node(self) -> None: + """Test that C4 produces an S-node (POLYGON) as root.""" + self.assertEqual(self.root.type, NodeType.S) + + def test_node_types_are_valid(self) -> None: + """Test that all node types are valid NodeType values.""" + for node in self.all_nodes: + self.assertIsInstance(node.type, NodeType) + + +class TestSPQRTwoParallel(unittest.TestCase): + """Tests for the SPQR-tree of two parallel edges.""" + + def setUp(self) -> None: + """Build the SPQR-tree for 2 parallel edges.""" + self.root: SPQRNode = \ + build_spqr_tree(_make_two_parallel()) + """The root node of the SPQR tree.""" + self.all_nodes: list[SPQRNode] = \ + _collect_all_nodes(self.root) + """All SPQR tree nodes.""" + + def test_root_is_q_node(self) -> None: + """Test that 2 parallel edges produce a single Q-node.""" + # 2 parallel edges: BOND with 2 edges -> Q-node. + self.assertEqual(self.root.type, NodeType.Q) + + def test_no_children(self) -> None: + """Test that a Q-node has no children.""" + self.assertEqual(len(self.root.children), 0) + + +class TestSPQRThreeParallel(unittest.TestCase): + """Tests for the SPQR-tree of three parallel edges.""" + + def setUp(self) -> None: + """Build the SPQR-tree for 3 parallel edges.""" + self.root: SPQRNode = \ + build_spqr_tree(_make_three_parallel()) + """The root node of the SPQR tree.""" + self.all_nodes: list[SPQRNode] = \ + _collect_all_nodes(self.root) + """All SPQR tree nodes.""" + + def test_root_is_p_node(self) -> None: + """Test that 3 parallel edges produce a single P-node.""" + self.assertEqual(self.root.type, NodeType.P) + + def test_node_types_are_valid(self) -> None: + """Test that all node types are valid NodeType values.""" + for node in self.all_nodes: + self.assertIsInstance(node.type, NodeType) + + +class TestSPQRInvariants(unittest.TestCase): + """Tests for global SPQR-tree invariants across all graphs.""" + + def _check_parent_links(self, root: SPQRNode) -> None: + """Check that parent-child links are consistent. + + :param root: The SPQR-tree root. + :return: None + """ + for node in _collect_all_nodes(root): + for child in node.children: + self.assertIs( + child.parent, + node, + f"Child {child.type} has wrong parent", + ) + + def _check_skeleton_edges(self, root: SPQRNode) -> None: + """Check that each node's skeleton has at least 1 edge. + + :param root: The SPQR-tree root. + :return: None + """ + for node in _collect_all_nodes(root): + self.assertGreater( + node.skeleton.num_edges(), + 0, + f"Node {node.type} has empty skeleton", + ) + + def test_k3_parent_links(self) -> None: + """Test parent link invariant for K3.""" + self._check_parent_links(build_spqr_tree(_make_k3())) + + def test_c4_parent_links(self) -> None: + """Test parent link invariant for C4.""" + self._check_parent_links(build_spqr_tree(_make_c4())) + + def test_k4_parent_links(self) -> None: + """Test parent link invariant for K4.""" + self._check_parent_links(build_spqr_tree(_make_k4())) + + def test_two_parallel_parent_links(self) -> None: + """Test parent link invariant for 2 parallel edges.""" + self._check_parent_links(build_spqr_tree(_make_two_parallel())) + + def test_three_parallel_parent_links(self) -> None: + """Test parent link invariant for 3 parallel edges.""" + self._check_parent_links( + build_spqr_tree(_make_three_parallel()) + ) + + def test_k3_skeleton_edges(self) -> None: + """Test skeleton edge invariant for K3.""" + self._check_skeleton_edges(build_spqr_tree(_make_k3())) + + def test_c4_skeleton_edges(self) -> None: + """Test skeleton edge invariant for C4.""" + self._check_skeleton_edges(build_spqr_tree(_make_c4())) + + def test_k4_skeleton_edges(self) -> None: + """Test skeleton edge invariant for K4.""" + self._check_skeleton_edges(build_spqr_tree(_make_k4())) + + def test_two_parallel_skeleton_edges(self) -> None: + """Test skeleton edge invariant for 2 parallel edges.""" + self._check_skeleton_edges( + build_spqr_tree(_make_two_parallel()) + ) + + def test_three_parallel_skeleton_edges(self) -> None: + """Test skeleton edge invariant for 3 parallel edges.""" + self._check_skeleton_edges( + build_spqr_tree(_make_three_parallel()) + ) + + +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. + + :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. + + :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. 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 TestSPQRDiamond(unittest.TestCase): + """Tests for the SPQR-tree of the diamond graph.""" + + def setUp(self) -> None: + """Build the SPQR-tree for the diamond graph.""" + self.root: SPQRNode = \ + build_spqr_tree(_make_diamond()) + """The root node of the SPQR tree.""" + self.all_nodes: list[SPQRNode] = \ + _collect_all_nodes(self.root) + """All SPQR tree nodes.""" + + def test_at_least_two_nodes(self) -> None: + """Test that diamond produces at least 2 SPQR-tree nodes.""" + self.assertGreaterEqual( + len(self.all_nodes), + 2, + "Diamond has a separation pair, expect >=2 SPQR nodes", + ) + + def test_node_types_are_valid(self) -> None: + """Test that all node types are valid NodeType values.""" + for node in self.all_nodes: + self.assertIsInstance(node.type, NodeType) + self.assertIn(node.type, list(NodeType)) + + def test_no_ss_adjacency(self) -> None: + """Test that no S-node is adjacent to another S-node.""" + _assert_no_ss_pp(self, self.root, NodeType.S) + + def test_no_pp_adjacency(self) -> None: + """Test that no P-node is adjacent to another P-node.""" + _assert_no_ss_pp(self, self.root, NodeType.P) + + def test_parent_links(self) -> None: + """Test that all parent-child links are consistent.""" + for node in self.all_nodes: + for child in node.children: + self.assertIs(child.parent, node) + + +class TestSPQRTheta(unittest.TestCase): + """Tests for the SPQR-tree of the theta graph.""" + + def setUp(self) -> None: + """Build the SPQR-tree for the theta graph.""" + self.root: SPQRNode = \ + build_spqr_tree(_make_theta()) + """The root node of the SPQR tree.""" + self.all_nodes: list[SPQRNode] = \ + _collect_all_nodes(self.root) + """All SPQR tree nodes.""" + + def test_root_is_p_node(self) -> None: + """Test that the theta graph produces a P-node as root. + + The theta graph has a separation pair {1,2} with 3 paths + between them, so the root should be a P-node. + """ + self.assertEqual( + self.root.type, + NodeType.P, + "Theta graph has 3 parallel paths between 1 and 2, " + "expect P-node at root", + ) + + def test_node_types_are_valid(self) -> None: + """Test that all node types are valid NodeType values.""" + for node in self.all_nodes: + self.assertIsInstance(node.type, NodeType) + + def test_no_ss_adjacency(self) -> None: + """Test that no S-node is adjacent to another S-node.""" + _assert_no_ss_pp(self, self.root, NodeType.S) + + def test_no_pp_adjacency(self) -> None: + """Test that no P-node is adjacent to another P-node.""" + _assert_no_ss_pp(self, self.root, NodeType.P) + + def test_parent_links(self) -> None: + """Test that all parent-child links are consistent.""" + for node in self.all_nodes: + for child in node.children: + self.assertIs(child.parent, node) + + +class TestSPQRPrism(unittest.TestCase): + """Tests for the SPQR-tree of the triangular prism graph.""" + + def setUp(self) -> None: + """Build the SPQR-tree for the triangular prism.""" + self.root: SPQRNode = \ + build_spqr_tree(_make_prism()) + """The root node of the SPQR tree.""" + self.all_nodes: list[SPQRNode] = \ + _collect_all_nodes(self.root) + """All SPQR tree nodes.""" + + def test_single_r_node(self) -> None: + """Test that the prism produces a single R-node.""" + self.assertEqual( + len(self.all_nodes), + 1, + "Prism is 3-connected, expect single R-node", + ) + self.assertEqual(self.root.type, NodeType.R) + + def test_no_children(self) -> None: + """Test that the single R-node has no children.""" + self.assertEqual(len(self.root.children), 0) + + def test_skeleton_has_nine_edges(self) -> None: + """Test that the R-node skeleton contains 9 edges.""" + self.assertEqual(self.root.skeleton.num_edges(), 9) + + +def _assert_no_ss_pp( + tc: unittest.TestCase, + root: SPQRNode, + ntype: NodeType, +) -> None: + """Assert that no node of *ntype* is adjacent to another of same type. + + In the SPQR-tree, S-nodes must not be adjacent to S-nodes, and + P-nodes must not be adjacent to P-nodes. + + :param tc: The TestCase instance for assertions. + :param root: The SPQR-tree root node. + :param ntype: The NodeType to check (S or P). + :return: None + """ + for node in _collect_all_nodes(root): + if node.type == ntype: + for child in node.children: + tc.assertNotEqual( + child.type, + ntype, + f"{ntype.value}-{ntype.value} adjacency found " + f"in SPQR-tree (not allowed)", + ) + if node.parent is not None: + tc.assertNotEqual( + node.parent.type, + ntype, + f"{ntype.value}-{ntype.value} adjacency found " + f"in SPQR-tree (not allowed)", + ) + + +class TestSPQRNoSSPPInvariants(unittest.TestCase): + """Tests that no S-S or P-P adjacency occurs for all graphs.""" + + def _check_tree(self, g: MultiGraph) -> None: + """Build SPQR-tree and check S-S and P-P invariants. + + :param g: The input multigraph. + :return: None + """ + root: SPQRNode = build_spqr_tree(g) + _assert_no_ss_pp(self, root, NodeType.S) + _assert_no_ss_pp(self, root, NodeType.P) + + def test_k3_no_ss_pp(self) -> None: + """Test no S-S or P-P adjacency for K3.""" + self._check_tree(_make_k3()) + + def test_c4_no_ss_pp(self) -> None: + """Test no S-S or P-P adjacency for C4.""" + self._check_tree(_make_c4()) + + def test_k4_no_ss_pp(self) -> None: + """Test no S-S or P-P adjacency for K4.""" + self._check_tree(_make_k4()) + + def test_two_parallel_no_ss_pp(self) -> None: + """Test no S-S or P-P adjacency for 2 parallel edges.""" + self._check_tree(_make_two_parallel()) + + def test_three_parallel_no_ss_pp(self) -> None: + """Test no S-S or P-P adjacency for 3 parallel edges.""" + self._check_tree(_make_three_parallel()) + + def test_diamond_no_ss_pp(self) -> None: + """Test no S-S or P-P adjacency for the diamond graph.""" + self._check_tree(_make_diamond()) + + def test_theta_no_ss_pp(self) -> None: + """Test no S-S or P-P adjacency for the theta graph.""" + self._check_tree(_make_theta()) + + def test_prism_no_ss_pp(self) -> None: + """Test no S-S or P-P adjacency for the triangular prism.""" + self._check_tree(_make_prism()) + + +def _count_real_edges_in_tree(root: SPQRNode) -> int: + """Count real (non-virtual) edges across all SPQR-tree skeletons. + + Each real edge should appear in exactly one node's skeleton. + + :param root: The SPQR-tree root. + :return: Total count of real edges summed over all nodes. + """ + total: int = 0 + for node in _collect_all_nodes(root): + for e in node.skeleton.edges: + if not e.virtual: + total += 1 + return total + + +def _check_spqr_invariants( + tc: unittest.TestCase, + g: MultiGraph, + root: SPQRNode, +) -> None: + """Check all SPQR-tree invariants for a given graph. + + Verifies: parent-child links consistent, each node has >= 1 skeleton + edge, real edge count preserved, no S-S or P-P adjacency. + + :param tc: The TestCase instance for assertions. + :param g: The original input graph. + :param root: The SPQR-tree root. + :return: None + """ + nodes: list[SPQRNode] = _collect_all_nodes(root) + # Parent-child links. + for node in nodes: + for child in node.children: + tc.assertIs(child.parent, node) + # Each node skeleton has at least 1 edge. + for node in nodes: + tc.assertGreater(node.skeleton.num_edges(), 0) + # Real edge count. + tc.assertEqual( + _count_real_edges_in_tree(root), + g.num_edges(), + "Real edge count mismatch between SPQR-tree and original", + ) + # No S-S adjacency. + _assert_no_ss_pp(tc, root, NodeType.S) + # No P-P adjacency. + _assert_no_ss_pp(tc, root, NodeType.P) + + +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]. + + :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. + + :return: A MultiGraph with embedded parallel edges. + """ + g: MultiGraph = MultiGraph() + g.add_edge(1, 2) + g.add_edge(2, 3) + g.add_edge(2, 3) + g.add_edge(3, 4) + g.add_edge(4, 5) + g.add_edge(1, 5) + g.add_edge(1, 5) + return g + + +class TestSPQRWikipediaExample(unittest.TestCase): + """Tests for the SPQR-tree of the Wikipedia example graph.""" + + def setUp(self) -> None: + """Build the SPQR-tree for the Wikipedia example.""" + self.g: MultiGraph = _make_wikipedia_example() + """The Wikipedia example graph under test.""" + self.root: SPQRNode = build_spqr_tree(self.g) + """The root node of the SPQR tree.""" + self.all_nodes: list[SPQRNode] = \ + _collect_all_nodes(self.root) + """All SPQR tree nodes.""" + + def test_all_invariants(self) -> None: + """Test all SPQR-tree invariants for the Wikipedia example.""" + _check_spqr_invariants(self, self.g, self.root) + + def test_at_least_two_nodes(self) -> None: + """Test that the Wikipedia example produces multiple nodes.""" + self.assertGreaterEqual( + len(self.all_nodes), + 2, + "Wikipedia example has separation pairs, expect >=2 nodes", + ) + + +class TestSPQRHTExample(unittest.TestCase): + """Tests for the SPQR-tree of the Hopcroft-Tarjan 1973 example.""" + + def setUp(self) -> None: + """Build the SPQR-tree for the HT1973 example.""" + self.g: MultiGraph = _make_ht_example() + """The HT1973 example graph under test.""" + self.root: SPQRNode = build_spqr_tree(self.g) + """The root node of the SPQR tree.""" + self.all_nodes: list[SPQRNode] = \ + _collect_all_nodes(self.root) + """All SPQR tree nodes.""" + + def test_all_invariants(self) -> None: + """Test all SPQR-tree invariants for the HT1973 example.""" + _check_spqr_invariants(self, self.g, self.root) + + def test_at_least_two_nodes(self) -> None: + """Test that the HT1973 example produces multiple nodes.""" + self.assertGreaterEqual( + len(self.all_nodes), + 2, + "HT1973 example has separation pairs, expect >=2 nodes", + ) + + +class TestSPQRGMExample(unittest.TestCase): + """Tests for the SPQR-tree of the Gutwenger-Mutzel 2001 example.""" + + def setUp(self) -> None: + """Build the SPQR-tree for the GM2001 example.""" + self.g: MultiGraph = _make_gm_example() + """The GM2001 example graph under test.""" + self.root: SPQRNode = build_spqr_tree(self.g) + """The root node of the SPQR tree.""" + self.all_nodes: list[SPQRNode] = \ + _collect_all_nodes(self.root) + """All SPQR tree nodes.""" + + def test_all_invariants(self) -> None: + """Test all SPQR-tree invariants for the GM2001 example.""" + _check_spqr_invariants(self, self.g, self.root) + + def test_at_least_two_nodes(self) -> None: + """Test that the GM2001 example produces multiple nodes.""" + self.assertGreaterEqual( + len(self.all_nodes), + 2, + "GM2001 example has separation pairs, expect >=2 nodes", + ) + + +class TestSPQRMultiEdgeComplex(unittest.TestCase): + """Tests for the SPQR-tree of a complex multi-edge graph. + + Inspired by the SageMath ``spqr_tree`` test suite. + """ + + def setUp(self) -> None: + """Build the SPQR-tree for the complex multi-edge graph.""" + self.g: MultiGraph = _make_multiedge_complex() + """The complex multi-edge graph under test.""" + self.root: SPQRNode = build_spqr_tree(self.g) + """The root node of the SPQR tree.""" + self.all_nodes: list[SPQRNode] = \ + _collect_all_nodes(self.root) + """All SPQR tree nodes.""" + + def test_all_invariants(self) -> None: + """Test all SPQR-tree invariants for the multi-edge graph.""" + _check_spqr_invariants(self, self.g, self.root) + + def test_has_p_node(self) -> None: + """Test that multi-edges produce P-nodes in the tree.""" + p_nodes: list[SPQRNode] = [ + n for n in self.all_nodes + if n.type == NodeType.P + ] + self.assertGreaterEqual( + len(p_nodes), 1, + "Multi-edge graph should have at least one P-node", + ) + + def test_has_s_node(self) -> None: + """Test that the cycle backbone produces an S-node.""" + s_nodes: list[SPQRNode] = [ + n for n in self.all_nodes + if n.type == NodeType.S + ] + self.assertGreaterEqual( + len(s_nodes), 1, + "Multi-edge graph should have at least one S-node", + ) + + def test_exact_node_structure(self) -> None: + """Test exact SPQR-tree node counts: 2 P-nodes, 1 S-node. + + Each parallel pair forms a BOND: 2 real + 1 virtual = 3 + edges -> P-node. The backbone cycle forms an S-node. + """ + p_count: int = sum( + 1 for n in self.all_nodes + if n.type == NodeType.P + ) + s_count: int = sum( + 1 for n in self.all_nodes + if n.type == NodeType.S + ) + self.assertEqual( + p_count, 2, + f"Expected 2 P-nodes, got {p_count}", + ) + self.assertEqual( + s_count, 1, + f"Expected 1 S-node, got {s_count}", + ) + + +def _make_single_edge() -> MultiGraph: + """Build a graph with a single edge (vertices 0 and 1). + + This is the minimal biconnected graph. Expected: Q-node. + + :return: A MultiGraph with one edge. + """ + g: MultiGraph = MultiGraph() + g.add_edge(0, 1) + return g + + +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 creates separation pair {0,3} yielding 3 SPQR nodes. + + :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 a single R-node in the SPQR-tree. + + :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, so it yields a single R-node. + + :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_petersen_augmented() -> MultiGraph: + """Build the Petersen graph with each edge subdivided by a path. + + For each original Petersen edge (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 nodes (15 P + 15 S + 1 R). + + :return: The augmented Petersen multigraph. + """ + g: MultiGraph = _make_petersen() + 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 + + +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 + 6 edges among {0,1,a,b}. The edge (0,1) appears 3 times. + Expected: 4 nodes (1 P + 3 R). + + :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 nodes (1 P + 3 S). + + :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 + + +def _count_nodes_by_type(root: SPQRNode) -> dict[str, int]: + """Count SPQR-tree nodes by type. + + :param root: The SPQR-tree root. + :return: Dict mapping type value string to count. + """ + counts: dict[str, int] = {} + for node in _collect_all_nodes(root): + key: str = node.type.value + counts[key] = counts.get(key, 0) + 1 + return counts + + +class TestSPQRSingleEdge(unittest.TestCase): + """Tests for the SPQR-tree of a single-edge graph. + + A single edge is the degenerate case: one Q-node, no children. + + Inspired by the SageMath ``spqr_tree`` test suite. + """ + + def setUp(self) -> None: + """Build the SPQR-tree for a single edge.""" + self.root: SPQRNode = \ + build_spqr_tree(_make_single_edge()) + """The root node of the SPQR tree.""" + self.all_nodes: list[SPQRNode] = \ + _collect_all_nodes(self.root) + """All SPQR tree nodes.""" + + def test_root_is_q_node(self) -> None: + """Test that a single edge produces a Q-node root.""" + self.assertEqual(self.root.type, NodeType.Q) + + def test_single_node_total(self) -> None: + """Test that there is exactly 1 node in the tree.""" + self.assertEqual(len(self.all_nodes), 1) + + def test_no_children(self) -> None: + """Test that the Q-node has no children.""" + self.assertEqual(len(self.root.children), 0) + + def test_skeleton_has_one_edge(self) -> None: + """Test that the Q-node skeleton has exactly 1 edge.""" + self.assertEqual(self.root.skeleton.num_edges(), 1) + + +class TestSPQRC5(unittest.TestCase): + """Tests for the SPQR-tree of the 5-cycle C5.""" + + def setUp(self) -> None: + """Build the SPQR-tree for C5.""" + self.root: SPQRNode = \ + build_spqr_tree(_make_c5()) + """The root node of the SPQR tree.""" + self.all_nodes: list[SPQRNode] = \ + _collect_all_nodes(self.root) + """All SPQR tree nodes.""" + + def test_root_is_s_node(self) -> None: + """Test that C5 produces a single S-node.""" + self.assertEqual(self.root.type, NodeType.S) + + def test_single_node_total(self) -> None: + """Test that there is exactly 1 node in the tree.""" + self.assertEqual(len(self.all_nodes), 1) + + def test_skeleton_has_five_edges(self) -> None: + """Test that the S-node skeleton has 5 edges.""" + self.assertEqual(self.root.skeleton.num_edges(), 5) + + +class TestSPQRC6(unittest.TestCase): + """Tests for the SPQR-tree of the 6-cycle C6. + + Expected: single S-node (the entire cycle). + + Inspired by the SageMath ``spqr_tree`` test suite. + """ + + def setUp(self) -> None: + """Build the SPQR-tree for C6.""" + self.root: SPQRNode = \ + build_spqr_tree(_make_c6()) + """The root node of the SPQR tree.""" + self.all_nodes: list[SPQRNode] = \ + _collect_all_nodes(self.root) + """All SPQR tree nodes.""" + + def test_root_is_s_node(self) -> None: + """Test that C6 produces a single S-node.""" + self.assertEqual(self.root.type, NodeType.S) + + def test_single_node_total(self) -> None: + """Test that C6 yields exactly 1 SPQR node.""" + self.assertEqual(len(self.all_nodes), 1) + + def test_no_children(self) -> None: + """Test that the root S-node has no children.""" + self.assertEqual(len(self.root.children), 0) + + def test_skeleton_has_six_edges(self) -> None: + """Test that the S-node skeleton has 6 edges.""" + self.assertEqual(self.root.skeleton.num_edges(), 6) + + +class TestSPQRC6Chord(unittest.TestCase): + """Tests for the SPQR-tree of C6 plus chord (0,3). + + The chord (0,3) creates separation pair {0,3} yielding 3 nodes: + 1 P-node (chord bond) + 2 S-nodes (the two 4-cycle halves). + + Inspired by the SageMath ``spqr_tree`` test suite. + """ + + def setUp(self) -> None: + """Build the SPQR-tree for C6 with chord.""" + self.g: MultiGraph = _make_c6_with_chord() + """The C6 with chord graph under test.""" + self.root: SPQRNode = build_spqr_tree(self.g) + """The root node of the SPQR tree.""" + self.all_nodes: list[SPQRNode] = \ + _collect_all_nodes(self.root) + """All SPQR tree nodes.""" + + def test_all_invariants(self) -> None: + """Test all SPQR-tree invariants for C6 plus chord.""" + _check_spqr_invariants(self, self.g, self.root) + + def test_three_nodes_total(self) -> None: + """Test that C6 plus chord yields exactly 3 SPQR nodes.""" + self.assertEqual( + len(self.all_nodes), + 3, + f"C6+chord should have 3 SPQR nodes, " + f"got {len(self.all_nodes)}", + ) + + def test_one_p_node(self) -> None: + """Test that there is exactly 1 P-node (the chord bond). + + The chord (0,3) forms a BOND: 1 real edge + 2 virtual edges + (one to each polygon side) = 3 total edges -> P-node. + """ + p: list[SPQRNode] = [ + n for n in self.all_nodes + if n.type == NodeType.P + ] + self.assertEqual( + len(p), 1, + f"Expected 1 P-node, got {len(p)}", + ) + + def test_two_s_nodes(self) -> None: + """Test that there are exactly 2 S-nodes (the two paths).""" + s: list[SPQRNode] = [ + n for n in self.all_nodes + if n.type == NodeType.S + ] + self.assertEqual( + len(s), 2, + f"Expected 2 S-nodes, got {len(s)}", + ) + + +class TestSPQRK5(unittest.TestCase): + """Tests for the SPQR-tree of the complete graph K5. + + K5 is 4-connected, so it yields a single R-node. + """ + + def setUp(self) -> None: + """Build the SPQR-tree for K5.""" + self.root: SPQRNode = \ + build_spqr_tree(_make_k5()) + """The root node of the SPQR tree.""" + self.all_nodes: list[SPQRNode] = \ + _collect_all_nodes(self.root) + """All SPQR tree nodes.""" + + def test_root_is_r_node(self) -> None: + """Test that K5 produces a single R-node.""" + self.assertEqual(self.root.type, NodeType.R) + + def test_single_node_total(self) -> None: + """Test that there is exactly 1 node in the tree.""" + self.assertEqual(len(self.all_nodes), 1) + + def test_skeleton_has_ten_edges(self) -> None: + """Test that the R-node skeleton has 10 edges.""" + self.assertEqual(self.root.skeleton.num_edges(), 10) + + +class TestSPQRPetersen(unittest.TestCase): + """Tests for the SPQR-tree of the Petersen graph. + + The Petersen graph is 3-connected, yielding a single R-node. + + Inspired by the SageMath ``spqr_tree`` test suite. + """ + + def setUp(self) -> None: + """Build the SPQR-tree for the Petersen graph.""" + self.root: SPQRNode = \ + build_spqr_tree(_make_petersen()) + """The root node of the SPQR tree.""" + self.all_nodes: list[SPQRNode] = \ + _collect_all_nodes(self.root) + """All SPQR tree nodes.""" + + def test_root_is_r_node(self) -> None: + """Test that the Petersen graph produces a single R-node.""" + self.assertEqual(self.root.type, NodeType.R) + + def test_single_node_total(self) -> None: + """Test that there is exactly 1 node in the tree.""" + self.assertEqual(len(self.all_nodes), 1) + + def test_no_children(self) -> None: + """Test that the R-node has no children.""" + self.assertEqual(len(self.root.children), 0) + + def test_skeleton_has_fifteen_edges(self) -> None: + """Test that the R-node skeleton has 15 edges.""" + self.assertEqual(self.root.skeleton.num_edges(), 15) + + +class TestSPQRThreeK4Cliques(unittest.TestCase): + """Tests for the SPQR-tree of 3 K4 cliques sharing poles {0,1}. + + Expected: 4 nodes: 1 P-node (3-way parallel at 0-1) and 3 + R-nodes (one per K4 clique). + + Inspired by the SageMath ``spqr_tree`` test suite. + """ + + def setUp(self) -> None: + """Build the SPQR-tree for the three-K4-cliques graph.""" + self.g: MultiGraph = _make_three_k4_cliques() + """The three-K4-cliques graph under test.""" + self.root: SPQRNode = build_spqr_tree(self.g) + """The root node of the SPQR tree.""" + self.all_nodes: list[SPQRNode] = \ + _collect_all_nodes(self.root) + """All SPQR tree nodes.""" + + def test_all_invariants(self) -> None: + """Test all SPQR-tree invariants for three K4 cliques.""" + _check_spqr_invariants(self, self.g, self.root) + + def test_four_nodes_total(self) -> None: + """Test that 3 K4 cliques yield exactly 4 SPQR nodes.""" + self.assertEqual( + len(self.all_nodes), + 4, + f"Expected 4 SPQR nodes, got {len(self.all_nodes)}", + ) + + def test_one_p_node(self) -> None: + """Test that there is exactly 1 P-node (3+ parallel at 0-1).""" + p: list[SPQRNode] = [ + n for n in self.all_nodes + if n.type == NodeType.P + ] + self.assertEqual( + len(p), 1, + f"Expected 1 P-node, got {len(p)}", + ) + + def test_three_r_nodes(self) -> None: + """Test that there are exactly 3 R-nodes (one per K4).""" + r: list[SPQRNode] = [ + n for n in self.all_nodes + if n.type == NodeType.R + ] + self.assertEqual( + len(r), 3, + f"Expected 3 R-nodes, got {len(r)}", + ) + + +class TestSPQRThreeLongPaths(unittest.TestCase): + """Tests for the SPQR-tree of 3 length-3 paths between 0 and 1. + + Expected: 4 nodes: 1 P-node (3-way connection at poles) and 3 + S-nodes (one per length-3 path). + + Inspired by the SageMath ``spqr_tree`` test suite. + """ + + def setUp(self) -> None: + """Build the SPQR-tree for the three-long-paths graph.""" + self.g: MultiGraph = _make_three_long_paths() + """The three-long-paths graph under test.""" + self.root: SPQRNode = build_spqr_tree(self.g) + """The root node of the SPQR tree.""" + self.all_nodes: list[SPQRNode] = \ + _collect_all_nodes(self.root) + """All SPQR tree nodes.""" + + def test_all_invariants(self) -> None: + """Test all SPQR-tree invariants for three long paths.""" + _check_spqr_invariants(self, self.g, self.root) + + def test_four_nodes_total(self) -> None: + """Test that three length-3 paths yield exactly 4 SPQR nodes.""" + self.assertEqual( + len(self.all_nodes), + 4, + f"Expected 4 SPQR nodes, got {len(self.all_nodes)}", + ) + + def test_one_p_node(self) -> None: + """Test that there is exactly 1 P-node (3-way connection).""" + p: list[SPQRNode] = [ + n for n in self.all_nodes + if n.type == NodeType.P + ] + self.assertEqual( + len(p), 1, + f"Expected 1 P-node, got {len(p)}", + ) + + def test_three_s_nodes(self) -> None: + """Test that there are exactly 3 S-nodes (one per path).""" + s: list[SPQRNode] = [ + n for n in self.all_nodes + if n.type == NodeType.S + ] + self.assertEqual( + len(s), 3, + f"Expected 3 S-nodes, got {len(s)}", + ) + + def test_s_node_skeletons_have_four_edges(self) -> None: + """Test that each S-node skeleton has 4 edges. + + Each length-3 path has 3 real edges plus 1 virtual edge + connecting its poles = 4 total edges in the skeleton. + """ + for node in self.all_nodes: + if node.type == NodeType.S: + self.assertEqual( + node.skeleton.num_edges(), + 4, + f"S-node skeleton should have 4 edges " + f"(3 real + 1 virtual), got " + f"{node.skeleton.num_edges()}", + ) + + +class TestSPQRPetersenAugmented(unittest.TestCase): + """Tests for the SPQR-tree of the augmented Petersen graph. + + Each Petersen edge (u,v) gets a parallel path u-w1-w2-v added. + Expected: 31 nodes (15 P + 15 S + 1 R). + + Inspired by the SageMath ``spqr_tree`` test suite. + """ + + def setUp(self) -> None: + """Build the SPQR-tree for the augmented Petersen graph.""" + self.g: MultiGraph = _make_petersen_augmented() + """The augmented Petersen graph under test.""" + self.root: SPQRNode = build_spqr_tree(self.g) + """The root node of the SPQR tree.""" + self.all_nodes: list[SPQRNode] = \ + _collect_all_nodes(self.root) + """All SPQR tree nodes.""" + + def test_all_invariants(self) -> None: + """Test all SPQR-tree invariants for augmented Petersen.""" + _check_spqr_invariants(self, self.g, self.root) + + def test_thirty_one_nodes_total(self) -> None: + """Test that the tree has 31 nodes total. + + Expected: 15 P + 15 S + 1 R = 31 total. + """ + self.assertEqual( + len(self.all_nodes), + 31, + f"Augmented Petersen should have 31 nodes, " + f"got {len(self.all_nodes)}: " + f"{_count_nodes_by_type(self.root)}", + ) + + def test_one_r_node(self) -> None: + """Test that there is exactly 1 R-node (Petersen skeleton).""" + r: list[SPQRNode] = [ + n for n in self.all_nodes + if n.type == NodeType.R + ] + self.assertEqual( + len(r), 1, + f"Expected 1 R-node, got {len(r)}", + ) + + def test_fifteen_s_nodes(self) -> None: + """Test that there are exactly 15 S-nodes.""" + s: list[SPQRNode] = [ + n for n in self.all_nodes + if n.type == NodeType.S + ] + self.assertEqual( + len(s), 15, + f"Expected 15 S-nodes, got {len(s)}", + ) + + def test_fifteen_p_nodes(self) -> None: + """Test that there are exactly 15 P-nodes. + + Each original Petersen edge (u,v) forms a BOND with the + parallel path: 1 real + 2 virtual = 3 edges -> P-node. + """ + p: list[SPQRNode] = [ + n for n in self.all_nodes + if n.type == NodeType.P + ] + self.assertEqual( + len(p), 15, + f"Expected 15 P-nodes, got {len(p)}", + ) + + +def _make_k33() -> MultiGraph: + """Build K_{3,3} (9 edges, vertices 0-5). + + K_{3,3} is 3-connected (triconnected), so its SPQR-tree is a + single R-node. + + :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 wheel W4: hub vertex 0, rim vertices 1-4. + + W4 has 8 edges (4 spokes + 4 rim) and is 3-connected, so its + SPQR-tree is a single R-node. + + :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, vertices 1-3). + + Each pair of parallel edges forms a BOND. The triangle forms a + POLYGON. Expected: 4 nodes (3 P + 1 S). + + :return: A MultiGraph representing doubled K3. + """ + 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 4 parallel edges between vertices 1 and 2. + + Four parallel edges form a single BOND component with 4 real + edges -> P-node. + + :return: A MultiGraph with 4 parallel edges. + """ + g: MultiGraph = MultiGraph() + for _ in range(4): + g.add_edge(1, 2) + return g + + +def _make_five_parallel() -> MultiGraph: + """Build 5 parallel edges between vertices 1 and 2. + + Five parallel edges form a single BOND component with 5 real + edges -> P-node. + + :return: A MultiGraph with 5 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 with all edges doubled. + + Vertices 0-7; paths: 0-2-3-1, 0-4-5-1, 0-6-7-1, each edge + doubled. Expected: 13 nodes (3 S + 10 P). + + :return: A MultiGraph with doubled three-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 multiple separation pairs. Expected: + 12 nodes (2 R + 5 S + 5 P). + + :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 a parallel + path u-w1-w2-v alongside. Round 2: for each of the 60 round-1 + edges, add another parallel path alongside. + Result: 160 vertices, 240 edges. + Expected: 136 nodes (60 P + 75 S + 1 R). + + :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 + 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 TestSPQRK33(unittest.TestCase): + """Tests for the SPQR-tree of K_{3,3}. + + K_{3,3} is 3-connected, so it decomposes into a single + TRICONNECTED component -> single R-node. + + Inspired by the SageMath ``spqr_tree`` test suite. + """ + + def setUp(self) -> None: + """Build the SPQR-tree for K_{3,3}.""" + self.g: MultiGraph = _make_k33() + """The K_{3,3} graph under test.""" + self.root: SPQRNode = build_spqr_tree(self.g) + """The root node of the SPQR tree.""" + self.all_nodes: list[SPQRNode] = \ + _collect_all_nodes(self.root) + """All SPQR tree nodes.""" + + def test_all_invariants(self) -> None: + """Test all SPQR-tree invariants for K_{3,3}.""" + _check_spqr_invariants(self, self.g, self.root) + + def test_single_r_node(self) -> None: + """Test that K_{3,3} produces exactly 1 R-node.""" + self.assertEqual( + len(self.all_nodes), 1, + f"K33 should have 1 node, got {len(self.all_nodes)}", + ) + self.assertEqual( + self.root.type, NodeType.R, + f"K33 root should be R-node, got {self.root.type}", + ) + + def test_nine_real_edges_in_skeleton(self) -> None: + """Test that the R-node skeleton has 9 real edges.""" + real: list[Edge] = [ + e for e in self.root.skeleton.edges + if not e.virtual + ] + self.assertEqual( + len(real), 9, + f"K33 skeleton should have 9 real edges, got {len(real)}", + ) + + +class TestSPQRW4(unittest.TestCase): + """Tests for the SPQR-tree of wheel W4. + + W4 (hub=0, rim=1-4) is 3-connected, so it decomposes into a + single TRICONNECTED component -> single R-node. + + Inspired by the SageMath ``spqr_tree`` test suite. + """ + + def setUp(self) -> None: + """Build the SPQR-tree for wheel W4.""" + self.g: MultiGraph = _make_w4() + """The W4 wheel graph under test.""" + self.root: SPQRNode = build_spqr_tree(self.g) + """The root node of the SPQR tree.""" + self.all_nodes: list[SPQRNode] = \ + _collect_all_nodes(self.root) + """All SPQR tree nodes.""" + + def test_all_invariants(self) -> None: + """Test all SPQR-tree invariants for W4.""" + _check_spqr_invariants(self, self.g, self.root) + + def test_single_r_node(self) -> None: + """Test that W4 produces exactly 1 R-node.""" + self.assertEqual( + len(self.all_nodes), 1, + f"W4 should have 1 node, got {len(self.all_nodes)}", + ) + self.assertEqual( + self.root.type, NodeType.R, + f"W4 root should be R-node, got {self.root.type}", + ) + + def test_eight_real_edges_in_skeleton(self) -> None: + """Test that the R-node skeleton has 8 real edges.""" + real: list[Edge] = [ + e for e in self.root.skeleton.edges + if not e.virtual + ] + self.assertEqual( + len(real), 8, + f"W4 skeleton should have 8 real edges, got {len(real)}", + ) + + +class TestSPQRK3Doubled(unittest.TestCase): + """Tests for the SPQR-tree of K3 with each edge doubled. + + Each pair of doubled edges forms a BOND (-> P-node). The + triangle K3 forms a POLYGON (-> S-node). + Expected: 4 nodes (3 P + 1 S). + + Inspired by the SageMath ``spqr_tree`` test suite. + """ + + def setUp(self) -> None: + """Build the SPQR-tree for doubled K3.""" + self.g: MultiGraph = _make_k3_doubled() + """The doubled K3 graph under test.""" + self.root: SPQRNode = build_spqr_tree(self.g) + """The root node of the SPQR tree.""" + self.all_nodes: list[SPQRNode] = \ + _collect_all_nodes(self.root) + """All SPQR tree nodes.""" + + def test_all_invariants(self) -> None: + """Test all SPQR-tree invariants for doubled K3.""" + _check_spqr_invariants(self, self.g, self.root) + + def test_four_nodes_total(self) -> None: + """Test that doubled K3 produces exactly 4 nodes. + + Expected: 4 total (3 P + 1 S). + """ + self.assertEqual( + len(self.all_nodes), 4, + f"Doubled K3 should have 4 nodes, " + f"got {len(self.all_nodes)}: " + f"{_count_nodes_by_type(self.root)}", + ) + + def test_one_s_node_three_p_nodes(self) -> None: + """Test that doubled K3 has 1 S-node and 3 P-nodes.""" + s: list[SPQRNode] = [ + n for n in self.all_nodes + if n.type == NodeType.S + ] + p: list[SPQRNode] = [ + n for n in self.all_nodes + if n.type == NodeType.P + ] + self.assertEqual( + len(s), 1, + f"Expected 1 S-node, got {len(s)}", + ) + self.assertEqual( + len(p), 3, + f"Expected 3 P-nodes, got {len(p)}", + ) + + +class TestSPQRFourParallel(unittest.TestCase): + """Tests for the SPQR-tree of 4 parallel edges. + + Four parallel edges form a single BOND with 4 real edges + -> single P-node. + """ + + def setUp(self) -> None: + """Build the SPQR-tree for 4 parallel edges.""" + self.g: MultiGraph = _make_four_parallel() + """The 4-parallel-edges graph under test.""" + self.root: SPQRNode = build_spqr_tree(self.g) + """The root node of the SPQR tree.""" + self.all_nodes: list[SPQRNode] = \ + _collect_all_nodes(self.root) + """All SPQR tree nodes.""" + + def test_all_invariants(self) -> None: + """Test all SPQR-tree invariants for 4 parallel edges.""" + _check_spqr_invariants(self, self.g, self.root) + + def test_single_p_node(self) -> None: + """Test that 4 parallel edges produce exactly 1 P-node.""" + self.assertEqual( + len(self.all_nodes), 1, + f"4-parallel should have 1 node, " + f"got {len(self.all_nodes)}", + ) + self.assertEqual( + self.root.type, NodeType.P, + f"4-parallel root should be P-node, " + f"got {self.root.type}", + ) + + def test_four_real_edges_in_skeleton(self) -> None: + """Test that the P-node skeleton has 4 real edges.""" + real: list[Edge] = [ + e for e in self.root.skeleton.edges + if not e.virtual + ] + self.assertEqual( + len(real), 4, + f"4-parallel skeleton should have 4 real edges, " + f"got {len(real)}", + ) + + +class TestSPQRFiveParallel(unittest.TestCase): + """Tests for the SPQR-tree of 5 parallel edges. + + Five parallel edges form a single BOND with 5 real edges + -> single P-node. + """ + + def setUp(self) -> None: + """Build the SPQR-tree for 5 parallel edges.""" + self.g: MultiGraph = _make_five_parallel() + """The 5-parallel-edges graph under test.""" + self.root: SPQRNode = build_spqr_tree(self.g) + """The root node of the SPQR tree.""" + self.all_nodes: list[SPQRNode] = \ + _collect_all_nodes(self.root) + """All SPQR tree nodes.""" + + def test_all_invariants(self) -> None: + """Test all SPQR-tree invariants for 5 parallel edges.""" + _check_spqr_invariants(self, self.g, self.root) + + def test_single_p_node(self) -> None: + """Test that 5 parallel edges produce exactly 1 P-node.""" + self.assertEqual( + len(self.all_nodes), 1, + f"5-parallel should have 1 node, " + f"got {len(self.all_nodes)}", + ) + self.assertEqual( + self.root.type, NodeType.P, + f"5-parallel root should be P-node, " + f"got {self.root.type}", + ) + + def test_five_real_edges_in_skeleton(self) -> None: + """Test that the P-node skeleton has 5 real edges.""" + real: list[Edge] = [ + e for e in self.root.skeleton.edges + if not e.virtual + ] + self.assertEqual( + len(real), 5, + f"5-parallel skeleton should have 5 real edges, " + f"got {len(real)}", + ) + + +class TestSPQRThreeLongPathsDoubled(unittest.TestCase): + """Tests for the SPQR-tree of three doubled length-3 paths. + + Three length-3 paths (0-a-b-1) with all edges doubled. Each + pair of doubled edges forms a P-node, and each path length-3 + forms an S-node. Expected: 13 nodes (3 S + 10 P). + + Inspired by the SageMath ``spqr_tree`` test suite. + """ + + def setUp(self) -> None: + """Build the SPQR-tree for doubled three-length-3 paths.""" + self.g: MultiGraph = \ + _make_three_long_paths_doubled() + """The doubled three-long-paths graph under test.""" + self.root: SPQRNode = build_spqr_tree(self.g) + """The root node of the SPQR tree.""" + self.all_nodes: list[SPQRNode] = \ + _collect_all_nodes(self.root) + """All SPQR tree nodes.""" + + def test_all_invariants(self) -> None: + """Test all SPQR-tree invariants for doubled long paths.""" + _check_spqr_invariants(self, self.g, self.root) + + def test_thirteen_nodes_total(self) -> None: + """Test that doubled three-paths has 13 total nodes. + + Expected: 13 total (3 S + 10 P). + """ + self.assertEqual( + len(self.all_nodes), 13, + f"Doubled three-paths should have 13 nodes, " + f"got {len(self.all_nodes)}: " + f"{_count_nodes_by_type(self.root)}", + ) + + def test_three_s_nodes_ten_p_nodes(self) -> None: + """Test that the tree has 3 S-nodes and 10 P-nodes.""" + s: list[SPQRNode] = [ + n for n in self.all_nodes + if n.type == NodeType.S + ] + p: list[SPQRNode] = [ + n for n in self.all_nodes + if n.type == NodeType.P + ] + self.assertEqual( + len(s), 3, + f"Expected 3 S-nodes, got {len(s)}", + ) + self.assertEqual( + len(p), 10, + f"Expected 10 P-nodes, got {len(p)}", + ) + + +class TestSPQRSageDocstringGraph(unittest.TestCase): + """Tests for the SPQR-tree of the 13V/23E graph (graph6 'LlCG{O@?GBoMw?'). + + This biconnected graph has multiple separation pairs and yields + 12 SPQR nodes (2 R + 5 S + 5 P). + + Inspired by the SageMath ``spqr_tree`` test suite. + """ + + def setUp(self) -> None: + """Build the SPQR-tree for the 13-vertex docstring graph.""" + self.g: MultiGraph = \ + _make_graph6_sage_docstring() + """The 13V/23E docstring graph under test.""" + self.root: SPQRNode = build_spqr_tree(self.g) + """The root node of the SPQR tree.""" + self.all_nodes: list[SPQRNode] = \ + _collect_all_nodes(self.root) + """All SPQR tree nodes.""" + + def test_all_invariants(self) -> None: + """Test all SPQR-tree invariants for the 13V/23E graph.""" + _check_spqr_invariants(self, self.g, self.root) + + def test_twelve_nodes_total(self) -> None: + """Test that the 13V/23E graph has 12 SPQR nodes total. + + Expected: 12 total (2 R + 5 S + 5 P). + Matches SageMath's ``spqr_tree`` output. + """ + self.assertEqual( + len(self.all_nodes), 12, + f"13V/23E graph should have 12 nodes, " + f"got {len(self.all_nodes)}: " + f"{_count_nodes_by_type(self.root)}", + ) + + def test_two_r_five_s_five_p(self) -> None: + """Test node type counts: 2 R-nodes, 5 S-nodes, 5 P-nodes.""" + r: list[SPQRNode] = [ + n for n in self.all_nodes + if n.type == NodeType.R + ] + s: list[SPQRNode] = [ + n for n in self.all_nodes + if n.type == NodeType.S + ] + p: list[SPQRNode] = [ + n for n in self.all_nodes + if n.type == NodeType.P + ] + self.assertEqual(len(r), 2, f"Expected 2 R-nodes, got {len(r)}") + self.assertEqual(len(s), 5, f"Expected 5 S-nodes, got {len(s)}") + self.assertEqual(len(p), 5, f"Expected 5 P-nodes, got {len(p)}") + + +class TestSPQRPetersenAugmentedTwice(unittest.TestCase): + """Tests for the SPQR-tree of the doubly-augmented Petersen graph. + + Round 1: for each of the 15 Petersen edges (u,v), add a parallel + path u-w1-w2-v alongside. Round 2: for each of the 60 round-1 + edges, add another parallel path alongside. + Result: 160 vertices, 240 edges, 136 SPQR nodes. + + Inspired by the SageMath ``spqr_tree`` test suite. + """ + + def setUp(self) -> None: + """Build the SPQR-tree for the doubly-augmented Petersen.""" + self.g: MultiGraph = \ + _make_petersen_augmented_twice() + """The doubly-augmented Petersen graph under test.""" + self.root: SPQRNode = build_spqr_tree(self.g) + """The root node of the SPQR tree.""" + self.all_nodes: list[SPQRNode] = \ + _collect_all_nodes(self.root) + """All SPQR tree nodes.""" + + def test_all_invariants(self) -> None: + """Test all SPQR-tree invariants for doubly-aug. Petersen.""" + _check_spqr_invariants(self, self.g, self.root) + + def test_136_nodes_total(self) -> None: + """Test that the doubly-augmented Petersen has 136 nodes. + + Expected: 136 total (60 P + 75 S + 1 R). + """ + self.assertEqual( + len(self.all_nodes), 136, + f"Doubly-augmented Petersen should have 136 nodes, " + f"got {len(self.all_nodes)}: " + f"{_count_nodes_by_type(self.root)}", + ) + + def test_one_r_node(self) -> None: + """Test that there is exactly 1 R-node (Petersen skeleton).""" + r: list[SPQRNode] = [ + n for n in self.all_nodes + if n.type == NodeType.R + ] + self.assertEqual( + len(r), 1, + f"Expected 1 R-node, got {len(r)}", + ) + + def test_sixty_p_nodes(self) -> None: + """Test that there are exactly 60 P-nodes.""" + p: list[SPQRNode] = [ + n for n in self.all_nodes + if n.type == NodeType.P + ] + self.assertEqual( + len(p), 60, + f"Expected 60 P-nodes, got {len(p)}", + ) + + def test_seventy_five_s_nodes(self) -> None: + """Test that there are exactly 75 S-nodes.""" + s: list[SPQRNode] = [ + n for n in self.all_nodes + if n.type == NodeType.S + ] + self.assertEqual( + len(s), 75, + f"Expected 75 S-nodes, got {len(s)}", + ) + + +class TestSPQRDiamondExact(unittest.TestCase): + """Exact SPQR-tree node tests for the diamond graph. + + The diamond has separation pair {2,3}. Expected: 3 nodes total: + 2 S-nodes (the two triangular halves as polygons) and 1 P-node + (the direct edge (2,3) forming a bond with 1 real + 2 virtual + edges = 3 edges -> P-node). + """ + + def setUp(self) -> None: + """Build the SPQR-tree for the diamond graph.""" + self.root: SPQRNode = \ + build_spqr_tree(_make_diamond()) + """The root node of the SPQR tree.""" + self.all_nodes: list[SPQRNode] = \ + _collect_all_nodes(self.root) + """All SPQR tree nodes.""" + + def test_exactly_three_nodes(self) -> None: + """Test that the diamond produces exactly 3 SPQR nodes.""" + self.assertEqual( + len(self.all_nodes), + 3, + f"Diamond should have 3 SPQR nodes, " + f"got {len(self.all_nodes)}", + ) + + def test_two_s_nodes_one_p_node(self) -> None: + """Test that diamond has 2 S-nodes and 1 P-node.""" + p: list[SPQRNode] = [ + n for n in self.all_nodes + if n.type == NodeType.P + ] + s: list[SPQRNode] = [ + n for n in self.all_nodes + if n.type == NodeType.S + ] + self.assertEqual(len(p), 1, "Expected 1 P-node") + self.assertEqual(len(s), 2, "Expected 2 S-nodes") + + +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 SPQR nodes: 3 S-nodes + 2 P-nodes. + + 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 TestSPQRLadder(unittest.TestCase): + """Tests for the SPQR-tree of the 3-rung ladder graph. + + The ladder (2x4 grid) has separation pairs {1,5} and {2,6}. + Expected: 5 nodes (3 S-nodes + 2 P-nodes). + + Inspired by the SageMath ``spqr_tree`` test suite. + """ + + def setUp(self) -> None: + """Build the SPQR-tree for the ladder graph.""" + self.g: MultiGraph = _make_ladder() + """The ladder graph under test.""" + self.root: SPQRNode = build_spqr_tree(self.g) + """The root node of the SPQR tree.""" + self.all_nodes: list[SPQRNode] = \ + _collect_all_nodes(self.root) + """All SPQR tree nodes.""" + + def test_all_invariants(self) -> None: + """Test all SPQR-tree invariants for the ladder graph.""" + _check_spqr_invariants(self, self.g, self.root) + + def test_five_nodes_total(self) -> None: + """Test that the ladder graph produces exactly 5 SPQR nodes.""" + self.assertEqual( + len(self.all_nodes), + 5, + f"Ladder should have 5 nodes, " + f"got {len(self.all_nodes)}: " + f"{_count_nodes_by_type(self.root)}", + ) + + def test_three_s_nodes_two_p_nodes(self) -> None: + """Test that the ladder has 3 S-nodes and 2 P-nodes.""" + s: list[SPQRNode] = [ + n for n in self.all_nodes + if n.type == NodeType.S + ] + p: list[SPQRNode] = [ + n for n in self.all_nodes + if n.type == NodeType.P + ] + self.assertEqual(len(s), 3, "Expected 3 S-nodes") + self.assertEqual(len(p), 2, "Expected 2 P-nodes") + + def test_no_r_or_q_nodes(self) -> None: + """Test that the ladder has no R-nodes or Q-nodes.""" + r: list[SPQRNode] = [ + n for n in self.all_nodes + if n.type == NodeType.R + ] + q: list[SPQRNode] = [ + n for n in self.all_nodes + if n.type == NodeType.Q + ] + self.assertEqual(len(r), 0, "Expected no R-nodes") + self.assertEqual(len(q), 0, "Expected no Q-nodes") + + +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 TestSPQRC7(unittest.TestCase): + """Tests for the SPQR-tree of the 7-cycle C7. + + C7 is a simple cycle with 7 edges. It yields a single + S-node (POLYGON). + """ + + def setUp(self) -> None: + """Build the SPQR-tree for C7.""" + self.g: MultiGraph = _make_c7() + """The C7 cycle graph under test.""" + self.root: SPQRNode = build_spqr_tree(self.g) + """The root node of the SPQR tree.""" + self.all_nodes: list[SPQRNode] = \ + _collect_all_nodes(self.root) + """All SPQR tree nodes.""" + + def test_all_invariants(self) -> None: + """Test all SPQR-tree invariants for C7.""" + _check_spqr_invariants(self, self.g, self.root) + + def test_single_s_node(self) -> None: + """Test that C7 produces exactly 1 S-node.""" + self.assertEqual(len(self.all_nodes), 1) + self.assertEqual(self.root.type, NodeType.S) + + def test_seven_real_edges_in_skeleton(self) -> None: + """Test that the S-node skeleton has 7 real edges.""" + real: list[Edge] = [ + e for e in self.root.skeleton.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 TestSPQRC8(unittest.TestCase): + """Tests for the SPQR-tree of the 8-cycle C8. + + C8 is a simple cycle with 8 edges. It yields a single + S-node (POLYGON). + """ + + def setUp(self) -> None: + """Build the SPQR-tree for C8.""" + self.g: MultiGraph = _make_c8() + """The C8 cycle graph under test.""" + self.root: SPQRNode = build_spqr_tree(self.g) + """The root node of the SPQR tree.""" + self.all_nodes: list[SPQRNode] = \ + _collect_all_nodes(self.root) + """All SPQR tree nodes.""" + + def test_all_invariants(self) -> None: + """Test all SPQR-tree invariants for C8.""" + _check_spqr_invariants(self, self.g, self.root) + + def test_single_s_node(self) -> None: + """Test that C8 produces exactly 1 S-node.""" + self.assertEqual(len(self.all_nodes), 1) + self.assertEqual(self.root.type, NodeType.S) + + def test_eight_real_edges_in_skeleton(self) -> None: + """Test that the S-node skeleton has 8 real edges.""" + real: list[Edge] = [ + e for e in self.root.skeleton.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 (separation pair {0,1}). Each internal + vertex x in {2,3,4} creates a 2-edge path 0-x-1, + yielding 4 SPQR nodes: 3 S-nodes + 1 P-node. + + 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 TestSPQRK23(unittest.TestCase): + """Tests for the SPQR-tree of K_{2,3}. + + K_{2,3} has 5 vertices and 6 edges. It has vertex + connectivity 2 (separation pair {0,1}), yielding + 4 SPQR nodes: 3 S-nodes + 1 P-node. + + Inspired by the SageMath ``spqr_tree`` test suite. + """ + + def setUp(self) -> None: + """Build the SPQR-tree for K_{2,3}.""" + self.g: MultiGraph = _make_k23() + """The K_{2,3} graph under test.""" + self.root: SPQRNode = build_spqr_tree(self.g) + """The root node of the SPQR tree.""" + self.all_nodes: list[SPQRNode] = \ + _collect_all_nodes(self.root) + """All SPQR tree nodes.""" + + def test_all_invariants(self) -> None: + """Test all SPQR-tree invariants for K_{2,3}.""" + _check_spqr_invariants(self, self.g, self.root) + + def test_four_nodes_total(self) -> None: + """Test that K_{2,3} produces exactly 4 SPQR nodes.""" + self.assertEqual( + len(self.all_nodes), 4, + f"Expected 4 nodes, got {len(self.all_nodes)}: " + f"{_count_nodes_by_type(self.root)}", + ) + + def test_three_s_one_p(self) -> None: + """Test that K_{2,3} has 3 S-nodes and 1 P-node.""" + s: list[SPQRNode] = [ + n for n in self.all_nodes + if n.type == NodeType.S + ] + p: list[SPQRNode] = [ + n for n in self.all_nodes + if n.type == NodeType.P + ] + self.assertEqual(len(s), 3, "Expected 3 S-nodes") + self.assertEqual(len(p), 1, "Expected 1 P-node") + + +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 a single R-node 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 TestSPQRW5(unittest.TestCase): + """Tests for the SPQR-tree of the wheel graph W5. + + W5 has 6 vertices and 10 edges. It is triconnected, + yielding a single R-node. + + Inspired by the SageMath ``spqr_tree`` test suite. + """ + + def setUp(self) -> None: + """Build the SPQR-tree for W5.""" + self.g: MultiGraph = _make_w5() + """The W5 wheel graph under test.""" + self.root: SPQRNode = build_spqr_tree(self.g) + """The root node of the SPQR tree.""" + self.all_nodes: list[SPQRNode] = \ + _collect_all_nodes(self.root) + """All SPQR tree nodes.""" + + def test_all_invariants(self) -> None: + """Test all SPQR-tree invariants for W5.""" + _check_spqr_invariants(self, self.g, self.root) + + def test_single_r_node(self) -> None: + """Test that W5 produces exactly 1 R-node.""" + self.assertEqual(len(self.all_nodes), 1) + self.assertEqual(self.root.type, NodeType.R) + + def test_ten_real_edges_in_skeleton(self) -> None: + """Test that the R-node skeleton has 10 real edges.""" + real: list[Edge] = [ + e for e in self.root.skeleton.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 a single R-node 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 TestSPQRW6(unittest.TestCase): + """Tests for the SPQR-tree of the wheel graph W6. + + W6 has 7 vertices and 12 edges. It is triconnected, + yielding a single R-node. + + Inspired by the SageMath ``spqr_tree`` test suite. + """ + + def setUp(self) -> None: + """Build the SPQR-tree for W6.""" + self.g: MultiGraph = _make_w6() + """The W6 wheel graph under test.""" + self.root: SPQRNode = build_spqr_tree(self.g) + """The root node of the SPQR tree.""" + self.all_nodes: list[SPQRNode] = \ + _collect_all_nodes(self.root) + """All SPQR tree nodes.""" + + def test_all_invariants(self) -> None: + """Test all SPQR-tree invariants for W6.""" + _check_spqr_invariants(self, self.g, self.root) + + def test_single_r_node(self) -> None: + """Test that W6 produces exactly 1 R-node.""" + self.assertEqual(len(self.all_nodes), 1) + self.assertEqual(self.root.type, NodeType.R) + + def test_twelve_real_edges_in_skeleton(self) -> None: + """Test that the R-node skeleton has 12 real edges.""" + real: list[Edge] = [ + e for e in self.root.skeleton.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 a single R-node. + + 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 TestSPQRQ3Cube(unittest.TestCase): + """Tests for the SPQR-tree of the Q3 cube graph. + + The cube (Q3) has 8 vertices and 12 edges. It is + 3-connected, yielding a single R-node. + + Inspired by the SageMath ``spqr_tree`` test suite. + """ + + def setUp(self) -> None: + """Build the SPQR-tree for the Q3 cube.""" + self.g: MultiGraph = _make_q3_cube() + """The Q3 cube graph under test.""" + self.root: SPQRNode = build_spqr_tree(self.g) + """The root node of the SPQR tree.""" + self.all_nodes: list[SPQRNode] = \ + _collect_all_nodes(self.root) + """All SPQR tree nodes.""" + + def test_all_invariants(self) -> None: + """Test all SPQR-tree invariants for Q3 cube.""" + _check_spqr_invariants(self, self.g, self.root) + + def test_single_r_node(self) -> None: + """Test that Q3 cube produces exactly 1 R-node.""" + self.assertEqual(len(self.all_nodes), 1) + self.assertEqual(self.root.type, NodeType.R) + + def test_twelve_real_edges_in_skeleton(self) -> None: + """Test that the R-node skeleton has 12 real edges.""" + real: list[Edge] = [ + e for e in self.root.skeleton.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 a single R-node. + + 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 TestSPQROctahedron(unittest.TestCase): + """Tests for the SPQR-tree of the octahedron graph. + + The octahedron has 6 vertices and 12 edges. It is + 4-connected, yielding a single R-node. + + Inspired by the SageMath ``spqr_tree`` test suite. + """ + + def setUp(self) -> None: + """Build the SPQR-tree for the octahedron.""" + self.g: MultiGraph = _make_octahedron() + """The octahedron graph under test.""" + self.root: SPQRNode = build_spqr_tree(self.g) + """The root node of the SPQR tree.""" + self.all_nodes: list[SPQRNode] = \ + _collect_all_nodes(self.root) + """All SPQR tree nodes.""" + + def test_all_invariants(self) -> None: + """Test all SPQR-tree invariants for octahedron.""" + _check_spqr_invariants(self, self.g, self.root) + + def test_single_r_node(self) -> None: + """Test that octahedron produces exactly 1 R-node.""" + self.assertEqual(len(self.all_nodes), 1) + self.assertEqual(self.root.type, NodeType.R) + + def test_twelve_real_edges_in_skeleton(self) -> None: + """Test that the R-node skeleton has 12 real edges.""" + real: list[Edge] = [ + e for e in self.root.skeleton.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 (1-2) is doubled, giving 7 + edges total. The doubled edge creates separation pair + {1,2}, yielding 2 SPQR nodes: 1 P-node (BOND with the + doubled edge) and 1 R-node (K4 skeleton). + + 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 TestSPQRK4OneDoubled(unittest.TestCase): + """Tests for the SPQR-tree of K4 + 1 doubled edge. + + K4 with one edge doubled has 7 edges. Expected: 2 SPQR + nodes: 1 P-node and 1 R-node. + + Inspired by the SageMath ``spqr_tree`` test suite. + """ + + def setUp(self) -> None: + """Build the SPQR-tree for K4 + 1 doubled edge.""" + self.g: MultiGraph = _make_k4_one_doubled() + """The K4 with one doubled edge under test.""" + self.root: SPQRNode = build_spqr_tree(self.g) + """The root node of the SPQR tree.""" + self.all_nodes: list[SPQRNode] = \ + _collect_all_nodes(self.root) + """All SPQR tree nodes.""" + + def test_all_invariants(self) -> None: + """Test all SPQR-tree invariants for K4+doubled.""" + _check_spqr_invariants(self, self.g, self.root) + + def test_two_nodes_total(self) -> None: + """Test that K4+doubled produces exactly 2 SPQR nodes.""" + self.assertEqual( + len(self.all_nodes), 2, + f"Expected 2 nodes, got {len(self.all_nodes)}: " + f"{_count_nodes_by_type(self.root)}", + ) + + def test_one_p_one_r(self) -> None: + """Test that K4+doubled has 1 P-node and 1 R-node.""" + p: list[SPQRNode] = [ + n for n in self.all_nodes + if n.type == NodeType.P + ] + r: list[SPQRNode] = [ + n for n in self.all_nodes + if n.type == NodeType.R + ] + self.assertEqual(len(p), 1, "Expected 1 P-node") + self.assertEqual(len(r), 1, "Expected 1 R-node") + + +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 a + single R-node 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 TestSPQRMobiusKantor(unittest.TestCase): + """Tests for the SPQR-tree of the Mobius-Kantor graph. + + The Mobius-Kantor graph has 16 vertices and 24 edges. + It is 3-connected, yielding a single R-node. + + Inspired by the SageMath ``spqr_tree`` test suite. + """ + + def setUp(self) -> None: + """Build the SPQR-tree for the Mobius-Kantor graph.""" + self.g: MultiGraph = _make_mobius_kantor() + """The Mobius-Kantor graph under test.""" + self.root: SPQRNode = build_spqr_tree(self.g) + """The root node of the SPQR tree.""" + self.all_nodes: list[SPQRNode] = \ + _collect_all_nodes(self.root) + """All SPQR tree nodes.""" + + def test_all_invariants(self) -> None: + """Test all SPQR-tree invariants for Mobius-Kantor.""" + _check_spqr_invariants(self, self.g, self.root) + + def test_single_r_node(self) -> None: + """Test that Mobius-Kantor produces exactly 1 R-node.""" + self.assertEqual(len(self.all_nodes), 1) + self.assertEqual(self.root.type, NodeType.R) + + def test_twenty_four_real_edges_in_skeleton(self) -> None: + """Test that the R-node skeleton has 24 real edges.""" + real: list[Edge] = [ + e for e in self.root.skeleton.edges + if not e.virtual + ] + self.assertEqual(len(real), 24) + + +def _make_large_ladder(n: int) -> MultiGraph: + """Build a ladder graph with n rungs (2n vertices, 3n-2 edges). + + The ladder graph is biconnected and produces a chain of + triconnected components, exercising the full SPQR algorithm. + + :param n: The number of rungs (must be >= 2). + :return: A MultiGraph representing the ladder. + """ + g: MultiGraph = MultiGraph() + for i in range(n): + g.add_edge(2 * i, 2 * i + 1) + for i in range(n - 1): + g.add_edge(2 * i, 2 * (i + 1)) + g.add_edge(2 * i + 1, 2 * (i + 1) + 1) + return g + + +class LinearTimeComplexityTest(unittest.TestCase): + """Test that SPQR-tree construction runs in linear time. + + Builds ladder graphs of increasing sizes, measures the time for + build_spqr_tree, and checks that the growth ratio is consistent + with O(n) rather than O(n^2). + """ + + def test_linear_time(self) -> None: + """Test that doubling graph size roughly doubles run time. + + Builds ladder graphs with 2000, 4000, 8000, and 16000 + rungs. For each size, measures the median time over 3 + runs. Asserts that every ratio t(2n)/t(n) is below 3.0, + which rejects O(n^2) behavior (expected ratio ~4) while + tolerating noise in a linear algorithm (expected ratio ~2). + """ + sizes: list[int] = [2000, 4000, 8000, 16000] + times: list[float] = [] + for n in sizes: + g: MultiGraph = _make_large_ladder(n) + trial_times: list[float] = [] + for _ in range(3): + t0: float = time.perf_counter() + build_spqr_tree(g.copy()) + t1: float = time.perf_counter() + trial_times.append(t1 - t0) + trial_times.sort() + times.append(trial_times[1]) # median + for i in range(1, len(sizes)): + ratio: float = times[i] / times[i - 1] + self.assertLess( + ratio, 3.0, + f"Time ratio for n={sizes[i]} vs " + f"n={sizes[i - 1]} is {ratio:.2f}, " + f"suggesting super-linear growth " + f"(times: {times})" + ) + + +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 TestSPQRWikimediaSpqr(unittest.TestCase): + """Tests for the SPQR-tree of the Wikimedia SPQR example.""" + + def setUp(self) -> None: + """Build the SPQR-tree for the Wikimedia example.""" + self.root: SPQRNode = \ + build_spqr_tree(_make_wikimedia_spqr()) + """The root node of the SPQR tree.""" + self.all_nodes: list[SPQRNode] = \ + _collect_all_nodes(self.root) + """All SPQR tree nodes.""" + + def test_node_count(self) -> None: + """Test that the tree has exactly 5 nodes.""" + self.assertEqual(len(self.all_nodes), 5) + + def test_r_node_count(self) -> None: + """Test that there are exactly 3 R-nodes.""" + n: int = sum( + 1 for nd in self.all_nodes + if nd.type == NodeType.R + ) + self.assertEqual(n, 3) + + def test_s_node_count(self) -> None: + """Test that there is exactly 1 S-node.""" + n: int = sum( + 1 for nd in self.all_nodes + if nd.type == NodeType.S + ) + self.assertEqual(n, 1) + + def test_p_node_count(self) -> None: + """Test that there is exactly 1 P-node.""" + n: int = sum( + 1 for nd in self.all_nodes + if nd.type == NodeType.P + ) + self.assertEqual(n, 1) + + def test_no_adjacent_s_nodes(self) -> None: + """Test that no S-node is adjacent to another S-node.""" + _assert_no_ss_pp(self, self.root, NodeType.S) + + def test_no_adjacent_p_nodes(self) -> None: + """Test that no P-node is adjacent to another P-node.""" + _assert_no_ss_pp(self, self.root, NodeType.P) + + +class TestSPQRRpstFig1a(unittest.TestCase): + """Tests for the SPQR-tree of the RPST Fig 1(a) graph.""" + + def setUp(self) -> None: + """Build the SPQR-tree for RPST Fig 1(a).""" + self.root: SPQRNode = \ + build_spqr_tree(_make_rpst_fig1a()) + """The root node of the SPQR tree.""" + self.all_nodes: list[SPQRNode] = \ + _collect_all_nodes(self.root) + """All SPQR tree nodes.""" + + def test_node_count(self) -> None: + """Test that the tree has exactly 10 nodes.""" + self.assertEqual(len(self.all_nodes), 10) + + def test_r_node_count(self) -> None: + """Test that there is exactly 1 R-node.""" + n: int = sum( + 1 for nd in self.all_nodes + if nd.type == NodeType.R + ) + self.assertEqual(n, 1) + + def test_s_node_count(self) -> None: + """Test that there are exactly 8 S-nodes.""" + n: int = sum( + 1 for nd in self.all_nodes + if nd.type == NodeType.S + ) + self.assertEqual(n, 8) + + def test_p_node_count(self) -> None: + """Test that there is exactly 1 P-node.""" + n: int = sum( + 1 for nd in self.all_nodes + if nd.type == NodeType.P + ) + self.assertEqual(n, 1) + + def test_no_adjacent_s_nodes(self) -> None: + """Test that no S-node is adjacent to another S-node.""" + _assert_no_ss_pp(self, self.root, NodeType.S) + + def test_no_adjacent_p_nodes(self) -> None: + """Test that no P-node is adjacent to another P-node.""" + _assert_no_ss_pp(self, self.root, NodeType.P) diff --git a/tests/test_triconnected.py b/tests/test_triconnected.py new file mode 100644 index 0000000..e345865 --- /dev/null +++ b/tests/test_triconnected.py @@ -0,0 +1,2787 @@ +# 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 unittest +from collections.abc import Hashable + +from spqrtree._graph import Edge, MultiGraph +from spqrtree._triconnected import ( + ComponentType, + 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_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)