From f58c2236cb531010759a597487342c45ac8e7cd2 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Fri, 31 Jul 2026 12:38:50 -0500 Subject: [PATCH 1/9] Add a direct (scipy-free) ingestion path to from_networkx from_networkx always routed nx -> scipy CSR -> Matrix, so every conversion paid for scipy.sparse (importing it alone costs over 100ms) plus an extra coo -> csr materialization. Simple graphs with numeric weights now build COO arrays straight from the nx adjacency and call Matrix.from_coo. The node selection preamble mirrors nx.to_scipy_sparse_array so ordering, subsetting, and error behavior match exactly; undirected graphs are symmetrized with the same diagonal correction nx uses (self-loop entries appear as wt + wt - wt under dup_op=plus, which is exact in IEEE arithmetic). Kept on the scipy fallback automatically: multigraphs (scipy's duplicate-coordinate summation matches exactly) and weights that do not form a 1-D numeric array. That second condition covers non-numeric attributes, including all-string weights, which infer a csr + # materialization and the scipy.sparse import, which alone costs over 100ms. + import numpy as np + + from ..binary import plus + from ..core.matrix import Matrix + + if len(G) == 0: + raise nx.NetworkXError("Graph has no nodes or edges") + + if nodelist is None: + nodelist = list(G) + nlen = len(G) + else: + nlen = len(nodelist) + if nlen == 0: + raise nx.NetworkXError("nodelist has no nodes") + nodeset = set(G.nbunch_iter(nodelist)) + if nlen != len(nodeset): + for n in nodelist: + if n not in G: + raise nx.NetworkXError(f"Node {n} in nodelist is not in G") + raise nx.NetworkXError("nodelist contains duplicates.") + if nlen < len(G): + G = G.subgraph(nodelist) + + index = dict(zip(nodelist, range(nlen), strict=True)) + coefficients = zip( + *((index[u], index[v], wt) for u, v, wt in G.edges(data=weight, default=1)), + strict=True, + ) + try: + row, col, data = coefficients + except ValueError: + # there is no edge in the (sub)graph + row, col, data = (), (), () + + if G.is_directed(): + rows, cols, vals = row, col, data + dup_op = None + else: + # Symmetrize: mirror off-diagonal entries. Self-loops would be double + # counted, so subtract the diagonal contribution once, matching + # nx.to_scipy_sparse_array. dup_op=plus then sums the diagonal triple + # (wt + wt - wt) back to wt; off-diagonal entries are unique so plus + # leaves them untouched. + d = data + data + r = row + col + c = col + row + selfloops = list(nx.selfloop_edges(G, data=weight, default=1)) + if selfloops: + diag_index, diag_data = zip(*((index[u], -wt) for u, v, wt in selfloops), strict=True) + d += diag_data + r += diag_index + c += diag_index + rows, cols, vals = r, c, d + dup_op = plus + + values = np.array(vals, dtype=dtype) + if values.ndim != 1 or values.dtype.kind not in "biufc": + # Defer to scipy so the error matches the previous behavior exactly. + # Two kinds of weight land here: non-numeric attributes (object arrays, + # but also e.g. all-string weights, which infer a default 1 + G.add_node(9) # isolated node + + A = nx.to_scipy_sparse_array(G, weight="weight") + reference = gb.io.from_scipy_sparse(A) + M = gb.io.from_networkx(G, weight="weight") + assert M.isequal(reference, check_dtype=True) + assert M.shape == reference.shape + + @pytest.mark.skipif("not ss") @pytest.mark.parametrize("engine", ["auto", "scipy", "fmm"]) def test_mmread_mmwrite(engine): From 871aaf0527794d93d71f6016edc374c04e6bfe9f Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Fri, 31 Jul 2026 12:22:44 -0500 Subject: [PATCH 2/9] Ingest multigraphs directly in from_networkx (drop the scipy detour) A networkx multigraph fell back to scipy in from_networkx because the direct path could not sum the weights of parallel edges. The directed branch now passes dup_op=plus when the graph is a multigraph, so parallel edges accumulate exactly as scipy's coo -> csr summation does. Simple graphs keep dup_op=None and are unchanged, and the undirected branch already summed because it uses plus for the self-loop diagonal correction. Numeric multigraphs no longer need scipy at all; non-numeric weights still defer to it. Parity with the retained scipy path holds for MultiGraph and MultiDiGraph with parallel edges, parallel self-loops, reciprocal parallel edges, parallel edges whose weights cancel to zero (both sides keep the explicit zero), absent weight attributes (nx defaults to 1), weight=None, bool and int weights, and nodelist permutations and subsets. New tests pin the multigraph diagonal sum and assert the numeric path never reaches the scipy fallback. --- graphblas/io/_networkx.py | 20 +++++++++----------- graphblas/tests/test_io.py | 25 ++++++++++++++++++++++--- 2 files changed, 31 insertions(+), 14 deletions(-) diff --git a/graphblas/io/_networkx.py b/graphblas/io/_networkx.py index cf16de896..096531d4d 100644 --- a/graphblas/io/_networkx.py +++ b/graphblas/io/_networkx.py @@ -30,14 +30,6 @@ def from_networkx(G, nodelist=None, dtype=None, weight="weight", name=None): if dtype is not None: dtype = lookup_dtype(dtype).np_type - # Multigraphs sum the weights of parallel edges. scipy does this by - # accumulating duplicate coordinates when a coo array is converted to csr; - # replicating it here would need the same self-loop bookkeeping as the - # undirected path plus a general dup_op. Defer to scipy so the summation - # matches exactly. - if G.is_multigraph(): - return _from_networkx_via_scipy(G, nodelist, dtype, weight, name) - # The node selection below mirrors nx.to_scipy_sparse_array so that empty # graphs, nodelist subsets, missing nodes, and duplicate nodes raise the # same errors and produce the same ordering. Building the coo arrays here @@ -80,13 +72,19 @@ def from_networkx(G, nodelist=None, dtype=None, weight="weight", name=None): if G.is_directed(): rows, cols, vals = row, col, data - dup_op = None + # A multigraph can have parallel edges (duplicate ``(u, v)``); summing + # them with ``plus`` matches scipy's coo -> csr accumulation. A simple + # graph has no duplicate coordinates, so ``dup_op=None`` is exact and + # skips the accumulator. + dup_op = plus if G.is_multigraph() else None else: # Symmetrize: mirror off-diagonal entries. Self-loops would be double # counted, so subtract the diagonal contribution once, matching # nx.to_scipy_sparse_array. dup_op=plus then sums the diagonal triple - # (wt + wt - wt) back to wt; off-diagonal entries are unique so plus - # leaves them untouched. + # (wt + wt - wt) back to wt. For a multigraph, plus also sums parallel + # edges (duplicate coordinates) the same way scipy's coo -> csr does; + # for a simple graph off-diagonal entries are unique so plus is a no-op + # there. d = data + data r = row + col c = col + row diff --git a/graphblas/tests/test_io.py b/graphblas/tests/test_io.py index 61b5c8d86..8fe672cee 100644 --- a/graphblas/tests/test_io.py +++ b/graphblas/tests/test_io.py @@ -248,12 +248,14 @@ def test_from_networkx_rejects_array_weights(graph_cls): "graph_cls", [nx.Graph, nx.DiGraph, nx.MultiGraph, nx.MultiDiGraph] if nx else [] ) def test_from_networkx_matches_scipy(graph_cls): - # The direct path (simple graphs) and the scipy fallback (multigraphs) must - # both reproduce the scipy round-trip exactly, including parallel-edge sums. + # Both simple graphs and multigraphs now build the coo directly (no scipy + # round-trip); the result must reproduce nx.to_scipy_sparse_array exactly, + # including parallel-edge and parallel-self-loop weight sums. G = graph_cls() G.add_weighted_edges_from([(0, 1, 2.0), (1, 2, 3.0), (2, 0, 4.0), (0, 0, 5.0)]) if G.is_multigraph(): - G.add_edge(0, 1, weight=1.5) # parallel edge summed by scipy + G.add_edge(0, 1, weight=1.5) # parallel edge -> weights summed + G.add_edge(0, 0, weight=2.5) # parallel self-loop -> diagonal summed G.add_edge(3, 4) # missing weight attr -> default 1 G.add_node(9) # isolated node @@ -264,6 +266,23 @@ def test_from_networkx_matches_scipy(graph_cls): assert M.shape == reference.shape +@pytest.mark.skipif("not nx") +def test_from_networkx_multigraph_is_scipy_free(monkeypatch): + # A numeric-weight multigraph must ingest via the direct coo path, not the + # scipy fallback, so networkx ingest no longer requires scipy for this case. + import graphblas.io._networkx as _gnx + + def _boom(*args, **kwargs): # pragma: no cover (only runs if the direct path regresses) + raise AssertionError("scipy fallback should not be used for a numeric multigraph") + + monkeypatch.setattr(_gnx, "_from_networkx_via_scipy", _boom) + G = nx.MultiDiGraph() + G.add_weighted_edges_from([(0, 1, 2.0), (0, 1, 3.0), (2, 0, 1.0), (1, 1, 4.0), (1, 1, 0.5)]) + M = gb.io.from_networkx(G) + assert M[0, 1].new().value == 5.0 # parallel edges summed + assert M[1, 1].new().value == 4.5 # parallel self-loops summed + + @pytest.mark.skipif("not ss") @pytest.mark.parametrize("engine", ["auto", "scipy", "fmm"]) def test_mmread_mmwrite(engine): From 74d81086c41b04ad4f4373c053542fff153ac00e Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Tue, 4 Aug 2026 16:57:28 -0700 Subject: [PATCH 3/9] Widen the inferred int32 to int64 in from_networkx --- graphblas/io/_networkx.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/graphblas/io/_networkx.py b/graphblas/io/_networkx.py index 096531d4d..bb20113d6 100644 --- a/graphblas/io/_networkx.py +++ b/graphblas/io/_networkx.py @@ -98,6 +98,12 @@ def from_networkx(G, nodelist=None, dtype=None, weight="weight", name=None): dup_op = plus values = np.array(vals, dtype=dtype) + if dtype is None and values.dtype == np.int32: # pragma: no cover (win64 numpy < 2) + # numpy < 2 infers the platform C long for a sequence of Python ints, which + # is 32-bit on Windows. values_to_numpy_buffer widens the same way for + # non-numpy input, so this keeps from_networkx agreeing with + # Matrix.from_coo on INT64 for an unweighted graph on every platform. + values = values.astype(np.int64) if values.ndim != 1 or values.dtype.kind not in "biufc": # Defer to scipy so the error matches the previous behavior exactly. # Two kinds of weight land here: non-numeric attributes (object arrays, From c7e0aebb560a58a5b62f7d4206ee5339d7228c75 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Tue, 4 Aug 2026 16:57:28 -0700 Subject: [PATCH 4/9] Raise ValueError for unsupported edge-weight dtypes across scipy versions --- graphblas/io/_networkx.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/graphblas/io/_networkx.py b/graphblas/io/_networkx.py index bb20113d6..e99b493fd 100644 --- a/graphblas/io/_networkx.py +++ b/graphblas/io/_networkx.py @@ -110,7 +110,23 @@ def from_networkx(G, nodelist=None, dtype=None, weight="weight", name=None): # but also e.g. all-string weights, which infer a csr conversion instead: scipy raises + # TypeError ("no supported conversion for types"), which networkx 3.4+ + # wraps in a NetworkXError that blames the sparse format while + # networkx <= 3.3 lets the TypeError propagate. + # Restate it so every supported stack reports the same error for the + # same graph. The graph and nodelist checks above are the ones + # nx.to_scipy_sparse_array makes, so a NetworkXError or TypeError from + # the fallback can only be that dtype complaint. + raise ValueError( + f"scipy.sparse does not support dtype {values.dtype}; " + "edge weights must be numeric scalars" + ) from err if values.size == 0: # An empty graph has no data to infer a dtype from; scipy defaults an # empty coo array to float64, so match that when dtype is unset. From 30aeccc6ffa6939842cd8ed02bdfb0b5324b6b86 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Tue, 25 Aug 2026 13:04:45 -0500 Subject: [PATCH 5/9] Keep the iso storage the scipy detour used to give from_networkx Converting through scipy ended in ``from_scipy_sparse``, which checks whether every value is the same and imports one value instead of nvals of them. The direct path added here ends in ``from_coo``, which does not, so an unweighted graph quietly started storing a full values array. Nothing caught it because SuiteSparse was still doing its own post-build iso check, and the result came out iso either way. 10.5.0 dropped that check, at which point ``test_matrix_to_from_networkx`` failed and the regression became visible. The memory it costs was there the whole time. Ask for iso outright, under the same uniformity test ``from_scipy_sparse`` uses. Multigraphs are excluded because ``plus`` sums parallel edges and can make uniform inputs non-uniform. Undirected graphs need no exclusion: without self-loops their coordinates are unique so ``plus`` never fires, and with them the diagonal correction puts both ``wt`` and ``-wt`` in the values array, which fails the uniformity test on its own. --- graphblas/io/_networkx.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/graphblas/io/_networkx.py b/graphblas/io/_networkx.py index e99b493fd..25ff17ca8 100644 --- a/graphblas/io/_networkx.py +++ b/graphblas/io/_networkx.py @@ -37,6 +37,7 @@ def from_networkx(G, nodelist=None, dtype=None, weight="weight", name=None): # materialization and the scipy.sparse import, which alone costs over 100ms. import numpy as np + from .. import backend from ..binary import plus from ..core.matrix import Matrix @@ -134,6 +135,21 @@ def from_networkx(G, nodelist=None, dtype=None, weight="weight", name=None): rows = np.array(rows, dtype=np.uint64) cols = np.array(cols, dtype=np.uint64) + if backend == "suitesparse" and not G.is_multigraph() and (values[[0]] == values).all(): + # Every edge carries the same weight, which is the whole story for an + # unweighted graph. Store one value rather than one per entry, as + # ``from_scipy_sparse`` does for the same data. SuiteSparse used to + # notice this after a build on its own; 10.5.0 stopped ("no longer do + # a post-iso check"), so ask for it outright. + # + # A multigraph is excluded because ``plus`` sums parallel edges, which + # can make uniform inputs non-uniform. An undirected graph needs no + # exclusion: without self-loops its coordinates are unique so ``plus`` + # never fires, and with them ``values`` holds both ``wt`` and ``-wt`` + # and fails the uniformity test above. + A = Matrix(lookup_dtype(values.dtype), nrows=nlen, ncols=nlen, name=name) + A.ss.build_scalar(rows, cols, values[0]) + return A return Matrix.from_coo(rows, cols, values, nrows=nlen, ncols=nlen, dup_op=dup_op, name=name) From 3be4512a36f589cdc6aee2be53166522eb71fdde Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Wed, 5 Aug 2026 01:30:01 -0500 Subject: [PATCH 6/9] Test the from_networkx int32 widening and correct its coverage claim The widening added while stabilizing CI was marked "pragma: no cover (win64 numpy < 2)", but the branch is reachable on every platform: np.int32 scalar edge weights infer an int32 array from the edge sequence anywhere, not only where python ints do. The pragma therefore hid live code from coverage, and the comment implied the change was platform-local when it is user-visible everywhere: int32-weighted graphs that previously round-tripped through scipy as INT32 now come back INT64. The widening itself is kept, deliberately. from_coo widens an int32 dtype inferred from sequence input to int64 on every platform (values_to_numpy_buffer), and from_networkx builds its values from a Python sequence, so identical weights now produce the same dtype through either constructor. Gating the widening to win64 instead would have re-introduced a from_coo/from_networkx disagreement everywhere else. Drop the pragma, restate the comment in terms of the real contract, and pin the behavior: np.int32 weights widen to INT64 and agree with from_coo, an explicit dtype= is preserved exactly, and np.int16 stays INT16 through both constructors. Release-note item: the from_networkx result dtype for int32-weighted graphs changes from INT32 to INT64. --- graphblas/io/_networkx.py | 12 +++++++----- graphblas/tests/test_io.py | 22 ++++++++++++++++++++++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/graphblas/io/_networkx.py b/graphblas/io/_networkx.py index 25ff17ca8..d9ec82a63 100644 --- a/graphblas/io/_networkx.py +++ b/graphblas/io/_networkx.py @@ -99,11 +99,13 @@ def from_networkx(G, nodelist=None, dtype=None, weight="weight", name=None): dup_op = plus values = np.array(vals, dtype=dtype) - if dtype is None and values.dtype == np.int32: # pragma: no cover (win64 numpy < 2) - # numpy < 2 infers the platform C long for a sequence of Python ints, which - # is 32-bit on Windows. values_to_numpy_buffer widens the same way for - # non-numpy input, so this keeps from_networkx agreeing with - # Matrix.from_coo on INT64 for an unweighted graph on every platform. + if dtype is None and values.dtype == np.int32: + # ``vals`` is a Python sequence, and from_coo widens an int32 dtype + # INFERRED from sequence input to int64 (values_to_numpy_buffer): python + # ints infer int32 on win64 with numpy < 2, and np.int32 scalar weights + # infer it on every platform. Widen the same way so from_networkx and + # Matrix.from_coo given the same weights agree on the result dtype. + # An explicit dtype= is applied above and never reaches this branch. values = values.astype(np.int64) if values.ndim != 1 or values.dtype.kind not in "biufc": # Defer to scipy so the error matches the previous behavior exactly. diff --git a/graphblas/tests/test_io.py b/graphblas/tests/test_io.py index 8fe672cee..335d39558 100644 --- a/graphblas/tests/test_io.py +++ b/graphblas/tests/test_io.py @@ -227,6 +227,28 @@ def test_from_networkx_undirected(): assert M_none.isequal(expected_none, check_dtype=True) +@pytest.mark.skipif("not nx") +def test_from_networkx_int32_weights_widen_like_from_coo(): + # np.int32 scalar weights infer an int32 array from the edge sequence on + # every platform (python ints do too on win64 with numpy < 2), and from_coo + # widens int32 inferred from sequence input to INT64. from_networkx must + # widen the same way or identical weights would produce different dtypes + # depending on which constructor they came through. + G = nx.DiGraph() + G.add_weighted_edges_from([(0, 1, np.int32(2)), (1, 0, np.int32(3))]) + M = gb.io.from_networkx(G) + expected = gb.Matrix.from_coo([0, 1], [1, 0], [np.int32(2), np.int32(3)]) + assert expected.dtype == dtypes.INT64 + assert M.isequal(expected, check_dtype=True) + # An explicit dtype bypasses inference and is preserved exactly. + assert gb.io.from_networkx(G, dtype="int32").dtype == dtypes.INT32 + # Narrower ints are not special-cased; this also matches from_coo. + H = nx.DiGraph() + H.add_weighted_edges_from([(0, 1, np.int16(2))]) + assert gb.io.from_networkx(H).dtype == dtypes.INT16 + assert gb.Matrix.from_coo([0], [1], [np.int16(2)]).dtype == dtypes.INT16 + + @pytest.mark.skipif("not nx or not ss") @pytest.mark.parametrize( "graph_cls", [nx.Graph, nx.DiGraph, nx.MultiGraph, nx.MultiDiGraph] if nx else [] From abfba0482a5cbb8857681347f3f6ad7769d40767 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Mon, 12 Oct 2026 03:00:29 -0500 Subject: [PATCH 7/9] Keep undirected self-loops iso and exact in from_networkx The direct ingestion path symmetrized an undirected graph the way nx.to_scipy_sparse_array does: it mirrored every entry, self-loops included, and appended -wt for each self-loop so that plus would sum the diagonal triple wt + wt - wt back to wt. That arithmetic had four consequences: - The iso test added in a1fa8f33 reads the values array, which then held both wt and -wt, so an unweighted (or uniformly weighted) undirected graph with a self-loop was stored with a full values array. Through scipy it was stored iso, because from_scipy_sparse tested the summed values. Code that branches on A.ss.is_iso (graphblas-algorithms runs BFS instead of Bellman-Ford for an iso graph) took the slow path. - An infinite self-loop came back nan (inf + inf - inf), and a finite one near the float max came back inf. - An unsigned dtype= raised OverflowError under numpy 2, which cannot write -wt in it. - bool weights came back INT64 when the graph had a self-loop (-True is the int -1) and BOOL when it did not. A self-loop is its own mirror, so mirror only the off-diagonal entries. Every stored value is then an edge's own weight, and a simple graph has no repeated coordinate: it builds without an accumulator, and a uniform one takes the iso path whether or not it has self-loops. Multigraphs still sum parallel edges and parallel self-loops with plus. The second pass over the graph for nx.selfloop_edges goes away. nx.to_scipy_sparse_array still sums wt + wt - wt, so from_networkx now differs from it in exactly those four cases. Compared with the parent over 74 graphs (empty, edgeless, isolated nodes, nodelist subsets and reorders, non-integer keys, int, float, bool, complex, int32, missing and nan weights, multigraphs, and the error cases), nothing else changes. --- graphblas/io/_networkx.py | 61 +++++++++++++----------------------- graphblas/tests/test_io.py | 64 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 39 deletions(-) diff --git a/graphblas/io/_networkx.py b/graphblas/io/_networkx.py index d9ec82a63..3e7cfba6e 100644 --- a/graphblas/io/_networkx.py +++ b/graphblas/io/_networkx.py @@ -71,36 +71,9 @@ def from_networkx(G, nodelist=None, dtype=None, weight="weight", name=None): # there is no edge in the (sub)graph row, col, data = (), (), () - if G.is_directed(): - rows, cols, vals = row, col, data - # A multigraph can have parallel edges (duplicate ``(u, v)``); summing - # them with ``plus`` matches scipy's coo -> csr accumulation. A simple - # graph has no duplicate coordinates, so ``dup_op=None`` is exact and - # skips the accumulator. - dup_op = plus if G.is_multigraph() else None - else: - # Symmetrize: mirror off-diagonal entries. Self-loops would be double - # counted, so subtract the diagonal contribution once, matching - # nx.to_scipy_sparse_array. dup_op=plus then sums the diagonal triple - # (wt + wt - wt) back to wt. For a multigraph, plus also sums parallel - # edges (duplicate coordinates) the same way scipy's coo -> csr does; - # for a simple graph off-diagonal entries are unique so plus is a no-op - # there. - d = data + data - r = row + col - c = col + row - selfloops = list(nx.selfloop_edges(G, data=weight, default=1)) - if selfloops: - diag_index, diag_data = zip(*((index[u], -wt) for u, v, wt in selfloops), strict=True) - d += diag_data - r += diag_index - c += diag_index - rows, cols, vals = r, c, d - dup_op = plus - - values = np.array(vals, dtype=dtype) + values = np.array(data, dtype=dtype) if dtype is None and values.dtype == np.int32: - # ``vals`` is a Python sequence, and from_coo widens an int32 dtype + # ``data`` is a Python sequence, and from_coo widens an int32 dtype # INFERRED from sequence input to int64 (values_to_numpy_buffer): python # ints infer int32 on win64 with numpy < 2, and np.int32 scalar weights # infer it on every platform. Widen the same way so from_networkx and @@ -135,20 +108,30 @@ def from_networkx(G, nodelist=None, dtype=None, weight="weight", name=None): # empty coo array to float64, so match that when dtype is unset. return Matrix(lookup_dtype(values.dtype), nrows=nlen, ncols=nlen, name=name) - rows = np.array(rows, dtype=np.uint64) - cols = np.array(cols, dtype=np.uint64) - if backend == "suitesparse" and not G.is_multigraph() and (values[[0]] == values).all(): + rows = np.array(row, dtype=np.uint64) + cols = np.array(col, dtype=np.uint64) + if not G.is_directed(): + # Mirror each edge into the other triangle. A self-loop is its own + # mirror, so it is stored once with its own weight. (nx.to_scipy_sparse_array + # mirrors every entry and sums ``wt + wt - wt`` on the diagonal instead, + # which turns an infinite self-loop into nan, overflows a large finite one, + # and cannot be written in an unsigned dtype.) + offdiag = rows != cols + rows, cols = np.concatenate([rows, cols[offdiag]]), np.concatenate([cols, rows[offdiag]]) + values = np.concatenate([values, values[offdiag]]) + dup_op = None + if G.is_multigraph(): + # Parallel edges share a coordinate; summing them with ``plus`` matches + # scipy's coo -> csr accumulation. A simple graph has no repeated + # coordinate, so it needs no accumulator. + dup_op = plus + elif backend == "suitesparse" and (values[[0]] == values).all(): # Every edge carries the same weight, which is the whole story for an # unweighted graph. Store one value rather than one per entry, as # ``from_scipy_sparse`` does for the same data. SuiteSparse used to # notice this after a build on its own; 10.5.0 stopped ("no longer do - # a post-iso check"), so ask for it outright. - # - # A multigraph is excluded because ``plus`` sums parallel edges, which - # can make uniform inputs non-uniform. An undirected graph needs no - # exclusion: without self-loops its coordinates are unique so ``plus`` - # never fires, and with them ``values`` holds both ``wt`` and ``-wt`` - # and fails the uniformity test above. + # a post-iso check"), so ask for it outright. A multigraph is excluded + # because ``plus`` can make uniform inputs non-uniform. A = Matrix(lookup_dtype(values.dtype), nrows=nlen, ncols=nlen, name=name) A.ss.build_scalar(rows, cols, values[0]) return A diff --git a/graphblas/tests/test_io.py b/graphblas/tests/test_io.py index 335d39558..56a06e2e6 100644 --- a/graphblas/tests/test_io.py +++ b/graphblas/tests/test_io.py @@ -227,6 +227,70 @@ def test_from_networkx_undirected(): assert M_none.isequal(expected_none, check_dtype=True) +def _uniform_adjacency(G, nodelist, value): + # The adjacency of G over nodelist with every entry ``value``, built entry by + # entry from coo arrays (not iso), to compare from_networkx against. + index = {node: i for i, node in enumerate(nodelist)} + coords = {(index[u], index[v]) for u, v in G.subgraph(nodelist).edges} + if not G.is_directed(): + coords |= {(j, i) for i, j in coords} + rows, cols = zip(*sorted(coords), strict=True) + n = len(nodelist) + return gb.Matrix.from_coo(rows, cols, [value] * len(rows), nrows=n, ncols=n) + + +@pytest.mark.skipif("not nx") +@pytest.mark.parametrize("graph_cls", [nx.Graph, nx.DiGraph] if nx else []) +@pytest.mark.parametrize("selfloop", [False, True]) +@pytest.mark.parametrize("weight", [None, "weight"]) +def test_from_networkx_uniform_weights_are_iso(graph_cls, selfloop, weight): + # Every stored entry is the same value, so the matrix is stored iso, as + # from_scipy_sparse stored it. An undirected self-loop used to put both wt and + # -wt (the diagonal correction) into the values the uniformity test reads. + G = graph_cls() + G.add_weighted_edges_from([(0, 1, 2.5), (1, 2, 2.5)]) + if selfloop: + G.add_edge(1, 1, weight=2.5) + value = 1 if weight is None else 2.5 + for nodelist in [None, [2, 1]]: # all nodes; a subset in another order + M = gb.io.from_networkx(G, nodelist=nodelist, weight=weight) + expected = _uniform_adjacency(G, list(G) if nodelist is None else nodelist, value) + assert M.isequal(expected, check_dtype=True) + if suitesparse: + assert M.ss.is_iso + + +@pytest.mark.skipif("not nx") +@pytest.mark.parametrize("wt", [np.inf, -np.inf, 1e308]) +def test_from_networkx_undirected_selfloop_keeps_its_weight(wt): + # Summing wt + wt - wt on the diagonal, as nx.to_scipy_sparse_array does, + # gives nan for an infinite self-loop and inf for 1e308. + G = nx.Graph() + G.add_weighted_edges_from([(0, 1, 1.0), (1, 1, wt)]) + M = gb.io.from_networkx(G) + expected = gb.Matrix.from_coo([0, 1, 1], [1, 0, 1], [1.0, 1.0, wt]) + assert M.isequal(expected, check_dtype=True) + + +@pytest.mark.skipif("not nx") +def test_from_networkx_undirected_selfloop_dtype(): + # A self-loop needs no diagonal correction -wt, which cannot be written in an + # unsigned dtype (numpy 2 raises OverflowError). + G = nx.Graph() + G.add_weighted_edges_from([(0, 1, 2), (1, 1, 5)]) + M = gb.io.from_networkx(G, dtype="uint8") + expected = gb.Matrix.from_coo([0, 1, 1], [1, 0, 1], [2, 2, 5], dtype="uint8") + assert M.isequal(expected, check_dtype=True) + # Nor does a self-loop change the inferred dtype (-True made bool weights int64). + G = nx.Graph() + G.add_weighted_edges_from([(0, 1, True), (1, 2, False), (1, 1, True)]) + M = gb.io.from_networkx(G) + expected = gb.Matrix.from_coo( + [0, 1, 1, 1, 2], [1, 0, 1, 2, 1], [True, True, True, False, False] + ) + assert M.isequal(expected, check_dtype=True) + + @pytest.mark.skipif("not nx") def test_from_networkx_int32_weights_widen_like_from_coo(): # np.int32 scalar weights infer an int32 array from the edge sequence on From ee2bef1128aa88a14b19ee68131f9d27cbbcf40c Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Sun, 18 Oct 2026 01:00:16 -0500 Subject: [PATCH 8/9] Test an unsigned numpy self-loop weight in from_networkx The old diagonal correction negated each self-loop's weight. For an np.uint8 weight that wraps (-np.uint8(1) is 255), and beside a Python int weight the values infer int64, so the self-loop of weight 1 summed to 1 + 1 + 255 = 257, with only a RuntimeWarning. The parent commit fixed this along with the four cases its message lists; pin it too. --- graphblas/tests/test_io.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/graphblas/tests/test_io.py b/graphblas/tests/test_io.py index 56a06e2e6..232adc3e8 100644 --- a/graphblas/tests/test_io.py +++ b/graphblas/tests/test_io.py @@ -289,6 +289,13 @@ def test_from_networkx_undirected_selfloop_dtype(): [0, 1, 1, 1, 2], [1, 0, 1, 2, 1], [True, True, True, False, False] ) assert M.isequal(expected, check_dtype=True) + # Nor is an unsigned numpy weight negated: -np.uint8(1) wraps to 255, so beside + # an int weight (the values infer int64) the self-loop summed to 257. + G = nx.Graph() + G.add_weighted_edges_from([(0, 1, 2), (1, 1, np.uint8(1))]) + M = gb.io.from_networkx(G) + expected = gb.Matrix.from_coo([0, 1, 1], [1, 0, 1], [2, 2, 1]) + assert M.isequal(expected, check_dtype=True) @pytest.mark.skipif("not nx") From 7453688aa172e0b31c5f696ae8b67608057848db Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Tue, 20 Oct 2026 07:44:29 -0500 Subject: [PATCH 9/9] Test the nodelist errors of from_networkx from_networkx checks a nodelist as nx.to_scipy_sparse_array does, so that the direct path raises its errors without needing scipy, but no test gave it an empty nodelist, a node not in the graph, or a duplicate. Check each message, and that a missing node is reported before a duplicate. --- graphblas/tests/test_io.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/graphblas/tests/test_io.py b/graphblas/tests/test_io.py index 232adc3e8..26c87107e 100644 --- a/graphblas/tests/test_io.py +++ b/graphblas/tests/test_io.py @@ -359,6 +359,22 @@ def test_from_networkx_matches_scipy(graph_cls): assert M.shape == reference.shape +@pytest.mark.skipif("not nx") +def test_from_networkx_rejects_bad_nodelist(): + # The checks and messages of nx.to_scipy_sparse_array, without scipy. A missing + # node is reported before a duplicate. + G = nx.path_graph(3) + cases = [ + ([], "nodelist has no nodes"), + ([0, 5], "Node 5 in nodelist is not in G"), + ([0, 0, 5], "Node 5 in nodelist is not in G"), + ([0, 1, 0], "nodelist contains duplicates."), + ] + for nodelist, message in cases: + with pytest.raises(nx.NetworkXError, match=message): + gb.io.from_networkx(G, nodelist=nodelist) + + @pytest.mark.skipif("not nx") def test_from_networkx_multigraph_is_scipy_free(monkeypatch): # A numeric-weight multigraph must ingest via the direct coo path, not the