diff --git a/graphblas/core/operator/agg.py b/graphblas/core/operator/agg.py index 574f4ec1b..f1100c32e 100644 --- a/graphblas/core/operator/agg.py +++ b/graphblas/core/operator/agg.py @@ -776,4 +776,6 @@ def _first_last_index(agg, updater, expr, opts, *, in_composite, semiring): agg.Aggregator = Aggregator agg.TypedAggregator = TypedAggregator -from .utils import get_typed_op # noqa: E402 isort:skip +from .utils import _register_aggregator_types, get_typed_op # noqa: E402 isort:skip + +_register_aggregator_types(Aggregator, TypedAggregator) diff --git a/graphblas/core/operator/utils.py b/graphblas/core/operator/utils.py index c3a8f5b8f..a4d727aab 100644 --- a/graphblas/core/operator/utils.py +++ b/graphblas/core/operator/utils.py @@ -1,3 +1,5 @@ +import math +import numbers from types import BuiltinFunctionType, FunctionType, ModuleType from ... import ( @@ -71,6 +73,22 @@ raise +# ``agg.py`` imports ``get_typed_op`` from this module, so the two form an import +# cycle and we can't import the Aggregator classes at module load. ``agg.py`` +# registers them here when it loads, which ``core.operator.__init__`` does +# eagerly at import. agg.py creates its Aggregators before it registers the +# classes, but none reaches ``get_typed_op`` until then, so before registration +# ``get_typed_op`` skips the check entirely. +_Aggregator = None +_TypedAggregator = None + + +def _register_aggregator_types(aggregator, typed_aggregator): + global _Aggregator, _TypedAggregator + _Aggregator = aggregator + _TypedAggregator = typed_aggregator + + def get_typed_op(op, dtype, dtype2=None, *, is_left_scalar=False, is_right_scalar=False, kind=None): if isinstance(op, OpBase): # UDTs always get compiled @@ -126,15 +144,14 @@ def get_typed_op(op, dtype, dtype2=None, *, is_left_scalar=False, is_right_scala if isinstance(op, TypedOpBase): return op - from .agg import Aggregator, TypedAggregator - - if isinstance(op, Aggregator): - # agg._any_dtype basically serves the same purpose as op._custom_dtype - if op._any_dtype is not None and op._any_dtype is not True: - return op[op._any_dtype] - return op[dtype] - if isinstance(op, TypedAggregator): - return op + if _Aggregator is not None: # pragma: no branch (safety) + if isinstance(op, _Aggregator): + # agg._any_dtype basically serves the same purpose as op._custom_dtype + if op._any_dtype is not None and op._any_dtype is not True: + return op[op._any_dtype] + return op[dtype] + if isinstance(op, _TypedAggregator): + return op if isinstance(op, str): if kind == "unary": op = unary_from_string(op) @@ -172,6 +189,14 @@ def get_typed_op(op, dtype, dtype2=None, *, is_left_scalar=False, is_right_scala kind=kind, ) if isinstance(op, FunctionType): + if op.__module__ in {"graphblas.indexunary", "graphblas.select"}: + # Not a UDF: a helper such as ``indexunary.value`` or a module function + modname = op.__module__.rpartition(".")[2] + raise TypeError( + f"{modname}.{op.__name__} is a helper, not an operator: it takes a comparison " + f"and returns its expression, as in {modname}.value(A > 3). To apply an op, " + f"give one such as {modname}.valuegt." + ) if kind == "unary": op = UnaryOp.register_anonymous(op, is_udt=True) return op._compile_udt(dtype, dtype2) @@ -317,6 +342,136 @@ def get_semiring(monoid, binaryop, name=None): return rv +def _resolve_index_expr(ns, modname, expr, callname, opname): + """Turn an infix comparison expression into an indexunary/select op call. + + Shared by the ``value``/``row``/``column`` helpers in the ``select`` and + ``indexunary`` namespaces (and ``select.index``). ``select.valuegt`` is a + SelectOp that defaults to ``.select``, while ``indexunary.valuegt`` is an + IndexUnaryOp that defaults to ``.apply``, so the same expression resolves to + a select or an apply depending on which namespace's ops ``ns`` holds. ``ns`` + is the namespace module's ``globals()`` dict and ``modname`` is its short + name (used in the error messages). + """ + from ..base import BaseExpression + + if not isinstance(expr, BaseExpression): + raise TypeError( + f"Expected ScalarExpression, VectorExpression, or MatrixExpression; " + f"found {type(expr)}\nTypical usage: {modname}.{callname}(x <= 5)" + ) + _check_index_expr(modname, expr, callname, opname) + tensor = expr.args[0] + thunk = expr.args[1] + if expr._is_scalar: + # Handle ScalarExpressions that change their arguments to Vector, before the + # bound below reads the thunk's value. A comparison (an ewise_mult) has both + # as Vectors of size 1; an apply keeps its thunk a Scalar. + if tensor._parent is not None: # e.g., suitesparse + tensor = tensor._parent + if not thunk._is_scalar: + thunk = thunk._parent + else: # e.g., suitesparse-vanilla + tensor = tensor[0].new() + if not thunk._is_scalar: + thunk = thunk[0].new() + name = expr.op.name + if opname != "value": + if name in {"lt", "ge"} and _namespace_op(ns, f"{opname}{name}") is not None: + # An op registered for it (a user's rowlt): the number as given + if expr._right_literal is not None: + thunk = expr._right_literal + else: + thunk, name = _index_bound(expr._right_literal, thunk, name) + method = f"{opname}{name}" + if _namespace_op(ns, method) is None: # pragma: no cover (sanity) + raise ValueError(f"Unknown or unregistered {modname} method: {method}") + return _namespace_op(ns, method)(tensor, thunk) + + +def _index_bound(literal, thunk, name): + """Return the thunk and comparison that select an index ``name`` (lt, le, gt, ge) a bound. + + The bound is the number the caller gave (``literal``), else the thunk Scalar's value. + The comparison typed its thunk beside the values, which is wrong for an index: 300 + beside INT8 values is infinity, and 2**24 + 1 beside FP32 rounds. Only rowle and + rowgt exist (and colle, colgt, indexle, indexgt), and indices are whole numbers, so + ``i < t`` is ``i <= ceil(t) - 1``, ``i >= t`` is ``i > ceil(t) - 1``, and ``i <= t`` + and ``i > t`` take ``floor(t)``, where GraphBLAS would truncate a float toward zero + (``i > -0.5`` dropped index 0). A whole number beyond INT64, which holds every index, + is clamped to it. NaN compares False with every index, so it keeps none. An empty + Scalar goes to the op as it is, which raises EmptyObject, and so does a thunk that is + no Scalar (from ``A.apply(binary.le, left=3)``), which raises the op's TypeError. + """ + shift = name in {"lt", "ge"} + if shift: + name = "le" if name == "lt" else "gt" + # The caller's number, if it is one; a Scalar expression (``w[0]``), a 0-d array or + # ``np.True_`` made the thunk keep its own type, so the thunk's value is the bound. + if isinstance(literal, numbers.Real): + bound = literal + elif getattr(thunk, "_is_scalar", False): + bound = thunk.value + else: + return thunk, name + if not isinstance(bound, numbers.Real): + return thunk, name + if math.isnan(bound): + return (-1 if name == "le" else 2**63 - 1), name # no index is <= -1 or > INT64_MAX + if math.isfinite(bound): + bound = math.ceil(bound) - 1 if shift else math.floor(bound) + bound = min(max(bound, -(2**63)), 2**63 - 1) + return bound, name + + +def _namespace_op(ns, name): + """The op ``name`` in namespace globals ``ns``, or None; it builds one registered lazily. + + A lazily registered op waits in the namespace's ``_delayed`` until its module + ``__getattr__`` builds it, so ``name in ns`` alone misses it until then. An + IndexUnaryOp registered lazily makes its SelectOp only once it is built, so select + builds it from indexunary's ``_delayed``. + """ + if name not in ns: + if name in ns.get("_delayed", ()): + ns["__getattr__"](name) + elif ns is vars(select) and name in indexunary._delayed: + getattr(indexunary, name) + return ns.get(name) + + +# What each helper compares: indices only by order, values by order or equality. +_INDEX_COMPARISONS = {"lt": "<", "le": "<=", "gt": ">", "ge": ">="} +_VALUE_COMPARISONS = {**_INDEX_COMPARISONS, "eq": "==", "ne": "!="} + + +def _check_index_expr(modname, expr, callname, opname): + """Raise unless ``expr`` is a comparison the ``callname`` helper can resolve. + + That is ``x OP k``: a built-in comparison applied to a Vector, Matrix or + Scalar ``x`` with a scalar ``k`` on its right, with OP one of ``<``, ``<=``, + ``>``, ``>=`` (and ``==``, ``!=`` for ``value``). A Scalar compares as the + ewise_mult of two Vectors of size 1. + """ + name = getattr(expr.op, "name", None) + if ( + expr.method_name not in ({"apply", "ewise_mult"} if expr._is_scalar else {"apply"}) + or len(expr.args) != 2 + or name not in _VALUE_COMPARISONS + or getattr(expr.op, "parent", None) is not getattr(binary, name) + or "left=" in expr.expr_repr + ): + raise TypeError( + f"{modname}.{callname} takes a comparison of a Vector, Matrix or Scalar with a " + f"scalar on its right, as in {modname}.{callname}(x <= 5); got {expr._format_expr()}" + ) + if opname != "value" and name not in _INDEX_COMPARISONS: + raise ValueError( + f"{modname}.{callname} compares indices with <, <=, > or >=, not " + f"{_VALUE_COMPARISONS[name]}" + ) + + unary.register_new = UnaryOp.register_new unary.register_anonymous = UnaryOp.register_anonymous indexbinary.register_new = IndexBinaryOp.register_new @@ -445,37 +600,212 @@ def _from_string(string, module, mapping, example): cur = None break op = getattr(cur, attr, None) - if op is None: - name = module.__name__.split(".")[-1] - raise ValueError(f"Unknown {name} string: {string!r}. Example usage: {example!r}") + if not _is_operator(op): + # A namespace also holds functions (``indexunary.value``), methods and modules + name = module.__name__.split(".")[-1] + raise ValueError(f"Unknown {name} string: {string!r}. Example usage: {example!r}") if dtype: op = op[dtype] return op +def _is_operator(obj): + return isinstance(obj, (OpBase, TypedOpBase, ParameterizedUdf)) or ( + _Aggregator is not None and isinstance(obj, (_Aggregator, _TypedAggregator)) + ) + + def unary_from_string(string): + """Look up a UnaryOp by name or symbol, optionally typed with ``"[dtype]"``. + + Backs ``gb.unary.from_string`` and the string coercion used wherever a + UnaryOp is accepted, such as ``v.apply("abs")``. + + Parameters + ---------- + string : str + A name in the ``gb.unary`` namespace (``"abs"``, or a dotted path such + as ``"numpy.negative"``) or a symbolic shorthand (``"-"`` for ``ainv``, + ``"~"`` for ``lnot``). Append ``"[dtype]"`` to type the operator, as in + ``"abs[int]"``. + + Returns + ------- + UnaryOp + + See Also + -------- + unary.register_new + op.from_string + + Examples + -------- + >>> gb.unary.from_string("abs") is gb.unary.abs + True + >>> gb.unary.from_string("abs[int]") is gb.unary.abs[int] + True + + """ return _from_string(string, unary, _str_to_unary, "abs[int]") def indexunary_from_string(string): + """Look up an IndexUnaryOp by name, optionally typed with ``"[dtype]"``. + + Parameters + ---------- + string : str + A name in the ``gb.indexunary`` namespace, such as ``"rowindex"``, + ``"diag"``, or ``"tril"``. Append ``"[dtype]"`` to type the operator, + as in ``"rowindex[int]"``. + + Returns + ------- + IndexUnaryOp + + See Also + -------- + indexunary.register_new + select.from_string + + Examples + -------- + >>> gb.indexunary.from_string("rowindex") is gb.indexunary.rowindex + True + + """ # "select" is a variant of IndexUnary, so the string abbreviations in # _str_to_select are appropriate to reuse here - return _from_string(string, indexunary, _str_to_select, "row_index") + return _from_string(string, indexunary, _str_to_select, "rowindex") def select_from_string(string): + """Look up a SelectOp by name or comparison symbol. + + Parameters + ---------- + string : str + A name in the ``gb.select`` namespace (``"tril"``, ``"triu"``, + ``"offdiag"``, ``"valuegt"``, ...) or a comparison shorthand such as + ``">="`` (``valuege``), ``"=="`` (``valueeq``), or ``"row>"`` + (``rowgt``). + + Returns + ------- + SelectOp + + See Also + -------- + select.register_new + indexunary.from_string + + Examples + -------- + >>> gb.select.from_string("tril") is gb.select.tril + True + >>> gb.select.from_string(">=") is gb.select.valuege + True + + """ return _from_string(string, select, _str_to_select, "tril") def binary_from_string(string): + """Look up a BinaryOp by name or symbol, optionally typed with ``"[dtype]"``. + + Backs ``gb.binary.from_string`` and the string coercion used wherever a + BinaryOp is accepted, such as ``A.ewise_mult(B, "+")``. + + Parameters + ---------- + string : str + A name in the ``gb.binary`` namespace (``"plus"``, or a dotted path such + as ``"numpy.mod"``) or an arithmetic/comparison shorthand such as ``"+"`` + (``plus``), ``"*"`` (``times``), or ``">="`` (``ge``). Append + ``"[dtype]"`` to type the operator, as in ``"plus[int]"``. + + Returns + ------- + BinaryOp + + See Also + -------- + binary.register_new + op.from_string + + Examples + -------- + >>> gb.binary.from_string("+") is gb.binary.plus + True + >>> gb.binary.from_string("minus[int]") is gb.binary.minus[int] + True + + """ return _from_string(string, binary, _str_to_binary, "+[int]") def monoid_from_string(string): + """Look up a Monoid by name or symbol, optionally typed with ``"[dtype]"``. + + Parameters + ---------- + string : str + A name in the ``gb.monoid`` namespace (``"plus"``, ``"times"``, ...) or a + symbolic shorthand such as ``"+"`` (``plus``), ``"*"`` (``times``), or + ``"|"`` (``lor``). Append ``"[dtype]"`` to type the monoid, as in + ``"plus[float]"``. + + Returns + ------- + Monoid + + See Also + -------- + monoid.register_new + semiring.from_string + + Examples + -------- + >>> gb.monoid.from_string("+[float]") is gb.monoid.plus[float] + True + + """ return _from_string(string, monoid, _str_to_monoid, "+[int]") def semiring_from_string(string): + """Look up a Semiring by name, optionally typed with ``"[dtype]"``. + + A semiring pairs a monoid with a binaryop. Name it either as the combined + namespace attribute (``"plus_times"``) or in ``"monoid.binaryop"`` form using + the monoid and binaryop shorthands (``"min.+"``); the two parts must be + separated by exactly one period. + + Parameters + ---------- + string : str + The semiring name, such as ``"plus_times"`` or ``"min_plus"``, or the + ``"monoid.binaryop"`` form ``"min.+"``. Append ``"[dtype]"`` to type the + semiring, as in ``"min.+[int]"``. + + Returns + ------- + Semiring + + See Also + -------- + semiring.register_new + semiring.get_semiring + op.from_string + + Examples + -------- + >>> gb.semiring.from_string("min.+") is gb.semiring.min_plus + True + >>> gb.semiring.from_string("min_plus") is gb.semiring.min_plus + True + + """ split = string.split(".") if len(split) == 1: try: @@ -494,6 +824,38 @@ def semiring_from_string(string): def op_from_string(string): + """Look up an operator of any kind by string. + + Each operator type is tried in turn (unary, binary, monoid, semiring, + indexunary, select, then aggregator) and the first match is returned, so an + unqualified name resolves to whichever kind defines it first. Use a + type-specific ``from_string`` (e.g. ``gb.binary.from_string``) when the kind + is known and matters. + + Parameters + ---------- + string : str + An operator name or symbol accepted by any of the type-specific + ``from_string`` functions, optionally typed with ``"[dtype]"``. + + Returns + ------- + UnaryOp, BinaryOp, Monoid, Semiring, IndexUnaryOp, SelectOp, or Aggregator + + See Also + -------- + unary.from_string + binary.from_string + semiring.from_string + + Examples + -------- + >>> gb.op.from_string("+") is gb.binary.plus + True + >>> gb.op.from_string("min.plus") is gb.semiring.min_plus + True + + """ for func in [ # Note: order matters here unary_from_string, @@ -528,6 +890,32 @@ def op_from_string(string): def aggregator_from_string(string): + """Look up an Aggregator by name or symbol, optionally typed with ``"[dtype]"``. + + Parameters + ---------- + string : str + A name in the ``gb.agg`` namespace (``"sum"``, ``"count"``, ``"any"``, + ...) or a symbolic shorthand such as ``"+"`` (``sum``), ``"*"`` + (``prod``), ``"&"`` (``all``), or ``"|"`` (``any``). Append ``"[dtype]"`` + to type the aggregator, as in ``"sum[int]"``. + + Returns + ------- + Aggregator + + See Also + -------- + op.from_string + + Examples + -------- + >>> gb.agg.from_string("sum[int]") is gb.agg.sum[int] + True + >>> gb.agg.from_string("|") is gb.agg.any + True + + """ return _from_string(string, agg, _str_to_agg, "sum[int]") diff --git a/graphblas/indexunary/__init__.py b/graphblas/indexunary/__init__.py index 5fa548c5a..40fe88eae 100644 --- a/graphblas/indexunary/__init__.py +++ b/graphblas/indexunary/__init__.py @@ -30,6 +30,62 @@ def __getattr__(key): raise _module_attr_error(__name__, key, __dir__()) +def _resolve_expr(expr, callname, opname): + from ..core.operator.utils import _resolve_index_expr + + return _resolve_index_expr(globals(), "indexunary", expr, callname, opname) + + +def value(expr): + """An advanced indexunary method for easily expressing value comparison logic. + + Example usage: + >>> gb.indexunary.value(A > 0) + + The example will dispatch to ``gb.indexunary.valuegt(A, 0)`` + while being nicer to read. + """ + return _resolve_expr(expr, "value", "value") + + +def row(expr): + """An advanced indexunary method for easily expressing Matrix row index comparison logic. + + Example usage: + >>> gb.indexunary.row(A <= 5) + + The example will dispatch to ``gb.indexunary.rowle(A, 5)`` + while being potentially nicer to read. + + For a Vector, ``gb.indexunary.row(v < 4)`` compares indices. Unlike + ``gb.select.index``, ``gb.indexunary.index`` is not a helper but the + ``rowindex`` op, so ``gb.indexunary.index(v < 4)`` gives the index of each + entry of ``v < 4``. + """ + return _resolve_expr(expr, "row", "row") + + +def column(expr): + """An advanced indexunary method for easily expressing Matrix column index comparison logic. + + Example usage: + >>> gb.indexunary.column(A <= 5) + + The example will dispatch to ``gb.indexunary.colle(A, 5)`` + while being potentially nicer to read. + """ + return _resolve_expr(expr, "column", "col") + + +# Note: an ``index`` helper (the Vector analogue of ``select.index``) is *not* +# provided here because ``indexunary.index`` already exists as an alias for the +# positional ``rowindex`` op (INT64). It is relied on as an operator, e.g. +# ``v.apply(indexunary.index)``, which a helper function would break. For a +# Vector index comparison use ``indexunary.row(v < k)`` (resolves to ``rowle``, +# the same op ``select.index`` uses) or the explicit ``indexunary.indexle`` / +# ``indexunary.indexgt``. + + from ..core import operator # noqa: E402 isort:skip del operator diff --git a/graphblas/select/__init__.py b/graphblas/select/__init__.py index a2d94a0a0..43e8c206e 100644 --- a/graphblas/select/__init__.py +++ b/graphblas/select/__init__.py @@ -3,9 +3,6 @@ # identical to IndexUnaryOp under the hood, except they must return # boolean and they default to `select` when called, rather than # `apply`). -import math -import numbers - _delayed = {} _binary_to_select = {} @@ -38,70 +35,9 @@ def __getattr__(key): def _resolve_expr(expr, callname, opname): - from ..core.base import BaseExpression - - if not isinstance(expr, BaseExpression): - raise TypeError( - f"Expected ScalarExpression, VectorExpression, or MatrixExpression; " - f"found {type(expr)}\nTypical usage: select.{callname}(x <= 5)" - ) - tensor = expr.args[0] - thunk = expr.args[1] - if expr._is_scalar: - # Handle ScalarExpressions that change their arguments to Vector - if tensor._parent is not None: # e.g., suitesparse - tensor = tensor._parent - thunk = thunk._parent - else: # e.g., suitesparse-vanilla - tensor = tensor[0].new() - thunk = thunk[0].new() - name = expr.op.name - if opname != "value": - if name in {"lt", "ge"} and f"{opname}{name}" in globals(): - # An op registered for it (a user's rowlt): the number as given - if expr._right_literal is not None: - thunk = expr._right_literal - else: - thunk, name = _index_bound(expr._right_literal, thunk, name) - method = f"{opname}{name}" - if method not in globals(): # pragma: no cover (sanity) - raise ValueError(f"Unknown or unregistered select method: {method}") - return globals()[method](tensor, thunk) - - -def _index_bound(literal, thunk, name): - """Return the thunk and comparison that select an index ``name`` (lt, le, gt, ge) a bound. - - The bound is the number the caller gave (``literal``), else the thunk Scalar's value. - The comparison typed its thunk beside the values, which is wrong for an index: 300 - beside INT8 values is infinity, and 2**24 + 1 beside FP32 rounds. Only rowle and - rowgt exist (and colle, colgt, indexle, indexgt), and indices are whole numbers, so - ``i < t`` is ``i <= ceil(t) - 1``, ``i >= t`` is ``i > ceil(t) - 1``, and ``i <= t`` - and ``i > t`` take ``floor(t)``, where GraphBLAS would truncate a float toward zero - (``i > -0.5`` dropped index 0). A whole number beyond INT64, which holds every index, - is clamped to it. NaN compares False with every index, so it keeps none. An empty - Scalar goes to the op as it is, which raises EmptyObject, and so does a thunk that is - no Scalar (from ``A.apply(binary.le, left=3)``), which raises the op's TypeError. - """ - shift = name in {"lt", "ge"} - if shift: - name = "le" if name == "lt" else "gt" - # The caller's number, if it is one; a Scalar expression (``w[0]``), a 0-d array or - # ``np.True_`` made the thunk keep its own type, so the thunk's value is the bound. - if isinstance(literal, numbers.Real): - bound = literal - elif getattr(thunk, "_is_scalar", False): - bound = thunk.value - else: - return thunk, name - if not isinstance(bound, numbers.Real): - return thunk, name - if math.isnan(bound): - return (-1 if name == "le" else 2**63 - 1), name # no index is <= -1 or > INT64_MAX - if math.isfinite(bound): - bound = math.ceil(bound) - 1 if shift else math.floor(bound) - bound = min(max(bound, -(2**63)), 2**63 - 1) - return bound, name + from ..core.operator.utils import _resolve_index_expr + + return _resolve_index_expr(globals(), "select", expr, callname, opname) def _match_expr(parent, expr): diff --git a/graphblas/tests/test_matrix.py b/graphblas/tests/test_matrix.py index 9f9fbab36..622b7e389 100644 --- a/graphblas/tests/test_matrix.py +++ b/graphblas/tests/test_matrix.py @@ -1363,6 +1363,98 @@ def test_apply_indexunary(A): assert pickle.loads(pickle.dumps(indexunary.tril[int])) is indexunary.tril[int] +def test_indexunary_helpers(A): + # indexunary.value/row/column mirror select's helpers (GH #239), but they + # produce apply expressions (BOOL) rather than select expressions. + assert indexunary.value(A > 3).new().isequal(A.apply(indexunary.valuegt, 3).new()) + assert indexunary.value(A == 3).new().isequal(A.apply(indexunary.valueeq, 3).new()) + # Only rowle/rowgt and colle/colgt exist, so `<` and `>=` are rewritten with + # a thunk shift, and `==`/`!=` have no counterpart (mirrors select's helpers). + assert indexunary.row(A <= 2).new().isequal(A.apply(indexunary.rowle, 2).new()) + assert indexunary.row(A < 3).new().isequal(A.apply(indexunary.rowle, 2).new()) + assert indexunary.row(A >= 3).new().isequal(A.apply(indexunary.rowgt, 2).new()) + assert indexunary.row(A > 2).new().isequal(A.apply(indexunary.rowgt, 2).new()) + assert indexunary.column(A < 3).new().isequal(A.apply(indexunary.colle, 2).new()) + assert indexunary.column(A > 2).new().isequal(A.apply(indexunary.colgt, 2).new()) + for expr in [indexunary.value(A > 3), indexunary.row(A <= 2), indexunary.column(A < 3)]: + assert expr.new().dtype == dtypes.BOOL + with pytest.raises(TypeError, match="indexunary.value"): + indexunary.value(A) + with pytest.raises(TypeError, match="indexunary.row"): + indexunary.row(A | A) + for helper, ns in [(indexunary.row, "indexunary"), (select.column, "select")]: + with pytest.raises(ValueError, match=f"{ns}.(row|column) compares indices with <, <="): + helper(A == 3) + # Anything but a comparison with a scalar on the right is refused by name. These + # raised IndexError, AttributeError, or the error of the op the helper picked. + for expr in [ + abs(A), + A.mxm(A), + A.apply(binary.gt, left=3), + A.apply(binary.plus, right=3), + A.apply(binary.isgt, right=3), + A.ewise_mult(A, binary.gt), + ]: + for helper in [indexunary.value, select.value, indexunary.row]: + with pytest.raises(TypeError, match=r"takes a comparison .* with a scalar on its"): + helper(expr) + # An empty Scalar thunk raises as it does for the op; it raised from the shift. + with pytest.raises(EmptyObject): + indexunary.row(A < Scalar(float)).new() + # Indices are compared with the bound given, as a whole number, not with the thunk + # typed beside the values: beside INT8 or UINT8, 300 is infinity, and beside FP32, + # 2**24 + 1 rounds (as for select's helpers). + n = 400 + for dtype in ["INT8", "UINT8"]: + B = Matrix.from_coo(np.arange(n), np.zeros(n, int), 1, dtype=dtype, nrows=n, ncols=1) + for expr, count in [(B < 300, 300), (B > 299, 100), (B <= 299, 300), (B >= 300, 100)]: + assert indexunary.row(expr).new().to_coo()[2].sum() == count + assert indexunary.column(B.T <= 299).new().to_coo()[2].sum() == 300 + rows = [2**24 - 1, 2**24, 2**24 + 1] + F = Matrix.from_coo(rows, [0, 0, 0], [1.0, 2.0, 3.0], dtype="FP32", nrows=2**24 + 3, ncols=1) + assert indexunary.row(F < 2**24 + 1).new().to_coo()[2].tolist() == [True, True, False] + A5 = Matrix.from_coo(np.arange(5), np.zeros(5, int), 1.0, nrows=5, ncols=1) + for expr, expected in [ + (A5 < A5.nrows / 2, [True, True, True, False, False]), + (A5 >= 2.5, [False, False, False, True, True]), + (A5 > -0.5, [True] * 5), + (A5 <= 2.5, [True, True, True, False, False]), + # A bound that is not a Python or numpy number: the thunk keeps its own type + (A5 < np.array(3), [True, True, True, False, False]), + (A5 >= np.array(3), [False, False, False, True, True]), + ]: + assert indexunary.row(expr).new().to_coo()[2].tolist() == expected + assert indexunary.column(A5.T < 2.5).new().to_coo()[2].tolist() == [True] * 3 + [False] * 2 + # NaN compares False with every index; cast to the INT64 thunk it was 0, so < and <= + # gave True for row 0, and > and >= for every other row (select's helpers too) + nan = float("nan") + for expr in [A5 < nan, A5 <= nan, A5 > nan, A5 >= nan, A5 < Scalar.from_value(nan)]: + assert indexunary.row(expr).new().to_coo()[2].tolist() == [False] * 5, expr + assert select.row(expr).new().nvals == 0, expr + + +@pytest.mark.skipif("not supports_udfs") +def test_index_helpers_find_a_lazily_registered_op(): + """A helper uses an op registered with ``lazy=True`` before anything touches it. + + It looked only at the namespace's built ops, so ``row(A < 2.5)`` took the + ``<=`` shift until something touched ``indexunary.rowlt``, then the op. select + has a SelectOp for it only once the indexunary op is built, so ``select.row`` + took the shift until ``indexunary.rowlt`` was touched. + """ + A = Matrix.from_coo([0, 1, 2, 3], [0, 0, 0, 0], [1, 1, 1, 1]) + try: + # It answers otherwise than the <= shift, so the result shows which ran. + indexunary.register_new("rowlt", lambda x, i, j, t: i == 0, lazy=True) + for _ in range(2): + assert select.row(A < 2.5).new().to_coo()[0].tolist() == [0] + assert indexunary.row(A < 2.5).new().to_coo()[2].tolist() == [True] + [False] * 3 + finally: + for ns in [indexunary, select]: + ns._delayed.pop("rowlt", None) + vars(ns).pop("rowlt", None) + + @autocompute def test_select_by_a_transposed_comparison(): """``A.select(A.T == x)`` keeps the entries where ``A.T == x``, as a computed mask does. @@ -1458,9 +1550,10 @@ def test_select_row_and_column_compare_indices_with_the_bound_given(): with gb.config.set(autocompute=True): assert select.row(A < w[0]).new().to_coo()[0].tolist() == [0, 1, 2] assert select.row(A >= w[0]).new().to_coo()[0].tolist() == [3, 4] - # A comparison with the scalar on the left gives the op's TypeError, as on main; it - # was AttributeError ('Matrix' object has no attribute 'value') - with pytest.raises(TypeError, match="Bad type for keyword argument `thunk=`"): + # A comparison with the scalar on the left is refused with a TypeError that names + # the helper (main gave the op's TypeError; it was briefly AttributeError: 'Matrix' + # object has no attribute 'value') + with pytest.raises(TypeError, match="select.row takes a comparison"): select.row(A.apply(binary.le, left=3)) # NaN compares False with every index, so it keeps none; NaN cast to the INT64 thunk # was 0, so < and <= kept row 0, and > and >= every other row (main too) diff --git a/graphblas/tests/test_op.py b/graphblas/tests/test_op.py index df5896e88..ea39ac5ac 100644 --- a/graphblas/tests/test_op.py +++ b/graphblas/tests/test_op.py @@ -1287,6 +1287,35 @@ def test_from_string(): with pytest.raises(ValueError, match="Unknown agg string"): agg.from_string("bad_agg") + assert select.from_string("tril") is select.tril + assert select.from_string(">=") is select.valuege + assert indexunary.from_string("rowindex") is indexunary.rowindex + assert indexunary.from_string("rowindex[int]") is indexunary.rowindex[int] + # The error's example is an op that resolves (it was 'row_index') + with pytest.raises(ValueError, match="Example usage: 'rowindex'"): + indexunary.from_string("bad_indexunary") + # Only operators: a namespace's helper functions and methods are not strings for one + for ns, name in [ + (indexunary, "value"), + (indexunary, "row"), + (indexunary, "column[int]"), + (indexunary, "_resolve_expr"), + (select, "value"), + (select, "register_new"), + (op, "value"), + (op, "register_new"), + ]: + with pytest.raises(ValueError, match="Unknown .* string"): + ns.from_string(name) + A = Matrix.from_coo([0], [0], [1]) + for helper in [indexunary.value, indexunary.row, select.column]: + with pytest.raises(TypeError, match=r"\.(value|row|column) is a helper, not an operator"): + A.apply(helper) + + # Every namespace's from_string carries a docstring (GH #513) + for ns in [unary, binary, monoid, semiring, select, indexunary, agg, op]: + assert ns.from_string.__doc__ + @pytest.mark.skipif("not supports_udfs") @pytest.mark.slow diff --git a/graphblas/tests/test_scalar.py b/graphblas/tests/test_scalar.py index e1136e07f..596de8802 100644 --- a/graphblas/tests/test_scalar.py +++ b/graphblas/tests/test_scalar.py @@ -827,6 +827,23 @@ def test_select(s): assert select.valuene(5, s).new().is_empty with pytest.raises(TypeError): select.value(s | s) + # An index helper reads the thunk after taking the Scalars out of their Vectors; + # it read it from a Vector (AttributeError: 'Vector' object has no attribute 'value'). + assert select.row(s < 1).new() == s + assert select.row(s >= 1).new().is_empty + assert gb.indexunary.row(s < 1).new().value is True + assert gb.indexunary.row(s >= 0.5).new().value is False + with pytest.raises(TypeError, match="select.value takes a comparison"): + select.value(s + 1) + # A Scalar's apply keeps its thunk a Scalar (AttributeError on suitesparse, and + # TypeError on vanilla, from taking it out of a Vector) + assert select.value(s.apply(binary.lt, right=10)).new() == s + assert gb.indexunary.row(s.apply(binary.gt, right=-0.5)).new().value is True + # The bound given, as a whole number, not the thunk typed beside the value + assert gb.indexunary.row(s > -0.5).new().value is True + s8 = Scalar.from_value(1, dtype=dtypes.INT8) + assert gb.indexunary.row(s8 < 300).new().value is True + assert gb.indexunary.row(s8 > 299).new().value is False @pytest.mark.skipif("not suitesparse") diff --git a/graphblas/tests/test_vector.py b/graphblas/tests/test_vector.py index 1f0ef108d..deac8cc85 100644 --- a/graphblas/tests/test_vector.py +++ b/graphblas/tests/test_vector.py @@ -810,6 +810,34 @@ def test_apply_indexunary(v): v.apply(indexunary.valueeq, left=s2) +def test_indexunary_helpers(v): + # indexunary.value/row/column mirror select's helpers (GH #239), returning + # apply expressions (BOOL) rather than select expressions. + assert indexunary.value(v == 1).new().isequal(v.apply(indexunary.valueeq, 1).new()) + assert indexunary.value(v >= 2).new().isequal(v.apply(indexunary.valuege, 2).new()) + # Index comparisons on a Vector go through the row helper (indexle is rowle). + assert indexunary.row(v < 4).new().isequal(v.apply(indexunary.rowle, 3).new()) + assert indexunary.row(v >= 4).new().isequal(v.apply(indexunary.rowgt, 3).new()) + assert indexunary.value(v == 1).new().dtype == dtypes.BOOL + assert indexunary.row(v < 4).new().dtype == dtypes.BOOL + # The index is compared with the bound given, not with 300 typed beside INT8 (inf) + w = Vector.from_coo([0, 200, 399], 1, dtype=dtypes.INT8, size=400) + assert indexunary.row(w < 300).new().to_coo()[1].tolist() == [True, True, False] + assert indexunary.row(w > 2.5).new().to_coo()[1].tolist() == [False, True, True] + # Scalar expression path resolves to a ScalarExpression, like select.value. + s = Scalar.from_value(1) + assert indexunary.value(s < 10).new() == s.apply(indexunary.valuelt, 10).new() + # `index` is intentionally not a helper: it remains the rowindex op alias, so + # `v.apply(indexunary.index)` keeps working. + assert indexunary.index is indexunary.rowindex + # A value mask reads naturally through the value helper. + assert ( + indexunary.value(v != False).new().isequal(indexunary.valuene(v, False).new()) # noqa: E712 + ) + with pytest.raises(TypeError, match="indexunary.value"): + indexunary.value(v) + + def test_select(v): result = Vector.from_coo([1, 3], [1, 1], size=7) w1 = v.select(select.valueeq, 1).new()