From 9dcd74d6dce9ff4666afdd907cdd66d4a67105a3 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Fri, 31 Jul 2026 14:11:34 -0500 Subject: [PATCH 01/17] Add fast path to Vector.get and Matrix.get for plain integer indices For a plain integer index, with a non-UDT dtype and no active Recorder, call GrB_*_extractElement directly instead of building an extract expression, a Scalar, and converting through Scalar.value. Semantics are unchanged: the same IndexError messages and negative-index handling as parse_index, bool indices still rejected, UDTs and active Recorders falling back to the expression path, and TransposedMatrix (which reuses Matrix.get) extracting the mirrored element. Measured on an Apple M5 Pro with timeit, best-of-7, warm, while other test suites were running (load average about 4.5), so indicative rather than clean-room: v.get(i) 5.05us -> 0.38us (13x) A.get(i, j) 6.67us -> 0.49us (14x) A bare GrB_Vector_extractElement_FP64 call through cffi measures about 0.06us on the same machine, so most of the remaining 0.38us is Python call and argument handling rather than GraphBLAS work. --- graphblas/core/base.py | 5 ++++ graphblas/core/matrix.py | 50 ++++++++++++++++++++++++++++++++++++++-- graphblas/core/vector.py | 33 ++++++++++++++++++++++++-- 3 files changed, 84 insertions(+), 4 deletions(-) diff --git a/graphblas/core/base.py b/graphblas/core/base.py index 95d89c670..3499af531 100644 --- a/graphblas/core/base.py +++ b/graphblas/core/base.py @@ -21,6 +21,11 @@ def record_raw(text): rec.record_raw(text) +def _is_recording(): + """Whether a Recorder is active; fast paths that bypass ``call`` must check this.""" + return _recorder.get(_prev_recorder) is not None + + def call(cfunc_name, args): call_args = [getattr(x, "_carg", x) if x is not None else NULL for x in args] cfunc = libget(cfunc_name) diff --git a/graphblas/core/matrix.py b/graphblas/core/matrix.py index 299d98d8c..35083f485 100644 --- a/graphblas/core/matrix.py +++ b/graphblas/core/matrix.py @@ -5,9 +5,16 @@ from .. import backend, binary, monoid, select, semiring from ..dtypes import _INDEX, FP64, INT64, lookup_dtype, unify -from ..exceptions import DimensionMismatch, InvalidValue, NoValue, check_status +from ..exceptions import ( + DimensionMismatch, + GrB_NO_VALUE, + InvalidValue, + NoValue, + check_status, + check_status_carg, +) from . import _supports_udfs, automethods, ffi, lib, utils -from .base import BaseExpression, BaseType, call +from .base import BaseExpression, BaseType, _is_recording, call from .descriptor import lookup as descriptor_lookup from .dtypes import _raise_dtype_or_arraylike from .expr import _ALL_INDICES, AmbiguousAssignOrExtract, IndexerResolver, InfixExprBase, Updater @@ -857,6 +864,45 @@ def get(self, row, col, default=None): Python scalar """ + # Fast path for plain integer indices: call GrB_Matrix_extractElement + # directly instead of building an extract expression, which costs ~10x + # more than the C call for single-element access. Fall back when a + # Recorder is active (so the call is recorded) and for UDTs (whose + # values need numpy-based conversion in Scalar.value). + if not self.dtype._is_udt and not _is_recording(): + try: + rowidx = row.__index__() + colidx = col.__index__() + except (AttributeError, TypeError): + rowidx = None + else: + if isinstance(row, (bool, np.bool_)) or isinstance(col, (bool, np.bool_)): + rowidx = None + if rowidx is not None: + nrows = self._nrows + ncols = self._ncols + if rowidx < 0: + rowidx += nrows + if rowidx < 0 or rowidx >= nrows: + raise IndexError(f"Index out of range: index={row}, size={nrows}") + if colidx < 0: + colidx += ncols + if colidx < 0 or colidx >= ncols: + raise IndexError(f"Index out of range: index={col}, size={ncols}") + if self._is_transposed: + # TransposedMatrix reuses this method; gb_obj is the + # untransposed parent, so extract the mirrored element. + rowidx, colidx = colidx, rowidx + dtype = self.dtype + res = ffi_new(f"{dtype.c_type}*") + err_code = utils.libget(f"GrB_Matrix_extractElement_{dtype.name}")( + res, self.gb_obj[0], rowidx, colidx + ) + if err_code: + if err_code == GrB_NO_VALUE: + return default + check_status_carg(err_code, "Matrix", self.gb_obj[0]) + return res[0] expr = self[row, col] if expr._is_scalar: rv = expr.new().value diff --git a/graphblas/core/vector.py b/graphblas/core/vector.py index 82fb5270f..328f5aa8b 100644 --- a/graphblas/core/vector.py +++ b/graphblas/core/vector.py @@ -4,9 +4,9 @@ from .. import backend, binary, monoid, select, semiring, unary from ..dtypes import _INDEX, FP64, INT64, UINT64, lookup_dtype, unify -from ..exceptions import DimensionMismatch, NoValue, check_status +from ..exceptions import DimensionMismatch, GrB_NO_VALUE, NoValue, check_status, check_status_carg from . import _supports_udfs, automethods, ffi, lib, utils -from .base import BaseExpression, BaseType, call +from .base import BaseExpression, BaseType, _is_recording, call from .descriptor import lookup as descriptor_lookup from .dtypes import _raise_dtype_or_arraylike from .expr import _ALL_INDICES, AmbiguousAssignOrExtract, IndexerResolver, InfixExprBase, Updater @@ -708,6 +708,35 @@ def get(self, index, default=None): Python scalar """ + # Fast path for a plain integer index: call GrB_Vector_extractElement + # directly instead of building an extract expression, which costs ~10x + # more than the C call for single-element access. Fall back when a + # Recorder is active (so the call is recorded) and for UDTs (whose + # values need numpy-based conversion in Scalar.value). + if not self.dtype._is_udt and not _is_recording(): + try: + idx = index.__index__() + except (AttributeError, TypeError): + idx = None + else: + if isinstance(index, (bool, np.bool_)): + idx = None + if idx is not None: + size = self._size + if idx < 0: + idx += size + if idx < 0 or idx >= size: + raise IndexError(f"Index out of range: index={index}, size={size}") + dtype = self.dtype + res = ffi_new(f"{dtype.c_type}*") + err_code = utils.libget(f"GrB_Vector_extractElement_{dtype.name}")( + res, self.gb_obj[0], idx + ) + if err_code: + if err_code == GrB_NO_VALUE: + return default + check_status_carg(err_code, "Vector", self.gb_obj[0]) + return res[0] expr = self[index] if expr._is_scalar: rv = expr.new().value From a56a575f83afeaba02e4a0396a44497141206e17 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Fri, 31 Jul 2026 14:02:02 -0500 Subject: [PATCH 02/17] Add fast paths for __contains__, scalar __setitem__, and ScalarIndexExpr.new() Applies the Vector/Matrix.get recipe from the previous commit to three more scalar access paths, calling GrB_*_extractElement or GrB_*_setElement directly instead of building an extract expression, an Updater, and a Scalar. Only exact-fit Python int, float, bool and complex values take the setitem fast path, so dtype inference and cffi coercion match the Updater path exactly. Masks, accum, opts, UDTs and an active Recorder all fall back to the full path. TransposedMatrix has no __setitem__, so the setitem fast path cannot be reached through a transposed view. An out-of-range index falls through from the fast lane to the expression path, so membership raises the same IndexError, with the same message, from either lane. Assignment likewise raises IndexError, in the fast lane as before. Measured on an Apple M5 Pro with timeit, best-of-7, warm, while other test suites were running (load average about 5), so these are indicative rather than clean-room: 0 in v 3.95us -> 0.40us (9.9x) (0, 0) in A 5.57us -> 0.53us (10.4x) v[i] = 5 3.69us -> 0.50us (7.3x) A[i, j] = 5 5.12us -> 0.62us (8.2x) The ScalarIndexExpr.new() path gains only the skipped per-argument marshalling in the `call` wrapper. That difference did not clear measurement noise on a loaded machine, so no figure is quoted for it; the change stands on doing strictly less work, not on a measured win. --- graphblas/core/matrix.py | 86 ++++++++++++++++++++++++++++++++++++++++ graphblas/core/scalar.py | 34 +++++++++++++++- graphblas/core/vector.py | 65 ++++++++++++++++++++++++++++++ 3 files changed, 183 insertions(+), 2 deletions(-) diff --git a/graphblas/core/matrix.py b/graphblas/core/matrix.py index 35083f485..1f0d395f0 100644 --- a/graphblas/core/matrix.py +++ b/graphblas/core/matrix.py @@ -379,6 +379,51 @@ def __setitem__(self, keys, expr, **opts): M[0, 0:3] = 17 """ + # Fast path for `A[i, j] = scalar`: a plain (row, col) integer pair and + # an exact-fit Python scalar, with no mask/accum/opts, a non-UDT dtype, + # and no active Recorder. Mirrors Updater -> _assign_element for a single + # element while skipping the resolver, Updater, and Scalar objects. Only + # int/float/bool/complex are taken here so dtype inference and cffi + # coercion match _assign_element exactly; everything else (slices, fancy + # indexing, numpy or Scalar values, `A(mask)[i, j] << x`) falls back to + # the full assign path, leaving mask/accum and coercion unchanged. + if ( + not opts + and type(keys) is tuple + and len(keys) == 2 + and type(expr) in (int, float, bool, complex) + and not self.dtype._is_udt + and not _is_recording() + ): + row, col = keys + try: + rowidx = row.__index__() + colidx = col.__index__() + except (AttributeError, TypeError): + rowidx = None + else: + if isinstance(row, (bool, np.bool_)) or isinstance(col, (bool, np.bool_)): + rowidx = None + if rowidx is not None: + nrows = self._nrows + ncols = self._ncols + if rowidx < 0: + rowidx += nrows + if rowidx < 0 or rowidx >= nrows: + raise IndexError(f"Index out of range: index={row}, size={nrows}") + if colidx < 0: + colidx += ncols + if colidx < 0 or colidx >= ncols: + raise IndexError(f"Index out of range: index={col}, size={ncols}") + vdtype = lookup_dtype(type(expr), expr) + cvalue = ffi_new(f"{vdtype.c_type}*") + cvalue[0] = expr # cffi coercion, identical to the Scalar.value setter + err_code = utils.libget(f"GrB_Matrix_setElement_{vdtype.name}")( + self.gb_obj[0], cvalue[0], rowidx, colidx + ) + if err_code: + check_status_carg(err_code, "Matrix", self.gb_obj[0]) + return Updater(self, opts=opts)[keys] = expr def __contains__(self, index): @@ -391,6 +436,47 @@ def __contains__(self, index): (10, 15) in M """ + # Fast path for a plain (row, col) integer pair: probe with + # GrB_Matrix_extractElement directly instead of building an extract + # expression and Scalar. An out-of-range index falls through to the + # expression path so it raises the same IndexError as the slow path. + # Fall back for a UDT dtype and an active Recorder (so the call is + # recorded), mirroring Matrix.get. TransposedMatrix reuses this method. + if ( + type(index) is tuple + and len(index) == 2 + and not self.dtype._is_udt + and not _is_recording() + ): + row, col = index + try: + rowidx = row.__index__() + colidx = col.__index__() + except (AttributeError, TypeError): + rowidx = None + else: + if isinstance(row, (bool, np.bool_)) or isinstance(col, (bool, np.bool_)): + rowidx = None + if rowidx is not None: + nrows = self._nrows + ncols = self._ncols + if rowidx < 0: + rowidx += nrows + if colidx < 0: + colidx += ncols + if 0 <= rowidx < nrows and 0 <= colidx < ncols: + if self._is_transposed: + rowidx, colidx = colidx, rowidx + dtype = self.dtype + res = ffi_new(f"{dtype.c_type}*") + err_code = utils.libget(f"GrB_Matrix_extractElement_{dtype.name}")( + res, self.gb_obj[0], rowidx, colidx + ) + if err_code: + if err_code == GrB_NO_VALUE: + return False + check_status_carg(err_code, "Matrix", self.gb_obj[0]) + return True extractor = self[index] if not extractor._is_scalar: raise TypeError( diff --git a/graphblas/core/scalar.py b/graphblas/core/scalar.py index 0b711c258..75630c19d 100644 --- a/graphblas/core/scalar.py +++ b/graphblas/core/scalar.py @@ -6,7 +6,7 @@ from ..dtypes import _INDEX, FP64, _index_dtypes, lookup_dtype, unify from ..exceptions import EmptyObject, check_status from . import _has_numba, _supports_udfs, automethods, ffi, lib, utils -from .base import BaseExpression, BaseType, call +from .base import BaseExpression, BaseType, _is_recording, call from .expr import AmbiguousAssignOrExtract from .literals import ( _SINGLE_PRECISION, @@ -1060,7 +1060,37 @@ class ScalarIndexExpr(AmbiguousAssignOrExtract): def new(self, dtype=None, *, is_cscalar=None, name=None, **opts): if is_cscalar is None: is_cscalar = False - return self.parent._extract_element( + parent = self.parent + # Fast path for the default `expr.new()`: extract a single element + # straight into a fresh GrB_Scalar via GrB_*_extractElement_Scalar, + # skipping the `call` wrapper's per-arg _carg marshalling. Falls back + # for a dtype cast, cscalar output, opts, UDTs, and an active Recorder + # (so the call is recorded). Result is a GrB_Scalar (is_cscalar=False), + # empty exactly when the element is missing, same as _extract_element. + if ( + dtype is None + and not is_cscalar + and not opts + and not parent.dtype._is_udt + and not _is_recording() + ): + indices = self.resolved_indexes.indices + result = Scalar(parent.dtype, is_cscalar=False, name=name) + if len(indices) == 1: + err_code = lib.GrB_Vector_extractElement_Scalar( + result.gb_obj[0], parent.gb_obj[0], indices[0].index._carg + ) + else: + rowidx, colidx = indices + if parent._is_transposed: + rowidx, colidx = colidx, rowidx + err_code = lib.GrB_Matrix_extractElement_Scalar( + result.gb_obj[0], parent.gb_obj[0], rowidx.index._carg, colidx.index._carg + ) + if err_code: + check_status(err_code, [result]) + return result + return parent._extract_element( self.resolved_indexes, dtype, opts, is_cscalar=is_cscalar, name=name ) diff --git a/graphblas/core/vector.py b/graphblas/core/vector.py index 328f5aa8b..0e07b1547 100644 --- a/graphblas/core/vector.py +++ b/graphblas/core/vector.py @@ -333,6 +333,42 @@ def __setitem__(self, keys, expr, **opts): v[:] = 1 """ + # Fast path for `v[i] = scalar`: a plain integer index and an exact-fit + # Python scalar, with no mask/accum/opts, a non-UDT dtype, and no active + # Recorder. This mirrors what Updater -> _assign_element does for a + # single element, but skips building the resolver, Updater, and Scalar. + # Only int/float/bool/complex are taken here so the dtype inference and + # cffi coercion match _assign_element exactly; everything else (slices, + # fancy indexing, numpy or Scalar values, `v(mask)[i] << x`) falls back + # to the full assign path, leaving mask/accum and coercion unchanged. + if ( + not opts + and type(expr) in (int, float, bool, complex) + and not self.dtype._is_udt + and not _is_recording() + ): + try: + idx = keys.__index__() + except (AttributeError, TypeError): + idx = None + else: + if isinstance(keys, (bool, np.bool_)): + idx = None + if idx is not None: + size = self._size + if idx < 0: + idx += size + if idx < 0 or idx >= size: + raise IndexError(f"Index out of range: index={keys}, size={size}") + vdtype = lookup_dtype(type(expr), expr) + cvalue = ffi_new(f"{vdtype.c_type}*") + cvalue[0] = expr # cffi coercion, identical to the Scalar.value setter + err_code = utils.libget(f"GrB_Vector_setElement_{vdtype.name}")( + self.gb_obj[0], cvalue[0], idx + ) + if err_code: + check_status_carg(err_code, "Vector", self.gb_obj[0]) + return Updater(self, opts=opts)[keys] = expr def __contains__(self, index): @@ -346,6 +382,35 @@ def __contains__(self, index): 15 in v """ + # Fast path for a plain integer index: probe with + # GrB_Vector_extractElement directly instead of building an extract + # expression and Scalar. An out-of-range index falls through to the + # expression path so it raises the same IndexError as the slow path. + # Fall back for a UDT dtype and an active Recorder (so the call is + # recorded), mirroring Vector.get. + if not self.dtype._is_udt and not _is_recording(): + try: + idx = index.__index__() + except (AttributeError, TypeError): + idx = None + else: + if isinstance(index, (bool, np.bool_)): + idx = None + if idx is not None: + size = self._size + if idx < 0: + idx += size + if 0 <= idx < size: + dtype = self.dtype + res = ffi_new(f"{dtype.c_type}*") + err_code = utils.libget(f"GrB_Vector_extractElement_{dtype.name}")( + res, self.gb_obj[0], idx + ) + if err_code: + if err_code == GrB_NO_VALUE: + return False + check_status_carg(err_code, "Vector", self.gb_obj[0]) + return True extractor = self[index] if not extractor._is_scalar: raise TypeError( From 0b42dedb8ad98b3d1db7cc08579a87ec18fc1d2a Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Fri, 31 Jul 2026 14:04:06 -0500 Subject: [PATCH 03/17] Add plain-int fast lane to parse_index A `typ is int` branch ahead of the np.issubdtype machinery skips two type checks costing about 125ns each for the overwhelmingly common case of a plain Python int index. output_type maps only the exact `int` type to `int`, so bool and numpy integer indices keep their existing handling. Error messages, negative wrapping, and the returned AxisIndex are unchanged, and a plain int is always signed, so the negative branch matches the signedinteger branch it bypasses. Measured on an Apple M5 Pro with timeit, min of 15 runs of 20000 calls, warm, while other test suites were running (load average about 5.7). Before and after were taken back to back with the same protocol: v[i] construction 1.85us -> 1.50us (-19%) v[i].new() 3.48us -> 3.13us (-10%) A[i, j].new() 5.04us -> 4.34us (-14%) These are small percentages, so the measurement is worth qualifying: min and median differed by under 2% on every case, and each saving (0.35us, 0.35us, 0.70us) is many times that spread, in the direction the diff predicts and of the size two skipped 125ns checks predict. List, slice, and array extracts are unchanged, since they never took the int branch. --- graphblas/core/expr.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/graphblas/core/expr.py b/graphblas/core/expr.py index 1e16c44b4..eeadea1b6 100644 --- a/graphblas/core/expr.py +++ b/graphblas/core/expr.py @@ -176,6 +176,21 @@ def parse_indices(self, indices, shape): def parse_index(self, index, typ, size): from .scalar import _as_scalar + if typ is int: + # Fast lane for a plain Python int, the overwhelmingly common case. + # The two np.issubdtype checks below cost ~125ns each (measured), a + # meaningful slice of single-element index parsing. output_type maps + # only the exact `int` type to `int` (bool -> bool, numpy ints -> their + # own type), so bool and numpy scalars never enter here and keep their + # existing handling. A plain int is always signed, so the negative + # branch always applies, matching the signedinteger branch below. + if index >= size: + raise IndexError(f"Index out of range: index={index}, size={size}") + if index < 0: + index = index + size + if index < 0: + raise IndexError(f"Index out of range: index={index - size}, size={size}") + return AxisIndex(None, _as_scalar(index, _INDEX, is_cscalar=True), None, size) if np.issubdtype(typ, np.integer): if index >= size: raise IndexError(f"Index out of range: index={index}, size={size}") From 4a957ed322da952859796d252eca8a60739e7acd Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Fri, 31 Jul 2026 14:23:29 -0500 Subject: [PATCH 04/17] Add parity tests for the scalar-access fast paths The fast lanes for get, __contains__, integer __setitem__, scalar extract, and plain-int index parsing were checked only by hand-run equivalence scripts that CI never executes. A fast lane that drifts from the expression path it stands in for returns a wrong answer rather than a slow one, so the comparison belongs in the suite where a refactor will trip over it. 211 tests compare each fast path against the expression path across the builtin dtypes (13 of them where complex is supported), hits and misses, negative and out-of-range indices, exception type and message parity, coercion and overflow edges, UDT and Recorder fallback, and TransposedMatrix. They run in the fast tier, in well under a second. The TransposedMatrix case for Matrix.get was absent from this file and is added here. It is not the only guard: test_matrix.py::test_get already asserts A.T.get(1, 0), and deleting the mirroring swap from the get fast path fails that test as well as this one. What the new case adds is shape and breadth. It uses a non-square 3x4 matrix, where equal dimensions cannot hide a row/column swap, and it sweeps every cell against the expression path rather than checking two positions. --- graphblas/tests/test_fastpath_parity.py | 769 ++++++++++++++++++++++++ 1 file changed, 769 insertions(+) create mode 100644 graphblas/tests/test_fastpath_parity.py diff --git a/graphblas/tests/test_fastpath_parity.py b/graphblas/tests/test_fastpath_parity.py new file mode 100644 index 000000000..f7d180eeb --- /dev/null +++ b/graphblas/tests/test_fastpath_parity.py @@ -0,0 +1,769 @@ +"""Parity tests for the scalar-access fast paths. + +Several hot scalar-access operations grew fast paths that bypass the general +expression machinery: ``Vector.get`` / ``Matrix.get``, ``index in obj`` +(``__contains__``), integer-key ``obj[i] = value`` (``__setitem__``), +``obj[i].new()`` on a scalar index expression, and the plain-int lane in +``parse_index``. Each fast path is only correct if it produces exactly what the +slower general path would. These tests pin that equivalence: for every case we +run the shipped fast path and an inlined copy of the pre-fast-path reference, +then require the results (values, dtypes, and raised exceptions with their +messages) to match. + +The references here are deliberately self-contained rather than imported from +the throwaway benchmark scripts they were distilled from, so a refactor of the +production code can never silently drag the reference along with it. +""" + +import numpy as np +import pytest + +import graphblas as gb +from graphblas import Matrix, Vector, binary, dtypes +from graphblas.core.expr import Updater +from graphblas.core.recorder import Recorder +from graphblas.core.scalar import Scalar + +# Builtin dtypes to sweep. Complex is SuiteSparse-only, so gate it the same way +# the dtype tests do; on the vanilla backend the parametrization simply omits it. +BUILTIN_DTYPES = [ + dtypes.BOOL, + dtypes.INT8, + dtypes.INT16, + dtypes.INT32, + dtypes.INT64, + dtypes.UINT8, + dtypes.UINT16, + dtypes.UINT32, + dtypes.UINT64, + dtypes.FP32, + dtypes.FP64, +] +if dtypes._supports_complex: + BUILTIN_DTYPES += [dtypes.FC32, dtypes.FC64] + +DTYPE_IDS = [dt.name for dt in BUILTIN_DTYPES] + + +def _hit_value(dt): + """A value that fits every builtin dtype (matches the source harnesses).""" + if "FC" in dt.name: + return 3 + 4j + if dt == dtypes.BOOL: + return True + return 3 + + +def _capture(fn): + """Return ``("val", result)`` or ``("exc", type_name, message)``.""" + try: + return ("val", fn()) + except Exception as e: + return ("exc", type(e).__name__, str(e)) + + +# --------------------------------------------------------------------------- +# Vector.get / Matrix.get +# --------------------------------------------------------------------------- +def _get_vector_slow(v, index, default=None): + expr = v[index] + if not expr._is_scalar: + raise ValueError("Bad index in Vector.get(...)") + rv = expr.new().value + return default if rv is None else rv + + +def _get_matrix_slow(A, r, c, default=None): + rv = A[r, c].new().value + return default if rv is None else rv + + +def _assert_get_equal(got, expected): + msg = f"got {got!r} ({type(got).__name__}), expected {expected!r} ({type(expected).__name__})" + assert got == expected, msg + assert type(got) is type(expected), msg + + +@pytest.mark.parametrize("dt", BUILTIN_DTYPES, ids=DTYPE_IDS) +def test_get_dtype_parity(dt): + val = _hit_value(dt) + v = Vector(dt, 10) + v[2] = val + _assert_get_equal(v.get(2), _get_vector_slow(v, 2)) + _assert_get_equal(v.get(3), _get_vector_slow(v, 3)) + _assert_get_equal(v.get(3, -1), _get_vector_slow(v, 3, -1)) + A = Matrix(dt, 5, 7) + A[1, 2] = val + _assert_get_equal(A.get(1, 2), _get_matrix_slow(A, 1, 2)) + _assert_get_equal(A.get(0, 0, 99), _get_matrix_slow(A, 0, 0, 99)) + + +def test_get_index_types(): + v = Vector.from_coo([0, 5, 9], [1.5, 2.5, 3.5], size=10) + _assert_get_equal(v.get(np.int64(5)), 2.5) + _assert_get_equal(v.get(np.uint8(5)), 2.5) + _assert_get_equal(v.get(-5), 2.5) + _assert_get_equal(v.get(-2, "d"), "d") + # empty-scalar semantics: a miss with no default is None + assert v.get(1) is None + + +@pytest.mark.parametrize("idx", [10, -11, 1000]) +def test_get_out_of_range(idx): + """Out-of-range raises IndexError with the same message on both paths.""" + v = Vector.from_coo([0, 5, 9], [1.5, 2.5, 3.5], size=10) + with pytest.raises(IndexError) as fast: + v.get(idx) + with pytest.raises(IndexError) as slow: + _get_vector_slow(v, idx) + assert str(fast.value) == str(slow.value) + + +@pytest.mark.parametrize("bad", [1.5, "x", None, [1, 2], slice(None), 2.0, True]) +def test_get_non_integer_index(bad): + """Non-integer indices raise the same exception type as the reference path.""" + v = Vector.from_coo([0, 5, 9], [1.5, 2.5, 3.5], size=10) + fast = _capture(lambda: v.get(bad)) + slow = _capture(lambda: _get_vector_slow(v, bad)) + assert fast[:2] == slow[:2], f"fast={fast} slow={slow}" + + +def test_get_matrix_negative_and_range(): + A = Matrix.from_coo([0, 1, 4], [1, 2, 6], [10, 20, 30], nrows=5, ncols=7) + _assert_get_equal(A.get(-1, -1, "d"), 30) + _assert_get_equal(A.get(-1, 6), 30) + _assert_get_equal(A.get(-2, -2, "d"), "d") + with pytest.raises(IndexError): + A.get(5, 0) + with pytest.raises(IndexError): + A.get(0, 7) + + +def test_get_transposed_matrix(): + """TransposedMatrix reuses Matrix.get and must extract the mirrored element.""" + # Non-square and non-symmetric, so a row/column swap cannot go unnoticed. + A = Matrix.from_coo([0, 0, 1, 2], [1, 3, 0, 2], [10, 30, 20, 40], nrows=3, ncols=4) + AT = A.T + assert AT.shape == (4, 3) + for r in range(4): + for c in range(3): + _assert_get_equal(AT.get(r, c, "d"), _get_matrix_slow(AT, r, c, "d")) + # The mirrored element is the same one A sees with the axes swapped. + _assert_get_equal(AT.get(r, c, "d"), A.get(c, r, "d")) + # Out-of-range is judged against the transposed dimensions. + with pytest.raises(IndexError): + AT.get(4, 0) + with pytest.raises(IndexError): + AT.get(0, 3) + # The setitem fast path is unreachable through a transposed view. + assert not hasattr(type(AT), "__setitem__") + + +def test_get_udt_fallback(): + udt = gb.dtypes.register_anonymous( + np.dtype([("fx", np.int64), ("fy", np.float64)]), "GetFastPathProbe" + ) + u = Vector(udt, 3) + u[1] = (7, 2.5) + r = u.get(1) + assert r["fx"] == 7, r + assert r["fy"] == 2.5, r + assert u.get(0, "dflt") == "dflt" + + +def test_get_recorder_fallback(): + """An active Recorder must see the extract call, i.e. the fast path defers.""" + v = Vector.from_coo([0], [1.5], size=4) + with Recorder() as rec: + assert v.get(0) == 1.5 + data = "".join(rec.data) + assert "extractElement" in data, f"recorder missed call: {data!r}" + + +def test_get_pending_value(): + """A value set in non-blocking mode is visible before an explicit wait.""" + v = Vector(float, 100) + v[3] = 2.25 # pending setElement in non-blocking mode + _assert_get_equal(v.get(3), 2.25) + + +# --------------------------------------------------------------------------- +# __contains__ (index in obj) +# --------------------------------------------------------------------------- +def _contains_vector_slow(v, index): + extractor = v[index] + if not extractor._is_scalar: + raise TypeError( + f"Invalid index to Vector contains: {index!r}. An integer is expected. " + "Doing `index in my_vector` checks whether a value is present at that index." + ) + scalar = extractor.new(name="s_contains") + return not scalar._is_empty + + +def _contains_matrix_slow(A, index): + extractor = A[index] + if not extractor._is_scalar: + raise TypeError( + f"Invalid index to Matrix contains: {index!r}. A 2-tuple of ints is expected. " + "Doing `(i, j) in my_matrix` checks whether a value is present at that index." + ) + scalar = extractor.new(name="s_contains") + return not scalar._is_empty + + +@pytest.mark.parametrize("dt", BUILTIN_DTYPES, ids=DTYPE_IDS) +def test_contains_dtype_parity(dt): + val = _hit_value(dt) + v = Vector(dt, 10) + v[2] = val + assert (2 in v) == _contains_vector_slow(v, 2) + assert (3 in v) == _contains_vector_slow(v, 3) + # a zero / false value is still "present" + v[4] = False if dt == dtypes.BOOL else 0 + assert (4 in v) == _contains_vector_slow(v, 4) + A = Matrix(dt, 5, 7) + A[1, 2] = val + assert ((1, 2) in A) == _contains_matrix_slow(A, (1, 2)) + assert ((0, 0) in A) == _contains_matrix_slow(A, (0, 0)) + + +def test_contains_index_types(): + v = Vector.from_coo([0, 5, 9], [1.5, 2.5, 3.5], size=10) + assert (np.int64(5) in v) == _contains_vector_slow(v, np.int64(5)) + assert (np.uint8(5) in v) == _contains_vector_slow(v, np.uint8(5)) + assert (np.int64(6) in v) == _contains_vector_slow(v, np.int64(6)) + assert (-5 in v) == _contains_vector_slow(v, -5) + assert (-2 in v) == _contains_vector_slow(v, -2) + assert (-1 in v) == _contains_vector_slow(v, -1) + + +@pytest.mark.parametrize("idx", [10, -11, 1000, -1000]) +def test_contains_out_of_range(idx): + """Out-of-range raises the identical IndexError through either lane.""" + v = Vector.from_coo([0, 5, 9], [1.5, 2.5, 3.5], size=10) + fast = _capture(lambda: idx in v) + slow = _capture(lambda: _contains_vector_slow(v, idx)) + assert fast == slow, f"fast={fast} slow={slow}" + assert fast[:2] == ("exc", "IndexError"), fast + + +@pytest.mark.parametrize("b", [True, False]) +def test_contains_bool_index(b): + v = Vector.from_coo([0, 5, 9], [1.5, 2.5, 3.5], size=10) + fast = _capture(lambda: b in v) + slow = _capture(lambda: _contains_vector_slow(v, b)) + assert fast == slow, f"fast={fast} slow={slow}" + + +@pytest.mark.parametrize("bad", [1.5, "x", None, [1, 2], slice(None), 2.0, (1, 2)]) +def test_contains_non_integer_index(bad): + """Non-integer indices raise the same exception type AND message.""" + v = Vector.from_coo([0, 5, 9], [1.5, 2.5, 3.5], size=10) + fast = _capture(lambda: bad in v) + slow = _capture(lambda: _contains_vector_slow(v, bad)) + assert fast == slow, f"fast={fast} slow={slow}" + + +def test_contains_matrix_negative_and_range(): + A = Matrix.from_coo([0, 1, 4], [1, 2, 6], [10, 20, 30], nrows=5, ncols=7) + assert ((-1, -1) in A) == _contains_matrix_slow(A, (-1, -1)) + assert ((-1, 6) in A) == _contains_matrix_slow(A, (-1, 6)) + assert ((-2, -2) in A) == _contains_matrix_slow(A, (-2, -2)) + for idx in [(5, 0), (0, 7), (-6, 0), (0, -8), (100, 100)]: + fast = _capture(lambda idx=idx: idx in A) + slow = _capture(lambda idx=idx: _contains_matrix_slow(A, idx)) + assert fast == slow, f"{idx}: fast={fast} slow={slow}" + assert fast[:2] == ("exc", "IndexError"), (idx, fast) + + +def test_contains_transposed_matrix(): + """TransposedMatrix shares Matrix.__contains__; mirrored indices must agree.""" + A = Matrix.from_coo([0, 1, 4], [1, 2, 6], [10, 20, 30], nrows=5, ncols=7) + AT = A.T + for idx in [(0, 0), (6, 0), (2, 1), (6, 4), (-1, -1)]: + assert (idx in AT) == _contains_matrix_slow(AT, idx), idx + fast = _capture(lambda: (0, 6) in AT) + slow = _capture(lambda: _contains_matrix_slow(AT, (0, 6))) + assert fast == slow, f"fast={fast} slow={slow}" + assert fast[:2] == ("exc", "IndexError"), fast + + +@pytest.mark.parametrize("bad", [5, (1, 2, 3), (1.5, 2), ("a", "b"), np.int64(3)]) +def test_contains_matrix_bad_index(bad): + A = Matrix.from_coo([0, 1, 4], [1, 2, 6], [10, 20, 30], nrows=5, ncols=7) + fast = _capture(lambda: bad in A) + slow = _capture(lambda: _contains_matrix_slow(A, bad)) + assert fast == slow, f"fast={fast} slow={slow}" + + +@pytest.mark.parametrize("pair", [(True, 0), (0, True), (True, True)]) +def test_contains_matrix_bool_pair(pair): + A = Matrix.from_coo([0, 1, 4], [1, 2, 6], [10, 20, 30], nrows=5, ncols=7) + fast = _capture(lambda: pair in A) + slow = _capture(lambda: _contains_matrix_slow(A, pair)) + assert fast == slow, f"fast={fast} slow={slow}" + + +def test_contains_udt_fallback(): + udt = gb.dtypes.register_anonymous( + np.dtype([("cx", np.int64), ("cy", np.float64)]), "ContainsFastPathProbe" + ) + u = Vector(udt, 3) + u[1] = (7, 2.5) + assert (1 in u) == _contains_vector_slow(u, 1) + assert (0 in u) == _contains_vector_slow(u, 0) + fast = _capture(lambda: 5 in u) + slow = _capture(lambda: _contains_vector_slow(u, 5)) + assert fast == slow, f"fast={fast} slow={slow}" + assert fast[:2] == ("exc", "IndexError"), fast + Mu = Matrix(udt, 3, 3) + Mu[1, 1] = (7, 2.5) + assert ((1, 1) in Mu) == _contains_matrix_slow(Mu, (1, 1)) + assert ((0, 0) in Mu) == _contains_matrix_slow(Mu, (0, 0)) + + +def test_contains_recorder_fallback(): + v = Vector.from_coo([0], [1.5], size=4) + with Recorder() as rec: + assert 0 in v + assert "extractElement" in "".join(rec.data) + A = Matrix.from_coo([0], [0], [1.5], nrows=4, ncols=4) + with Recorder() as rec: + assert (0, 0) in A + assert "extractElement" in "".join(rec.data) + + +def test_contains_pending_value(): + v = Vector(float, 100) + v[3] = 2.25 + assert (3 in v) is True + assert (4 in v) is False + + +# --------------------------------------------------------------------------- +# __setitem__ with an integer key +# --------------------------------------------------------------------------- +def _set_vector_slow(v, key, val): + Updater(v, opts={})[key] = val + + +def _set_matrix_slow(A, key, val): + Updater(A, opts={})[key] = val + + +def _assert_setitem_vector(dt, key, val, size=10): + a = Vector(dt, size) + b = Vector(dt, size) + ea = _capture(lambda: a.__setitem__(key, val)) + eb = _capture(lambda: _set_vector_slow(b, key, val)) + # On success both capture ("val", None); on error both capture the full + # ("exc", type, message), so equality pins exception type AND message. + assert ea == eb, f"exc fast={ea} slow={eb}" + if ea[0] == "val": + assert a.isequal(b, check_dtype=True), f"value fast={a.to_coo()} slow={b.to_coo()}" + + +def _assert_setitem_matrix(dt, key, val, nrows=5, ncols=7): + a = Matrix(dt, nrows, ncols) + b = Matrix(dt, nrows, ncols) + ea = _capture(lambda: a.__setitem__(key, val)) + eb = _capture(lambda: _set_matrix_slow(b, key, val)) + assert ea == eb, f"exc fast={ea} slow={eb}" + if ea[0] == "val": + assert a.isequal(b, check_dtype=True), f"value fast={a.to_coo()} slow={b.to_coo()}" + + +def _setitem_natural_value(dt): + if "FC" in dt.name: + return 3 + 4j + if dt == dtypes.BOOL: + return True + if "FP" in dt.name: + return 3.5 + return 3 + + +# Cross-type coercion: the value's Python type differs from the container dtype. +# The point is that whatever SuiteSparse does on the cast, the fast path does it +# too. Complex-target cases are gated on complex support. +CROSS_COERCION = [ + (dtypes.FP64, 3), # int -> FP64 + (dtypes.FP32, 3), # int -> FP32 + (dtypes.INT64, 3.9), # float -> INT64 (truncation via SS cast) + (dtypes.INT32, -2.5), # float -> INT32 + (dtypes.INT8, True), # bool -> INT8 + (dtypes.UINT8, True), # bool -> UINT8 + (dtypes.FP64, True), # bool -> FP64 + (dtypes.UINT8, -1), # negative int -> unsigned (wrap per SS) + (dtypes.UINT8, 256), # int overflow of the container (SS casts) + (dtypes.INT8, 200), # int overflow of the container + (dtypes.INT64, 2**62), # large int that fits int64 + (dtypes.INT64, 2**63), # overflow int64 -> OverflowError on both + (dtypes.UINT64, 2**63), # fits uint64 + (dtypes.FP64, 1e308), # large float + (dtypes.FP32, 1e308), # overflow FP32 -> inf via SS cast +] +if dtypes._supports_complex: + CROSS_COERCION += [ + (dtypes.FP64, 3 + 0j), # complex -> real (whatever SS does) + (dtypes.FC64, 3), # int -> FC64 + (dtypes.FC64, 3.5), # float -> FC64 + ] + +CROSS_IDS = [f"{dt.name}<-{val!r}" for dt, val in CROSS_COERCION] + + +@pytest.mark.parametrize("dt", BUILTIN_DTYPES, ids=DTYPE_IDS) +def test_setitem_dtype_natural(dt): + val = _setitem_natural_value(dt) + _assert_setitem_vector(dt, 2, val) + _assert_setitem_matrix(dt, (1, 2), val) + + +@pytest.mark.parametrize(("dt", "val"), CROSS_COERCION, ids=CROSS_IDS) +def test_setitem_cross_coercion(dt, val): + _assert_setitem_vector(dt, 4, val) + _assert_setitem_matrix(dt, (2, 3), val) + + +@pytest.mark.parametrize("dt", [dtypes.FP64, dtypes.INT64, dtypes.BOOL], ids=lambda dt: dt.name) +def test_setitem_overwrite(dt): + a = Vector(dt, 6) + b = Vector(dt, 6) + a[1] = 1 + _set_vector_slow(b, 1, 1) + a[1] = 5 # overwrite + _set_vector_slow(b, 1, 5) + assert a.isequal(b, check_dtype=True) + + +def test_setitem_numpy_index(): + for key in [np.int64(3), np.uint8(3), np.int32(3)]: + _assert_setitem_vector(dtypes.FP64, key, 2.5) + _assert_setitem_matrix(dtypes.FP64, (np.int64(1), np.int32(2)), 2.5) + + +def test_setitem_negative_index(): + _assert_setitem_vector(dtypes.FP64, -1, 9.0) + _assert_setitem_vector(dtypes.FP64, -10, 9.0) + _assert_setitem_matrix(dtypes.FP64, (-1, -1), 9.0) + _assert_setitem_matrix(dtypes.FP64, (-2, 3), 9.0) + + +@pytest.mark.parametrize("key", [10, -11, 1000, -1000]) +def test_setitem_vector_out_of_range(key): + _assert_setitem_vector(dtypes.FP64, key, 1.0) + + +@pytest.mark.parametrize("key", [(5, 0), (0, 7), (-6, 0), (0, -8), (100, 3), (2, 100)]) +def test_setitem_matrix_out_of_range(key): + _assert_setitem_matrix(dtypes.FP64, key, 1.0) + + +@pytest.mark.parametrize("key", [1.5, "x", None, slice(None), [1, 2], (1, 2)]) +def test_setitem_vector_bad_key(key): + _assert_setitem_vector(dtypes.FP64, key, 1.0) + + +@pytest.mark.parametrize("key", [5, (1, 2, 3), (1.5, 2), ("a", "b"), 1.5, slice(None)]) +def test_setitem_matrix_bad_key(key): + _assert_setitem_matrix(dtypes.FP64, key, 1.0) + + +def test_setitem_bool_key(): + # bool is treated as an int index by parse_index; the fast path falls back. + _assert_setitem_vector(dtypes.FP64, True, 1.0) + _assert_setitem_vector(dtypes.FP64, False, 1.0) + _assert_setitem_matrix(dtypes.FP64, (True, 0), 1.0) + + +def test_setitem_value_fallbacks(): + # numpy scalar, Scalar, None, and str all route through the slow path. + _assert_setitem_vector(dtypes.FP64, 2, np.int32(7)) + _assert_setitem_vector(dtypes.INT64, 2, np.float64(7.9)) + _assert_setitem_vector(dtypes.FP64, 2, Scalar.from_value(7.5)) + _assert_setitem_vector(dtypes.FP64, 2, None) + _assert_setitem_vector(dtypes.FP64, 2, "bad") + _assert_setitem_matrix(dtypes.FP64, (1, 1), np.int32(7)) + _assert_setitem_matrix(dtypes.FP64, (1, 1), None) + + +def test_setitem_udt_fallback(): + udt = gb.dtypes.register_anonymous( + np.dtype([("sx", np.int64), ("sy", np.float64)]), "SetitemFastPathProbe" + ) + _assert_setitem_vector(udt, 1, None) # None clears; both fall back + au = Vector(udt, 3) + bu = Vector(udt, 3) + au[1] = (7, 2.5) + _set_vector_slow(bu, 1, (7, 2.5)) + assert au.isequal(bu, check_dtype=True) + + +def test_setitem_recorder_fallback(): + v = Vector(dtypes.FP64, 4) + with Recorder() as rec: + v[0] = 1.5 + assert "setElement" in "".join(rec.data) + A = Matrix(dtypes.FP64, 4, 4) + with Recorder() as rec: + A[0, 0] = 1.5 + assert "setElement" in "".join(rec.data) + + +def test_setitem_update_forms(): + """``obj[i] << x`` and masked / accum forms stay on the Updater path.""" + w = Vector(dtypes.FP64, 5) + w[2] << 3.5 + assert w.get(2) == 3.5 + w(accum=binary.plus)[2] << 1.5 + assert w.get(2) == 5.0 + A = Matrix(dtypes.FP64, 4, 4) + A[1, 1] << 3.5 + assert A.get(1, 1) == 3.5 + m = Vector(dtypes.FP64, 5) + m[:] = 1.0 + mask = Vector(dtypes.BOOL, 5) + mask[0] = True + mask[2] = True + m(mask.V)[:] = 9.0 + assert m.get(0) == 9.0 + assert m.get(1) == 1.0 + assert m.get(2) == 9.0 + + +def test_setitem_empty_vector(): + e = Vector(dtypes.INT32, 3) + e[1] = 42 + assert e.get(1) == 42 + assert e.nvals == 1 + + +# --------------------------------------------------------------------------- +# ScalarIndexExpr.new() +# --------------------------------------------------------------------------- +def _new_slow(expr, dtype=None, is_cscalar=None, name=None, **opts): + if is_cscalar is None: + is_cscalar = False + return expr.parent._extract_element( + expr.resolved_indexes, dtype, opts, is_cscalar=is_cscalar, name=name + ) + + +def _assert_scalars_equal(fast, slow): + assert fast.dtype == slow.dtype, f"dtype {fast.dtype} vs {slow.dtype}" + assert fast.is_cscalar == slow.is_cscalar, f"is_cscalar {fast.is_cscalar} vs {slow.is_cscalar}" + assert fast.is_empty == slow.is_empty, f"is_empty {fast.is_empty} vs {slow.is_empty}" + fv, sv = fast.value, slow.value + assert fv == sv or (fv is None and sv is None), f"value {fv!r} vs {sv!r}" + assert type(fv) is type(sv), f"value type {type(fv).__name__} vs {type(sv).__name__}" + + +@pytest.mark.parametrize("dt", BUILTIN_DTYPES, ids=DTYPE_IDS) +def test_scalarnew_dtype_parity(dt): + val = _hit_value(dt) + v = Vector(dt, 10) + v[2] = val + _assert_scalars_equal(v[2].new(), _new_slow(v[2])) + _assert_scalars_equal(v[3].new(), _new_slow(v[3])) + A = Matrix(dt, 5, 7) + A[1, 2] = val + _assert_scalars_equal(A[1, 2].new(), _new_slow(A[1, 2])) + _assert_scalars_equal(A[0, 0].new(), _new_slow(A[0, 0])) + + +def test_scalarnew_negative_and_numpy(): + v = Vector.from_coo([0, 5, 9], [1.5, 2.5, 3.5], size=10) + _assert_scalars_equal(v[-1].new(), _new_slow(v[-1])) + _assert_scalars_equal(v[-5].new(), _new_slow(v[-5])) + _assert_scalars_equal(v[-2].new(), _new_slow(v[-2])) + _assert_scalars_equal(v[np.int64(5)].new(), _new_slow(v[np.int64(5)])) + + +def test_scalarnew_transposed_matrix(): + A = Matrix.from_coo([0, 1, 4], [1, 2, 6], [10.0, 20.0, 30.0], nrows=5, ncols=7) + AT = A.T + _assert_scalars_equal(AT[1, 0].new(), _new_slow(AT[1, 0])) + _assert_scalars_equal(AT[6, 4].new(), _new_slow(AT[6, 4])) + _assert_scalars_equal(AT[0, 0].new(), _new_slow(AT[0, 0])) + _assert_scalars_equal(AT[-1, -1].new(), _new_slow(AT[-1, -1])) + + +def test_scalarnew_name_honored(): + v = Vector.from_coo([0, 5, 9], [1.5, 2.5, 3.5], size=10) + s = v[5].new(name="myscalar") + assert s.name == "myscalar" + + +def test_scalarnew_dtype_cast_fallback(): + v = Vector.from_coo([0, 5, 9], [1.5, 2.5, 3.5], size=10) + _assert_scalars_equal(v[5].new(dtype=dtypes.INT32), _new_slow(v[5], dtype=dtypes.INT32)) + _assert_scalars_equal(v[3].new(dtype=dtypes.INT32), _new_slow(v[3], dtype=dtypes.INT32)) + + +def test_scalarnew_is_cscalar_fallback(): + v = Vector.from_coo([0, 5, 9], [1.5, 2.5, 3.5], size=10) + f_cs = v[5].new(is_cscalar=True) + s_cs = _new_slow(v[5], is_cscalar=True) + assert f_cs.is_cscalar + assert s_cs.is_cscalar + assert f_cs.value == s_cs.value == 2.5 + + +def test_scalarnew_udt_fallback(): + udt = gb.dtypes.register_anonymous( + np.dtype([("nx", np.int64), ("ny", np.float64)]), "NewFastPathProbe" + ) + u = Vector(udt, 3) + u[1] = (7, 2.5) + fu = u[1].new() + su = _new_slow(u[1]) + assert fu.dtype == su.dtype + assert fu.value["nx"] == su.value["nx"] == 7 + assert u[0].new().is_empty + assert _new_slow(u[0]).is_empty + + +def test_scalarnew_recorder_fallback(): + v = Vector.from_coo([0], [1.5], size=4) + with Recorder() as rec: + assert v[0].new().value == 1.5 + assert "extractElement" in "".join(rec.data) + + +def test_scalarnew_autocompute_value_path(): + """With autocompute on, ``v[i].value`` / ``float(v[i])`` still match ``get``.""" + v = Vector.from_coo([0, 5, 9], [1.5, 2.5, 3.5], size=10) + with gb.config.set(autocompute=True): + for idx in [0, 5, 9, 3]: # 3 is empty + want = v.get(idx) + got_val = v[idx].value + got_get = v[idx].get() + if want is None: + assert got_val is None, (idx, got_val, got_get) + assert got_get is None, (idx, got_val, got_get) + else: + assert got_val == want, (idx, got_val, got_get, want) + assert got_get == want, (idx, got_val, got_get, want) + assert float(v[5]) == 2.5 + assert int(v[0]) == 1 # 1.5 -> int truncates via Scalar.__int__ + + +def test_scalarnew_autocompute_false_raises(): + """With autocompute off, the value path raises but ``.new()`` still works.""" + v = Vector.from_coo([0, 5, 9], [1.5, 2.5, 3.5], size=10) + with gb.config.set(autocompute=False): + with pytest.raises(TypeError): + v[5].value + with pytest.raises(TypeError): + float(v[5]) + assert v[5].new().value == 2.5 + + +# --------------------------------------------------------------------------- +# parse_index plain-int lane +# --------------------------------------------------------------------------- +def _resolved_fingerprint(expr): + """A comparable fingerprint of a resolved scalar index expression.""" + ax = expr.resolved_indexes.indices + return tuple((a.size, a.index.value, a.dimsize, a._carg.__class__.__name__) for a in ax) + + +def _index_probe_vector(v, i): + return _capture(lambda: (v[i].new().value, _resolved_fingerprint(v[i]))) + + +def _index_probe_matrix(A, r, c): + return _capture(lambda: (A[r, c].new().value, _resolved_fingerprint(A[r, c]))) + + +@pytest.mark.parametrize("i", [0, 2, 4, 9, -1, -10, -6]) +def test_indexparse_vector_valid(i): + """The plain-int fast lane matches the numpy-int lane for valid indices.""" + v = Vector.from_coo([0, 2, 4, 9], [10.0, 20.0, 30.0, 90.0], size=10) + fast = _index_probe_vector(v, i) + slow = _index_probe_vector(v, np.int64(i)) + assert fast == slow, f"fast={fast} slow={slow}" + + +@pytest.mark.parametrize("i", [10, 11, 1000, -11, -100]) +def test_indexparse_vector_out_of_range(i): + """Both lanes raise the identical IndexError for out-of-range indices.""" + v = Vector.from_coo([0, 2, 4, 9], [10.0, 20.0, 30.0, 90.0], size=10) + fast = _capture(lambda: v[i]) + slow = _capture(lambda: v[np.int64(i)]) + assert fast == slow, f"fast={fast} slow={slow}" + + +@pytest.mark.parametrize("b", [True, False]) +def test_indexparse_vector_bool(b): + """A bool must not take the int lane; it keeps the pre-existing TypeError.""" + v = Vector.from_coo([0, 2, 4, 9], [10.0, 20.0, 30.0, 90.0], size=10) + r = _capture(lambda: v[b]) + assert r[0] == "exc", r + assert r[1] == "TypeError", r + + +@pytest.mark.parametrize("bad", [2.0, "x", None]) +def test_indexparse_vector_non_integer(bad): + v = Vector.from_coo([0, 2, 4, 9], [10.0, 20.0, 30.0, 90.0], size=10) + r = _capture(lambda: v[bad]) + assert r[0] == "exc", r + + +@pytest.mark.parametrize("r", [0, 1, 4, -1, -5]) +@pytest.mark.parametrize("c", [0, 2, 6, -1, -7]) +def test_indexparse_matrix_valid(r, c): + A = Matrix.from_coo([0, 1, 4], [0, 2, 6], [1.0, 2.0, 3.0], nrows=5, ncols=7) + fast = _index_probe_matrix(A, r, c) + slow = _capture( + lambda: ( + A[np.int64(r), np.int64(c)].new().value, + _resolved_fingerprint(A[np.int64(r), np.int64(c)]), + ) + ) + assert fast == slow, f"fast={fast} slow={slow}" + + +@pytest.mark.parametrize("r", [1, -1]) +@pytest.mark.parametrize("c", [2, -2]) +def test_indexparse_matrix_mixed_lane(r, c): + """Plain int on one axis, numpy int on the other, must still agree.""" + A = Matrix.from_coo([0, 1, 4], [0, 2, 6], [1.0, 2.0, 3.0], nrows=5, ncols=7) + a = _capture(lambda: (A[r, np.int64(c)].new().value, _resolved_fingerprint(A[r, np.int64(c)]))) + b = _capture(lambda: (A[np.int64(r), c].new().value, _resolved_fingerprint(A[np.int64(r), c]))) + assert a == b, f"a={a} b={b}" + + +@pytest.mark.parametrize(("r", "c"), [(5, 0), (100, 0), (-6, 0), (0, 7), (0, 1000), (0, -8)]) +def test_indexparse_matrix_out_of_range(r, c): + A = Matrix.from_coo([0, 1, 4], [0, 2, 6], [1.0, 2.0, 3.0], nrows=5, ncols=7) + fast = _capture(lambda: A[r, c]) + slow = _capture(lambda: A[np.int64(r), np.int64(c)]) + assert fast == slow, f"fast={fast} slow={slow}" + + +@pytest.mark.parametrize("probe", ["row", "col", "false"]) +def test_indexparse_matrix_bool(probe): + A = Matrix.from_coo([0, 1, 4], [0, 2, 6], [1.0, 2.0, 3.0], nrows=5, ncols=7) + probes = { + "row": lambda: A[True, 0], + "col": lambda: A[0, True], + "false": lambda: A[False, 1], + } + r = _capture(probes[probe]) + assert r[0] == "exc", r + assert r[1] == "TypeError", r + + +def test_indexparse_fancy_unaffected(): + """List / ndarray fancy indexing is untouched by the plain-int lane.""" + v = Vector.from_coo([0, 2, 4, 9], [10.0, 20.0, 30.0, 90.0], size=10) + fast = _capture(lambda: v[[1, 3, 5]].new().to_coo()) + slow = _capture(lambda: v[np.array([1, 3, 5])].new().to_coo()) + assert fast[0] == slow[0] == "val" + # to_coo returns numpy arrays; compare structurally + (fi, fx), (si, sx) = fast[1], slow[1] + assert np.array_equal(fi, si) + assert np.array_equal(fx, sx) From 985734ce1575a4383c0101cd266a93a2a895e6f1 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Fri, 31 Jul 2026 14:23:29 -0500 Subject: [PATCH 05/17] Add single-extract fast path for v[i].value / float(v[i]) scalar reads The .value, __float__, __int__ and related accessors on an index-extract expression (v[i], A[i, j]) resolved through automethods._get_value, which called .new() to build a GrB_Scalar (extract number one) and then read Scalar.value off it (extract number two, plus nvals). For the nine read-only scalar attrs that only need the raw element, resolve with a single extractElement straight into a cscalar through a new private ScalarIndexExpr._extract_fast hook. _get_value consults the hook only for _fast_scalar_attrs and only when the expression defines it, so every other expression type is unchanged and falls to .new() as before. UDT values, which need numpy conversion in Scalar.value, and active Recorders fall back to .new(). Measured on an Apple M5 Pro with timeit, min of 15 runs of 20000 calls, warm, before and after taken back to back while other test suites were running (load average about 5): v[i].value 4.72us -> 3.14us (1.50x) float(v[i]) 4.88us -> 3.18us (1.53x) A[i, j].value 5.93us -> 4.29us (1.38x) v[i].new() was measured alongside as a control and did not move, which is what this diff predicts since it does not touch that path. .get(i), at about 0.38us, remains the fast API; this only helps the older v[i].value and float(v[i]) idiom, whose floor is the cost of building the v[i] expression. The hook region sits above the autogenerate markers, so regenerating automethods.py leaves it in place. Private surface only (_extract_fast, _fast_scalar_attrs). --- graphblas/core/automethods.py | 29 +++++- graphblas/core/scalar.py | 15 +++ graphblas/tests/test_fastpath_parity.py | 121 ++++++++++++++++++++++++ graphblas/tests/test_scalar.py | 53 +++++++++++ 4 files changed, 217 insertions(+), 1 deletion(-) diff --git a/graphblas/core/automethods.py b/graphblas/core/automethods.py index 600a6e139..0c86cfe2d 100644 --- a/graphblas/core/automethods.py +++ b/graphblas/core/automethods.py @@ -10,11 +10,38 @@ from .. import config +# Scalar read-only attributes whose value is a plain read of the underlying C +# scalar. For an index-extract expression (v[i], A[i, j]) these resolve with a +# single extractElement straight into a cscalar via ScalarIndexExpr._extract_fast, +# skipping the extra GrB_Scalar round-trip that `.new()` + Scalar.value performs. +# Only ScalarIndexExpr defines the hook, and no Vector/Matrix expression routes +# these attrs through _get_value, so their resolution short-circuits on the +# membership test. +_fast_scalar_attrs = frozenset( + { + "value", + "__float__", + "__int__", + "__complex__", + "__index__", + "__bool__", + "__array__", + "is_empty", + "_is_empty", + } +) + def _get_value(self, attr=None, default=None): if config.get("autocompute"): if self._value is None: - self._value = self.new() + if ( + attr in _fast_scalar_attrs + and (extract_fast := getattr(self, "_extract_fast", None)) is not None + ): + self._value = extract_fast() + else: + self._value = self.new() if attr is None: return self._value return getattr(self._value, attr) diff --git a/graphblas/core/scalar.py b/graphblas/core/scalar.py index 75630c19d..97cf919f3 100644 --- a/graphblas/core/scalar.py +++ b/graphblas/core/scalar.py @@ -1102,6 +1102,21 @@ def dup(self, dtype=None, *, clear=False, is_cscalar=False, name=None, **opts): return Scalar(dtype, is_cscalar=is_cscalar, name=name) return self.new(dtype, is_cscalar=is_cscalar, name=name, **opts) + def _extract_fast(self): + """Resolve a value read (``.value``, ``float(...)``, ...) with one extract. + + Those readers only need the raw element, so extract it straight into a + cscalar and skip the extra GrB_Scalar round-trip that ``.new()`` followed + by ``Scalar.value`` would perform. Defer to the full ``.new()`` for UDTs + (whose values need numpy conversion in ``Scalar.value``) and while a + Recorder is active (so it observes the same calls as the expression path). + ``automethods._get_value`` consults this hook for ``_fast_scalar_attrs``. + """ + parent = self.parent + if parent.dtype._is_udt or _is_recording(): + return self.new() + return parent._extract_element(self.resolved_indexes, None, {}, is_cscalar=True) + is_cscalar = Scalar.is_cscalar is_grbscalar = Scalar.is_grbscalar __hash__ = None diff --git a/graphblas/tests/test_fastpath_parity.py b/graphblas/tests/test_fastpath_parity.py index f7d180eeb..60b574113 100644 --- a/graphblas/tests/test_fastpath_parity.py +++ b/graphblas/tests/test_fastpath_parity.py @@ -661,6 +661,127 @@ def test_scalarnew_autocompute_false_raises(): assert v[5].new().value == 2.5 +# --------------------------------------------------------------------------- +# ScalarIndexExpr value reads (.value / float / int / ...): the single-extract +# fast path in automethods._get_value via ScalarIndexExpr._extract_fast. +# --------------------------------------------------------------------------- +# The reference is the pre-fast-path resolution: _get_value used to resolve the +# expression to a GrB_Scalar (is_cscalar=False) via `.new()`, then read that +# scalar's attribute. `_new_slow` reproduces that GrB_Scalar exactly, so +# `getattr(_new_slow(expr), attr)` is the old behavior for every read attr. +_VALUE_READ_ATTRS = [ + "value", + "is_empty", + "_is_empty", + "__float__", + "__int__", + "__complex__", + "__bool__", + "__index__", + "__array__", +] + + +def _read_attr(scalar, attr): + """Fingerprint of a value-read attr on a Scalar (or index expr). + + Captures the whole access (``__index__`` raises via its property on + non-integral dtypes) and reduces the result to ``(repr, typename)`` so NaN + from an empty float scalar and numpy arrays compare structurally, not by + value. + """ + + def go(): + getter = getattr(scalar, attr) + if attr in ("__float__", "__int__", "__complex__", "__bool__", "__index__", "__array__"): + return getter() + return getter + + cap = _capture(go) + if cap[0] == "val": + return ("val", repr(cap[1]), type(cap[1]).__name__) + return cap + + +@pytest.mark.parametrize("dt", BUILTIN_DTYPES, ids=DTYPE_IDS) +def test_scalarvalue_dtype_parity(dt): + """Every read attr matches the pre-fast-path GrB_Scalar result, hit and miss.""" + val = _hit_value(dt) + v = Vector(dt, 10) + v[2] = val + A = Matrix(dt, 5, 7) + A[1, 2] = val + with gb.config.set(autocompute=True): + for probe in [v[2], v[3], A[1, 2], A[0, 0]]: + slow = _new_slow(probe) # GrB_Scalar, old path + for attr in _VALUE_READ_ATTRS: + assert _read_attr(probe, attr) == _read_attr(slow, attr), (dt.name, attr) + + +def test_scalarvalue_transposed_matrix(): + A = Matrix.from_coo([0, 1, 4], [1, 2, 6], [10.0, 20.0, 30.0], nrows=5, ncols=7) + AT = A.T + with gb.config.set(autocompute=True): + for r, c in [(1, 0), (6, 4), (0, 0), (-1, -1)]: + for attr in _VALUE_READ_ATTRS: + got = _read_attr(AT[r, c], attr) + want = _read_attr(_new_slow(AT[r, c]), attr) + assert got == want, (r, c, attr, got, want) + + +def test_scalarvalue_negative_and_numpy_index(): + v = Vector.from_coo([0, 5, 9], [1.5, 2.5, 3.5], size=10) + with gb.config.set(autocompute=True): + for idx in [-1, -5, -2, np.int64(5)]: + assert repr(v[idx].value) == repr(_new_slow(v[idx]).value) + got = _capture(lambda: float(v[idx])) # noqa: B023 + want = _capture(lambda: float(_new_slow(v[idx]))) # noqa: B023 + assert got == want, (idx, got, want) + + +def test_scalarvalue_udt_fallback(): + """UDT reads defer to `.new()`; Scalar.value's numpy conversion still runs.""" + udt = gb.dtypes.register_anonymous( + np.dtype([("vx", np.int64), ("vy", np.float64)]), "ValueFastPathProbe" + ) + u = Vector(udt, 3) + u[1] = (7, 2.5) + with gb.config.set(autocompute=True): + got = u[1].value + want = _new_slow(u[1]).value + assert got["vx"] == want["vx"] == 7 + assert got["vy"] == want["vy"] == 2.5 + assert u[0].value is None + assert u[0].is_empty is True + + +def test_scalarvalue_recorder_fallback(): + """With a Recorder active the value path still records an extractElement.""" + v = Vector.from_coo([0], [1.5], size=4) + with gb.config.set(autocompute=True), Recorder() as rec: + assert v[0].value == 1.5 + assert "extractElement" in "".join(rec.data) + + +def test_scalarvalue_pending_value(): + """A value written in non-blocking mode is read back correctly (iso/pending).""" + v = Vector(dtypes.FP64, 100) + v[3] = 2.25 + with gb.config.set(autocompute=True): + assert v[3].value == 2.25 + assert float(v[3]) == 2.25 + + +def test_scalarvalue_index_integral_only(): + """__index__ is available on integral dtypes and absent on float, via the fast path.""" + vi = Vector.from_coo([2], [7], dtype=dtypes.INT64, size=5) + vf = Vector.from_coo([2], [7.5], dtype=dtypes.FP64, size=5) + with gb.config.set(autocompute=True): + assert vi[2].__index__() == 7 + with pytest.raises(AttributeError): + vf[2].__index__ + + # --------------------------------------------------------------------------- # parse_index plain-int lane # --------------------------------------------------------------------------- diff --git a/graphblas/tests/test_scalar.py b/graphblas/tests/test_scalar.py index 35439f34b..ed81e0ca2 100644 --- a/graphblas/tests/test_scalar.py +++ b/graphblas/tests/test_scalar.py @@ -904,6 +904,59 @@ def test_get(s): assert s.get("mittens") == "mittens" +@autocompute +def test_index_expr_value_fast_path(): + """v[i].value / float(v[i]) resolve with a single extract (see automethods). + + The read must match get() / .new().value exactly and the UDT + Recorder + fallbacks must keep working; autocompute=False still raises (tested below). + """ + v = Vector.from_coo([0, 2, 5], [1.5, 2.5, 3.5], size=10) + assert v[2].value == 2.5 + assert type(v[2].value) is float + assert float(v[2]) == 2.5 + assert int(v[2]) == 2 # Scalar.__int__ truncates + assert v[2].is_empty is False + assert v[4].value is None # miss + assert v[4].is_empty is True + with pytest.raises(TypeError): + float(v[4]) # float(None) + assert v[2].value == v[2].new().value == v.get(2) + + A = Matrix.from_coo([0, 1, 4], [1, 2, 6], [1.5, 2.5, 3.5], nrows=5, ncols=7) + assert A[1, 2].value == 2.5 + assert A.T[2, 1].value == 2.5 + assert A[0, 0].value is None + + # UDT reads defer to `.new()` (numpy conversion in Scalar.value) + udt = dtypes.register_anonymous( + np.dtype([("sx", np.int64), ("sy", np.float64)]), "_ScalarValueFastUdt" + ) + u = Vector(udt, 3) + u[1] = (7, 2.5) + assert u[1].value["sx"] == 7 + assert u[0].value is None + + # A Recorder must still observe the extract (fall-back path) + from graphblas.core.recorder import Recorder + + v2 = Vector.from_coo([0], [1.5], size=4) + with Recorder() as rec: + assert v2[0].value == 1.5 + assert "extractElement" in "".join(rec.data) + + +def test_index_expr_value_autocompute_false(): + """The value path stays gated on autocompute; .new() remains the escape hatch.""" + v = Vector.from_coo([0, 2, 5], [1.5, 2.5, 3.5], size=10) + with gb.config.set(autocompute=False): + with pytest.raises(TypeError): + v[2].value + with pytest.raises(TypeError): + float(v[2]) + assert v[2].new().value == 2.5 + + def test_ss_descriptors(s): v = Vector.from_coo([0, 2], [10, 20]) if suitesparse: From 2e1138a20c38ad826889361c95edd5f92937ba2d Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Mon, 3 Aug 2026 15:47:33 -0500 Subject: [PATCH 06/17] Gate the scalar fast lanes on real integers, matching parse_index The fast lanes for Vector/Matrix get, __contains__, and integer-key __setitem__ accepted any object implementing __index__, while the expression path (IndexerResolver.parse_index) accepts only exact int and np.integer scalars. Anything else implementing __index__, such as a 0-d ndarray, answered on the fast lane but raised on the slow path. Reproduced at 5581edc8 (fast vs slow), where the slow-path error is TypeError: Invalid number of dimensions for index: 0: Vector.get(np.array(5)) -> 2.5 vs TypeError np.array(5) in v -> True vs TypeError v[np.array(5)] = 1.0 -> sets vs TypeError Matrix.get(np.array(1), np.array(2)) -> 20 vs TypeError (np.array(1), np.array(2)) in A -> True vs TypeError A[np.array(1), np.array(2)] = 1.0 -> sets vs TypeError A custom class implementing __index__ diverged the same way in all six lanes; for it the slow path raises TypeError: Invalid type for index: ...; unable to convert to list. Replace the duck-typed __index__ probe in all six lanes with the type check parse_index applies: exact int or np.integer (bool has its own exact type; np.bool_ is not an np.integer). Anything else now falls through to the expression path and gets its canonical error. Bounds checks and negative wrapping are unchanged for accepted types. The remaining fast lanes on this branch cannot see such inputs: the parse_index int lane already dispatches on exact type, and ScalarIndexExpr.new / _extract_fast run only on indices that parse_index has already validated. Tests: add a 0-d ndarray and an __index__-implementing class to the bad-input parity cases for get, __contains__, and __setitem__ on both Vector and Matrix (17 new cases). With the gate reverted, exactly these 17 fail; with it, test_fastpath_parity.py goes 230 -> 247 passed and the pinned suite 1034 -> 1051 passed, 141 skipped. --- graphblas/core/matrix.py | 95 ++++++++------- graphblas/core/vector.py | 148 ++++++++++++------------ graphblas/tests/test_fastpath_parity.py | 76 +++++++++++- 3 files changed, 190 insertions(+), 129 deletions(-) diff --git a/graphblas/core/matrix.py b/graphblas/core/matrix.py index 1f0d395f0..b57fd948a 100644 --- a/graphblas/core/matrix.py +++ b/graphblas/core/matrix.py @@ -396,15 +396,14 @@ def __setitem__(self, keys, expr, **opts): and not _is_recording() ): row, col = keys - try: + # Only int and np.integer keys take the fast lane (bools excluded), + # matching parse_index; other __index__ objects fall through to the + # expression path and its canonical errors. + if (type(row) is int or isinstance(row, np.integer)) and ( + type(col) is int or isinstance(col, np.integer) + ): rowidx = row.__index__() colidx = col.__index__() - except (AttributeError, TypeError): - rowidx = None - else: - if isinstance(row, (bool, np.bool_)) or isinstance(col, (bool, np.bool_)): - rowidx = None - if rowidx is not None: nrows = self._nrows ncols = self._ncols if rowidx < 0: @@ -449,15 +448,14 @@ def __contains__(self, index): and not _is_recording() ): row, col = index - try: + # Only int and np.integer take the fast lane (bools excluded), + # matching parse_index; other __index__ objects fall through to the + # expression path and its canonical errors. + if (type(row) is int or isinstance(row, np.integer)) and ( + type(col) is int or isinstance(col, np.integer) + ): rowidx = row.__index__() colidx = col.__index__() - except (AttributeError, TypeError): - rowidx = None - else: - if isinstance(row, (bool, np.bool_)) or isinstance(col, (bool, np.bool_)): - rowidx = None - if rowidx is not None: nrows = self._nrows ncols = self._ncols if rowidx < 0: @@ -955,40 +953,41 @@ def get(self, row, col, default=None): # more than the C call for single-element access. Fall back when a # Recorder is active (so the call is recorded) and for UDTs (whose # values need numpy-based conversion in Scalar.value). - if not self.dtype._is_udt and not _is_recording(): - try: - rowidx = row.__index__() - colidx = col.__index__() - except (AttributeError, TypeError): - rowidx = None - else: - if isinstance(row, (bool, np.bool_)) or isinstance(col, (bool, np.bool_)): - rowidx = None - if rowidx is not None: - nrows = self._nrows - ncols = self._ncols - if rowidx < 0: - rowidx += nrows - if rowidx < 0 or rowidx >= nrows: - raise IndexError(f"Index out of range: index={row}, size={nrows}") - if colidx < 0: - colidx += ncols - if colidx < 0 or colidx >= ncols: - raise IndexError(f"Index out of range: index={col}, size={ncols}") - if self._is_transposed: - # TransposedMatrix reuses this method; gb_obj is the - # untransposed parent, so extract the mirrored element. - rowidx, colidx = colidx, rowidx - dtype = self.dtype - res = ffi_new(f"{dtype.c_type}*") - err_code = utils.libget(f"GrB_Matrix_extractElement_{dtype.name}")( - res, self.gb_obj[0], rowidx, colidx - ) - if err_code: - if err_code == GrB_NO_VALUE: - return default - check_status_carg(err_code, "Matrix", self.gb_obj[0]) - return res[0] + # Only int and np.integer take the fast lane (bools excluded), matching + # parse_index; other __index__ objects fall through to the expression + # path and its canonical errors. + if ( + (type(row) is int or isinstance(row, np.integer)) + and (type(col) is int or isinstance(col, np.integer)) + and not self.dtype._is_udt + and not _is_recording() + ): + rowidx = row.__index__() + colidx = col.__index__() + nrows = self._nrows + ncols = self._ncols + if rowidx < 0: + rowidx += nrows + if rowidx < 0 or rowidx >= nrows: + raise IndexError(f"Index out of range: index={row}, size={nrows}") + if colidx < 0: + colidx += ncols + if colidx < 0 or colidx >= ncols: + raise IndexError(f"Index out of range: index={col}, size={ncols}") + if self._is_transposed: + # TransposedMatrix reuses this method; gb_obj is the + # untransposed parent, so extract the mirrored element. + rowidx, colidx = colidx, rowidx + dtype = self.dtype + res = ffi_new(f"{dtype.c_type}*") + err_code = utils.libget(f"GrB_Matrix_extractElement_{dtype.name}")( + res, self.gb_obj[0], rowidx, colidx + ) + if err_code: + if err_code == GrB_NO_VALUE: + return default + check_status_carg(err_code, "Matrix", self.gb_obj[0]) + return res[0] expr = self[row, col] if expr._is_scalar: rv = expr.new().value diff --git a/graphblas/core/vector.py b/graphblas/core/vector.py index 0e07b1547..2d8e92b88 100644 --- a/graphblas/core/vector.py +++ b/graphblas/core/vector.py @@ -337,38 +337,35 @@ def __setitem__(self, keys, expr, **opts): # Python scalar, with no mask/accum/opts, a non-UDT dtype, and no active # Recorder. This mirrors what Updater -> _assign_element does for a # single element, but skips building the resolver, Updater, and Scalar. - # Only int/float/bool/complex are taken here so the dtype inference and - # cffi coercion match _assign_element exactly; everything else (slices, - # fancy indexing, numpy or Scalar values, `v(mask)[i] << x`) falls back - # to the full assign path, leaving mask/accum and coercion unchanged. + # Only int/float/bool/complex values are taken here so the dtype + # inference and cffi coercion match _assign_element exactly, and only + # int and np.integer keys (bools excluded) so the accepted indices + # match parse_index; everything else (slices, fancy indexing, 0-d + # arrays and other __index__ objects, numpy or Scalar values, + # `v(mask)[i] << x`) falls back to the full assign path, leaving + # mask/accum, coercion, and index errors unchanged. if ( not opts and type(expr) in (int, float, bool, complex) + and (type(keys) is int or isinstance(keys, np.integer)) and not self.dtype._is_udt and not _is_recording() ): - try: - idx = keys.__index__() - except (AttributeError, TypeError): - idx = None - else: - if isinstance(keys, (bool, np.bool_)): - idx = None - if idx is not None: - size = self._size - if idx < 0: - idx += size - if idx < 0 or idx >= size: - raise IndexError(f"Index out of range: index={keys}, size={size}") - vdtype = lookup_dtype(type(expr), expr) - cvalue = ffi_new(f"{vdtype.c_type}*") - cvalue[0] = expr # cffi coercion, identical to the Scalar.value setter - err_code = utils.libget(f"GrB_Vector_setElement_{vdtype.name}")( - self.gb_obj[0], cvalue[0], idx - ) - if err_code: - check_status_carg(err_code, "Vector", self.gb_obj[0]) - return + idx = keys.__index__() + size = self._size + if idx < 0: + idx += size + if idx < 0 or idx >= size: + raise IndexError(f"Index out of range: index={keys}, size={size}") + vdtype = lookup_dtype(type(expr), expr) + cvalue = ffi_new(f"{vdtype.c_type}*") + cvalue[0] = expr # cffi coercion, identical to the Scalar.value setter + err_code = utils.libget(f"GrB_Vector_setElement_{vdtype.name}")( + self.gb_obj[0], cvalue[0], idx + ) + if err_code: + check_status_carg(err_code, "Vector", self.gb_obj[0]) + return Updater(self, opts=opts)[keys] = expr def __contains__(self, index): @@ -386,31 +383,30 @@ def __contains__(self, index): # GrB_Vector_extractElement directly instead of building an extract # expression and Scalar. An out-of-range index falls through to the # expression path so it raises the same IndexError as the slow path. - # Fall back for a UDT dtype and an active Recorder (so the call is - # recorded), mirroring Vector.get. - if not self.dtype._is_udt and not _is_recording(): - try: - idx = index.__index__() - except (AttributeError, TypeError): - idx = None - else: - if isinstance(index, (bool, np.bool_)): - idx = None - if idx is not None: - size = self._size - if idx < 0: - idx += size - if 0 <= idx < size: - dtype = self.dtype - res = ffi_new(f"{dtype.c_type}*") - err_code = utils.libget(f"GrB_Vector_extractElement_{dtype.name}")( - res, self.gb_obj[0], idx - ) - if err_code: - if err_code == GrB_NO_VALUE: - return False - check_status_carg(err_code, "Vector", self.gb_obj[0]) - return True + # Only int and np.integer take the fast lane (bools excluded), matching + # parse_index; other __index__ objects fall through to the expression + # path and its canonical errors. Fall back for a UDT dtype and an active + # Recorder (so the call is recorded), mirroring Vector.get. + if ( + (type(index) is int or isinstance(index, np.integer)) + and not self.dtype._is_udt + and not _is_recording() + ): + idx = index.__index__() + size = self._size + if idx < 0: + idx += size + if 0 <= idx < size: + dtype = self.dtype + res = ffi_new(f"{dtype.c_type}*") + err_code = utils.libget(f"GrB_Vector_extractElement_{dtype.name}")( + res, self.gb_obj[0], idx + ) + if err_code: + if err_code == GrB_NO_VALUE: + return False + check_status_carg(err_code, "Vector", self.gb_obj[0]) + return True extractor = self[index] if not extractor._is_scalar: raise TypeError( @@ -777,31 +773,31 @@ def get(self, index, default=None): # directly instead of building an extract expression, which costs ~10x # more than the C call for single-element access. Fall back when a # Recorder is active (so the call is recorded) and for UDTs (whose - # values need numpy-based conversion in Scalar.value). - if not self.dtype._is_udt and not _is_recording(): - try: - idx = index.__index__() - except (AttributeError, TypeError): - idx = None - else: - if isinstance(index, (bool, np.bool_)): - idx = None - if idx is not None: - size = self._size - if idx < 0: - idx += size - if idx < 0 or idx >= size: - raise IndexError(f"Index out of range: index={index}, size={size}") - dtype = self.dtype - res = ffi_new(f"{dtype.c_type}*") - err_code = utils.libget(f"GrB_Vector_extractElement_{dtype.name}")( - res, self.gb_obj[0], idx - ) - if err_code: - if err_code == GrB_NO_VALUE: - return default - check_status_carg(err_code, "Vector", self.gb_obj[0]) - return res[0] + # values need numpy-based conversion in Scalar.value). Only int and + # np.integer take the fast lane (bools excluded), matching parse_index; + # other __index__ objects fall through to the expression path and its + # canonical errors. + if ( + (type(index) is int or isinstance(index, np.integer)) + and not self.dtype._is_udt + and not _is_recording() + ): + idx = index.__index__() + size = self._size + if idx < 0: + idx += size + if idx < 0 or idx >= size: + raise IndexError(f"Index out of range: index={index}, size={size}") + dtype = self.dtype + res = ffi_new(f"{dtype.c_type}*") + err_code = utils.libget(f"GrB_Vector_extractElement_{dtype.name}")( + res, self.gb_obj[0], idx + ) + if err_code: + if err_code == GrB_NO_VALUE: + return default + check_status_carg(err_code, "Vector", self.gb_obj[0]) + return res[0] expr = self[index] if expr._is_scalar: rv = expr.new().value diff --git a/graphblas/tests/test_fastpath_parity.py b/graphblas/tests/test_fastpath_parity.py index 60b574113..b08f33711 100644 --- a/graphblas/tests/test_fastpath_parity.py +++ b/graphblas/tests/test_fastpath_parity.py @@ -62,6 +62,23 @@ def _capture(fn): return ("exc", type(e).__name__, str(e)) +class _IndexLike: + """Implements ``__index__`` without being an int; must not take any fast lane. + + ``parse_index`` rejects it, so a fast lane that duck-types ``__index__`` + would diverge from the expression path (gh: scalar fast-path parity). + """ + + def __init__(self, value): + self.value = value + + def __index__(self): + return self.value + + def __repr__(self): + return f"_IndexLike({self.value})" + + # --------------------------------------------------------------------------- # Vector.get / Matrix.get # --------------------------------------------------------------------------- @@ -119,7 +136,9 @@ def test_get_out_of_range(idx): assert str(fast.value) == str(slow.value) -@pytest.mark.parametrize("bad", [1.5, "x", None, [1, 2], slice(None), 2.0, True]) +@pytest.mark.parametrize( + "bad", [1.5, "x", None, [1, 2], slice(None), 2.0, True, np.array(3), _IndexLike(3)] +) def test_get_non_integer_index(bad): """Non-integer indices raise the same exception type as the reference path.""" v = Vector.from_coo([0, 5, 9], [1.5, 2.5, 3.5], size=10) @@ -128,6 +147,24 @@ def test_get_non_integer_index(bad): assert fast[:2] == slow[:2], f"fast={fast} slow={slow}" +@pytest.mark.parametrize( + ("r", "c"), + [ + (np.array(1), 2), + (1, np.array(2)), + (np.array(1), np.array(2)), + (_IndexLike(1), 2), + (1, _IndexLike(2)), + ], +) +def test_get_matrix_non_integer_index(r, c): + """Index-like objects that parse_index rejects must fail identically in get.""" + A = Matrix.from_coo([0, 1, 4], [1, 2, 6], [10, 20, 30], nrows=5, ncols=7) + fast = _capture(lambda: A.get(r, c)) + slow = _capture(lambda: _get_matrix_slow(A, r, c)) + assert fast == slow, f"fast={fast} slow={slow}" + + def test_get_matrix_negative_and_range(): A = Matrix.from_coo([0, 1, 4], [1, 2, 6], [10, 20, 30], nrows=5, ncols=7) _assert_get_equal(A.get(-1, -1, "d"), 30) @@ -256,7 +293,9 @@ def test_contains_bool_index(b): assert fast == slow, f"fast={fast} slow={slow}" -@pytest.mark.parametrize("bad", [1.5, "x", None, [1, 2], slice(None), 2.0, (1, 2)]) +@pytest.mark.parametrize( + "bad", [1.5, "x", None, [1, 2], slice(None), 2.0, (1, 2), np.array(3), _IndexLike(3)] +) def test_contains_non_integer_index(bad): """Non-integer indices raise the same exception type AND message.""" v = Vector.from_coo([0, 5, 9], [1.5, 2.5, 3.5], size=10) @@ -289,7 +328,19 @@ def test_contains_transposed_matrix(): assert fast[:2] == ("exc", "IndexError"), fast -@pytest.mark.parametrize("bad", [5, (1, 2, 3), (1.5, 2), ("a", "b"), np.int64(3)]) +@pytest.mark.parametrize( + "bad", + [ + 5, + (1, 2, 3), + (1.5, 2), + ("a", "b"), + np.int64(3), + (np.array(1), np.array(2)), + (1, np.array(2)), + (_IndexLike(1), 2), + ], +) def test_contains_matrix_bad_index(bad): A = Matrix.from_coo([0, 1, 4], [1, 2, 6], [10, 20, 30], nrows=5, ncols=7) fast = _capture(lambda: bad in A) @@ -461,12 +512,27 @@ def test_setitem_matrix_out_of_range(key): _assert_setitem_matrix(dtypes.FP64, key, 1.0) -@pytest.mark.parametrize("key", [1.5, "x", None, slice(None), [1, 2], (1, 2)]) +@pytest.mark.parametrize( + "key", [1.5, "x", None, slice(None), [1, 2], (1, 2), np.array(3), _IndexLike(3)] +) def test_setitem_vector_bad_key(key): _assert_setitem_vector(dtypes.FP64, key, 1.0) -@pytest.mark.parametrize("key", [5, (1, 2, 3), (1.5, 2), ("a", "b"), 1.5, slice(None)]) +@pytest.mark.parametrize( + "key", + [ + 5, + (1, 2, 3), + (1.5, 2), + ("a", "b"), + 1.5, + slice(None), + (np.array(1), np.array(2)), + (1, np.array(2)), + (_IndexLike(1), 2), + ], +) def test_setitem_matrix_bad_key(key): _assert_setitem_matrix(dtypes.FP64, key, 1.0) From 12e52c7687461b08c9b0665b4e9925374fd3d25e Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Tue, 4 Aug 2026 16:57:27 -0700 Subject: [PATCH 07/17] Skip the setitem UDT fallback test without UDF support --- graphblas/tests/test_fastpath_parity.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/graphblas/tests/test_fastpath_parity.py b/graphblas/tests/test_fastpath_parity.py index b08f33711..e34fb6ee0 100644 --- a/graphblas/tests/test_fastpath_parity.py +++ b/graphblas/tests/test_fastpath_parity.py @@ -20,6 +20,7 @@ import graphblas as gb from graphblas import Matrix, Vector, binary, dtypes +from graphblas.core import _supports_udfs as supports_udfs # noqa: F401 from graphblas.core.expr import Updater from graphblas.core.recorder import Recorder from graphblas.core.scalar import Scalar @@ -555,6 +556,10 @@ def test_setitem_value_fallbacks(): _assert_setitem_matrix(dtypes.FP64, (1, 1), None) +# Both halves compare UDT vectors with ``isequal``, which needs ``binary.eq`` +# compiled for the UDT. The sibling UDT tests here never call ``isequal``, so +# they keep running on builds without numba. +@pytest.mark.skipif("not supports_udfs") def test_setitem_udt_fallback(): udt = gb.dtypes.register_anonymous( np.dtype([("sx", np.int64), ("sy", np.float64)]), "SetitemFastPathProbe" From 64fba192842a5ec270291009b931adc9e8fa7ae1 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Tue, 4 Aug 2026 16:57:27 -0700 Subject: [PATCH 08/17] Keep the scalar-element fast path a GrB scalar under bizarro scalars --- graphblas/core/scalar.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphblas/core/scalar.py b/graphblas/core/scalar.py index 97cf919f3..054920cf7 100644 --- a/graphblas/core/scalar.py +++ b/graphblas/core/scalar.py @@ -1075,7 +1075,7 @@ def new(self, dtype=None, *, is_cscalar=None, name=None, **opts): and not _is_recording() ): indices = self.resolved_indexes.indices - result = Scalar(parent.dtype, is_cscalar=False, name=name) + result = Scalar(parent.dtype, is_cscalar=False, name=name) # pragma: is_grbscalar if len(indices) == 1: err_code = lib.GrB_Vector_extractElement_Scalar( result.gb_obj[0], parent.gb_obj[0], indices[0].index._carg From f90544e99da14720a2ed8cc29ecfe38554ebc496 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Thu, 8 Oct 2026 14:43:10 -0500 Subject: [PATCH 09/17] Leave ints beyond INT64 to the full setitem path The fast path for v[i] = x and A[i, j] = x typed a Python int by itself, so 2**63 was INT64 and raised OverflowError, while the full assign path now types an int beyond INT64 as the destination's dtype and stores it into UINT64 (#591). Such an int now takes the full path, as numpy values and Scalars do. --- graphblas/core/matrix.py | 5 ++++- graphblas/core/vector.py | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/graphblas/core/matrix.py b/graphblas/core/matrix.py index b57fd948a..fead05227 100644 --- a/graphblas/core/matrix.py +++ b/graphblas/core/matrix.py @@ -386,12 +386,15 @@ def __setitem__(self, keys, expr, **opts): # int/float/bool/complex are taken here so dtype inference and cffi # coercion match _assign_element exactly; everything else (slices, fancy # indexing, numpy or Scalar values, `A(mask)[i, j] << x`) falls back to - # the full assign path, leaving mask/accum and coercion unchanged. + # the full assign path, leaving mask/accum and coercion unchanged. So + # does an int beyond INT64, which that path types as the matrix's dtype + # (``uint64_mat[i, j] = 2**63``; scalar._literal_store_dtype). if ( not opts and type(keys) is tuple and len(keys) == 2 and type(expr) in (int, float, bool, complex) + and (type(expr) is not int or -(2**63) <= expr < 2**63) and not self.dtype._is_udt and not _is_recording() ): diff --git a/graphblas/core/vector.py b/graphblas/core/vector.py index 2d8e92b88..782f12d88 100644 --- a/graphblas/core/vector.py +++ b/graphblas/core/vector.py @@ -343,10 +343,13 @@ def __setitem__(self, keys, expr, **opts): # match parse_index; everything else (slices, fancy indexing, 0-d # arrays and other __index__ objects, numpy or Scalar values, # `v(mask)[i] << x`) falls back to the full assign path, leaving - # mask/accum, coercion, and index errors unchanged. + # mask/accum, coercion, and index errors unchanged. So does an int + # beyond INT64, which that path types as the vector's dtype + # (``uint64_vec[i] = 2**63``; scalar._literal_store_dtype). if ( not opts and type(expr) in (int, float, bool, complex) + and (type(expr) is not int or -(2**63) <= expr < 2**63) and (type(keys) is int or isinstance(keys, np.integer)) and not self.dtype._is_udt and not _is_recording() From d0099bbe1db1beacee9d2437528d5e03f591b489 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Wed, 14 Oct 2026 13:01:42 -0500 Subject: [PATCH 10/17] Update `scalar._literal_store_dtype` to `literals._literal_store_dtype` --- graphblas/core/matrix.py | 2 +- graphblas/core/vector.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/graphblas/core/matrix.py b/graphblas/core/matrix.py index fead05227..373d6c38c 100644 --- a/graphblas/core/matrix.py +++ b/graphblas/core/matrix.py @@ -388,7 +388,7 @@ def __setitem__(self, keys, expr, **opts): # indexing, numpy or Scalar values, `A(mask)[i, j] << x`) falls back to # the full assign path, leaving mask/accum and coercion unchanged. So # does an int beyond INT64, which that path types as the matrix's dtype - # (``uint64_mat[i, j] = 2**63``; scalar._literal_store_dtype). + # (``uint64_mat[i, j] = 2**63``; literals._literal_store_dtype). if ( not opts and type(keys) is tuple diff --git a/graphblas/core/vector.py b/graphblas/core/vector.py index 782f12d88..9fbdf4543 100644 --- a/graphblas/core/vector.py +++ b/graphblas/core/vector.py @@ -345,7 +345,7 @@ def __setitem__(self, keys, expr, **opts): # `v(mask)[i] << x`) falls back to the full assign path, leaving # mask/accum, coercion, and index errors unchanged. So does an int # beyond INT64, which that path types as the vector's dtype - # (``uint64_vec[i] = 2**63``; scalar._literal_store_dtype). + # (``uint64_vec[i] = 2**63``; literals._literal_store_dtype). if ( not opts and type(expr) in (int, float, bool, complex) From ba1d1c1eec95241ddc8d6c4470aecd88810cedb2 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Sun, 18 Oct 2026 03:26:24 -0500 Subject: [PATCH 11/17] Teach codespell the bu in the fast-path parity tests test_fastpath_parity.py names a pair of UDT Vectors au and bu, and codespell reads bu as a misspelling of by, but, bug and so on. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index cf0251968..e6372b708 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -232,7 +232,7 @@ skip_empty = true exclude_lines = ["pragma: no cover", "raise AssertionError", "raise NotImplementedError"] [tool.codespell] -ignore-words-list = "coo,ba" +ignore-words-list = "bu,coo,ba" [tool.ruff] # https://github.com/charliermarsh/ruff/ From 458aa9d4fffbf4420e46cfdef53e53095c399b54 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Tue, 20 Oct 2026 08:54:46 -0500 Subject: [PATCH 12/17] Stop the Recorders two tests leave running, and fail a test that does test_record_failed_call made a Recorder (gb.Recorder() starts one) and test_record_repr_html started one (and called start() too), and neither stopped it, so it kept running for the rest of the pytest session. Both are on main: test_record_failed_call since #114 (2021), and test_record_repr_html since #263 (2022). There that only means every later call was recorded. Here it matters: the scalar fast paths (get, in, integer-key setitem, v[i].new() and value reads) step aside while a Recorder runs, so every test that ran after test_recorder.py took the full path instead. In file order that is test_scalar.py onward; under CI's random order it can be any test, the fast-path parity tests included, which then compared the full path with itself. Both tests now record inside a with block. A new autouse fixture in conftest.py fails a test that leaves a Recorder running (other than the session's own one under --record) and stops it, so one leak cannot change what the tests after it test. No other test trips it. --- graphblas/tests/conftest.py | 24 ++++++++++++++++++++++++ graphblas/tests/test_recorder.py | 15 +++++++-------- 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/graphblas/tests/conftest.py b/graphblas/tests/conftest.py index f68a597fd..7f16d9fdd 100644 --- a/graphblas/tests/conftest.py +++ b/graphblas/tests/conftest.py @@ -170,6 +170,30 @@ def _reset_name_counters(): gb.Scalar._name_counter = itertools.count() +@pytest.fixture(autouse=True) +def _stop_a_leaked_recorder(): + """Fail a test that leaves a Recorder running, and stop that Recorder. + + A running Recorder records every later call, and the scalar fast paths step + aside while one runs, so every test after a leak would test the full path. + """ + from graphblas.core import base + + def running(): + return base._recorder.get(base._prev_recorder) + + before = running() # the session's own Recorder under --record, else None + yield + rec = running() + if rec is not None and rec is not before: # pragma: no cover (only on failure) + while rec is not None and rec is not before: + rec.stop() + if running() is rec: + break + rec = running() + pytest.fail("The test left a Recorder running; stop it, or use `with Recorder():`") + + @pytest.fixture(scope="session", autouse=True) def _warn_if_no_memory_handler(): """Fail a test that frees an array that owns its data but has no memory handler. diff --git a/graphblas/tests/test_recorder.py b/graphblas/tests/test_recorder.py index dab432485..d8753058b 100644 --- a/graphblas/tests/test_recorder.py +++ b/graphblas/tests/test_recorder.py @@ -215,9 +215,8 @@ def test_record_repr_markdown(switch): def test_record_repr_html(): A = gb.Matrix(int, 3, 3, name="A") - rec = gb.Recorder() - rec.start() - A[0, 0].new(name="c") + with gb.Recorder() as rec: + A[0, 0].new(name="c") try: import IPython # noqa: F401 except ImportError: @@ -232,11 +231,11 @@ def test_record_failed_call(): small = gb.Vector(int, size=2**55) BIG[:] = 1 small[0] = 2 - rec = gb.Recorder() - try: - BIG.ewise_add(small).new() - except OutOfMemory: - pass + with gb.Recorder() as rec: + try: + BIG.ewise_add(small).new() + except OutOfMemory: + pass assert "ERROR: OutOfMemory" in rec.data[-1] From 8c054ab03664f685d82eb3ed511eeb7eb7f89cb5 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Tue, 20 Oct 2026 09:00:51 -0500 Subject: [PATCH 13/17] Report GraphBLAS's error when extracting an element into a C scalar Behavior fix. When GrB_*_extractElement_ failed for a reason other than a missing value, check_status built the error from the call's first argument, the fresh empty C-scalar output, and asking it for its C value raised EmptyObject ("Empty C scalar is invalid when passed as value ..."), which hid GraphBLAS's error. Extracting from an object whose data was exported with give_ownership=True did this whenever the element went to a C scalar. The two cases differ in age. v[0].new(is_cscalar=True), and v[0].new() under C-scalar defaults, raised EmptyObject on main too. v[0].value raised NullPointer on main, which read the value through a GrB_Scalar; this PR's single-extract fast path for value reads (_extract_fast) reads every value into a C scalar, and so made .value raise EmptyObject. That part is a regression from this PR, fixed here before it ships. _extract_element now names the input as the object to report against, through an optional exc_arg on _Pointer that check_status already honors (as for _MatrixArray), so the error is GraphBLAS's, as with a GrB_Scalar output and as in the fast paths. A TransposedMatrix reports against its Matrix, since there is no GrB_TransposedMatrix_error. check_status is unchanged. The new test extracts from a given-away Matrix, its transpose and a Vector into both kinds of Scalar, and checks the same NullPointer text for both; it fails before this with EmptyObject, and with the "Unable to get the error string" text if a TransposedMatrix reports against itself. --- graphblas/core/matrix.py | 5 ++++- graphblas/core/utils.py | 8 ++++++-- graphblas/core/vector.py | 5 ++++- graphblas/tests/test_matrix.py | 18 ++++++++++++++++++ 4 files changed, 32 insertions(+), 4 deletions(-) diff --git a/graphblas/core/matrix.py b/graphblas/core/matrix.py index 373d6c38c..335f71fd1 100644 --- a/graphblas/core/matrix.py +++ b/graphblas/core/matrix.py @@ -3232,10 +3232,13 @@ def _extract_element( desc = descriptor_lookup(**opts) # noqa: F841 (keep desc in scope for context) if is_cscalar: dtype_name = "UDT" if dtype._is_udt else dtype.name + # Report an error against this Matrix (not a TransposedMatrix, which has no + # error function): the empty C scalar has no GraphBLAS object to ask. + out = _Pointer(result, exc_arg=self._matrix if self._is_transposed else self) if ( call( f"GrB_Matrix_extractElement_{dtype_name}", - [_Pointer(result), self, rowidx.index, colidx.index], + [out, self, rowidx.index, colidx.index], ) is not NoValue ): diff --git a/graphblas/core/utils.py b/graphblas/core/utils.py index 2d3de41cb..ddad1d327 100644 --- a/graphblas/core/utils.py +++ b/graphblas/core/utils.py @@ -498,10 +498,14 @@ def name(self): class _Pointer: - __slots__ = "val" + __slots__ = "val", "_exc_arg" - def __init__(self, val): + def __init__(self, val, exc_arg=None): self.val = val + if exc_arg is not None: + # check_status asks this object for the error text instead of ``val``, since a + # C-scalar ``val`` has no GraphBLAS object to ask + self._exc_arg = exc_arg @property def _carg(self): diff --git a/graphblas/core/vector.py b/graphblas/core/vector.py index 9fbdf4543..7730bc166 100644 --- a/graphblas/core/vector.py +++ b/graphblas/core/vector.py @@ -1988,8 +1988,11 @@ def _extract_element( desc = descriptor_lookup(**opts) # noqa: F841 (keep desc in scope for context) if is_cscalar: dtype_name = "UDT" if dtype._is_udt else dtype.name + # Report an error against this Vector: the empty C scalar has no + # GraphBLAS object to ask for the error text. + out = _Pointer(result, exc_arg=self) if ( - call(f"GrB_Vector_extractElement_{dtype_name}", [_Pointer(result), self, idx.index]) + call(f"GrB_Vector_extractElement_{dtype_name}", [out, self, idx.index]) is not NoValue ): result._empty = False diff --git a/graphblas/tests/test_matrix.py b/graphblas/tests/test_matrix.py index 611e1a13e..54d5a129b 100644 --- a/graphblas/tests/test_matrix.py +++ b/graphblas/tests/test_matrix.py @@ -350,6 +350,24 @@ def test_extract_element(A): assert s.dtype == "FP64" +@pytest.mark.skipif("not suitesparse") +def test_extract_element_raises_the_graphblas_error(): + # export(give_ownership=True) leaves no GraphBLAS object, so the extract fails. + # Into a C scalar the error was an EmptyObject about the output, hiding this one. + # The text must match too: a TransposedMatrix has no error function of its own. + A = Matrix.from_coo([0], [1], [1.5], nrows=2, ncols=3) + v = Vector.from_coo([0], [1.5], size=3) + A.ss.export(give_ownership=True) + v.ss.export(give_ownership=True) + for expr in [A[0, 1], A.T[1, 0], v[0]]: + texts = [] + for is_cscalar in [False, True]: + with pytest.raises(NullPointer) as exc_info: + expr.new(is_cscalar=is_cscalar) + texts.append(str(exc_info.value)) + assert texts[0] == texts[1], (expr, texts) + + def test_set_element(A): assert compute(A[1, 1].new().value) is None assert A[3, 0].new() == 3 From 69b5bb71e0128a6983d1558638d404798f58cf9c Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Tue, 20 Oct 2026 10:56:57 -0500 Subject: [PATCH 14/17] Report GraphBLAS's error from nvals, nrows, ncols, size and Scalar reads Behavior fix, the same as the previous commit's for the other calls that write into a C scalar. Matrix.nrows, ncols and nvals, Vector.size and nvals, Scalar.nvals and the GrB_Scalar read in Scalar.value pass their count or value to GraphBLAS in a C scalar, and when the call failed, check_status asked that empty C scalar for the error text, which raised EmptyObject ("Empty C scalar is invalid when passed as value ...") and hid GraphBLAS's error. Each now names its own object for the error, through _Pointer's exc_arg; check_status is unchanged. A TransposedMatrix, which shares Matrix's nrows, ncols and nvals, names its Matrix, as in the previous commit, since there is no GrB_TransposedMatrix_error. main has all of these. Of them, only the Matrix and Vector ones can be reached through a public call: an object whose data was exported with give_ownership=True has no GraphBLAS object left, and A.nvals, A.T.nvals or v.size on it raised EmptyObject instead of NullPointer. Nothing leaves a Scalar without its GraphBLAS object, and Scalar.value checks for emptiness first, which already reported the error correctly, so the read itself cannot fail; it changes for consistency only. The tests check that a given-away Matrix, its transpose and a Vector, and a GrB_Scalar set to NULL the way export leaves the others, raise NullPointer with the same text as checking the object itself; they fail before this with EmptyObject, and A.T with the "Unable to get the error string" text if it reports against itself. --- graphblas/core/matrix.py | 12 +++++++++--- graphblas/core/scalar.py | 5 +++-- graphblas/core/vector.py | 4 ++-- graphblas/tests/test_matrix.py | 24 ++++++++++++++++++++++++ graphblas/tests/test_scalar.py | 20 +++++++++++++++++++- 5 files changed, 57 insertions(+), 8 deletions(-) diff --git a/graphblas/core/matrix.py b/graphblas/core/matrix.py index 335f71fd1..508a7988a 100644 --- a/graphblas/core/matrix.py +++ b/graphblas/core/matrix.py @@ -601,14 +601,18 @@ def isclose(self, other, *, rel_tol=1e-7, abs_tol=0.0, check_dtype=False, **opts def nrows(self): """Number of rows in the Matrix.""" scalar = _scalar_index("s_nrows") - call("GrB_Matrix_nrows", [_Pointer(scalar), self]) + # TransposedMatrix shares this, and reports against its Matrix (no error function) + matrix = self._matrix if self._is_transposed else self + call("GrB_Matrix_nrows", [_Pointer(scalar, exc_arg=matrix), self]) return scalar.gb_obj[0] @property def ncols(self): """Number of columns in the Matrix.""" scalar = _scalar_index("s_ncols") - call("GrB_Matrix_ncols", [_Pointer(scalar), self]) + # TransposedMatrix shares this, and reports against its Matrix (no error function) + matrix = self._matrix if self._is_transposed else self + call("GrB_Matrix_ncols", [_Pointer(scalar, exc_arg=matrix), self]) return scalar.gb_obj[0] @property @@ -620,7 +624,9 @@ def shape(self): def nvals(self): """Number of non-empty values in the Matrix.""" scalar = _scalar_index("s_nvals") - call("GrB_Matrix_nvals", [_Pointer(scalar), self]) + # TransposedMatrix shares this, and reports against its Matrix (no error function) + matrix = self._matrix if self._is_transposed else self + call("GrB_Matrix_nvals", [_Pointer(scalar, exc_arg=matrix), self]) return scalar.gb_obj[0] @property diff --git a/graphblas/core/scalar.py b/graphblas/core/scalar.py index 054920cf7..af3681e4e 100644 --- a/graphblas/core/scalar.py +++ b/graphblas/core/scalar.py @@ -396,7 +396,8 @@ def value(self): else: scalar = Scalar(self.dtype, is_cscalar=True, name="s_temp") dtype_name = "UDT" if is_udt else self.dtype.name - call(f"GrB_Scalar_extractElement_{dtype_name}", [_Pointer(scalar), self]) + out = _Pointer(scalar, exc_arg=self) + call(f"GrB_Scalar_extractElement_{dtype_name}", [out, self]) if is_udt: return _element_value(self.dtype, scalar.gb_obj) return scalar.gb_obj[0] @@ -431,7 +432,7 @@ def nvals(self): if self._is_cscalar: return 0 if self._empty else 1 scalar = _scalar_index("s_nvals") - call("GrB_Scalar_nvals", [_Pointer(scalar), self]) + call("GrB_Scalar_nvals", [_Pointer(scalar, exc_arg=self), self]) return scalar.gb_obj[0] @property diff --git a/graphblas/core/vector.py b/graphblas/core/vector.py index 7730bc166..49325b34e 100644 --- a/graphblas/core/vector.py +++ b/graphblas/core/vector.py @@ -525,7 +525,7 @@ def isclose(self, other, *, rel_tol=1e-7, abs_tol=0.0, check_dtype=False, **opts def size(self): """Size of the Vector.""" scalar = _scalar_index("s_size") - call("GrB_Vector_size", [_Pointer(scalar), self]) + call("GrB_Vector_size", [_Pointer(scalar, exc_arg=self), self]) return scalar.gb_obj[0] @property @@ -537,7 +537,7 @@ def shape(self): def nvals(self): """Number of non-empty values in the Vector.""" scalar = _scalar_index("s_nvals") - call("GrB_Vector_nvals", [_Pointer(scalar), self]) + call("GrB_Vector_nvals", [_Pointer(scalar, exc_arg=self), self]) return scalar.gb_obj[0] @property diff --git a/graphblas/tests/test_matrix.py b/graphblas/tests/test_matrix.py index 54d5a129b..34b05b2ff 100644 --- a/graphblas/tests/test_matrix.py +++ b/graphblas/tests/test_matrix.py @@ -253,6 +253,30 @@ def test_nvals(A): assert A.nvals == 12 +@pytest.mark.skipif("not suitesparse") +def test_nvals_and_shape_raise_the_graphblas_error(): + # export(give_ownership=True) leaves no GraphBLAS object, so these calls fail. Each + # count lands in a C scalar, and the error was an EmptyObject about that scalar; it + # is now the one checking the object itself gives (``_nvals``). A.T reports against + # A, since there is no error function for a TransposedMatrix. + A = Matrix.from_coo([0], [1], [1.5], nrows=2, ncols=3) + v = Vector.from_coo([0], [1.5], size=3) + A.ss.export(give_ownership=True) + v.ss.export(give_ownership=True) + matrix_names = ["nrows", "ncols", "nvals"] + for obj, ref, names in [ + (A, A, matrix_names), + (A.T, A, matrix_names), + (v, v, ["size", "nvals"]), + ]: + with pytest.raises(NullPointer) as expected: + ref._nvals + for name in names: + with pytest.raises(NullPointer) as got: + getattr(obj, name) + assert str(got.value) == str(expected.value), name + + @pytest.mark.skipif("not suitesparse") def test_ss_export_failing_after_graphblas_frees_the_object(monkeypatch): # GraphBLAS frees the object as it hands its arrays over. An error after that, diff --git a/graphblas/tests/test_scalar.py b/graphblas/tests/test_scalar.py index ed81e0ca2..e1136e07f 100644 --- a/graphblas/tests/test_scalar.py +++ b/graphblas/tests/test_scalar.py @@ -13,7 +13,7 @@ import graphblas as gb from graphblas import backend, binary, dtypes, monoid, replace, select, unary from graphblas.core.ss import version_major as ss_version_major # noqa: F401 (used in skipif) -from graphblas.exceptions import EmptyObject +from graphblas.exceptions import EmptyObject, NullPointer from .conftest import autocompute, compute, pypy @@ -381,6 +381,24 @@ def test_nvals(s): assert s.nvals == 0 +def test_nvals_raises_the_graphblas_error(): + # No call leaves a Scalar without its GraphBLAS object, as ss.export(give_ownership=True) + # does a Matrix or Vector, so set it to NULL the way that does. The count lands in a C + # scalar, and the error was an EmptyObject about that scalar. + s = Scalar.from_value(1.5, is_cscalar=False) # pragma: is_grbscalar + gb_obj = s.gb_obj[0] + s.gb_obj[0] = gb.core.NULL + try: + with pytest.raises(NullPointer) as expected: + s._nvals + with pytest.raises(NullPointer) as got: + s.nvals + assert str(got.value) == str(expected.value) + finally: + s.gb_obj[0] = gb_obj + assert s.value == 1.5 + + def test_unsupported_ops(s): with pytest.raises(AttributeError): s.S From 2d13242ed0ad11a783b6810436442b8e8563e3ef Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Tue, 20 Oct 2026 07:48:25 -0500 Subject: [PATCH 15/17] Test that the scalar fast paths raise GraphBLAS errors as the full path does get, in, integer-key setitem and v[i].new() call GraphBLAS directly and check its status themselves; get and in must also tell "no value" apart from an error. Value reads extract through the full path's C-scalar branch instead, which reports GraphBLAS's error since "Report GraphBLAS's error when extracting an element into a C scalar". Nothing reached those checks: Python validates every index and value first. An object whose data was exported with give_ownership=True is left with no GraphBLAS object, and every call on it fails, so the parity tests now use one to check that each fast path, and each value read, raises the same NullPointer as its reference, for a Vector and a Matrix, and for v[i].new() into C scalars too. A get or in that read any error as a missing value would return the default or False instead. --- graphblas/tests/test_fastpath_parity.py | 80 +++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/graphblas/tests/test_fastpath_parity.py b/graphblas/tests/test_fastpath_parity.py index e34fb6ee0..d536ac97d 100644 --- a/graphblas/tests/test_fastpath_parity.py +++ b/graphblas/tests/test_fastpath_parity.py @@ -25,6 +25,8 @@ from graphblas.core.recorder import Recorder from graphblas.core.scalar import Scalar +suitesparse = gb.backend == "suitesparse" + # Builtin dtypes to sweep. Complex is SuiteSparse-only, so gate it the same way # the dtype tests do; on the vanilla backend the parametrization simply omits it. BUILTIN_DTYPES = [ @@ -80,6 +82,14 @@ def __repr__(self): return f"_IndexLike({self.value})" +def _given_away(obj): + """``obj`` after ``obj.ss.export(give_ownership=True)``, which leaves no GraphBLAS + object behind, so every C call on it fails (GrB_NULL_POINTER). + """ + obj.ss.export(give_ownership=True) + return obj + + # --------------------------------------------------------------------------- # Vector.get / Matrix.get # --------------------------------------------------------------------------- @@ -225,6 +235,20 @@ def test_get_pending_value(): _assert_get_equal(v.get(3), 2.25) +@pytest.mark.skipif("not suitesparse") +def test_get_graphblas_error(): + """An error from GraphBLAS is raised as the reference raises it, not read as a miss.""" + v = _given_away(Vector.from_coo([0, 5], [1.5, 2.5], size=10)) + A = _given_away(Matrix.from_coo([0, 1], [1, 2], [1.5, 2.5], nrows=5, ncols=7)) + for fast, slow in [ + (lambda: v.get(0, "d"), lambda: _get_vector_slow(v, 0, "d")), + (lambda: A.get(0, 1, "d"), lambda: _get_matrix_slow(A, 0, 1, "d")), + ]: + f, s = _capture(fast), _capture(slow) + assert f == s, f"fast={f} slow={s}" + assert f[:2] == ("exc", "NullPointer"), f + + # --------------------------------------------------------------------------- # __contains__ (index in obj) # --------------------------------------------------------------------------- @@ -393,6 +417,20 @@ def test_contains_pending_value(): assert (4 in v) is False +@pytest.mark.skipif("not suitesparse") +def test_contains_graphblas_error(): + """An error from GraphBLAS is raised as the reference raises it, not read as absent.""" + v = _given_away(Vector.from_coo([0, 5], [1.5, 2.5], size=10)) + A = _given_away(Matrix.from_coo([0, 1], [1, 2], [1.5, 2.5], nrows=5, ncols=7)) + for fast, slow in [ + (lambda: 0 in v, lambda: _contains_vector_slow(v, 0)), + (lambda: (0, 1) in A, lambda: _contains_matrix_slow(A, (0, 1))), + ]: + f, s = _capture(fast), _capture(slow) + assert f == s, f"fast={f} slow={s}" + assert f[:2] == ("exc", "NullPointer"), f + + # --------------------------------------------------------------------------- # __setitem__ with an integer key # --------------------------------------------------------------------------- @@ -611,6 +649,19 @@ def test_setitem_empty_vector(): assert e.nvals == 1 +@pytest.mark.skipif("not suitesparse") +def test_setitem_graphblas_error(): + v = _given_away(Vector(dtypes.FP64, 10)) + A = _given_away(Matrix(dtypes.FP64, 5, 7)) + for fast, slow in [ + (lambda: v.__setitem__(0, 1.5), lambda: _set_vector_slow(v, 0, 1.5)), + (lambda: A.__setitem__((0, 1), 1.5), lambda: _set_matrix_slow(A, (0, 1), 1.5)), + ]: + f, s = _capture(fast), _capture(slow) + assert f == s, f"fast={f} slow={s}" + assert f[:2] == ("exc", "NullPointer"), f + + # --------------------------------------------------------------------------- # ScalarIndexExpr.new() # --------------------------------------------------------------------------- @@ -703,6 +754,22 @@ def test_scalarnew_recorder_fallback(): assert "extractElement" in "".join(rec.data) +@pytest.mark.skipif("not suitesparse") +def test_scalarnew_graphblas_error(): + v = _given_away(Vector.from_coo([0, 5], [1.5, 2.5], size=10)) + A = _given_away(Matrix.from_coo([0, 1], [1, 2], [1.5, 2.5], nrows=5, ncols=7)) + for fast, slow in [ + (v[0].new, lambda: _new_slow(v[0])), + (A[0, 1].new, lambda: _new_slow(A[0, 1])), + # Into a C scalar both take the full path, which reports against the input + (lambda: v[0].new(is_cscalar=True), lambda: _new_slow(v[0], is_cscalar=True)), + (lambda: A.T[1, 0].new(is_cscalar=True), lambda: _new_slow(A.T[1, 0], is_cscalar=True)), + ]: + f, s = _capture(fast), _capture(slow) + assert f == s, f"fast={f} slow={s}" + assert f[:2] == ("exc", "NullPointer"), f + + def test_scalarnew_autocompute_value_path(): """With autocompute on, ``v[i].value`` / ``float(v[i])`` still match ``get``.""" v = Vector.from_coo([0, 5, 9], [1.5, 2.5, 3.5], size=10) @@ -843,6 +910,19 @@ def test_scalarvalue_pending_value(): assert float(v[3]) == 2.25 +@pytest.mark.skipif("not suitesparse") +def test_scalarvalue_graphblas_error(): + """A value read, which extracts into a C scalar, raises what the GrB_Scalar read does.""" + v = _given_away(Vector.from_coo([0, 5], [1.5, 2.5], size=10)) + A = _given_away(Matrix.from_coo([0, 1], [1, 2], [1.5, 2.5], nrows=5, ncols=7)) + with gb.config.set(autocompute=True): + for probe in [v[0], A[0, 1], A.T[1, 0]]: + got = _read_attr(probe, "value") + want = _capture(lambda: _new_slow(probe).value) # noqa: B023 + assert got == want, f"fast={got} slow={want}" + assert got[:2] == ("exc", "NullPointer"), got + + def test_scalarvalue_index_integral_only(): """__index__ is available on integral dtypes and absent on float, via the fast path.""" vi = Vector.from_coo([2], [7], dtype=dtypes.INT64, size=5) From 49257e5befd98b3a115372473722db5509389e36 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Tue, 20 Oct 2026 07:48:28 -0500 Subject: [PATCH 16/17] Cover the row and column case of Matrix in; exclude failure-only helpers (0, slice(None)) in A and ([0, 1], 2) in A extract a row or a column, not an element, so both the full path and _contains_matrix_slow raise the TypeError that asks for a 2-tuple of ints; no case gave one. _IndexLike.__index__ runs only if a fast lane accepts the object, which the tests exist to catch, and __repr__ only in a failure message, so both are marked "only on failure". --- graphblas/tests/test_fastpath_parity.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/graphblas/tests/test_fastpath_parity.py b/graphblas/tests/test_fastpath_parity.py index d536ac97d..8794f2347 100644 --- a/graphblas/tests/test_fastpath_parity.py +++ b/graphblas/tests/test_fastpath_parity.py @@ -75,10 +75,10 @@ class _IndexLike: def __init__(self, value): self.value = value - def __index__(self): + def __index__(self): # pragma: no cover (only on failure) return self.value - def __repr__(self): + def __repr__(self): # pragma: no cover (only on failure) return f"_IndexLike({self.value})" @@ -364,6 +364,8 @@ def test_contains_transposed_matrix(): (np.array(1), np.array(2)), (1, np.array(2)), (_IndexLike(1), 2), + (0, slice(None)), + ([0, 1], 2), ], ) def test_contains_matrix_bad_index(bad): From 36a86108e4f2ac37612cc0311f7aa3d915fbf9f5 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Tue, 3 Nov 2026 15:15:56 -0800 Subject: [PATCH 17/17] Give a scalar index expression's other attributes a GrB_Scalar after a value read A value read of v[i] (e.value, float(e), ...) extracts into a C scalar, and the expression caches what it reads through. Its other attributes then saw that C scalar: e.gb_obj was an int8_t * where a GrB_Scalar * was, so a C call taking a GrB_Scalar refused it with TypeError, while e.is_cscalar still said False. Main and the Recorder path gave a GrB_Scalar (r599 R1). Now an attribute other than a value read converts the cached C scalar once into the kind of Scalar the expression reports, holding the same value, so a value read is still a snapshot, as on main. --- graphblas/core/automethods.py | 8 ++++++++ graphblas/tests/test_fastpath_parity.py | 23 +++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/graphblas/core/automethods.py b/graphblas/core/automethods.py index 0c86cfe2d..01b238634 100644 --- a/graphblas/core/automethods.py +++ b/graphblas/core/automethods.py @@ -42,6 +42,14 @@ def _get_value(self, attr=None, default=None): self._value = extract_fast() else: self._value = self.new() + elif ( + attr not in _fast_scalar_attrs + and getattr(self, "_extract_fast", None) is not None + and self._value._is_cscalar != self._is_cscalar + ): + # A value read cached a C scalar (_extract_fast); give the other attributes + # the kind of Scalar this expression reports, holding the same value. + self._value = self._value.dup(is_cscalar=self._is_cscalar, name=self._value.name) if attr is None: return self._value return getattr(self._value, attr) diff --git a/graphblas/tests/test_fastpath_parity.py b/graphblas/tests/test_fastpath_parity.py index 8794f2347..9c0a7e5f6 100644 --- a/graphblas/tests/test_fastpath_parity.py +++ b/graphblas/tests/test_fastpath_parity.py @@ -925,6 +925,29 @@ def test_scalarvalue_graphblas_error(): assert got[:2] == ("exc", "NullPointer"), got +def test_scalarvalue_then_another_attribute(): + """After a value read, the other attributes see the kind of Scalar the expression reports. + + The value read caches the C scalar it extracts into, and ``gb_obj`` handed that out: an + ``int8_t *`` where a ``GrB_Scalar *`` was, which a C call taking a GrB_Scalar refused + with TypeError, while ``is_cscalar`` said False. + """ + from graphblas.core import ffi + + v = Vector.from_coo([2], [3], size=4, dtype=dtypes.INT8) + A = Matrix.from_coo([1], [2], [3], dtype=dtypes.INT8, nrows=3, ncols=4) + with gb.config.set(autocompute=True): + for make in [lambda: v[2], lambda: v[3], lambda: A[1, 2], lambda: A.T[2, 1]]: + fresh = make() + e = make() + value = e.value + assert ffi.typeof(e.gb_obj) is ffi.typeof(fresh.gb_obj) + assert e.is_cscalar is fresh.is_cscalar + assert e.nvals == fresh.nvals + assert e.value == value # still the value read, as on main + assert (e + 1).new().isequal((fresh + 1).new(), check_dtype=True) + + def test_scalarvalue_index_integral_only(): """__index__ is available on integral dtypes and absent on float, via the fast path.""" vi = Vector.from_coo([2], [7], dtype=dtypes.INT64, size=5)