From 95be9957a5376765335b2d9c8af9d3285932e7a1 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Wed, 21 Oct 2026 00:06:26 -0500 Subject: [PATCH 1/7] Keep a literal on its own side of a Scalar's `&` and `|` Behavior fix. Since 2023.1.0, `x & s` and `x | s`, with a literal x and a Scalar s, built the expression with s on the left and x on the right, so the op applied to it ran with its operands swapped, and every op that does not commute gave wrong values, silently: s = Scalar.from_value(5) binary.minus(1 & s) # was 4, now -4 binary.truediv(1 | s) # was 5.0, now 0.2 binary.first(1 & s) # was 5, now 1 binary.lt(1 & s) # was False, now True So did rminus, cdiv, rdiv, pow, the other comparisons and is-comparisons, and the rest of the ops that do not commute. So did the defaults of an ewise_union, each of which went with the other operand: with an empty Scalar e, `binary.minus(1 | e, left_default=0, right_default=10)` was -1 and is now -9. And so did chains: `binary.minus(1 & (2 & s))` was 2 and is now 4. The expression now keeps the literal where it was written, as it is, and the op applied to it types it: Scalar's ewise_mult, ewise_add and ewise_union take a literal on the left as they take one on the right, typed beside the Scalar by literals._literal_operand. A Python number is weak, as beside `-`: `binary.minus(2 & int8_s)` is INT8 and `binary.minus(0.5 & int8_s)` is FP64, as `2 - int8_s` and `0.5 - int8_s` are. Beside a UDT, a lifted op such as minus types the literal as numpy would and `first` makes it an element of the UDT. How a literal is typed depends on the op (300 beside INT8 is an OverflowError for `-` and is compared as it is by `<`), so the literal is not converted when `&` or `|` builds the expression. Converting it then, with its own dtype, would get the typing wrong: the 2 of `int8_s & 2` would be INT64 under every op, where `int8_s - 2` keeps it INT8, and `0.5 & int8_udt_s` would cut 0.5 to 0 in the UDT. `op(x & y)` now asks the expression to call its left operand's method (InfixExprBase._call_left), so a Scalar expression can pass a literal there. Two more errors with a literal operand are fixed: - Computing a BOOL expression with a literal by itself, as `(s & True).new()` and `(False | s).new()` do, raised AttributeError: 'bool' object has no attribute 'dtype'. It now types the literal for LAND or LOR and computes, or raises the usual TypeError for a dtype other than BOOL. - `s & object()` gave an expression that failed only when an op was applied; it now raises TypeError at once, as `s + object()` does. The check types the literal as `==` would and drops it. Beside an infix expression, as in `(s & t) & object()`, it raises when the op is applied. --- graphblas/core/expr.py | 4 ++ graphblas/core/infix.py | 78 ++++++++++++++++++---- graphblas/core/operator/base.py | 4 +- graphblas/core/operator/binary.py | 4 +- graphblas/core/scalar.py | 66 +++++++++---------- graphblas/tests/test_infix.py | 103 +++++++++++++++++++++++++++++- 6 files changed, 204 insertions(+), 55 deletions(-) diff --git a/graphblas/core/expr.py b/graphblas/core/expr.py index 47ff18a0b..3190c3e86 100644 --- a/graphblas/core/expr.py +++ b/graphblas/core/expr.py @@ -511,6 +511,10 @@ def _to_expr(self): self._expr = getattr(self.left, self.method_name)(self.right) return self._expr + def _call_left(self, method_name, *args, **kwargs): + """Call the left operand's ``method_name`` with the right operand, as ``op(x & y)`` does.""" + return getattr(self.left, method_name)(self.right, *args, **kwargs) + def _get_value(self, attr=None, default=None): expr = self._to_expr() return expr._get_value(attr=attr, default=default) diff --git a/graphblas/core/infix.py b/graphblas/core/infix.py index 73b3b91b5..7800be765 100644 --- a/graphblas/core/infix.py +++ b/graphblas/core/infix.py @@ -1,3 +1,5 @@ +import numpy as np + from .. import backend, binary from ..dtypes import BOOL from ..exceptions import DimensionMismatch @@ -6,9 +8,10 @@ from . import automethods, recorder, utils from .base import _expect_op, _expect_type from .expr import InfixExprBase +from .literals import _LITERAL_KINDS, _literal_operand from .mask import Mask from .matrix import Matrix, MatrixExpression, TransposedMatrix -from .scalar import Scalar, ScalarExpression +from .scalar import Scalar, ScalarExpression, _literal_scalar from .utils import output_type, wrapdoc from .vector import Vector, VectorExpression @@ -16,15 +19,27 @@ InfixExprBase._expect_type = _expect_type +def _typed_operands(self, op): + """Return the operands of ``self``, with a literal beside a Scalar typed as ``op``'s operand.""" + left, right = self.left, self.right + if self._is_scalar: + if output_type(left) is not Scalar: + left = _literal_scalar(right, left, op, within=self.method_name, argname="left") + elif output_type(right) is not Scalar: + right = _literal_scalar(left, right, op, within=self.method_name, argname="right") + return left, right + + def _ewise_add_to_expr(self): if self._expr is not None: return self._expr - if self.left.dtype == BOOL and self.right.dtype == BOOL: - self._expr = self.left.ewise_add(self.right, lor) + left, right = _typed_operands(self, lor) + if left.dtype == BOOL and right.dtype == BOOL: + self._expr = left.ewise_add(right, lor) return self._expr raise TypeError( "Bad dtypes for `x | y`! Automatic computation of `x | y` infix expressions is only valid " - f"for BOOL dtypes. The argument dtypes are {self.left.dtype} and {self.right.dtype}.\n\n" + f"for BOOL dtypes. The argument dtypes are {left.dtype} and {right.dtype}.\n\n" "When auto-computed for boolean dtypes, `x | y` performs ewise_add (union) using LOR.\n\n" "Typical usage is to create an ewise_add expression such as `monoid.plus(x | y)`." ) @@ -33,18 +48,27 @@ def _ewise_add_to_expr(self): def _ewise_mult_to_expr(self): if self._expr is not None: return self._expr - if self.left.dtype == BOOL and self.right.dtype == BOOL: - self._expr = self.left.ewise_mult(self.right, land) + left, right = _typed_operands(self, land) + if left.dtype == BOOL and right.dtype == BOOL: + self._expr = left.ewise_mult(right, land) return self._expr raise TypeError( "Bad dtypes for `x & y`! Automatic computation of `x & y` infix expressions is only valid " - f"for BOOL dtypes. The argument dtypes are {self.left.dtype} and {self.right.dtype}.\n\n" + f"for BOOL dtypes. The argument dtypes are {left.dtype} and {right.dtype}.\n\n" "When auto-computed for boolean dtypes, `x & y` performs ewise_mult (intersection) using " "LAND.\n\n" "Typical usage is to create an ewise_mult expression such as `monoid.times(x & y)`." ) +def _scalar_call_left(self, method_name, *args, **kwargs): + if output_type(self.left) is Scalar: + return getattr(self.left, method_name)(self.right, *args, **kwargs) + # A literal stays on the left: `binary.minus(1 & s)` is `1 - s`. Scalar's method types + # it beside `s` once the op is known, as it types a literal on the right. + return getattr(Scalar, method_name)(self.left, self.right, *args, **kwargs) + + class ScalarInfixExpr(InfixExprBase): __slots__ = () ndim = 0 @@ -127,6 +151,7 @@ class ScalarEwiseAddExpr(ScalarInfixExpr): _infix = "|" _to_expr = _ewise_add_to_expr + _call_left = _scalar_call_left # Allow e.g. `plus(x | y | z)` __or__ = Scalar.__or__ @@ -155,6 +180,7 @@ class ScalarEwiseMultExpr(ScalarInfixExpr): _infix = "&" _to_expr = _ewise_mult_to_expr + _call_left = _scalar_call_left # Allow e.g. `plus(x & y & z)` __and__ = Scalar.__and__ @@ -498,6 +524,33 @@ def _mismatched(left, right, method, op): raise DimensionMismatch # pragma: no cover +def _check_literal(scalar, value, *, within, argname): + """Raise TypeError now if ``value`` cannot be a literal beside ``scalar``, as ``+`` does. + + How a literal is typed depends on the op (``literals._literal_operand``), which + comes later, so here it is typed as ``==`` types it, the op that takes the most + literals, and dropped. A Python number is always a literal, so it is not checked. + Nor is a literal beside an infix expression, which has no dtype until an op is + applied: ``(s & t) & object()`` raises only then. + """ + if type(value) in _LITERAL_KINDS or isinstance(scalar, ScalarInfixExpr): + return + value, dtype = _literal_operand(scalar.dtype, value, binary.eq) + try: + # A float too large for a float32 leaf warns once, when the op types the literal + with np.errstate(over="ignore"): + Scalar.from_value(value, dtype, is_cscalar=False, name="") + except TypeError: + _expect_type( + scalar, + value, + Scalar, + within=within, + argname=argname, + extra_message="Literal scalars also accepted.", + ) + + def _ewise_infix_expr(left, right, *, method, within): left_type = output_type(left) right_type = output_type(right) @@ -530,14 +583,15 @@ def _ewise_infix_expr(left, right, *, method, within): left._expect_type(right, tuple(types), within=within, argname="right") if right_type in types: right._expect_type(left, tuple(types), within=within, argname="left") - if left_type is Scalar: + if left_type is Scalar or right_type is Scalar: + # A literal operand stays a literal, on its own side, until an op types it + if left_type is not Scalar: + _check_literal(right, left, within=within, argname="left") + elif right_type is not Scalar: + _check_literal(left, right, within=within, argname="right") if method == "ewise_mult": return ScalarEwiseMultExpr(left, right) return ScalarEwiseAddExpr(left, right) - if right_type is Scalar: - if method == "ewise_mult": - return ScalarEwiseMultExpr(right, left) - return ScalarEwiseAddExpr(right, left) raise TypeError( # pragma: no cover (sanity) f"Bad types for ewise infix: {type(left).__name__}, {type(right).__name__}" ) diff --git a/graphblas/core/operator/base.py b/graphblas/core/operator/base.py index aea4a6f51..b9cd34e52 100644 --- a/graphblas/core/operator/base.py +++ b/graphblas/core/operator/base.py @@ -111,9 +111,7 @@ def _call_op(op, left, right=None, thunk=None, **kwargs): if right is None and thunk is None: if isinstance(left, InfixExprBase): # op(A & B), op(A | B), op(A @ B) - return getattr(left.left, f"_{left.method_name}")( - left.right, op, is_infix=True, **kwargs - ) + return left._call_left(f"_{left.method_name}", op, is_infix=True, **kwargs) if find_opclass(op)[1] == "Semiring": raise TypeError( f"Bad type when calling {op!r}. Got type: {type(left)}.\n" diff --git a/graphblas/core/operator/binary.py b/graphblas/core/operator/binary.py index 491a3e0ac..b2860b3d7 100644 --- a/graphblas/core/operator/binary.py +++ b/graphblas/core/operator/binary.py @@ -203,9 +203,7 @@ def __call__(self, left, right=None, *, left_default=None, right_default=None): f">>> {self}(x | y, left_default=0, right_default=0)\n\nwhere x and y " "are Vectors or Matrices, and left_default and right_default are scalars." ) - return left.left._ewise_union( - left.right, self, left_default, right_default, is_infix=True - ) + return left._call_left("_ewise_union", self, left_default, right_default, is_infix=True) return _call_op(self, left, right) @property diff --git a/graphblas/core/scalar.py b/graphblas/core/scalar.py index a7cf2189b..f5fff642a 100644 --- a/graphblas/core/scalar.py +++ b/graphblas/core/scalar.py @@ -689,20 +689,11 @@ def _ewise_add(self, other, op=monoid.plus, is_infix=False): self = op(self).new() if isinstance(other, ScalarEwiseAddExpr): other = op(other).new() + if output_type(self) is not Scalar: # A literal on the left, as in `op(1 | s)` + self = _literal_scalar(other, self, op, within=method_name, argname="left") if type(other) is not Scalar: - other, dtype = _literal_operand(self.dtype, other, op) - try: - other = Scalar.from_value(other, dtype, is_cscalar=False, name="") - except TypeError: - other = self._expect_type( - other, - Scalar, - within=method_name, - keyword_name="other", - extra_message="Literal scalars also accepted.", - op=op, - ) + other = _literal_scalar(self, other, op, within=method_name, keyword_name="other") op = get_typed_op(op, self.dtype, other.dtype, kind="binary") self._expect_op(op, ("BinaryOp", "Monoid"), within=method_name, argname="op") if _ewise_add_needs_cast(op, self.dtype, other.dtype): @@ -763,20 +754,11 @@ def _ewise_mult(self, other, op=binary.times, is_infix=False): self = op(self).new() if isinstance(other, ScalarEwiseMultExpr): other = op(other).new() + if output_type(self) is not Scalar: # A literal on the left, as in `op(1 & s)` + self = _literal_scalar(other, self, op, within=method_name, argname="left") if type(other) is not Scalar: - other, dtype = _literal_operand(self.dtype, other, op) - try: - other = Scalar.from_value(other, dtype, is_cscalar=False, name="") - except TypeError: - other = self._expect_type( - other, - Scalar, - within=method_name, - keyword_name="other", - extra_message="Literal scalars also accepted.", - op=op, - ) + other = _literal_scalar(self, other, op, within=method_name, keyword_name="other") op = get_typed_op(op, self.dtype, other.dtype, kind="binary") self._expect_op(op, ("BinaryOp", "Monoid"), within=method_name, argname="op") expr = ScalarExpression( @@ -841,22 +823,13 @@ def _ewise_union(self, other, op, left_default, right_default, is_infix=False): self = op(self, left_default=left_default, right_default=right_default).new() if isinstance(other, ScalarEwiseAddExpr): other = op(other, left_default=left_default, right_default=right_default).new() + if output_type(self) is not Scalar: # A literal on the left, as in `op(1 | s)` + self = _literal_scalar(other, self, op, within=method_name, argname="left") right_dtype = self.dtype dtype = right_dtype if right_dtype._is_udt else None if type(other) is not Scalar: - other, literal_dtype = _literal_operand(right_dtype, other, op) - try: - other = Scalar.from_value(other, literal_dtype, is_cscalar=False, name="") - except TypeError: - other = self._expect_type( - other, - Scalar, - within=method_name, - keyword_name="other", - extra_message="Literal scalars also accepted.", - op=op, - ) + other = _literal_scalar(self, other, op, within=method_name, keyword_name="other") else: # A typed Scalar keeps its dtype beside a lifted op, as it does for # Vector.ewise_union: ``s8 - sf`` is float, not ``sf`` cut to int8. @@ -1208,6 +1181,27 @@ def _as_scalar(scalar, dtype=None, *, is_cscalar): return scalar +def _literal_scalar(scalar, value, op, *, within, argname=None, keyword_name=None): + """Return literal ``value``, ``op``'s operand beside ``scalar`` on either side, as a Scalar. + + It is typed as any literal operand is (``literals._literal_operand``). A value + that cannot be a Scalar raises TypeError, naming ``argname`` or ``keyword_name``. + """ + value, dtype = _literal_operand(scalar.dtype, value, op) + try: + return Scalar.from_value(value, dtype, is_cscalar=False, name="") + except TypeError: + return scalar._expect_type( + value, + Scalar, + within=within, + argname=argname, + keyword_name=keyword_name, + extra_message="Literal scalars also accepted.", + op=op, + ) + + def _union_default(obj, default, input_dtype, op, keyword_name): """Return ``default`` for ``obj.ewise_union`` as a Scalar, where the op takes ``input_dtype``. diff --git a/graphblas/tests/test_infix.py b/graphblas/tests/test_infix.py index 94d2555ae..f0e91a2a4 100644 --- a/graphblas/tests/test_infix.py +++ b/graphblas/tests/test_infix.py @@ -1,6 +1,8 @@ +import numpy as np import pytest -from graphblas import binary, monoid, op +from graphblas import binary, dtypes, monoid, op +from graphblas.core import _supports_udfs as supports_udfs # noqa: F401 (used in skipif) from graphblas.exceptions import DimensionMismatch from .conftest import autocompute @@ -119,6 +121,11 @@ def test_bad_ewise(s1, v1, A1, A2): (s1, A1.T), (A1, 1), (1, A1), + # A Scalar takes a literal, but not just any object, as `s1 + object()` does not + (s1, object()), + (object(), s1), + (s1, "x"), + (None, s1), ]: with pytest.raises(TypeError, match="Bad type for argument"): left | right @@ -763,6 +770,100 @@ def test_multi_infix_scalar(): (v1 | v2).ewise_mult(v3) +def test_scalar_infix_literal_keeps_its_side(): + # `op(2 & s)` applies op to 2 and s in that order, as `op(2, s)` does + s = Scalar.from_value(5, dtype=dtypes.INT8) + expr = 2 & s + assert expr.left == 2 + assert expr.right is s + for binop, expected, dtype in [ + (binary.minus, -3, dtypes.INT8), + (binary.rminus, 3, dtypes.INT8), + (binary.cdiv, 0, dtypes.INT8), + (binary.rdiv, 2, dtypes.INT8), + (binary.truediv, 0.4, dtypes.FP64), + (binary.first, 2, dtypes.INT8), + (binary.second, 5, dtypes.INT8), + (binary.pow, 32, dtypes.INT8), + (binary.lt, True, dtypes.BOOL), + (binary.gt, False, dtypes.BOOL), + (binary.isgt, 0, dtypes.INT8), + ]: + for expr in [2 & s, 2 | s]: + result = binop(expr).new() + assert result.value == expected + assert result.dtype == dtype + assert result.isequal(binop(2, s).new(), check_dtype=True) + # Each default goes with its side + empty = Scalar(dtypes.INT8) + assert binary.minus(2 | empty, left_default=0, right_default=10).new().value == -8 + assert binary.minus(empty | 2, left_default=0, right_default=10).new().value == -2 + assert binary.minus(2 | s, left_default=0, right_default=10).new().value == -3 + # And so does each literal in a chain + assert binary.minus(1 & (2 & s)).new().value == 4 + assert binary.minus((2 & s) & 1).new().value == -4 + assert binary.minus(1 | (2 | s)).new().value == 4 + + # A Python literal is typed beside the Scalar as by `-` (weak), once the op is known + f32 = Scalar.from_value(2.5, dtype=dtypes.FP32) + t = Scalar.from_value(True) + for lit, x in [(2, s), (0.5, s), (0.5, f32), (2, f32), (2, t)]: + for expr in [lit & x, lit | x]: + assert binary.minus(expr).new().isequal((lit - x).new(), check_dtype=True) + assert binary.minus(0.5 & s).new().dtype == dtypes.FP64 + assert binary.minus(0.5 & f32).new().dtype == dtypes.FP32 + assert binary.first(False & t).new().dtype == dtypes.BOOL + assert binary.lt(False | t).new().value is True + # 300 does not fit INT8, which `-` reports, and `<` compares it as it is + with pytest.raises(OverflowError): + binary.minus(300 & s) + assert binary.lt(300 & s).new().value is False + assert binary.gt(300 | s).new().value is True + + # BOOL `&` and `|` with a literal compute themselves, by LAND and LOR + for expr, expected in [ + (t & True, True), + (False & t, False), + (t | False, True), + (False | t, True), + ]: + result = expr.new() + assert result.dtype == dtypes.BOOL + assert result.value is expected + with pytest.raises(TypeError, match="The argument dtypes are INT64 and BOOL"): + (1 & t).new() + with pytest.raises(TypeError, match="only valid for BOOL"): + (s | 2).new() + + +@pytest.mark.skipif("not supports_udfs") +def test_scalar_infix_literal_keeps_its_side_udt(): + # The op types a literal beside a UDT, on the left as on the right: a lifted op as numpy + # would, and `first` as an element of the UDT, which 0.5 is not + udt = dtypes.register_anonymous(np.dtype((np.int8, (2,))), "_InfixLiteralI8") + s = Scalar.from_value([5, 7], udt) + result = binary.minus((1, 2) & s).new() + assert result.dtype == udt + np.testing.assert_array_equal(result.value, [-4, -5]) + result = binary.minus(0.5 | s).new() + assert result.dtype.np_type == np.dtype((np.float64, (2,))) + np.testing.assert_array_equal(result.value, [-4.5, -6.5]) + np.testing.assert_array_equal(binary.first([1, 2] & s).new().value, [1, 2]) + with pytest.raises(ValueError, match="does not fit"): + binary.first(0.5 & s) + with pytest.raises(TypeError, match="Bad type for argument `left`"): + object() & s + # 1e300 overflows a float32 leaf: the op warns once, as `+` does, and `&` does not + f32s = Scalar.from_value( + [1, 2], dtypes.register_anonymous(np.dtype((np.float32, (2,))), "_InfixLiteralF32") + ) + expr = f32s & (1e300, 1.0) # the tests turn warnings into errors + with pytest.warns(RuntimeWarning, match="overflow") as record: + result = binary.plus(expr).new() + assert len(record) == 1 + np.testing.assert_array_equal(result.value, [np.inf, 3]) + + @autocompute def test_multi_infix_scalar_auto(): v1 = Scalar.from_value(1) From 021ec7f018c2898d28c54c8f479a15a0125f314c Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Wed, 21 Oct 2026 00:25:36 -0500 Subject: [PATCH 2/7] Apply `lit - s` and the like as minus(lit, s), not as rminus(s, lit) Behavior fix. Since 2023.1.0, a Scalar's reflected operators (`lit - s`, `lit / s`, `lit ** s` and so on, with a literal on the left) computed the op's commutes_to with the Scalar first, rminus(s, lit). That fails where no such op exists: 1 - udt_s, 1 / udt_s, 1 // udt_s, (1, 2) - udt_s # KeyError: 'rminus does not work with ...'; no reflected op # takes a UDT, though `udt_s - 1` and `1 - udt_vector` work 7 % s # TypeError for every dtype: numpy.mod has no reflected op 2 ** s # TypeError without Numba: rpow is a Numba function Now the literal becomes a Scalar on the left, typed beside s as in `binary.minus(1 & s)`, and the op itself is applied: `1 - udt_s` is [-4, -6] for udt_s = [5, 7], and `7 % int8_s` is 2 for 5. `lit ** s` now gives what `binary.pow(lit, s)` and `lit ** vector` give. That changes four kinds of result, where rpow, a Numba function, differed: - an integer power out of range saturates, as GraphBLAS's pow does, where rpow wrapped: `3 ** int8_s` for 5 was -13 and is now 127, and `0 ** int8_s` for -3 was 0 and is now 127; - `False ** bool_s` is BOOL, not INT8; - `0 ** complex_s` is 0, not nan (for 1+2j), and other complex powers may differ in the last bit; - a Python int beyond INT64 beside a BOOL Scalar, as in `(2**64 - 1) ** bool_s`, raises OverflowError, as `bool_s ** (2**64 - 1)` and `(2**64 - 1) ** bool_vector` do, where it gave a UINT64. Nothing else changes: for `+ - * / // % ** ^`, nine Scalars (INT8, INT64, UINT8, FP32, FP64, BOOL, empty) and eight literals, every result the previous code gave is the same but those of `**`; and for the seven that are not empty, `lit OP s` now matches `binary.op(lit, s)` for each of `+ - * / // % **` (it did not in 66 of 392 cases). --- graphblas/core/infixmethods.py | 20 +++++++++----------- graphblas/tests/test_scalar.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 11 deletions(-) diff --git a/graphblas/core/infixmethods.py b/graphblas/core/infixmethods.py index 53f9d76f0..609f44c9d 100644 --- a/graphblas/core/infixmethods.py +++ b/graphblas/core/infixmethods.py @@ -2,7 +2,7 @@ from ..dtypes import BOOL from .infix import MatrixInfixExpr, ScalarInfixExpr, VectorInfixExpr from .matrix import Matrix, MatrixExpression, MatrixIndexExpr, TransposedMatrix -from .scalar import Scalar, ScalarExpression, ScalarIndexExpr +from .scalar import Scalar, ScalarExpression, ScalarIndexExpr, _literal_scalar from .udt import _ewise_add_casts_udt, _plus_zero from .utils import output_type from .vector import Vector, VectorExpression, VectorIndexExpr @@ -32,18 +32,16 @@ def call_op(self, other, op, *, outer=False, union=False): return op(self, other) if type2 in types: return op(self, other) - # Scalars + # Scalars. A literal on the left stays there: `1 - s` is minus(1, s), typed as + # `binary.minus(1 & s)` is, since rminus may not exist for s's dtype (a UDT). + method = "ewise_add" if outer else "ewise_union" if union else "ewise_mult" + if type1 is not Scalar: + self = _literal_scalar(other, self, op, within=method, argname="left") if outer: - if type1 is Scalar: - return self.ewise_add(other, op) - return other.ewise_add(self, op.commutes_to) + return self.ewise_add(other, op) if union: - if type1 is Scalar: - return self.ewise_union(other, op, False, False) - return other.ewise_union(self, op.commutes_to, False, False) - if type1 is Scalar: - return self.ewise_mult(other, op) - return other.ewise_mult(self, op.commutes_to) + return self.ewise_union(other, op, False, False) + return self.ewise_mult(other, op) def __divmod__(self, other): diff --git a/graphblas/tests/test_scalar.py b/graphblas/tests/test_scalar.py index 3ade2fb25..5f85cc759 100644 --- a/graphblas/tests/test_scalar.py +++ b/graphblas/tests/test_scalar.py @@ -905,3 +905,37 @@ def test_scalar_operators(s): unary.sin(object()) with pytest.raises(TypeError): binary.plus(object(), object()) + + +@autocompute +def test_scalar_operators_literal_on_the_left(): + # `3 ** s` is pow(3, s), as `binary.pow(3, s)` is, with no reflected op: numpy.mod has + # none, rpow is a Numba function, and none takes a UDT + s = Scalar.from_value(5, dtype=dtypes.INT8) + assert 2 - s == -3 + assert 2**s == 32 + result = (3**s).new() + assert result.dtype == dtypes.INT8 + assert result.value == 127 # GraphBLAS's pow saturates + t = Scalar.from_value(True) + result = (False**t).new() + assert result.dtype == dtypes.BOOL + assert result.value is False + if gb.core._supports_udfs: + assert 7 % s == 2 + + +@pytest.mark.skipif("not gb.core._supports_udfs") +def test_scalar_operators_literal_on_the_left_udt(): + udt = dtypes.register_anonymous(np.dtype((np.int8, (2,))), "_LeftLiteralI8") + u = Scalar.from_value([5, 7], udt) + for result, expected in [ + (1 - u, [-4, -6]), + ((1, 2) - u, [-4, -5]), + (1 // u, [0, 0]), + (10 / u, [2.0, 10 / 7]), + ]: + result = result.new() + np.testing.assert_array_equal(result.value, expected) + assert (1 - u).new().dtype == udt + assert (10 / u).new().dtype.np_type == np.dtype((np.float64, (2,))) From d8fb8ac96751f518c2f3ca101b7dce0e89d753d2 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Wed, 21 Oct 2026 00:29:40 -0500 Subject: [PATCH 3/7] Show a Scalar's `&` and `|` with a literal or a non-BOOL dtype Behavior fix. Since 2023.1.0, showing a Scalar `&` or `|` expression raised in two cases: repr(s & 1), repr(1 | s), repr(t & True), and their _repr_html_ # AttributeError: 'int' object has no attribute 'name' repr(s & s) # s not BOOL, with autocompute on (the default) # TypeError: Bad dtypes for `x & y`! Automatic computation ... A literal operand now shows its value, as an unnamed Scalar does in an expression, and its own type where a dtype goes, since it has no dtype until an op types it: gb.ScalarEwiseMultExpr left_dtype right_dtype s_1 & 1 INT8 int The text repr now computes the expression to show its result only where the HTML repr, and the reprs of Vector and Matrix infix expressions, do: for `x @ y`, and for `x & y` and `x | y` of BOOL operands. A literal counts as BOOL when it is a bool, so `t & True` shows its result. A chain such as `(s & t) & u` still raises in a repr, as it does for Vector and Matrix. --- graphblas/core/formatting.py | 44 ++++++++++++++++------- graphblas/core/infix.py | 20 +++++++++++ graphblas/tests/test_formatting.py | 58 ++++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+), 13 deletions(-) diff --git a/graphblas/core/formatting.py b/graphblas/core/formatting.py index 5fe9b6972..2fb6927f2 100644 --- a/graphblas/core/formatting.py +++ b/graphblas/core/formatting.py @@ -4,6 +4,7 @@ from .. import backend, config, monoid, unary from ..dtypes import BOOL from ..exceptions import OutOfMemory +from .infix import _is_literal from .matrix import Matrix, TransposedMatrix from .vector import Vector @@ -717,11 +718,7 @@ def _format_infix_expression(expr, header, expr_name): ) computed = "" - if config.get("autocompute") and ( - expr.method_name not in {"ewise_add", "ewise_mult"} - or expr.left.dtype == BOOL - and expr.right.dtype == BOOL - ): + if config.get("autocompute") and _computes_itself(expr): computed = get_expr_result(expr, html=True) if "__EXPR__" in computed: return computed.replace("__EXPR__", topline) @@ -740,17 +737,42 @@ def _format_infix_expression(expr, header, expr_name): ) +def _operand_dtype(operand): + """The dtype to show for an operand of a Scalar infix expression. + + A literal, as in ``s & 1``, has no dtype until an op types it, so its own + type is shown. + """ + if _is_literal(operand): + return type(operand).__name__ + return operand.dtype + + +def _computes_itself(expr): + """Whether infix ``expr`` computes without an op, as ``x @ y`` and BOOL ``x & y`` do.""" + if expr.method_name not in {"ewise_add", "ewise_mult"}: + return True + return _is_bool(expr.left) and _is_bool(expr.right) + + +def _is_bool(operand): + if _is_literal(operand): + # Beside a Scalar, as in `s & True`, a literal is BOOL beside BOOL if it is a bool + return type(operand) in {bool, np.bool_} + return operand.dtype == BOOL + + def format_scalar_infix_expression(expr): expr_repr = expr._format_expr() name = f"gb.{type(expr).__name__}" header = create_header( expr_repr, ["left_dtype", "right_dtype"], - [expr.left.dtype, expr.right.dtype], + [_operand_dtype(expr.left), _operand_dtype(expr.right)], name=name, quote=False, ) - arg_string = get_result_string(expr) + arg_string = get_infix_result_string(expr) return ( f"{header}\n\n" f"{arg_string}" @@ -764,17 +786,13 @@ def format_scalar_infix_expression_html(expr): header = create_header_html( expr_html, ["left_dtype", "right_dtype"], - [expr.left.dtype, expr.right.dtype], + [_operand_dtype(expr.left), _operand_dtype(expr.right)], ) return _format_infix_expression(expr, header, expr_html) def get_infix_result_string(expr): - if ( - expr.method_name not in {"ewise_add", "ewise_mult"} - or expr.left.dtype == BOOL - and expr.right.dtype == BOOL - ): + if _computes_itself(expr): arg_string = get_result_string(expr) else: arg_string = "" diff --git a/graphblas/core/infix.py b/graphblas/core/infix.py index 7800be765..0af2084fd 100644 --- a/graphblas/core/infix.py +++ b/graphblas/core/infix.py @@ -61,6 +61,18 @@ def _ewise_mult_to_expr(self): ) +def _is_literal(operand): + """Whether an operand of an infix expression is a literal, which only a Scalar takes.""" + return output_type(operand) not in {Scalar, Vector, Matrix, TransposedMatrix} + + +def _operand_name(operand, *, html=False): + if _is_literal(operand): + # As in `s & 1`; it shows its value, as an unnamed Scalar does + return repr(operand) + return operand._name_html if html else operand.name + + def _scalar_call_left(self, method_name, *args, **kwargs): if output_type(self.left) is Scalar: return getattr(self.left, method_name)(self.right, *args, **kwargs) @@ -94,6 +106,14 @@ def dup(self, dtype=None, *, clear=False, is_cscalar=False, name=None, **opts): def is_grbscalar(self): return not self.is_cscalar + def _format_expr(self): + return f"{_operand_name(self.left)} {self._infix} {_operand_name(self.right)}" + + def _format_expr_html(self): + left = _operand_name(self.left, html=True) + right = _operand_name(self.right, html=True) + return f"{left} {self._infix} {right}" + __hash__ = None # Begin auto-generated code: Scalar diff --git a/graphblas/tests/test_formatting.py b/graphblas/tests/test_formatting.py index 2b412ac1d..75393afa0 100644 --- a/graphblas/tests/test_formatting.py +++ b/graphblas/tests/test_formatting.py @@ -4010,6 +4010,64 @@ def test_infix_expr_repr(A, B, v): ) +@pytest.mark.skipif("not pd") +def test_scalar_infix_expr_repr_literal(): + # A literal shows its value and its own type, as it has no dtype until an op types it + s = Scalar.from_value(5, dtype=dtypes.INT8, name="s_1") + repr_printer(s & 1, "s & 1") + assert repr(s & 1) == ( + "gb.ScalarEwiseMultExpr left_dtype right_dtype\n" + "s_1 & 1 INT8 int\n" + "\n" + "Do op(expr) to create a ScalarExpression for ewise_mult.\n" + "For example: times(s_1 & 1)" + ) + repr_printer(0.5 | s, "0.5 | s") + assert repr(0.5 | s) == ( + "gb.ScalarEwiseAddExpr left_dtype right_dtype\n" + "0.5 | s_1 float INT8\n" + "\n" + "Do op(expr) to create a ScalarExpression for ewise_add.\n" + "For example: plus(0.5 | s_1)" + ) + html = repr_html(s & 1) + assert "
s1 & 1
" in html + assert "INT8\n int" in html + + +@autocompute +def test_scalar_infix_expr_repr_auto(): + if not pd: # pragma: no cover (import) + pytest.skip("needs pandas") + # As for Vector, only BOOL `x & y` and `x | y` compute without an op, to show the result + s = Scalar.from_value(5, dtype=dtypes.INT8, name="s_1") + repr_printer(s & s, "s & s") + assert repr(s & s) == ( + "gb.ScalarEwiseMultExpr left_dtype right_dtype\n" + "s_1 & s_1 INT8 INT8\n" + "\n" + "Do op(expr) to create a ScalarExpression for ewise_mult.\n" + "For example: times(s_1 & s_1)" + ) + assert "Result" not in repr(s | 1) + assert '
' not in repr_html(s & s) + t = Scalar.from_value(True, name="t") + repr_printer(t & True, "t & True") + assert repr(t & True) == ( + "gb.ScalarEwiseMultExpr left_dtype right_dtype\n" + "t & True BOOL bool\n" + "\n" + '"Result" value dtype\n' + "gb.Scalar True BOOL\n" + "\n" + "Do op(expr) to create a ScalarExpression for ewise_mult.\n" + "For example: times(t & True)" + ) + html = repr_html(False | t) + assert '
' in html + assert "True\n BOOL" in html + + @pytest.mark.skipif("not pd") def test_inner_outer_repr_html(v): html_printer(v.inner(v), "v.inner(v)") From 2dd910c7df839b8216f0357f960b7708636eb966 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Tue, 27 Oct 2026 09:06:50 -0500 Subject: [PATCH 4/7] Raise DimensionMismatch, not TypeError, for a size mismatch beside an infix expression A behavior fix. When an operand of `&`, `|` or `@` was itself an infix expression and the sizes did not match, building the error crashed in _mismatched, which called _dummy(obj) while _dummy took a second parameter, obj_type, that it never used (since #517). Before: >>> (v & v) & w # v of size 3, w of size 4 TypeError: _dummy() missing 1 required positional argument: 'obj_type' After: >>> (v & v) & w DimensionMismatch: ... The same held with the expression on the right (w & (v & v)), for `|`, for Matrix operands ((A & A) & B), and for `@` ((A @ A) @ w). _dummy now takes only obj. Tests cover Vector and Matrix with the expression on either side, for `|`, `&` and `@`. --- graphblas/core/infix.py | 2 +- graphblas/tests/test_infix.py | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/graphblas/core/infix.py b/graphblas/core/infix.py index 0af2084fd..7e6fa77b8 100644 --- a/graphblas/core/infix.py +++ b/graphblas/core/infix.py @@ -531,7 +531,7 @@ def __init__(self, left, right, *, nrows, ncols): utils._output_types[MatrixMatMulExpr] = Matrix -def _dummy(obj, obj_type): +def _dummy(obj): with recorder.skip_record: return output_type(obj)(BOOL, *obj.shape, name="") diff --git a/graphblas/tests/test_infix.py b/graphblas/tests/test_infix.py index f0e91a2a4..925cddb91 100644 --- a/graphblas/tests/test_infix.py +++ b/graphblas/tests/test_infix.py @@ -170,6 +170,13 @@ def test_bad_ewise(s1, v1, A1, A2): A1.T | A1 with pytest.raises(DimensionMismatch): A1.T & A1 + # Beside an infix expression, on either side + for left, right in [(v1 | v1, w), (w, v1 | v1), (A2 | A2, A1), (A1, A2 | A2)]: + with pytest.raises(DimensionMismatch): + left | right + for left, right in [(v1 & v1, w), (w, v1 & v1), (A2 & A2, A1), (A1, A2 & A2)]: + with pytest.raises(DimensionMismatch): + left & right # These are okay now # with pytest.raises(TypeError): @@ -227,6 +234,15 @@ def test_bad_matmul(s1, v1, A1, A2): A1.T @ A1.T with pytest.raises(DimensionMismatch): A1 @= A1 + # Beside an infix expression, on either side + for left, right in [ + (A1 @ A2, v1), + (v1, A1 @ A2), + (A2 @ A1, A2.T), + (A2, A2 @ A1), + ]: + with pytest.raises(DimensionMismatch): + left @ right with pytest.raises(TypeError): s1 @ 1 with pytest.raises(TypeError): From 7607e069079d0bd40f5bd19299084b8fd54eacb5 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Wed, 28 Oct 2026 22:56:08 -0500 Subject: [PATCH 5/7] Let numpy's operators defer to graphblas objects Behavior change. A numpy scalar or 0-d array on the left of an infix operator was computed by numpy, which converted a Scalar with __array__ and could not convert a Vector or Matrix. graphblas objects now set __array_priority__, so numpy's operators return NotImplemented and Python calls the graphblas reflected method, which takes the numpy value as a literal operand, typed as it is on the right: s = Scalar.from_value(5, dtypes.INT8) np.int16(2) - s # was np.int16(-3), now an INT16 ScalarExpression np.int16(2) < s # was np.True_, now a BOOL ScalarExpression np.int16(5) == s # was np.True_, now True (Scalar's ==) np.int16(2) - v # was a TypeError, now an INT16 VectorExpression np.int16(2) == A # was a TypeError, now a BOOL MatrixExpression The same holds for expressions, A.T and index expressions. `&`, `|` and `^` now go to graphblas too: `np.int16(2) & s` was numpy's bitwise and, np.int16(0), and is now the TypeError of `2 & s` (only BOOL computes). With an empty Scalar, numpy computed with nan; the result is now what a Python float on the left gives: e = Scalar(float) np.float64(5) + e # was nan, now 5.0, as 5.0 + e is (and `-`) np.float64(5) * e # was nan, now an empty Scalar, as 5.0 * e is np.float64(5) < e # was np.False_, now an empty Scalar np.float64(5) == e # was np.False_, now False What numpy computed and graphblas does not now raises TypeError: np.array([1, 2]) - s # was array([-4, -3]) arr -= s # was array([-4, -3]), in place np.int16(2) << s # was np.int16(64); graphblas has no `<<` On numpy < 2, where a size-1 array beside a Scalar is a literal (on the right too), `np.array([1]) - s` is now an INT64 ScalarExpression. Ufuncs still use __array__: np.sin(s), np.subtract(np.int16(2), s) and np.asarray(s) give numpy values as before. --- docs/user_guide/types.rst | 4 +- graphblas/core/base.py | 7 +++ graphblas/core/expr.py | 2 + graphblas/core/matrix.py | 1 + graphblas/tests/test_infix.py | 90 ++++++++++++++++++++++++++++++++++- 5 files changed, 101 insertions(+), 3 deletions(-) diff --git a/docs/user_guide/types.rst b/docs/user_guide/types.rst index 65b941cf9..2a0649e38 100644 --- a/docs/user_guide/types.rst +++ b/docs/user_guide/types.rst @@ -41,7 +41,9 @@ with an INT8 vector ``v``, ``v + 1`` is INT8 and ``v * 0.5`` is FP64, while ``v + 300`` raises ``OverflowError`` rather than wrapping, and ``v * 2`` wraps in INT8 as it does in numpy. An FP32 vector times ``0.5`` is FP32, plus ``1j`` is FC32, and plus ``1e300`` is infinity, with a ``RuntimeWarning`` as in numpy. numpy scalars, 0-d arrays and ``Scalar`` objects keep their own -type: ``v + np.int64(1)`` is INT64. python-graphblas applies this rule itself, +type: ``v + np.int64(1)`` is INT64, and so is ``np.int64(1) + v``, since numpy's +operators leave an operation with a python-graphblas object to python-graphblas. +python-graphblas applies this rule itself, so it does not depend on the installed version of numpy. User-defined types follow the same rule, field by field (see :doc:`udt`). diff --git a/graphblas/core/base.py b/graphblas/core/base.py index a3562f922..76a2ae3cc 100644 --- a/graphblas/core/base.py +++ b/graphblas/core/base.py @@ -190,6 +190,12 @@ class BaseType: __slots__ = "gb_obj", "dtype", "name", "__weakref__" # Flag for operations which depend on scalar vs vector/matrix _is_scalar = False + # numpy's operators return NotImplemented for an operand of higher priority than theirs + # (ndarray's is 0, np.matrix's 10), so with a numpy scalar or array on the left Python calls + # our reflected method, and `np.int16(2) - s` is the expression `s - np.int16(2)` would be, + # not numpy's value of `s.__array__()`. Ufuncs such as `np.sin(s)` still use __array__. + # Expressions, TransposedMatrix and the index expressions set it too. + __array_priority__ = 1000 def __call__( self, @@ -577,6 +583,7 @@ class BaseExpression: ) output_type = None _is_scalar = False + __array_priority__ = BaseType.__array_priority__ # numpy's operators defer to ours def __init__( self, diff --git a/graphblas/core/expr.py b/graphblas/core/expr.py index 3190c3e86..236e29534 100644 --- a/graphblas/core/expr.py +++ b/graphblas/core/expr.py @@ -290,6 +290,7 @@ def __bool__(self): class AmbiguousAssignOrExtract: __slots__ = "parent", "resolved_indexes", "_value", "__weakref__" _is_scalar = False + __array_priority__ = 1000 # numpy's operators defer to ours; see BaseType def __init__(self, parent, resolved_indexes): self.parent = parent @@ -484,6 +485,7 @@ def __bool__(self): class InfixExprBase: __slots__ = "left", "right", "_expr", "__weakref__" _is_scalar = False + __array_priority__ = 1000 # numpy's operators defer to ours; see BaseType def __init__(self, left, right): self.left = left diff --git a/graphblas/core/matrix.py b/graphblas/core/matrix.py index e1876c58f..23d65f4fa 100644 --- a/graphblas/core/matrix.py +++ b/graphblas/core/matrix.py @@ -3893,6 +3893,7 @@ class TransposedMatrix: ndim = 2 _is_scalar = False _is_transposed = True + __array_priority__ = BaseType.__array_priority__ # numpy's operators defer to ours __networkx_backend__ = "graphblas" __networkx_plugin__ = "graphblas" diff --git a/graphblas/tests/test_infix.py b/graphblas/tests/test_infix.py index 925cddb91..75150d7d0 100644 --- a/graphblas/tests/test_infix.py +++ b/graphblas/tests/test_infix.py @@ -1,8 +1,11 @@ +import operator + import numpy as np import pytest -from graphblas import binary, dtypes, monoid, op -from graphblas.core import _supports_udfs as supports_udfs # noqa: F401 (used in skipif) +from graphblas import binary, dtypes, monoid, op, unary +from graphblas.core import _supports_udfs as supports_udfs +from graphblas.core.utils import output_type from graphblas.exceptions import DimensionMismatch from .conftest import autocompute @@ -880,6 +883,89 @@ def test_scalar_infix_literal_keeps_its_side_udt(): np.testing.assert_array_equal(result.value, [np.inf, 3]) +@autocompute +def test_numpy_value_on_the_left(): + # numpy's operators defer to ours (__array_priority__), so a numpy scalar or 0-d array on + # the left is a literal operand, as on the right, and numpy does not compute the result + s = Scalar.from_value(5, dtype=dtypes.INT8) + v = Vector.from_coo([0, 2], [5, 6], dtype=dtypes.INT8, size=3) + A = Matrix.from_coo([0, 1], [1, 0], [5, 6], dtype=dtypes.INT8) + ops = [ + (operator.add, binary.plus), + (operator.sub, binary.minus), + (operator.mul, binary.times), + (operator.truediv, binary.truediv), + (operator.pow, binary.pow), + (operator.lt, binary.lt), + (operator.le, binary.le), + (operator.gt, binary.gt), + (operator.ge, binary.ge), + ] + if supports_udfs: # integer floordiv and numpy.mod are Numba functions + ops += [(operator.floordiv, binary.floordiv), (operator.mod, binary.numpy.mod)] + operands = [ + (s, s), + (s.apply(unary.identity), s), + (v[0], s), + (v @ v, (v @ v).new()), + (v, v), + (v.apply(unary.identity), v), + (v[[0, 1, 2]], v), + (A, A), + (A.T, A.T.new()), + (A.apply(unary.identity), A), + (A[:, :], A), + (A @ A, (A @ A).new()), + ] + for x, computed in operands: + is_scalar = output_type(x) is Scalar + cases = ops if is_scalar else [*ops, (operator.eq, binary.eq), (operator.ne, binary.ne)] + for lit in [np.int16(2), np.float32(0.5), np.array(3)]: + for infix, binop in cases: + expected = binop(lit, computed).new() + assert infix(lit, x).new().isequal(expected, check_dtype=True) + if is_scalar: + # A Scalar's `==` is a bool, not numpy's np.bool_ + assert (np.int16(computed.value) == x) is True + assert (np.int16(computed.value) != x) is False + with pytest.raises(TypeError, match="unsupported operand"): + np.int16(2) @ x + else: + with pytest.raises(TypeError, match="Bad type for argument `left`"): + np.int16(2) @ x + with pytest.raises(TypeError, match="Bad type for argument `left`"): + np.ones(x.shape[0]) @ x + # Before, numpy computed these from s.__array__() and raised for v and A + with pytest.raises(TypeError, match="Bad type"): + np.array([1, 2]) - x + with pytest.raises(TypeError, match="unsupported operand"): + np.int16(2) << x + + # `&`, `|` and `^` as with a Python literal + t = Scalar.from_value(True) + bv = Vector.from_coo([0, 2], [True, False], size=3) + for infix in [operator.and_, operator.or_, operator.xor]: + assert infix(np.False_, t).new().isequal(infix(False, t).new(), check_dtype=True) + assert (np.True_ ^ bv).new().isequal((True ^ bv).new(), check_dtype=True) + for infix in [operator.and_, operator.or_]: + with pytest.raises(TypeError, match="Bad type for argument `left`"): + infix(np.True_, bv) + with pytest.raises(TypeError, match="only valid for BOOL"): + (np.int16(2) & s).new() + + # In place, numpy no longer computes an array minus a Scalar + arr = np.array([1, 2]) + with pytest.raises(TypeError, match="Bad type"): + arr -= s + np.testing.assert_array_equal(arr, [1, 2]) + # Ufuncs still convert a Scalar with __array__ + assert np.sin(s) == np.sin(np.int8(5)) + np.testing.assert_array_equal(np.asarray(s), np.array(5, np.int8)) + result = np.subtract(np.int16(2), s) + assert isinstance(result, np.int16) + assert result == -3 + + @autocompute def test_multi_infix_scalar_auto(): v1 = Scalar.from_value(1) From 7b13e55f30a442e975a7b22524963bc57a4ce8f5 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Tue, 3 Nov 2026 08:31:26 -0800 Subject: [PATCH 6/7] Type a literal on the left of a Scalar op as the op's left operand _literal_scalar types a literal on either side of a Scalar op, as in op(1 & s) or 1 - s, through _literal_operand, which now takes left=True for a left operand: a typed op takes each operand in its own input type ("Type a literal beside a typed op in the op's own input type", in #591). _literal_scalar did not pass it, so with an op typed for two different types, a literal on the left was typed as the right operand. Every left-side caller passes argname="left", so _literal_scalar passes left=argname == "left". An untyped op, as every infix operator is, types the literal the same either way. Behavior change (before -> after), with A a record of two float64 fields, s = Scalar.from_value((3.0, 4.0), A) and add_a a UDT BinaryOp of x + y.ll_a: - add_a[INT64, A](2 & s): GraphBLAS's DomainMismatch (2 became an A element where the op takes an INT64) -> 5.0. --- graphblas/core/scalar.py | 7 ++++--- graphblas/tests/test_scalar.py | 7 +++++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/graphblas/core/scalar.py b/graphblas/core/scalar.py index f5fff642a..5f1ac7c01 100644 --- a/graphblas/core/scalar.py +++ b/graphblas/core/scalar.py @@ -1184,10 +1184,11 @@ def _as_scalar(scalar, dtype=None, *, is_cscalar): def _literal_scalar(scalar, value, op, *, within, argname=None, keyword_name=None): """Return literal ``value``, ``op``'s operand beside ``scalar`` on either side, as a Scalar. - It is typed as any literal operand is (``literals._literal_operand``). A value - that cannot be a Scalar raises TypeError, naming ``argname`` or ``keyword_name``. + It is typed as any literal operand is (``literals._literal_operand``), as the + left operand when ``argname`` is ``"left"``. A value that cannot be a Scalar + raises TypeError, naming ``argname`` or ``keyword_name``. """ - value, dtype = _literal_operand(scalar.dtype, value, op) + value, dtype = _literal_operand(scalar.dtype, value, op, left=argname == "left") try: return Scalar.from_value(value, dtype, is_cscalar=False, name="") except TypeError: diff --git a/graphblas/tests/test_scalar.py b/graphblas/tests/test_scalar.py index 5f85cc759..4bd5be4ec 100644 --- a/graphblas/tests/test_scalar.py +++ b/graphblas/tests/test_scalar.py @@ -939,3 +939,10 @@ def test_scalar_operators_literal_on_the_left_udt(): np.testing.assert_array_equal(result.value, expected) assert (1 - u).new().dtype == udt assert (10 / u).new().dtype.np_type == np.dtype((np.float64, (2,))) + # An op typed for its two sides takes a literal on the left in its left type + rec = dtypes.register_anonymous(np.dtype([("ll_a", "f8"), ("ll_b", "f8")]), "_LeftLiteralRec") + add_a = gb.core.operator.BinaryOp.register_anonymous( + lambda x, y: x + y.ll_a, "_left_literal_add_a", is_udt=True + ) + s = Scalar.from_value((3.0, 4.0), rec) + assert add_a[dtypes.INT64, rec](2 & s).new().value == 5.0 From d2faf3cf21f56636526b7bf0e9a04c07012839a8 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Tue, 10 Nov 2026 22:55:30 -0600 Subject: [PATCH 7/7] Tidy the helpers for a literal operand of a Scalar infix expression No behavior change. - ScalarInfixExpr._call_left is a method that falls back to InfixExprBase._call_left, in place of a module function assigned on ScalarEwiseAddExpr and ScalarEwiseMultExpr. - _typed_operands asks _is_literal of each operand, without a check for a Scalar expression first: a Vector or Matrix infix expression never holds a literal. - formatting._is_bool, which had one caller, is folded into _computes_itself. - call_op names the method for an error only where a literal is on the left. --- graphblas/core/formatting.py | 13 +++++-------- graphblas/core/infix.py | 26 +++++++++++--------------- graphblas/core/infixmethods.py | 2 +- 3 files changed, 17 insertions(+), 24 deletions(-) diff --git a/graphblas/core/formatting.py b/graphblas/core/formatting.py index 2fb6927f2..471b264d3 100644 --- a/graphblas/core/formatting.py +++ b/graphblas/core/formatting.py @@ -752,14 +752,11 @@ def _computes_itself(expr): """Whether infix ``expr`` computes without an op, as ``x @ y`` and BOOL ``x & y`` do.""" if expr.method_name not in {"ewise_add", "ewise_mult"}: return True - return _is_bool(expr.left) and _is_bool(expr.right) - - -def _is_bool(operand): - if _is_literal(operand): - # Beside a Scalar, as in `s & True`, a literal is BOOL beside BOOL if it is a bool - return type(operand) in {bool, np.bool_} - return operand.dtype == BOOL + # A literal beside a Scalar, as in `s & True`, is BOOL beside BOOL if it is a bool + return all( + type(x) in {bool, np.bool_} if _is_literal(x) else x.dtype == BOOL + for x in (expr.left, expr.right) + ) def format_scalar_infix_expression(expr): diff --git a/graphblas/core/infix.py b/graphblas/core/infix.py index 7e6fa77b8..2bdbb00c6 100644 --- a/graphblas/core/infix.py +++ b/graphblas/core/infix.py @@ -22,11 +22,10 @@ def _typed_operands(self, op): """Return the operands of ``self``, with a literal beside a Scalar typed as ``op``'s operand.""" left, right = self.left, self.right - if self._is_scalar: - if output_type(left) is not Scalar: - left = _literal_scalar(right, left, op, within=self.method_name, argname="left") - elif output_type(right) is not Scalar: - right = _literal_scalar(left, right, op, within=self.method_name, argname="right") + if _is_literal(left): + left = _literal_scalar(right, left, op, within=self.method_name, argname="left") + elif _is_literal(right): + right = _literal_scalar(left, right, op, within=self.method_name, argname="right") return left, right @@ -73,14 +72,6 @@ def _operand_name(operand, *, html=False): return operand._name_html if html else operand.name -def _scalar_call_left(self, method_name, *args, **kwargs): - if output_type(self.left) is Scalar: - return getattr(self.left, method_name)(self.right, *args, **kwargs) - # A literal stays on the left: `binary.minus(1 & s)` is `1 - s`. Scalar's method types - # it beside `s` once the op is known, as it types a literal on the right. - return getattr(Scalar, method_name)(self.left, self.right, *args, **kwargs) - - class ScalarInfixExpr(InfixExprBase): __slots__ = () ndim = 0 @@ -114,6 +105,13 @@ def _format_expr_html(self): right = _operand_name(self.right, html=True) return f"{left} {self._infix} {right}" + def _call_left(self, method_name, *args, **kwargs): + if _is_literal(self.left): + # A literal stays on the left: `binary.minus(1 & s)` is `1 - s`. Scalar's method + # types it beside `s` once the op is known, as it types a literal on the right. + return getattr(Scalar, method_name)(self.left, self.right, *args, **kwargs) + return super()._call_left(method_name, *args, **kwargs) + __hash__ = None # Begin auto-generated code: Scalar @@ -171,7 +169,6 @@ class ScalarEwiseAddExpr(ScalarInfixExpr): _infix = "|" _to_expr = _ewise_add_to_expr - _call_left = _scalar_call_left # Allow e.g. `plus(x | y | z)` __or__ = Scalar.__or__ @@ -200,7 +197,6 @@ class ScalarEwiseMultExpr(ScalarInfixExpr): _infix = "&" _to_expr = _ewise_mult_to_expr - _call_left = _scalar_call_left # Allow e.g. `plus(x & y & z)` __and__ = Scalar.__and__ diff --git a/graphblas/core/infixmethods.py b/graphblas/core/infixmethods.py index 609f44c9d..dfe539468 100644 --- a/graphblas/core/infixmethods.py +++ b/graphblas/core/infixmethods.py @@ -34,8 +34,8 @@ def call_op(self, other, op, *, outer=False, union=False): return op(self, other) # Scalars. A literal on the left stays there: `1 - s` is minus(1, s), typed as # `binary.minus(1 & s)` is, since rminus may not exist for s's dtype (a UDT). - method = "ewise_add" if outer else "ewise_union" if union else "ewise_mult" if type1 is not Scalar: + method = "ewise_add" if outer else "ewise_union" if union else "ewise_mult" self = _literal_scalar(other, self, op, within=method, argname="left") if outer: return self.ewise_add(other, op)