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 47ff18a0b..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 @@ -511,6 +513,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/formatting.py b/graphblas/core/formatting.py index 5fe9b6972..471b264d3 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,39 @@ 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 + # 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): 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 +783,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 73b3b91b5..2bdbb00c6 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,26 @@ 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 _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 + + 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 +47,31 @@ 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 _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 + + class ScalarInfixExpr(InfixExprBase): __slots__ = () ndim = 0 @@ -70,6 +97,21 @@ 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}" + + 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 @@ -485,7 +527,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="") @@ -498,6 +540,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 +599,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/infixmethods.py b/graphblas/core/infixmethods.py index 53f9d76f0..dfe539468 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). + 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: - 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/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/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..5f1ac7c01 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,28 @@ 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``), 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, left=argname == "left") + 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_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)") diff --git a/graphblas/tests/test_infix.py b/graphblas/tests/test_infix.py index 94d2555ae..75150d7d0 100644 --- a/graphblas/tests/test_infix.py +++ b/graphblas/tests/test_infix.py @@ -1,6 +1,11 @@ +import operator + +import numpy as np import pytest -from graphblas import binary, monoid, op +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 @@ -119,6 +124,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 @@ -163,6 +173,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): @@ -220,6 +237,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): @@ -763,6 +789,183 @@ 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_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) diff --git a/graphblas/tests/test_scalar.py b/graphblas/tests/test_scalar.py index 3ade2fb25..4bd5be4ec 100644 --- a/graphblas/tests/test_scalar.py +++ b/graphblas/tests/test_scalar.py @@ -905,3 +905,44 @@ 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,))) + # 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