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 "