From 96e5479f50c31d7c9a62597012ad7760740cfa1d Mon Sep 17 00:00:00 2001 From: Peter Corke Date: Mon, 12 Oct 2026 12:32:57 +1000 Subject: [PATCH] test: incidence() is checked cell by cell 0.6.5 returned the wrong matrix for every graph with an edge: a loop variable overwrote the vertex index, so edges were marked in the wrong rows, and for a directed graph the vertex at the end of an edge was not marked. The tests that existed checked only column and row sums, because edges() was a set and the column order was not defined, so neither bug was caught. edges() is now in the order the edges were added, so the matrix can be checked by position. Each expectation is written out by hand, for both kinds of graph: paths, a star, edges pointing backwards, a vertex that is only ever an end, creation order against names, parallel and antiparallel edges, loops, and rows and columns closing up after a removal. 16 of them fail on the 0.6.5 source. Co-Authored-By: Claude Sonnet 5.5 --- tests/test_graph.py | 7 +- tests/test_incidence.py | 199 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 202 insertions(+), 4 deletions(-) create mode 100644 tests/test_incidence.py diff --git a/tests/test_graph.py b/tests/test_graph.py index 31e36280..f8d6204d 100644 --- a/tests/test_graph.py +++ b/tests/test_graph.py @@ -592,10 +592,9 @@ def test_matrices(self): expected_L = np.diag([2, 1, 1, 0]) - expected_A nt.assert_array_equal(L, expected_L) - # edge order (columns) is not deterministic -- self.edges() is a - # set -- so check incidence structurally rather than by position: - # each column (edge) touches exactly 2 vertices, and each row - # (vertex)'s total matches its degree + # the structure: each column (edge) touches exactly 2 vertices, and + # each row (vertex)'s total matches its degree. Cell by cell is in + # test_incidence.py I = g.incidence() self.assertEqual(I.shape, (g.n, g.ne)) nt.assert_array_equal(I.sum(axis=0), np.full(g.ne, 2)) diff --git a/tests/test_incidence.py b/tests/test_incidence.py new file mode 100644 index 00000000..c23226fe --- /dev/null +++ b/tests/test_incidence.py @@ -0,0 +1,199 @@ +"""``incidence()`` cell by cell. + +``I[i, j]`` is 1 where vertex ``i`` is an end of edge ``j``: rows are vertices +in the order they were created, columns are edges in the order they were added +(the order of ``edges()``). Both ends of an edge are marked whichever way it +points. An undirected self-loop touches its vertex at both ends, so is 2. + +0.6.5 returned the wrong matrix for every graph with an edge. A loop variable +overwrote the vertex index, so edges were marked in the wrong rows, and for a +directed graph only each vertex's outgoing edges were visited, so the vertex at +the end of an edge was not marked. The tests that existed checked only column +and row sums, because the edge order was then that of a set, so neither bug was +caught. Each expectation below is written out by hand, not derived from the +code under test. +""" + +import unittest + +import numpy as np + +from pgraph import DGraph, UGraph + + +def matrix(rows): + return np.array(rows, dtype=float) + + +class _Incidence: + cls: type + + def graph(self, names): + g = self.cls() + return g, [g.add_vertex(name=n) for n in names] + + def assertIncidence(self, g, expected): + np.testing.assert_array_equal(g.incidence(), matrix(expected)) + + # ---------------------------------------------------------------- shape + + def test_empty_graph(self): + g = self.cls() + self.assertEqual(g.incidence().shape, (0, 0)) + + def test_vertices_without_edges(self): + g, _ = self.graph("abc") + self.assertEqual(g.incidence().shape, (3, 0)) + + # ------------------------------------------------------ the two 0.6.5 bugs + + def test_path(self): + # a - b - c: the middle vertex is on both edges + g, (a, b, c) = self.graph("abc") + g.add_edge(a, b) + g.add_edge(b, c) + self.assertIncidence(g, [[1, 0], + [1, 1], + [0, 1]]) + + def test_star(self): + # a hub with three spokes: the hub row is all ones + g, (h, x, y, z) = self.graph("hxyz") + g.add_edge(h, x) + g.add_edge(h, y) + g.add_edge(h, z) + self.assertIncidence(g, [[1, 1, 1], + [1, 0, 0], + [0, 1, 0], + [0, 0, 1]]) + + def test_the_end_of_an_edge_is_marked_whichever_way_it_points(self): + # edges that run backwards through the alphabet: the vertex at the + # head of each is marked as well as the one at the tail + g, (a, b, c) = self.graph("abc") + g.add_edge(c, b) + g.add_edge(b, a) + self.assertIncidence(g, [[0, 1], + [1, 1], + [1, 0]]) + + def test_a_vertex_that_is_only_ever_an_end(self): + # in a directed graph c has no outgoing edge, so a visit to each + # vertex's own edges never finds it + g, (a, b, c) = self.graph("abc") + g.add_edge(a, c) + g.add_edge(b, c) + self.assertIncidence(g, [[1, 0], + [0, 1], + [1, 1]]) + + # ------------------------------------------------------------- the order + + def test_rows_follow_creation_order_not_names(self): + g, (z, a, m) = self.graph("zam") + g.add_edge(z, a) + g.add_edge(a, m) + self.assertIncidence(g, [[1, 0], # z + [1, 1], # a + [0, 1]]) # m + + def test_columns_follow_the_order_edges_were_added(self): + g, (a, b, c, d) = self.graph("abcd") + g.add_edge(c, d) + g.add_edge(a, b) + g.add_edge(b, c) + self.assertIncidence(g, [[0, 1, 0], + [0, 1, 1], + [1, 0, 1], + [1, 0, 0]]) + + def test_columns_are_edges_in_edges_order(self): + g, (a, b, c, d) = self.graph("abcd") + edges = [g.add_edge(a, b), g.add_edge(c, d), g.add_edge(b, c), g.add_edge(a, d)] + self.assertEqual(g.edges(), edges) + I = g.incidence() + index = {v: i for i, v in enumerate(g)} + for j, e in enumerate(edges): + marked = set(np.flatnonzero(I[:, j])) + self.assertEqual(marked, {index[e.v1], index[e.v2]}) + + def test_connect_and_add_edge_agree(self): + g, (a, b, c) = self.graph("abc") + a.connect(b) + g.add_edge(b, c) + self.assertIncidence(g, [[1, 0], + [1, 1], + [0, 1]]) + + # ----------------------------------------------- multigraph and removal + + def test_parallel_edges_have_a_column_each(self): + g, (a, b, c) = self.graph("abc") + g.add_edge(a, b) + g.add_edge(a, b) + g.add_edge(b, c) + self.assertIncidence(g, [[1, 1, 0], + [1, 1, 1], + [0, 0, 1]]) + + def test_antiparallel_edges_have_a_column_each(self): + g, (a, b) = self.graph("ab") + g.add_edge(a, b) + g.add_edge(b, a) + self.assertIncidence(g, [[1, 1], + [1, 1]]) + + def test_columns_close_up_when_an_edge_is_removed(self): + g, (a, b, c) = self.graph("abc") + e1 = g.add_edge(a, b) + g.add_edge(b, c) + g.remove_edge(e1) + self.assertIncidence(g, [[0], + [1], + [1]]) + + def test_rows_close_up_when_a_vertex_is_removed(self): + # a - b - c - d: removing b takes a-b and b-c with it, leaving c-d + g, (a, b, c, d) = self.graph("abcd") + g.add_edge(a, b) + g.add_edge(b, c) + g.add_edge(c, d) + g.remove_vertex(b) + self.assertIncidence(g, [[0], # a, now isolated + [1], # c + [1]]) # d + + def test_an_isolated_vertex_is_a_row_of_zeros(self): + g, (a, b, c) = self.graph("abc") + g.add_edge(a, c) + self.assertIncidence(g, [[1], + [0], + [1]]) + + +class TestUndirectedIncidence(_Incidence, unittest.TestCase): + cls = UGraph + + def test_a_loop_touches_its_vertex_at_both_ends(self): + g, (a, b) = self.graph("ab") + g.add_edge(a, a) + g.add_edge(a, b) + self.assertIncidence(g, [[2, 1], + [0, 1]]) + + +class TestDirectedIncidence(_Incidence, unittest.TestCase): + cls = DGraph + + def test_a_loop_is_one_in_its_row(self): + # the incidence matrix does not say which end is which, so a directed + # loop is a single 1; the in and out degree of the vertex say more + g, (a, b) = self.graph("ab") + g.add_edge(a, a) + g.add_edge(a, b) + self.assertIncidence(g, [[1, 1], + [0, 1]]) + + +if __name__ == "__main__": + unittest.main()