Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 32 additions & 26 deletions graphblas/core/dtypes.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,16 +190,32 @@ def _set_udt_jit_c_definition(datatype):
}


def _iter_leaves(np_type):
"""Yield each leaf dtype of ``np_type``: a scalar, or an array of scalars, shape and all.

Walks records, nested records, arrays of records and arrays of arrays
(numpy keeps ``np.dtype((np.dtype(("?", (2,))), (3,)))`` as it is given),
in field order, so it reaches every value an element holds. A leaf inside
an array of records or of arrays comes once, as its record or inner array
declares it. ``udt._iter_record_leaves``, which pairs two records' leaves
for an op, yields an array of records as one leaf.
"""
if np_type.names is not None:
for name in np_type.names:
yield from _iter_leaves(np_type.fields[name][0])
elif np_type.subdtype is not None and (
np_type.subdtype[0].names is not None or np_type.subdtype[0].subdtype is not None
):
yield from _iter_leaves(np_type.subdtype[0])
else:
yield np_type


def _numba_untyped_array_element(dtype):
"""The kind of the first array element in dtype that Numba cannot type, or None."""
if dtype.subdtype is not None:
base = dtype.subdtype[0]
if base.kind in _NUMBA_UNTYPED_ARRAY_ELEMENTS:
return base.kind
return _numba_untyped_array_element(base)
for field_dtype, *_ in (dtype.fields or {}).values():
if (kind := _numba_untyped_array_element(field_dtype)) is not None:
return kind
for leaf in _iter_leaves(dtype):
if leaf.subdtype is not None and leaf.base.kind in _NUMBA_UNTYPED_ARRAY_ELEMENTS:
return leaf.base.kind
return None


Expand Down Expand Up @@ -230,14 +246,7 @@ def _numba_type(dtype):

def _raw_bytes_element(dtype):
"""The first void dtype with no fields (raw bytes) in dtype, or None."""
if dtype.subdtype is not None:
return _raw_bytes_element(dtype.subdtype[0])
if dtype.names is None:
return dtype if dtype.kind == "V" else None
for field_dtype, *_ in dtype.fields.values():
if (raw := _raw_bytes_element(field_dtype)) is not None:
return raw
return None
return next((leaf.base for leaf in _iter_leaves(dtype) if leaf.base.kind == "V"), None)


def register_new(name, dtype=None):
Expand Down Expand Up @@ -672,11 +681,12 @@ def unify(type1, type2, *, is_left_scalar=False, is_right_scalar=False):
unify(BOOL, UINT16) -> UINT16
unify(FP32, INT32) -> FP64

``is_left_scalar`` and ``is_right_scalar`` no longer change the result. A
Python number is typed before it gets here (``literals._literal_operand``), and
a typed scalar promotes as any operand does, as numpy 2 promotes it. They
used to stand the scalar in as a 0-d array of value 0, which numpy 1 typed
by value, so ``int8_vec + 300`` wrapped there.
``is_left_scalar`` and ``is_right_scalar`` are accepted and ignored, for code
that still passes them; python-graphblas itself no longer does. A Python
number is typed before it gets here (``literals._literal_operand``), and a
typed scalar promotes as any operand does, as numpy 2 promotes it. They used
to stand the scalar in as a 0-d array of value 0, which numpy 1 typed by
value, so ``int8_vec + 300`` wrapped there.
"""
if type1 is type2:
return type1
Expand Down Expand Up @@ -785,11 +795,7 @@ def _nats_in_leaf_units(values, np_type):

def _has_duration_leaf(np_type):
"""Whether ``np_type`` has a timedelta64 leaf: a record field, an array's element, or itself."""
if np_type.subdtype is not None:
return _has_duration_leaf(np_type.subdtype[0])
if np_type.names is not None:
return any(_has_duration_leaf(np_type.fields[name][0]) for name in np_type.names)
return np_type.kind == "m"
return any(leaf.base.kind == "m" for leaf in _iter_leaves(np_type))


def _dtype_to_string(dtype):
Expand Down
81 changes: 35 additions & 46 deletions graphblas/core/literals.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,10 +126,10 @@ def _literal_operand(dtype, value, op, *, left=False):
parts = _as_record_parts(np_type, value)
# First: making an int beyond its leaf infinity reshapes the part.
_refuse_misfit_fields(dtype, np_type, parts)
if _int_beyond_leaf(np_type, parts) is not None:
if (infinite := _beyond_as_infinity(np_type, parts)) is not None:
# As infinity in its leaf, it still equals no element, and it
# stays out of an array numpy makes of numpy values beside it.
value = _beyond_as_infinity(np_type, parts)
value = infinite
literal_type = _literal_type(dtype, value, exact=exact)
if literal_type is not None:
return value, literal_type
Expand Down Expand Up @@ -828,56 +828,45 @@ def _refuse_misfit_fields(dtype, np_type, value):
)


def _int_beyond_leaf(np_type, value):
"""Return a Python int in literal ``value`` beyond its integer leaf's range, or ``None``.
def _beyond_as_infinity(np_type, value):
"""Return literal ``value`` with each int beyond its integer leaf's range as infinity.

``value`` is laid out like an element of ``np_type``, as
:func:`_weak_literal_udt` reads it (a record's dict already a tuple). No
element equals a literal with such an int, whatever its other leaves.
element equals a literal with such an int, whatever its other leaves, and
infinity of the int's sign equals no element either. Every other value is
kept, so a text leaf still pairs with its field. Return ``None`` if
``value`` holds no such int.
"""
if np_type.names is not None:
parts = value if isinstance(value, (tuple, list)) else [value] * len(np_type.names)
if len(parts) != len(np_type.names):
return None # not laid out like an element; typing it reports that
for name, part in zip(np_type.names, parts, strict=True):
found = _int_beyond_leaf(np_type.fields[name][0], part)
if found is not None:
return found
return None
base, shape = np_type.subdtype or (np_type, ())
if base.kind in "iu":
lo, hi = _int_bounds(base)
for x in _literal_leaf_values(value, shape, numpy_scalars=True) or ():
if type(x) is int and not lo <= x <= hi:
return x
return None


def _beyond_as_infinity(np_type, value):
"""Return literal ``value`` with each int beyond its integer leaf's range as infinity.
found = False

``value`` is laid out as :func:`_int_beyond_leaf` reads it. Infinity of the
int's sign equals no element, as the int does; every other value is kept,
so a text leaf still pairs with its field.
"""
if np_type.names is not None:
parts = value if isinstance(value, (tuple, list)) else [value] * len(np_type.names)
if len(parts) != len(np_type.names):
return value
return tuple(
_beyond_as_infinity(np_type.fields[name][0], part)
for name, part in zip(np_type.names, parts, strict=True)
def walk(np_type, value):
nonlocal found
if np_type.names is not None:
parts = value if isinstance(value, (tuple, list)) else [value] * len(np_type.names)
if len(parts) != len(np_type.names):
return value # not laid out like an element; typing it reports that
return tuple(
walk(np_type.fields[name][0], part)
for name, part in zip(np_type.names, parts, strict=True)
)
base, shape = np_type.subdtype or (np_type, ())
values = (
_literal_leaf_values(value, shape, numpy_scalars=True) if base.kind in "iu" else None
)
base, shape = np_type.subdtype or (np_type, ())
values = _literal_leaf_values(value, shape, numpy_scalars=True) if base.kind in "iu" else None
if values is None:
return value
lo, hi = _int_bounds(base)
values = [
(math.inf if x > 0 else -math.inf) if type(x) is int and not lo <= x <= hi else x
for x in values
]
return np.array(values, dtype=object).reshape(shape).tolist() if shape else values[0]
if values is None:
return value
lo, hi = _int_bounds(base)
beyond = [type(x) is int and not lo <= x <= hi for x in values]
found = found or any(beyond)
values = [
(math.inf if x > 0 else -math.inf) if is_beyond else x
for x, is_beyond in zip(values, beyond, strict=True)
]
return np.array(values, dtype=object).reshape(shape).tolist() if shape else values[0]

value = walk(np_type, value)
return value if found else None


def _array_literal_udt(dtype, value):
Expand Down
16 changes: 7 additions & 9 deletions graphblas/core/matrix.py
Original file line number Diff line number Diff line change
Expand Up @@ -1749,7 +1749,7 @@ def to_dense(self, fill_value=None, dtype=None, **opts):
keyword_name="fill_value",
extra_message="Literal scalars also accepted.",
)
dtype = unify(fill_value.dtype, self.dtype, is_left_scalar=True)
dtype = unify(fill_value.dtype, self.dtype)

rv = self.dup(dtype, clear=True, name="to_dense", **opts)
rv(**opts) << fill_value
Expand Down Expand Up @@ -2354,8 +2354,8 @@ def _ewise_union(self, other, op, left_default, right_default, is_infix=False):
op1 = get_typed_op(op, self.dtype, right.dtype, kind="binary")
op2 = get_typed_op(op, left.dtype, other.dtype, kind="binary")
if op1 is not op2:
left_dtype = unify(op1.type, op2.type, is_right_scalar=True)
right_dtype = unify(op1.type2, op2.type2, is_left_scalar=True)
left_dtype = unify(op1.type, op2.type)
right_dtype = unify(op1.type2, op2.type2)
op = get_typed_op(op, left_dtype, right_dtype, kind="binary")
else:
op = op1
Expand Down Expand Up @@ -2669,7 +2669,7 @@ def apply(self, op, right=None, *, left=None):
extra_message="Literal scalars also accepted.",
op=op,
)
op = get_typed_op(op, left.dtype, self.dtype, is_left_scalar=True, kind="binary")
op = get_typed_op(op, left.dtype, self.dtype, kind="binary")
if opclass == "Monoid":
op = op.binaryop
else:
Expand Down Expand Up @@ -2707,12 +2707,10 @@ def apply(self, op, right=None, *, left=None):
op=op,
)
if opclass in {"IndexUnaryOp", "SelectOp"}:
op = get_typed_op(
op, self.dtype, right.dtype, is_right_scalar=True, kind="indexunary"
)
op = get_typed_op(op, self.dtype, right.dtype, kind="indexunary")
cfunc_method = "IndexOp"
else:
op = get_typed_op(op, self.dtype, right.dtype, is_right_scalar=True, kind="binary")
op = get_typed_op(op, self.dtype, right.dtype, kind="binary")
cfunc_method = "BinaryOp2nd"
if opclass == "Monoid":
op = op.binaryop
Expand Down Expand Up @@ -2843,7 +2841,7 @@ def select(self, op, thunk=None):
ncols=self._ncols,
dtype=self.dtype,
)
op = get_typed_op(op, self.dtype, thunk.dtype, is_right_scalar=True, kind="select")
op = get_typed_op(op, self.dtype, thunk.dtype, kind="select")
op, thunk = _value_select_in_own_type(op, self.dtype, thunk, given)
self._expect_op(op, ("SelectOp", "IndexUnaryOp"), within=method_name, argname="op")
if thunk._is_cscalar:
Expand Down
42 changes: 6 additions & 36 deletions graphblas/core/operator/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ def _register_aggregator_types(aggregator, typed_aggregator):
_TypedAggregator = typed_aggregator


def get_typed_op(op, dtype, dtype2=None, *, is_left_scalar=False, is_right_scalar=False, kind=None):
def get_typed_op(op, dtype, dtype2=None, *, kind=None):
if isinstance(op, OpBase):
# UDTs always get compiled
if op._is_udt:
Expand Down Expand Up @@ -122,9 +122,7 @@ def get_typed_op(op, dtype, dtype2=None, *, is_left_scalar=False, is_right_scala
return op._compile_udt(dtype, dtype2)
# Generic case: try to unify the two dtypes
try:
return op[
unify(dtype, dtype2, is_left_scalar=is_left_scalar, is_right_scalar=is_right_scalar)
]
return op[unify(dtype, dtype2)]
# Two built-in types always unify, and a UDT beside any other type returned above.
except (TypeError, AttributeError): # pragma: no cover (safety)
# Failure to unify implies a dtype is UDT; some builtin operators can handle UDTs
Expand All @@ -135,14 +133,7 @@ def get_typed_op(op, dtype, dtype2=None, *, is_left_scalar=False, is_right_scala
return op._compile_udt(dtype, dtype2)
if isinstance(op, ParameterizedUdf):
op = op() # Use default parameters of parameterized UDFs
return get_typed_op(
op,
dtype,
dtype2,
is_left_scalar=is_left_scalar,
is_right_scalar=is_right_scalar,
kind=kind,
)
return get_typed_op(op, dtype, dtype2, kind=kind)
if isinstance(op, TypedOpBase):
return op

Expand Down Expand Up @@ -182,14 +173,7 @@ def get_typed_op(op, dtype, dtype2=None, *, is_left_scalar=False, is_right_scala
'"unary", "binary", "monoid", "semiring", "indexunary", "select", '
'or "binary|aggregator".'
)
return get_typed_op(
op,
dtype,
dtype2,
is_left_scalar=is_left_scalar,
is_right_scalar=is_right_scalar,
kind=kind,
)
return get_typed_op(op, dtype, dtype2, 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
Expand All @@ -206,14 +190,7 @@ def get_typed_op(op, dtype, dtype2=None, *, is_left_scalar=False, is_right_scala
op = BinaryOp.register_anonymous(op, is_udt=True)
return op._compile_udt(dtype, dtype2)
if isinstance(op, BuiltinFunctionType) and op in _builtin_to_op:
return get_typed_op(
_builtin_to_op[op],
dtype,
dtype2,
is_left_scalar=is_left_scalar,
is_right_scalar=is_right_scalar,
kind=kind,
)
return get_typed_op(_builtin_to_op[op], dtype, dtype2, kind=kind)
raise TypeError(f"Unable to get typed operator from object with type {type(op)}")


Expand All @@ -231,14 +208,7 @@ def _get_typed_op_from_exprs(op, left, right, *, kind=None):
right_dtype = right_op.type2
else:
right_dtype = right.dtype
return get_typed_op(
op,
left_dtype,
right_dtype,
is_left_scalar=left._is_scalar,
is_right_scalar=right._is_scalar,
kind=kind,
)
return get_typed_op(op, left_dtype, right_dtype, kind=kind)


def get_semiring(monoid, binaryop, name=None):
Expand Down
22 changes: 4 additions & 18 deletions graphblas/core/scalar.py
Original file line number Diff line number Diff line change
Expand Up @@ -333,25 +333,11 @@ def isclose(self, other, *, rel_tol=1e-7, abs_tol=0.0, check_dtype=False):
if not _has_numba:
# Check if types are compatible, and compare in the type they unify to, as
# with Numba (INT64 with UINT64 is in FP64). Python ints do not wrap in x - y.
eq = get_typed_op(
binary.eq,
self.dtype,
other.dtype,
is_left_scalar=True,
is_right_scalar=True,
kind="binary",
)
eq = get_typed_op(binary.eq, self.dtype, other.dtype, kind="binary")
x = eq.type.np_type.type(self.value).item()
y = eq.type2.np_type.type(other.value).item()
return isclose_func(x, y)
isclose_func = get_typed_op(
isclose_func,
self.dtype,
other.dtype,
is_left_scalar=True,
is_right_scalar=True,
kind="binary",
)
isclose_func = get_typed_op(isclose_func, self.dtype, other.dtype, kind="binary")
# Pass the values in the op's input types, as ewise_mult does; Numba types a
# Python int as int64, which a UINT64 value need not fit
x = isclose_func.type.np_type.type(self.value)
Expand Down Expand Up @@ -867,8 +853,8 @@ def _ewise_union(self, other, op, left_default, right_default, is_infix=False):
op1 = get_typed_op(op, self.dtype, right.dtype, kind="binary")
op2 = get_typed_op(op, left.dtype, other.dtype, kind="binary")
if op1 is not op2:
left_dtype = unify(op1.type, op2.type, is_right_scalar=True)
right_dtype = unify(op1.type2, op2.type2, is_left_scalar=True)
left_dtype = unify(op1.type, op2.type)
right_dtype = unify(op1.type2, op2.type2)
op = get_typed_op(op, left_dtype, right_dtype, kind="binary")
else:
op = op1
Expand Down
Loading
Loading