From 4fddf2d90a53623200e2ccc87bf06e1c6648effa Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Mon, 3 Aug 2026 14:51:10 -0500 Subject: [PATCH 1/2] Test the fmm engine guards and config key completions Two behaviors had no test at all in the fast tier. Asking for the deprecated "fmm"/"fast_matrix_market" Matrix Market engine when fast_matrix_market is not installed must warn about the deprecation before it fails, and then fail with an ImportError naming the engine; neither read nor write had coverage for that, since the existing engine tests skip themselves when the package is missing. And the config mapping's _ipython_key_completions_ hook, which drives `config[]` in IPython, was never called. Each test was checked by perturbing the line it covers and confirming it goes red. --- graphblas/core/ss/config.py | 2 +- graphblas/tests/test_io.py | 18 ++++++++++++++++++ graphblas/tests/test_ss_utils.py | 16 ++++++++++++++++ 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/graphblas/core/ss/config.py b/graphblas/core/ss/config.py index 70a7dd196..e1ccd15a0 100644 --- a/graphblas/core/ss/config.py +++ b/graphblas/core/ss/config.py @@ -210,5 +210,5 @@ def __repr__(self): + "})" ) - def _ipython_key_completions_(self): # pragma: no cover (ipython) + def _ipython_key_completions_(self): return list(self) diff --git a/graphblas/tests/test_io.py b/graphblas/tests/test_io.py index 962595aaa..fd7a8b558 100644 --- a/graphblas/tests/test_io.py +++ b/graphblas/tests/test_io.py @@ -388,6 +388,24 @@ def test_matrix_market_bad_engine(): gb.io.mmread(mm_out, engine="bad_engine") +@pytest.mark.skipif("not ss") +@pytest.mark.skipif("fmm is not None") +def test_matrix_market_fmm_engine_unavailable(): + # Naming the deprecated engine warns before anything else, so the caller hears + # about the deprecation even when the install that would satisfy it is missing. + A = gb.Matrix.from_coo([0, 0, 3, 5], [1, 4, 0, 2], [1, 0, 2, -1], nrows=7, ncols=6) + with ( + pytest.warns(DeprecationWarning, match="fast_matrix_market is no longer maintained"), + pytest.raises(ImportError, match="required to write Matrix Market files"), + ): + gb.io.mmwrite(BytesIO(), A, engine="fmm") + with ( + pytest.warns(DeprecationWarning, match="fast_matrix_market is no longer maintained"), + pytest.raises(ImportError, match="required to read Matrix Market files"), + ): + gb.io.mmread(BytesIO(), engine="fast_matrix_market") + + @pytest.mark.skipif("not ss") def test_scipy_sparse(): a = np.arange(12).reshape(3, 4) diff --git a/graphblas/tests/test_ss_utils.py b/graphblas/tests/test_ss_utils.py index 6e7a86b8e..86d9abd51 100644 --- a/graphblas/tests/test_ss_utils.py +++ b/graphblas/tests/test_ss_utils.py @@ -305,6 +305,22 @@ def test_global_config(monkeypatch): assert "format" in repr(config) +def test_global_config_key_completions(): + # IPython offers these for `config[]`, so they must be the keys that + # actually resolve, not the attributes of the mapping object. + config = gb.ss.config + completions = config._ipython_key_completions_() + assert set(completions) == set(config._options) + for key in completions: + config[key] + # About aliases the same hook onto its own __iter__ (gb.ss.about[]) + about = gb.ss.about + completions = about._ipython_key_completions_() + assert set(completions) == set(about) + for key in completions: + about[key] + + @pytest.mark.skipif("gb.core.ss._IS_SSGB7") def test_context(): context = gb.ss.Context() From 1e128f8dca1470a7235e7a408d48fa5637bfeb10 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Tue, 27 Oct 2026 23:18:36 -0500 Subject: [PATCH 2/2] Combine repeated indices in an imported csr, csc, or GCXS array Behavior fix. A scipy csr or csc array outside canonical format, and a pydata GCXS array, may repeat an index within a row (column), and from_scipy_sparse imported the arrays unchecked. The Matrix was invalid (GxB_Matrix_fprint: GrB_INVALID_OBJECT), with nvals counting both entries. Before: from_pydata_sparse of a 2x3 GCXS with indices [1, 1, 0] in row 0 (values 1, 2, 3) and [2] in row 1 gave nvals 4 with two (0, 1) entries and to_dense 2.0 at (0, 1), where pydata's todense gives 3.0. from_scipy_sparse of the same csr array did the same. After: from_scipy_sparse builds such an array through coo, so repeats need dup_op as they do in coo input (ValueError "Duplicate indices found" without it). from_pydata_sparse sums them by default (with scipy's sum_duplicates, on a copy, so any dtype works), as pydata's todense does and its COO does when built; dup_op combines them otherwise. Unsorted but distinct indices give the same Matrix as before, now built from coo. A 1-d GCXS already summed its repeats (through asformat("coo")). --- graphblas/io/_scipy.py | 8 ++++- graphblas/io/_sparse.py | 13 ++++++-- graphblas/tests/test_io.py | 62 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 3 deletions(-) diff --git a/graphblas/io/_scipy.py b/graphblas/io/_scipy.py index 228432eed..2003d3281 100644 --- a/graphblas/io/_scipy.py +++ b/graphblas/io/_scipy.py @@ -15,7 +15,8 @@ def from_scipy_sparse(A, *, dup_op=None, name=None): A : scipy.sparse Scipy sparse array or matrix dup_op : BinaryOp, optional - Aggregation function for formats that allow duplicate entries (e.g. coo) + Aggregation function for duplicate entries, which coo allows (as do csr and csc + not in canonical format). Duplicates without it raise ValueError. name : str, optional Name of resulting Matrix @@ -28,6 +29,11 @@ def from_scipy_sparse(A, *, dup_op=None, name=None): dtype = lookup_dtype(A.dtype) if A.nnz == 0: return Matrix(dtype, nrows=nrows, ncols=ncols, name=name) + if A.format in {"csr", "csc"} and not A.has_canonical_format: + # An index may repeat within a row (column), and an import takes the indices + # unchecked: a repeat would make an invalid Matrix. Build from coo instead, where + # dup_op combines repeats. + A = A.tocoo() if backend == "suitesparse" and A.format in {"csr", "csc"}: data = A.data is_iso = (data[[0]] == data).all() diff --git a/graphblas/io/_sparse.py b/graphblas/io/_sparse.py index c0d4beabb..e780ee96a 100644 --- a/graphblas/io/_sparse.py +++ b/graphblas/io/_sparse.py @@ -15,7 +15,9 @@ def from_pydata_sparse(s, *, dup_op=None, name=None): s : sparse PyData sparse array or matrix (see https://sparse.pydata.org) dup_op : BinaryOp, optional - Aggregation function for formats that allow duplicate entries (e.g. coo) + Combines the values of an index repeated within a row (or column) of a + two-dimensional gcxs array. By default they are summed, as pydata's todense does. + (A coo array sums repeated coordinates when it is built.) name : str, optional Name of resulting Matrix @@ -45,7 +47,14 @@ def from_pydata_sparse(s, *, dup_op=None, name=None): ) # handle two-dimensional arrays if isinstance(s, sparse.GCXS): - return from_scipy_sparse(s.to_scipy_sparse(), dup_op=dup_op, name=name) + A = s.to_scipy_sparse() + if dup_op is None and not A.has_canonical_format: + # pydata lets an index repeat within a row (column) and means the sum. scipy + # sums in any dtype it holds, which binary.plus may not take (a UDT). Copy + # first: A shares its arrays with s. + A = A.copy() + A.sum_duplicates() + return from_scipy_sparse(A, dup_op=dup_op, name=name) if isinstance(s, (sparse.DOK, sparse.COO)): _s = s.asformat("coo") return Matrix.from_coo( diff --git a/graphblas/tests/test_io.py b/graphblas/tests/test_io.py index fd7a8b558..a655bb5e0 100644 --- a/graphblas/tests/test_io.py +++ b/graphblas/tests/test_io.py @@ -355,6 +355,31 @@ def test_from_scipy_sparse_duplicates(): assert a2.isequal(expected) +@pytest.mark.skipif("not ss") +@pytest.mark.parametrize("format", ["csr", "csc"]) +def test_from_scipy_sparse_compressed_duplicates(format): + # Outside canonical format an index may repeat within a row (column); importing the + # arrays as they are made an invalid Matrix (nvals 4, two entries at one position). + cls, shape = (ss.csr_array, (2, 3)) if format == "csr" else (ss.csc_array, (3, 2)) + a = cls(([1, 2, 3, 4], [1, 1, 0, 2], [0, 3, 4]), shape=shape) + assert not a.has_canonical_format + with pytest.raises(ValueError, match="Duplicate indices found"): + gb.io.from_scipy_sparse(a) + A = gb.io.from_scipy_sparse(a, dup_op=gb.binary.plus) + assert A.nvals == 3 + np.testing.assert_array_equal(A.to_dense(fill_value=0), a.toarray()) + A = gb.io.from_scipy_sparse(a, dup_op=gb.binary.min) + expected = np.array([[3, 1, 0], [0, 0, 4]]) + if format == "csc": + expected = expected.T + np.testing.assert_array_equal(A.to_dense(fill_value=0), expected) + # Unsorted but distinct indices need no dup_op + b = cls(([1, 2, 3], [2, 1, 0], [0, 3, 3]), shape=shape) + assert not b.has_canonical_format + B = gb.io.from_scipy_sparse(b) + np.testing.assert_array_equal(B.to_dense(fill_value=0), b.toarray()) + + @pytest.mark.skipif("not ss") @pytest.mark.parametrize("engine", ["auto", "scipy", "fast_matrix_market"]) def test_matrix_market_sparse_duplicates(engine): @@ -549,3 +574,40 @@ def test_matrix_to_from_pydata_sparse(): gb.io.to_pydata_sparse(w, format="badformat") with pytest.raises(TypeError, match="sparse.pydata"): gb.io.from_pydata_sparse(w) + + +@pytest.mark.skipif("not sparse") +@pytest.mark.slow +@pytest.mark.parametrize("axis", [0, 1]) +@pytest.mark.parametrize("dtype", ["int64", "float64", "bool"]) +def test_from_pydata_sparse_gcxs_duplicates(axis, dtype): + # pydata checks neither the order nor the distinctness of the indices in a GCXS row + # (column), and todense sums a repeat. Importing the arrays as they are made an invalid + # Matrix (nvals 4, two entries at one position). + data = np.array([1, 2, 3, 4], dtype=dtype) + indptr = np.array([0, 3, 4]) + shape = (2, 3) if axis == 0 else (3, 2) + s = sparse.GCXS((data, np.array([1, 1, 0, 2]), indptr), shape=shape, compressed_axes=[axis]) + A = gb.io.from_pydata_sparse(s) + assert A.nvals == 3 + assert A.dtype == dtype + np.testing.assert_array_equal(A.to_dense(fill_value=0), s.todense()) + A = gb.io.from_pydata_sparse(s, dup_op=gb.binary.min) + expected = np.array([[3, 1, 0], [0, 0, 4]], dtype=dtype) + if axis == 1: + expected = expected.T + np.testing.assert_array_equal(A.to_dense(fill_value=0), expected) + # Unsorted but distinct indices need no combining + s = sparse.GCXS((data, np.array([2, 1, 0, 2]), indptr), shape=shape, compressed_axes=[axis]) + A = gb.io.from_pydata_sparse(s) + assert A.nvals == 4 + np.testing.assert_array_equal(A.to_dense(fill_value=0), s.todense()) + # The default sum needs no binary op for the dtype (complex is a UDT on vanilla), + # and it leaves the array alone + cdata = data + 1j + s = sparse.GCXS((cdata, np.array([1, 1, 0, 2]), indptr), shape=shape, compressed_axes=[axis]) + indices = s.indices.copy() + A = gb.io.from_pydata_sparse(s) + assert A.nvals == 3 + np.testing.assert_array_equal(A.to_dense(fill_value=0), s.todense()) + np.testing.assert_array_equal(s.indices, indices)