diff --git a/src/pgraph/PGraph.py b/src/pgraph/PGraph.py index 75dc7e4b..c82e50c9 100644 --- a/src/pgraph/PGraph.py +++ b/src/pgraph/PGraph.py @@ -1218,6 +1218,7 @@ def remove_vertex(self, vertex: Vertex) -> None: self._vertexlist.remove(vertex) del self._vertexdict[vertex.name] vertex._graph = None # it is not in any graph now + vertex._label = None # and so not in a component self._connectivitychange = True def remove(self, x: Edge | Vertex) -> None: @@ -1306,19 +1307,52 @@ def ne(self) -> int: """ return len(self._edges) - def _graphcolor(self) -> int | None: + def _graphcolor(self) -> None: """ - Color the graph (subclass method) + Label the components of the graph (private method) - Concrete graph coloring algorithm, provided by :meth:`UGraph._graphcolor` - and :meth:`DGraph._graphcolor`. + Assigns every vertex a ``label``, the number of its component, from 0 + in the order of the first vertex of each component, and sets the + number of components. Components are *weakly* connected: the + direction of an edge is ignored, so a vertex is in the component of + every vertex that it has a path to or from. - :raises NotImplementedError: not provided by this graph kind + This is a single pass over the vertices and edges, O(V + E), and is + only done again when the graph has changed since, which every + operation that adds or removes a vertex or edge records in the graph's + ``_connectivitychange``. + + :seealso: :meth:`nc` :meth:`component` """ - raise NotImplementedError( - f"{type(self).__name__} does not define how to color the graph; " - "subclass UGraph or DGraph" - ) + if not self._connectivitychange: + return + + for vertex in self: + vertex._label = None + vertex._connectivitychange = False + + count = 0 + for start in self: + if start._label is not None: + continue + start._label = count + frontier = [start] + while frontier: + v = frontier.pop() + # an edge may be followed against its direction, so the + # vertices that lead to this one count as well as those it + # leads to; in an undirected graph they are the same + around = v.adjacent() + if self.directed: + around = around + v.predecessors() + for w in around: + if w._label is None: + w._label = count + frontier.append(w) + count += 1 + + self._ncomponents = count + self._connectivitychange = False @property def nc(self) -> int: @@ -1330,6 +1364,16 @@ def nc(self) -> int: Number of components in the graph. Components are labeled from 0 to ``g.nc-1``. + For a directed graph the components are *weakly* connected: the + direction of the edges is ignored, so two vertices are in the same + component if there is a path between them that may run against the + arrows. A graph with no vertices has no components. + + .. versionchanged:: 1.0 + Does not hang. For many directed graphs, the smallest being the + 4-cycle, the algorithm of 0.6.5 never finished, which also stopped + :meth:`component`, :meth:`samecomponent` and :meth:`plot`. + .. runblock:: pycon >>> from pgraph import UGraph @@ -1347,10 +1391,7 @@ def nc(self) -> int: :seealso: :meth:`component` :meth:`samecomponent` """ - n = self._graphcolor() - if n is not None: - self._ncomponents = n - + self._graphcolor() return self._ncomponents def _metricfunc(self, metric: Callable[[NDArray], float] | str) -> Callable[[NDArray], float]: @@ -2677,10 +2718,11 @@ def samecomponent(self, v1: Vertex, v2: Vertex) -> bool: :return: true if vertices belong to same graph component :rtype: bool - Test whether vertices belong to the same component. For a: - - - directed graph this implies a path between them - - undirected graph there is not necessarily a path between them + Test whether vertices belong to the same component. A component is a + set of vertices that are connected by a path of edges, ignoring their + direction, so for a directed graph this does not mean that there is a + path from one to the other: ``a -> c <- b`` is one component, but there + is no path from ``a`` to ``b``. .. runblock:: pycon @@ -3124,50 +3166,6 @@ def _endpoints_match(self, edge: Edge, v1: Vertex, v2: Vertex) -> bool: """ return (edge.v1 is v1 and edge.v2 is v2) or (edge.v1 is v2 and edge.v2 is v1) - def _graphcolor(self) -> None: - """ - Color the graph - - Performs a depth-first labeling operation, assigning the ``label`` - attribute of every vertex with a sequential integer starting from 0. - - This method checks the ``_connectivitychange`` attribute of all vertices - and if any are True it will perform the coloring operation. This flag - is set True by any operation that adds or removes a vertex or edge. - - :seealso: :meth:`nc` - """ - if self._connectivitychange or any([n._connectivitychange for n in self]): - - # color the graph - - # clear all the labels - for vertex in self: - vertex._label = None - vertex._connectivitychange = False - - lastlabel: int | None = None - for label in range(self.n): - assignment = False - for v in self: - # find first vertex with no label - if v.label is None: - # do BFS - q = [v] # initialize frontier - while len(q) > 0: - v = q.pop() # expand v - v._label = label - for n in v.adjacent(): - if n.label is None: - q.append(n) - lastlabel = label - assignment = True - break - if not assignment: - break - - self._ncomponents = 0 if lastlabel is None else lastlabel + 1 - class DGraph(Graph): """ @@ -3213,79 +3211,6 @@ def _incoming(self, vertex: Vertex) -> list[Edge]: """The edges that end at the vertex""" return vertex._inlist - def _graphcolor(self) -> int | None: - """ - Color the graph - - Performs a depth-first labeling operation, assigning the ``label`` - attribute of every vertex with a sequential integer starting from 0. - - This method checks the ``_connectivitychange`` attribute of all vertices - and if any are True it will perform the coloring operation. This flag - is set True by any operation that adds or removes a vertex or edge. - - :seealso: :meth:`nc` - """ - if self._connectivitychange or any([n._connectivitychange for n in self]): - - # color the graph - - # clear all the labels - for vertex in self: - vertex._label = None - vertex._connectivitychange = False - - # initial labeling pass - merge: dict[int, int] = {} - nextlabel = 1 - for v in self: - if v.label is None: - # no label, try to inherit one from a neighbour - for n in v.adjacent(): - if n.label is not None: - # neighbour has a label - v._label = n.label - break - - if v.label is None: - # still not labeled, assign a new label - v._label = nextlabel - nextlabel += 1 - - label = v.label - assert label is not None - - # now look for clashes - for n in v.adjacent(): - if n.label is None: - # neighbour has no label, give it this one - n._label = label - elif label != n.label: - # label clash, note it for merging - assert n.label is not None - merge[n.label] = label - - # merge labels and find unique labels - unique: set[int] = set() - for v in self: - vlabel = v.label - assert vlabel is not None - while vlabel in merge: - vlabel = merge[vlabel] - v._label = vlabel - unique.add(vlabel) - - final = {u: i for i, u in enumerate(unique)} - for v in self: - vlabel = v.label - assert vlabel is not None - v._label = final[vlabel] - - return len(unique) - else: - # no coloring performed - return None - # ========================================================================== # @@ -4413,21 +4338,25 @@ def label(self) -> int | None: """ Number of the graph component that contains the vertex - :return: component number, counted from 0, or None if the components - have not been found yet + :return: component number, counted from 0, or None if the vertex is + not in a graph :rtype: int or None - Assigned by the graph when its components are found, which happens when - :attr:`Graph.nc`, :meth:`Graph.component` or - :meth:`Graph.samecomponent` is used after the graph has changed. - It is read-only. + Assigned by the graph, which finds its components when this is read + after the graph has changed, as it does for :attr:`Graph.nc`, + :meth:`Graph.component` and :meth:`Graph.samecomponent`. The numbering + is by the first vertex of each component, in the order the vertices + were added. It is read-only. .. versionchanged:: 1.0 Read-only. It was a plain attribute, but it is computed from the - graph's structure and meaningless to assign. + graph's structure and meaningless to assign. Reading it finds the + components; in 0.6.5 it was ``None`` until something else had. :seealso: :attr:`Graph.nc` :meth:`Graph.component` """ + if self._graph is not None: + self._graph._graphcolor() return self._label @property diff --git a/tests/test_components.py b/tests/test_components.py new file mode 100644 index 00000000..e37e10b9 --- /dev/null +++ b/tests/test_components.py @@ -0,0 +1,234 @@ +""" +Components of a graph: ``nc``, ``component()``, ``samecomponent()`` and the +``label`` of a vertex. + +The components of a directed graph are *weakly* connected: the direction of an +edge is ignored, so two vertices are in the same component if there is a path +between them when it may run against the arrows. This is what a directed graph +has always done (an undirected graph is unchanged), but the algorithm that did +it merged labels in a loop that never ended for many directed graphs: the +smallest is the 4-cycle 0->2->1->3->0, a random digraph of 100 vertices and 300 +edges hangs, and so do ``plot()``, ``component()`` and ``samecomponent()``, +which all ask for the components. It was the same in 0.6.5. + +Expectations are written out by hand, or computed by a union-find in this file, +not by the code under test. +""" + +import signal +import unittest +from contextlib import contextmanager + +import numpy as np + +from pgraph import DGraph, UGraph + + +@contextmanager +def within(seconds): + """Fail, rather than hang, if the body takes longer than ``seconds``""" + if not hasattr(signal, "setitimer"): # not on this platform: no guard + yield + return + + def expired(signum, frame): + raise AssertionError(f"did not finish in {seconds} s: it hangs") + + previous = signal.signal(signal.SIGALRM, expired) + signal.setitimer(signal.ITIMER_REAL, seconds) + try: + yield + finally: + signal.setitimer(signal.ITIMER_REAL, 0) + signal.signal(signal.SIGALRM, previous) + + +def partition(n, edges): + """The components of n vertices 0..n-1 by union-find: a set of frozensets""" + parent = list(range(n)) + + def find(x): + while parent[x] != x: + parent[x] = parent[parent[x]] + x = parent[x] + return x + + for a, b in edges: + parent[find(a)] = find(b) + groups = {} + for v in range(n): + groups.setdefault(find(v), set()).add(v) + return {frozenset(s) for s in groups.values()} + + +def build(cls, n, edges): + g = cls() + vs = [g.add_vertex(name=str(i)) for i in range(n)] + for a, b in edges: + g.add_edge(vs[a], vs[b]) + return g + + +def found(g): + """The components of g, from its labels, as a set of frozensets of ints""" + groups = {} + for v in g: + groups.setdefault(v.label, set()).add(int(v.name)) + return {frozenset(s) for s in groups.values()} + + +class TestTheHang(unittest.TestCase): + def test_directed_four_cycle(self): + # the smallest digraph for which the old algorithm never returned + g = build(DGraph, 4, [(0, 2), (2, 1), (1, 3), (3, 0)]) + with within(5): + self.assertEqual(g.nc, 1) + + def test_every_call_that_asks_for_components(self): + g = build(DGraph, 4, [(0, 2), (2, 1), (1, 3), (3, 0)]) + with within(5): + self.assertEqual(len(g.component(0)), 4) + self.assertTrue(g.samecomponent(g[0], g[3])) + + def test_random_digraphs(self): + rng = np.random.default_rng(12345) + for trial in range(30): + n = int(rng.integers(2, 40)) + m = int(rng.integers(1, 3 * n)) + edges = [tuple(int(x) for x in rng.integers(0, n, 2)) for _ in range(m)] + g = build(DGraph, n, edges) + with self.subTest(trial=trial, n=n, m=m): + with within(5): + g.nc + self.assertEqual(found(g), partition(n, edges)) + + def test_a_large_digraph(self): + rng = np.random.default_rng(7) + n, m = 2000, 3000 + edges = [tuple(int(x) for x in rng.integers(0, n, 2)) for _ in range(m)] + g = build(DGraph, n, edges) + with within(10): + self.assertEqual(g.nc, len(partition(n, edges))) + + +class _Components: + cls: type + + def test_empty(self): + self.assertEqual(self.cls().nc, 0) + + def test_isolated_vertices_are_a_component_each(self): + g = build(self.cls, 3, []) + self.assertEqual(g.nc, 3) + + def test_two_paths(self): + g = build(self.cls, 5, [(0, 1), (1, 2), (3, 4)]) + self.assertEqual(g.nc, 2) + self.assertEqual(found(g), {frozenset({0, 1, 2}), frozenset({3, 4})}) + + def test_labels_are_in_order_of_first_vertex(self): + g = build(self.cls, 5, [(3, 4), (0, 1)]) # vertex 2 on its own + self.assertEqual([v.label for v in g], [0, 0, 1, 2, 2]) + + def test_component_lists_vertices_in_creation_order(self): + g = build(self.cls, 5, [(4, 1), (1, 3)]) + self.assertEqual([v.name for v in g.component(0)], ["0"]) + self.assertEqual([v.name for v in g.component(1)], ["1", "3", "4"]) + self.assertEqual([v.name for v in g.component(2)], ["2"]) + + def test_loops_and_parallel_edges(self): + g = build(self.cls, 3, [(0, 0), (0, 1), (0, 1), (2, 2)]) + self.assertEqual(found(g), {frozenset({0, 1}), frozenset({2})}) + + def test_the_labels_are_0_to_nc_minus_1(self): + g = build(self.cls, 6, [(0, 5), (1, 2), (2, 3)]) + self.assertEqual({v.label for v in g}, set(range(g.nc))) + + def test_matches_a_union_find(self): + rng = np.random.default_rng(99) + for trial in range(20): + n = int(rng.integers(1, 30)) + m = int(rng.integers(0, 2 * n)) + edges = [tuple(int(x) for x in rng.integers(0, n, 2)) for _ in range(m)] + g = build(self.cls, n, edges) + with self.subTest(trial=trial): + self.assertEqual(found(g), partition(n, edges)) + self.assertEqual(g.nc, len(partition(n, edges))) + + # ----------------------------------------------- the graph changes + + def test_adding_an_edge_joins_components(self): + g = build(self.cls, 4, [(0, 1), (2, 3)]) + self.assertEqual(g.nc, 2) + g.add_edge(g[1], g[2]) + self.assertEqual(g.nc, 1) + + def test_removing_an_edge_splits_a_component(self): + g = build(self.cls, 4, [(0, 1), (1, 2), (2, 3)]) + self.assertEqual(g.nc, 1) + g.remove_edge(g.edges()[1]) + self.assertEqual(g.nc, 2) + self.assertEqual(found(g), {frozenset({0, 1}), frozenset({2, 3})}) + + def test_removing_a_vertex(self): + g = build(self.cls, 3, [(0, 1), (1, 2)]) + g.remove_vertex(g[1]) + self.assertEqual(g.nc, 2) + + def test_adding_a_vertex(self): + g = build(self.cls, 2, [(0, 1)]) + self.assertEqual(g.nc, 1) + g.add_vertex(name="x") + self.assertEqual(g.nc, 2) + + def test_not_recomputed_when_nothing_changed(self): + g = build(self.cls, 4, [(0, 1), (2, 3)]) + g.nc + self.assertFalse(g._connectivitychange) + self.assertTrue(all(not v._connectivitychange for v in g)) + # a changed label would show if it were: poke one, ask again + before = [v.label for v in g] + g.nc + self.assertEqual([v.label for v in g], before) + + def test_samecomponent(self): + g = build(self.cls, 4, [(0, 1), (2, 3)]) + self.assertTrue(g.samecomponent(g[0], g[1])) + self.assertFalse(g.samecomponent(g[1], g[2])) + + +class TestUndirectedComponents(_Components, unittest.TestCase): + cls = UGraph + + +class TestDirectedComponents(_Components, unittest.TestCase): + cls = DGraph + + def test_direction_is_ignored(self): + # a -> c <- b: no path from a to b either way, but one component + g = build(DGraph, 3, [(0, 2), (1, 2)]) + self.assertEqual(g.nc, 1) + self.assertTrue(g.samecomponent(g[0], g[1])) + + def test_a_vertex_that_is_only_a_source(self): + g = build(DGraph, 3, [(0, 1), (0, 2)]) + self.assertEqual(g.nc, 1) + + def test_opposite_edges(self): + g = build(DGraph, 2, [(0, 1), (1, 0)]) + self.assertEqual(g.nc, 1) + + def test_agrees_with_the_undirected_graph_on_the_same_edges(self): + rng = np.random.default_rng(2024) + for trial in range(20): + n = int(rng.integers(2, 25)) + edges = [tuple(int(x) for x in rng.integers(0, n, 2)) for _ in range(int(rng.integers(0, 2 * n)))] + with self.subTest(trial=trial): + d, u = build(DGraph, n, edges), build(UGraph, n, edges) + with within(5): + self.assertEqual(d.nc, u.nc) + self.assertEqual([v.label for v in d], [v.label for v in u]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_unified_graph.py b/tests/test_unified_graph.py index 3259b7d0..72016c72 100644 --- a/tests/test_unified_graph.py +++ b/tests/test_unified_graph.py @@ -219,7 +219,6 @@ def test_the_base_hooks_say_which_kinds_provide_them(self): lambda: Graph._attach(g, e), lambda: Graph._detach(g, e), lambda: Graph._incoming(g, a), - lambda: Graph._graphcolor(g), ): with self.assertRaises(NotImplementedError): call()