Initial commit

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 09:33:44 +08:00
parent 06153f7d76
commit 23282de176
26 changed files with 9732 additions and 0 deletions

40
.readthedocs.yaml Normal file
View File

@@ -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

202
LICENSE Normal file
View File

@@ -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.

22
MANIFEST.in Normal file
View File

@@ -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

138
README.rst Normal file
View File

@@ -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

20
docs/Makefile Normal file
View File

@@ -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)

35
docs/make.bat Normal file
View File

@@ -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

1
docs/requirements.txt Normal file
View File

@@ -0,0 +1 @@
sphinx_rtd_theme

View File

View File

View File

@@ -0,0 +1,3 @@
Change Log
==========

33
docs/source/conf.py Normal file
View File

@@ -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']

25
docs/source/index.rst Normal file
View File

@@ -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`

180
docs/source/intro.rst Normal file
View File

@@ -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 <https://pypi.org/project/spqrtree/>`_ 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 <https://doi.org/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
<https://doi.org/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
<https://doi.org/10.1007/3-540-44541-2_8>`_
Acknowledgments
---------------
The test suite was validated against the SPQR-tree implementation in
`SageMath <https://www.sagemath.org/>`_, which served as the reference
for verifying correctness of the decomposition results.

7
docs/source/modules.rst Normal file
View File

@@ -0,0 +1,7 @@
src
===
.. toctree::
:maxdepth: 4
spqrtree

10
docs/source/spqrtree.rst Normal file
View File

@@ -0,0 +1,10 @@
spqrtree package
================
Module contents
---------------
.. automodule:: spqrtree
:members:
:show-inheritance:
:undoc-members:

68
pyproject.toml Normal file
View File

@@ -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"}

105
src/spqrtree/__init__.py Normal file
View File

@@ -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

306
src/spqrtree/_graph.py Normal file
View File

@@ -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)

363
src/spqrtree/_palm_tree.py Normal file
View File

@@ -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)

340
src/spqrtree/_spqr.py Normal file
View File

@@ -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

File diff suppressed because it is too large Load Diff

0
src/spqrtree/py.typed Normal file
View File

228
tests/test_graph.py Normal file
View File

@@ -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)

471
tests/test_palm_tree.py Normal file
View File

@@ -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)

2853
tests/test_spqrtree.py Normal file

File diff suppressed because it is too large Load Diff

2787
tests/test_triconnected.py Normal file

File diff suppressed because it is too large Load Diff