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)