From dbe2f2ec5e7805b0ca775745734a00b9651db1d2 Mon Sep 17 00:00:00 2001 From: huxiaoyi-ovo Date: Thu, 15 Oct 2026 16:41:11 +0800 Subject: [PATCH 01/11] feat: detect parallel edges in directed and undirected graphs --- src/pgraph/PGraph.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/pgraph/PGraph.py b/src/pgraph/PGraph.py index e0fa4de0..b7e44548 100644 --- a/src/pgraph/PGraph.py +++ b/src/pgraph/PGraph.py @@ -2313,6 +2313,32 @@ def edges(self) -> list[Edge]: """ return list(self._edges) + def isparallel(self) -> bool: + """Test whether the graph contains parallel edges. + + :return: True if two distinct edges connect the same pair of vertices + + Direction matters for directed graphs: ``a -> b`` and ``b -> a`` + are not parallel. For undirected graphs the order is ignored. + One self-loop is not a parallel pair, but two at the same vertex are. + + Checks each edge once, taking O(E) time and O(E) space in the worst + case, and stops as soon as a repeated pair is found. + + :seealso: :meth:`edges` :meth:`iscyclic` + """ + seen: set[tuple[int, int]] = set() + for edge in self._edges: + assert edge.v1 is not None and edge.v2 is not None + a, b = id(edge.v1), id(edge.v2) + if not self._directed and a > b: + a, b = b, a + endpoints = (a, b) + if endpoints in seen: + return True + seen.add(endpoints) + return False + def _edge_layout(self) -> tuple[dict[Edge, float], dict[Edge, int]]: """ How each edge is to be drawn, apart from its end points (private method) From e64a77b426dc62fb92473ce058953d326637d2a7 Mon Sep 17 00:00:00 2001 From: huxiaoyi-ovo Date: Thu, 15 Oct 2026 16:41:39 +0800 Subject: [PATCH 02/11] test: cover parallel edges, direction, loops and removals --- tests/test_isparallel.py | 78 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 tests/test_isparallel.py diff --git a/tests/test_isparallel.py b/tests/test_isparallel.py new file mode 100644 index 00000000..46850537 --- /dev/null +++ b/tests/test_isparallel.py @@ -0,0 +1,78 @@ +"""Tests for the graph-level parallel-edge predicate.""" + +import unittest + +from pgraph import DGraph, Graph, UGraph + + +class TestIsParallel(unittest.TestCase): + @staticmethod + def vertices(graph): + a = graph.add_vertex([0, 0], name="a") + b = graph.add_vertex([1, 0], name="b") + c = graph.add_vertex([2, 0], name="c") + return a, b, c + + def test_empty_and_simple_graphs(self): + for graph_class in (UGraph, DGraph): + with self.subTest(graph_class=graph_class): + graph = graph_class() + self.assertFalse(graph.isparallel()) + a, b, c = self.vertices(graph) + self.assertFalse(graph.isparallel()) + graph.add_edge(a, b, cost=1) + graph.add_edge(b, c, cost=2) + self.assertFalse(graph.isparallel()) + self.assertEqual(graph.validate(), []) + + def test_repeated_edge_and_removal(self): + for graph_class in (UGraph, DGraph): + with self.subTest(graph_class=graph_class): + graph = graph_class() + a, b, _ = self.vertices(graph) + first = graph.add_edge(a, b, cost=1) + second = graph.add_edge(a, b, cost=2) + self.assertTrue(graph.isparallel()) + self.assertTrue(graph.isparallel()) # read-only predicate + graph.remove_edge(second) + self.assertFalse(graph.isparallel()) + self.assertIn(first, graph.edges()) + self.assertEqual(graph.validate(), []) + + def test_opposite_directions(self): + for graph_class, expected in ((UGraph, True), (DGraph, False)): + with self.subTest(graph_class=graph_class): + graph = graph_class() + a, b, _ = self.vertices(graph) + graph.add_edge(a, b, cost=1) + graph.add_edge(b, a, cost=2) + self.assertEqual(graph.isparallel(), expected) + self.assertEqual(graph.validate(), []) + + def test_self_loops(self): + for graph_class in (UGraph, DGraph): + with self.subTest(graph_class=graph_class): + graph = graph_class() + a, b, _ = self.vertices(graph) + first = graph.add_edge(a, a, cost=1) + graph.add_edge(b, b, cost=2) + self.assertFalse(graph.isparallel()) + second = graph.add_edge(a, a, cost=3) + self.assertTrue(graph.isparallel()) + graph.remove_edge(first) + self.assertFalse(graph.isparallel()) + self.assertIn(second, graph.edges()) + self.assertEqual(graph.validate(), []) + + def test_factory_graph_direction(self): + for directed, expected in ((False, True), (True, False)): + with self.subTest(directed=directed): + graph = Graph(directed=directed) + a, b, _ = self.vertices(graph) + graph.add_edge(a, b) + graph.add_edge(b, a) + self.assertEqual(graph.isparallel(), expected) + + +if __name__ == "__main__": + unittest.main() From edbbf0761a717a13bce4afe7ccce46ba8b84e37b Mon Sep 17 00:00:00 2001 From: huxiaoyi-ovo Date: Thu, 15 Oct 2026 16:42:06 +0800 Subject: [PATCH 03/11] docs: describe parallel-edge detection --- docs/llms_intro.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/llms_intro.md b/docs/llms_intro.md index 8969ac8b..061d72a2 100644 --- a/docs/llms_intro.md +++ b/docs/llms_intro.md @@ -22,7 +22,7 @@ g.highlight_path(path) - **Classes.** `UGraph` (undirected) and `DGraph` (directed) share the base class `Graph`, which is concrete: `Graph(directed=True)` is a `DGraph`. There is one `Vertex` class for both and one `Edge` class. `UVertex` and `DVertex` are deprecated aliases of `Vertex`. Constructor arguments are keyword-only: `UGraph(dim=2, metric="L2", heuristic=None, vertex_class=None, edge_class=None, verbose=False)`; `UGraph(2)` is a `TypeError`. - **Vertices.** `g.add_vertex(coord=None, name=None)` returns the vertex. Default names are `"#0"`, `"#1"`, ..., names are unique, and `g["a"]` or `g[0]` finds a vertex. `for v in g` iterates in the order added; `g.n`, `g.ne` and `g.nc` are the numbers of vertices, edges and components. `g.remove_vertex(v)` and `g.remove_edge(e)` remove them, with their edges. -- **Edges.** `g.add_edge(v1, v2, cost=None)`; with no `cost` it is the metric distance between the coordinates, and `None` if the vertices have none (planning then raises `ValueError`). `g.edges()` and `v.edges()` return new lists in the order added. `e.v1` and `e.v2` are read-only. `v.adjacent()` is the neighbours, `v.successors()` and `v.predecessors()` are the directed ones, `v.edgeto(w)` the cheapest edge to `w` and `v.edges_to(w)` all of them. A self-loop of an undirected vertex is in `v.edges()` twice and counts 2 in its degree. +- **Edges.** `g.add_edge(v1, v2, cost=None)`; with no `cost` it is the metric distance between the coordinates, and `None` if the vertices have none (planning then raises `ValueError`). `g.edges()` and `v.edges()` return new lists in the order added. `e.v1` and `e.v2` are read-only. `v.adjacent()` is the neighbours, `v.successors()` and `v.predecessors()` are the directed ones, `v.edgeto(w)` the cheapest edge to `w` and `v.edges_to(w)` all of them. A self-loop of an undirected vertex is in `v.edges()` twice and counts 2 in its degree. `g.isparallel()` reports whether two distinct edges join the same vertices (direction matters in a `DGraph`; a single self-loop does not count). - **Directed graphs.** `degree` is in plus out (`in_degree`, `out_degree`); `nc` and `component` are weakly connected, ignoring direction. - **Planning.** `path_BFS(S, G)` returns `(path, length)` with the fewest edges; `path_UCS(S, G)` and `path_Astar(S, G)` return `(path, length, parents)` of least cost. `S` and `G` are vertices or names, and all three return `None` when there is no path, so check before unpacking. - **Metrics.** `"L2"` (default), `"L1"`, `"SE2"` for `(x, y, theta)` poses, `SE2Metric(weight)` to weight the angle, or a function of one difference vector. A* uses `heuristic` (default: the metric), which must be in the same units as the edge costs and must not exceed the true cost to go, or the path is not the shortest. From 26b337362d8b292eeff2f74d44556f713cda4f1e Mon Sep 17 00:00:00 2001 From: huxiaoyi-ovo Date: Thu, 15 Oct 2026 16:42:09 +0800 Subject: [PATCH 04/11] docs: describe parallel-edge detection --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index d0ff4260..6f45d5aa 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,7 @@ Graphs belong to the class `UGraph` or `DGraph` for undirected or directed graph *** - `g.add_edge()` connect two vertices - `g.edges()` all edges in the graph +- `g.isparallel()` true if any two edges join the same vertices (respecting direction) - `g.plot()` plots the vertices and edges - `g.nc` the number of graph components, 1 if fully connected - `g.component(c)` the vertices in component number `c`, numbered from 0 From c704e50a0d7e8ef9bb965966e6b1a0aeb0d7d94c Mon Sep 17 00:00:00 2001 From: huxiaoyi-ovo Date: Fri, 30 Oct 2026 03:55:11 -0400 Subject: [PATCH 05/11] feat(graph): group parallel edges using shared endpoint keys --- src/pgraph/PGraph.py | 51 ++++++++++++++++++++++++++++++-------------- 1 file changed, 35 insertions(+), 16 deletions(-) diff --git a/src/pgraph/PGraph.py b/src/pgraph/PGraph.py index b7e44548..41acbbbf 100644 --- a/src/pgraph/PGraph.py +++ b/src/pgraph/PGraph.py @@ -2313,32 +2313,51 @@ def edges(self) -> list[Edge]: """ return list(self._edges) - def isparallel(self) -> bool: - """Test whether the graph contains parallel edges. + def _parallel_edge_key(self, edge: Edge) -> tuple[int, int]: + """Identify an edge's endpoints, preserving direction when directed.""" + assert edge.v1 is not None and edge.v2 is not None + a, b = id(edge.v1), id(edge.v2) + if not self._directed and a > b: + a, b = b, a + return a, b - :return: True if two distinct edges connect the same pair of vertices + def isparallel(self) -> bool: + """Test whether at least two distinct edges join the same vertices. - Direction matters for directed graphs: ``a -> b`` and ``b -> a`` - are not parallel. For undirected graphs the order is ignored. - One self-loop is not a parallel pair, but two at the same vertex are. + Direction matters in directed graphs: ``a -> b`` and ``b -> a`` + are not parallel. Undirected edges are orientation-independent. + A single self-loop is not parallel, but two self-loops are. - Checks each edge once, taking O(E) time and O(E) space in the worst - case, and stops as soon as a repeated pair is found. + Checks each edge once and returns as soon as a repeated endpoint + pair is found. Worst-case time and space are O(E). - :seealso: :meth:`edges` :meth:`iscyclic` + :seealso: :meth:`parallel_edges` :meth:`edges` """ seen: set[tuple[int, int]] = set() for edge in self._edges: - assert edge.v1 is not None and edge.v2 is not None - a, b = id(edge.v1), id(edge.v2) - if not self._directed and a > b: - a, b = b, a - endpoints = (a, b) - if endpoints in seen: + key = self._parallel_edge_key(edge) + if key in seen: return True - seen.add(endpoints) + seen.add(key) return False + def parallel_edges(self) -> list[tuple[Edge, ...]]: + """Return groups of at least two distinct parallel edges. + + Each tuple contains all edges joining the same vertices, in edge + insertion order. Groups are ordered by the first edge in each group. + Direction matters for directed graphs but not undirected graphs. + A single self-loop is excluded; two or more are grouped together. + + :return: List of tuples of parallel edges, or an empty list + :seealso: :meth:`isparallel` :meth:`edges` + """ + groups: dict[tuple[int, int], list[Edge]] = {} + for edge in self._edges: + key = self._parallel_edge_key(edge) + groups.setdefault(key, []).append(edge) + return [tuple(edges) for edges in groups.values() if len(edges) >= 2] + def _edge_layout(self) -> tuple[dict[Edge, float], dict[Edge, int]]: """ How each edge is to be drawn, apart from its end points (private method) From c0987f906a631238401021fa537e2b72ac4d6cbd Mon Sep 17 00:00:00 2001 From: huxiaoyi-ovo Date: Fri, 30 Oct 2026 03:55:40 -0400 Subject: [PATCH 06/11] test(graph): verify parallel groups, ordering, loops, and random graphs --- tests/test_isparallel.py | 98 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/tests/test_isparallel.py b/tests/test_isparallel.py index 46850537..b2222728 100644 --- a/tests/test_isparallel.py +++ b/tests/test_isparallel.py @@ -1,5 +1,6 @@ """Tests for the graph-level parallel-edge predicate.""" +import random import unittest from pgraph import DGraph, Graph, UGraph @@ -74,5 +75,102 @@ def test_factory_graph_direction(self): self.assertEqual(graph.isparallel(), expected) + def test_parallel_groups_are_complete_and_ordered_by_first_edge(self): + for graph_class in (UGraph, DGraph): + with self.subTest(graph_class=graph_class): + graph = graph_class() + a, b, c = self.vertices(graph) + first_ab = graph.add_edge(a, b, cost=1) + first_bc = graph.add_edge(b, c, cost=2) + second_bc = graph.add_edge(b, c, cost=3) + second_ab = graph.add_edge(a, b, cost=4) + third_ab = graph.add_edge(a, b, cost=5) + + # The b-c group completes first, but the a-b group starts first. + self.assertEqual( + graph.parallel_edges(), + [(first_ab, second_ab, third_ab), (first_bc, second_bc)], + ) + self.assertTrue(graph.isparallel()) + self.assertEqual(graph.edges(), [ + first_ab, first_bc, second_bc, second_ab, third_ab + ]) + + graph.remove_edge(second_ab) + self.assertEqual( + graph.parallel_edges(), + [(first_ab, third_ab), (first_bc, second_bc)], + ) + + def test_reversed_undirected_edge_and_directed_edge(self): + for graph_class in (UGraph, DGraph): + with self.subTest(graph_class=graph_class): + graph = graph_class() + a, b, _ = self.vertices(graph) + forward = graph.add_edge(a, b, cost=1) + reverse = graph.add_edge(b, a, cost=2) + if graph_class is UGraph: + self.assertEqual(graph.parallel_edges(), [(forward, reverse)]) + else: + self.assertEqual(graph.parallel_edges(), []) + repeated = graph.add_edge(a, b, cost=3) + self.assertEqual(graph.parallel_edges(), [(forward, repeated)]) + self.assertEqual(graph.isparallel(), bool(graph.parallel_edges())) + + def test_single_and_double_self_loop_groups(self): + for graph_class in (UGraph, DGraph): + with self.subTest(graph_class=graph_class): + graph = graph_class() + a, b, _ = self.vertices(graph) + first = graph.add_edge(a, a, cost=1) + graph.add_edge(b, b, cost=2) + self.assertEqual(graph.parallel_edges(), []) + second = graph.add_edge(a, a, cost=3) + self.assertEqual(graph.parallel_edges(), [(first, second)]) + graph.remove_edge(first) + self.assertEqual(graph.parallel_edges(), []) + self.assertFalse(graph.isparallel()) + + def test_random_graphs_match_independent_pairwise_reference(self): + rng = random.Random(20261030) + + def same_endpoints(left, right, directed): + same = left.v1 is right.v1 and left.v2 is right.v2 + reverse = left.v1 is right.v2 and left.v2 is right.v1 + return same or (not directed and reverse) + + for graph_class in (UGraph, DGraph): + for trial in range(250): + with self.subTest(graph_class=graph_class, trial=trial): + graph = graph_class() + vertices = [ + graph.add_vertex([i, 0], name=f"v{i}") + for i in range(5) + ] + for _ in range(rng.randrange(31)): + a = rng.choice(vertices) + b = rng.choice(vertices) + graph.add_edge(a, b, cost=1) + + edges = graph.edges() + used = set() + expected = [] + for i, edge in enumerate(edges): + if i in used: + continue + indices = [ + j for j in range(i, len(edges)) + if same_endpoints( + edge, edges[j], graph_class is DGraph + ) + ] + used.update(indices) + if len(indices) >= 2: + expected.append(tuple(edges[j] for j in indices)) + + self.assertEqual(graph.parallel_edges(), expected) + self.assertEqual(graph.isparallel(), bool(expected)) + + if __name__ == "__main__": unittest.main() From f0a98a589aa4e3d67091071821651e2cead8d9a4 Mon Sep 17 00:00:00 2001 From: huxiaoyi-ovo Date: Fri, 30 Oct 2026 03:55:52 -0400 Subject: [PATCH 07/11] docs(graph): describe parallel edge groups in README --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 6f45d5aa..7097bde6 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,8 @@ Graphs belong to the class `UGraph` or `DGraph` for undirected or directed graph *** - `g.add_edge()` connect two vertices - `g.edges()` all edges in the graph -- `g.isparallel()` true if any two edges join the same vertices (respecting direction) +- `g.isparallel()` true if at least two distinct edges join the same vertices (respecting direction) +- `g.parallel_edges()` groups of two or more parallel edges, ordered by first appearance, each tuple in edge insertion order - `g.plot()` plots the vertices and edges - `g.nc` the number of graph components, 1 if fully connected - `g.component(c)` the vertices in component number `c`, numbered from 0 From d66a4a3ab866df1e9c601872ba967e36e1c9474f Mon Sep 17 00:00:00 2001 From: huxiaoyi-ovo Date: Fri, 30 Oct 2026 03:56:02 -0400 Subject: [PATCH 08/11] docs(graph): explain parallel edge grouping and ordering --- docs/llms_intro.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/llms_intro.md b/docs/llms_intro.md index 061d72a2..97830ba2 100644 --- a/docs/llms_intro.md +++ b/docs/llms_intro.md @@ -22,7 +22,7 @@ g.highlight_path(path) - **Classes.** `UGraph` (undirected) and `DGraph` (directed) share the base class `Graph`, which is concrete: `Graph(directed=True)` is a `DGraph`. There is one `Vertex` class for both and one `Edge` class. `UVertex` and `DVertex` are deprecated aliases of `Vertex`. Constructor arguments are keyword-only: `UGraph(dim=2, metric="L2", heuristic=None, vertex_class=None, edge_class=None, verbose=False)`; `UGraph(2)` is a `TypeError`. - **Vertices.** `g.add_vertex(coord=None, name=None)` returns the vertex. Default names are `"#0"`, `"#1"`, ..., names are unique, and `g["a"]` or `g[0]` finds a vertex. `for v in g` iterates in the order added; `g.n`, `g.ne` and `g.nc` are the numbers of vertices, edges and components. `g.remove_vertex(v)` and `g.remove_edge(e)` remove them, with their edges. -- **Edges.** `g.add_edge(v1, v2, cost=None)`; with no `cost` it is the metric distance between the coordinates, and `None` if the vertices have none (planning then raises `ValueError`). `g.edges()` and `v.edges()` return new lists in the order added. `e.v1` and `e.v2` are read-only. `v.adjacent()` is the neighbours, `v.successors()` and `v.predecessors()` are the directed ones, `v.edgeto(w)` the cheapest edge to `w` and `v.edges_to(w)` all of them. A self-loop of an undirected vertex is in `v.edges()` twice and counts 2 in its degree. `g.isparallel()` reports whether two distinct edges join the same vertices (direction matters in a `DGraph`; a single self-loop does not count). +- **Edges.** `g.add_edge(v1, v2, cost=None)`; with no `cost` it is the metric distance between the coordinates, and `None` if the vertices have none (planning then raises `ValueError`). `g.edges()` and `v.edges()` return new lists in the order added. `e.v1` and `e.v2` are read-only. `v.adjacent()` is the neighbours, `v.successors()` and `v.predecessors()` are the directed ones, `v.edgeto(w)` the cheapest edge to `w` and `v.edges_to(w)` all of them. A self-loop of an undirected vertex is in `v.edges()` twice and counts 2 in its degree. `g.isparallel()` reports whether at least two distinct edges join the same vertices (direction matters in a `DGraph`; a single self-loop does not count). `g.parallel_edges()` returns a list of tuples, one per group of two or more parallel edges; each tuple follows edge insertion order and groups follow their first edge's order. Reversed edges form a group only in an undirected graph. - **Directed graphs.** `degree` is in plus out (`in_degree`, `out_degree`); `nc` and `component` are weakly connected, ignoring direction. - **Planning.** `path_BFS(S, G)` returns `(path, length)` with the fewest edges; `path_UCS(S, G)` and `path_Astar(S, G)` return `(path, length, parents)` of least cost. `S` and `G` are vertices or names, and all three return `None` when there is no path, so check before unpacking. - **Metrics.** `"L2"` (default), `"L1"`, `"SE2"` for `(x, y, theta)` poses, `SE2Metric(weight)` to weight the angle, or a function of one difference vector. A* uses `heuristic` (default: the metric), which must be in the same units as the edge costs and must not exceed the true cost to go, or the path is not the shortest. From 10e9785d2acf346f4910450abd30971b662997e4 Mon Sep 17 00:00:00 2001 From: huxiaoyi-ovo Date: Fri, 30 Oct 2026 03:56:13 -0400 Subject: [PATCH 09/11] docs(changelog): note parallel edge detection and grouping --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c39aabea..acd6af73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,7 @@ Deprecated (still work, with a `DeprecationWarning`) Added +- `Graph.isparallel()` detects two or more distinct edges with the same endpoints; `Graph.parallel_edges()` returns their groups as insertion-ordered tuples (respecting direction in a `DGraph`). - `adjacent()` and `isadjacent()`, the graph-theory names, which have one spelling. `neighbours()`, `neighbors()` and `isneighbour()` from 0.6.5 are kept as aliases, with `isneighbor()` added, and none of them warn. From 7ba649bc5945324ff24474ec2a5cc6ca5f052e28 Mon Sep 17 00:00:00 2001 From: huxiaoyi-ovo Date: Fri, 30 Oct 2026 04:14:01 -0400 Subject: [PATCH 10/11] test(graph): cover dynamic group order and refine parallel-edge API docs --- CHANGELOG.md | 5 ++++- README.md | 2 +- src/pgraph/PGraph.py | 2 ++ tests/test_isparallel.py | 31 ++++++++++++++++++++++++++----- 4 files changed, 33 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index acd6af73..a439f2ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,7 +62,10 @@ Deprecated (still work, with a `DeprecationWarning`) Added -- `Graph.isparallel()` detects two or more distinct edges with the same endpoints; `Graph.parallel_edges()` returns their groups as insertion-ordered tuples (respecting direction in a `DGraph`). +- `Graph.isparallel()` detects two or more distinct edges with the same + endpoints; `Graph.parallel_edges()` returns their groups as tuples in edge + insertion order, with groups ordered by first appearance (respecting + direction in a `DGraph`). - `adjacent()` and `isadjacent()`, the graph-theory names, which have one spelling. `neighbours()`, `neighbors()` and `isneighbour()` from 0.6.5 are kept as aliases, with `isneighbor()` added, and none of them warn. diff --git a/README.md b/README.md index 7097bde6..553c726d 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ Graphs belong to the class `UGraph` or `DGraph` for undirected or directed graph - `g.add_edge()` connect two vertices - `g.edges()` all edges in the graph - `g.isparallel()` true if at least two distinct edges join the same vertices (respecting direction) -- `g.parallel_edges()` groups of two or more parallel edges, ordered by first appearance, each tuple in edge insertion order +- `g.parallel_edges()` returns `list[tuple[Edge, ...]]` for groups of two or more parallel edges; edges and groups follow insertion order - `g.plot()` plots the vertices and edges - `g.nc` the number of graph components, 1 if fully connected - `g.component(c)` the vertices in component number `c`, numbered from 0 diff --git a/src/pgraph/PGraph.py b/src/pgraph/PGraph.py index 41acbbbf..4586e4fe 100644 --- a/src/pgraph/PGraph.py +++ b/src/pgraph/PGraph.py @@ -2331,6 +2331,7 @@ def isparallel(self) -> bool: Checks each edge once and returns as soon as a repeated endpoint pair is found. Worst-case time and space are O(E). + :return: True if at least two edges share the same endpoints :seealso: :meth:`parallel_edges` :meth:`edges` """ seen: set[tuple[int, int]] = set() @@ -2348,6 +2349,7 @@ def parallel_edges(self) -> list[tuple[Edge, ...]]: insertion order. Groups are ordered by the first edge in each group. Direction matters for directed graphs but not undirected graphs. A single self-loop is excluded; two or more are grouped together. + Grouping takes O(E) time and O(E) space. :return: List of tuples of parallel edges, or an empty list :seealso: :meth:`isparallel` :meth:`edges` diff --git a/tests/test_isparallel.py b/tests/test_isparallel.py index b2222728..3a74c70b 100644 --- a/tests/test_isparallel.py +++ b/tests/test_isparallel.py @@ -19,11 +19,13 @@ def test_empty_and_simple_graphs(self): with self.subTest(graph_class=graph_class): graph = graph_class() self.assertFalse(graph.isparallel()) + self.assertEqual(graph.parallel_edges(), []) a, b, c = self.vertices(graph) self.assertFalse(graph.isparallel()) graph.add_edge(a, b, cost=1) graph.add_edge(b, c, cost=2) self.assertFalse(graph.isparallel()) + self.assertEqual(graph.parallel_edges(), []) self.assertEqual(graph.validate(), []) def test_repeated_edge_and_removal(self): @@ -34,9 +36,11 @@ def test_repeated_edge_and_removal(self): first = graph.add_edge(a, b, cost=1) second = graph.add_edge(a, b, cost=2) self.assertTrue(graph.isparallel()) + self.assertEqual(graph.parallel_edges(), [(first, second)]) self.assertTrue(graph.isparallel()) # read-only predicate graph.remove_edge(second) self.assertFalse(graph.isparallel()) + self.assertEqual(graph.parallel_edges(), []) self.assertIn(first, graph.edges()) self.assertEqual(graph.validate(), []) @@ -70,9 +74,13 @@ def test_factory_graph_direction(self): with self.subTest(directed=directed): graph = Graph(directed=directed) a, b, _ = self.vertices(graph) - graph.add_edge(a, b) - graph.add_edge(b, a) + forward = graph.add_edge(a, b) + reverse = graph.add_edge(b, a) self.assertEqual(graph.isparallel(), expected) + self.assertEqual( + graph.parallel_edges(), + [(forward, reverse)] if expected else [], + ) def test_parallel_groups_are_complete_and_ordered_by_first_edge(self): @@ -92,9 +100,10 @@ def test_parallel_groups_are_complete_and_ordered_by_first_edge(self): [(first_ab, second_ab, third_ab), (first_bc, second_bc)], ) self.assertTrue(graph.isparallel()) - self.assertEqual(graph.edges(), [ - first_ab, first_bc, second_bc, second_ab, third_ab - ]) + self.assertEqual( + graph.edges(), + [first_ab, first_bc, second_bc, second_ab, third_ab], + ) graph.remove_edge(second_ab) self.assertEqual( @@ -102,6 +111,18 @@ def test_parallel_groups_are_complete_and_ordered_by_first_edge(self): [(first_ab, third_ab), (first_bc, second_bc)], ) + # Removing the first edge changes the order of surviving groups. + graph.remove_edge(first_ab) + self.assertEqual( + graph.parallel_edges(), [(first_bc, second_bc)] + ) + replacement = graph.add_edge(a, b, cost=6) + self.assertEqual( + graph.parallel_edges(), + [(first_bc, second_bc), (third_ab, replacement)], + ) + self.assertEqual(graph.validate(), []) + def test_reversed_undirected_edge_and_directed_edge(self): for graph_class in (UGraph, DGraph): with self.subTest(graph_class=graph_class): From 2ff9aa6a24e9575ac1353727e40ca783a74da251 Mon Sep 17 00:00:00 2001 From: huxiaoyi-ovo Date: Fri, 30 Oct 2026 04:38:54 -0400 Subject: [PATCH 11/11] test(graph): document deterministic non-cryptographic RNG use --- tests/test_isparallel.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_isparallel.py b/tests/test_isparallel.py index 3a74c70b..b890605d 100644 --- a/tests/test_isparallel.py +++ b/tests/test_isparallel.py @@ -153,7 +153,8 @@ def test_single_and_double_self_loop_groups(self): self.assertFalse(graph.isparallel()) def test_random_graphs_match_independent_pairwise_reference(self): - rng = random.Random(20261030) + # Reproducible test data only; no security-sensitive randomness. + rng = random.Random(20261030) # nosec B311 def same_endpoints(left, right, directed): same = left.v1 is right.v1 and left.v2 is right.v2