Source code for spacecore.backend._ops

from __future__ import annotations

from abc import ABC, abstractmethod
import math
from typing import Any, Sequence, Tuple, Callable, Optional, Type, ClassVar, Generic, cast

from ..types._array import DenseT

from ..types import DenseArray, SparseArray, DType, ArrayLike, Index, T, X, Y, R, Carry


[docs] class BackendOps(ABC, Generic[DenseT]): """ Public numerical contract for SpaceCore backends. Common dense-array operations delegate to the backend's Array API-compatible ``xp`` namespace. Subclasses provide backend-specific sparse conversion, dtype policy, indexing mutation, control flow, device/autograd behavior, and operations not covered by the Array API. """ _family: ClassVar[str] _allow_sparse: ClassVar[bool] xp: ClassVar[Any] def __init__(self) -> None: self._constant_cache: dict[str, DenseT] = {} @property def family(self) -> str: """Backend family identifier.""" return type(self)._family @property def allow_sparse(self) -> bool: """Whether this backend supports sparse arrays.""" return self._allow_sparse @property def has_native_vmap(self) -> bool: """Whether ``vmap`` is implemented by the backend rather than a Python loop.""" return False
[docs] def is_tracing(self) -> bool: """Whether code is running under this backend's tracer (``jit``, ``vmap``, ``compile``).""" return False
[docs] @classmethod def install_pytree_protocol(cls) -> None: """ Register SpaceCore containers with this backend's pytree machinery; a no-op by default. Backends with transforms (JAX, torch) override this and import their modules inside the method, so a missing dependency cannot break ``import spacecore``. """ return
def __eq__(self, other: Any) -> bool: return isinstance(other, BackendOps) and self.family == other.family def __hash__(self) -> int: return hash((type(self).__name__, self.family)) @property @abstractmethod def dense_array(self) -> Type[Any]: """Dense array type accepted by this backend.""" ... @property @abstractmethod def sparse_array(self) -> Tuple[Type[Any], ...] | None: """Sparse array types accepted by this backend, or None.""" ...
[docs] @abstractmethod def sanitize_dtype(self, dtype: DType | None) -> DType: """Normalize a dtype specifier for this backend.""" ...
[docs] def is_dense(self, x: Any) -> bool: """Return whether x is a dense array for this backend.""" return isinstance(x, self.dense_array)
[docs] def is_sparse(self, x: Any) -> bool: """Return whether x is a sparse array for this backend.""" return self.sparse_array is not None and isinstance(x, self.sparse_array)
[docs] def is_array(self, x: Any) -> bool: """Return whether x is any array for this backend.""" return self.is_dense(x) or self.is_sparse(x)
[docs] @abstractmethod def assparse(self, x: Any, *, dtype: DType | None = None) -> SparseArray: """Convert input to a backend sparse array.""" ...
[docs] @abstractmethod def sparse_matmul(self, a: SparseArray, b: DenseArray) -> DenseT: """Multiply a sparse array by a dense array.""" ...
[docs] @abstractmethod def logsumexp( self, a: DenseArray, axis: int | Sequence[int] | None = None, b: DenseArray | None = None, keepdims: bool = False, return_sign: bool = False, ) -> DenseT | Tuple[DenseT, DenseT]: """Compute a stable log-sum-exp reduction.""" ...
def _copy(self, x: DenseArray) -> DenseT: """Return a mutable copy of ``x``. Mutable backends override this with their native copy (``x.copy()`` for NumPy/CuPy, ``x.clone()`` for PyTorch). Immutable backends such as JAX override :meth:`index_set` / :meth:`index_add` directly and never call this helper. """ raise NotImplementedError(f"{type(self).__name__} does not implement _copy.") def _scatter_add_inplace(self, y: DenseArray, index: Index, values: ArrayLike | float | complex) -> None: """Accumulate ``values`` into ``y`` at ``index`` in place. Backend mutation primitive used by the default :meth:`index_add`. Repeated-index accumulation is backend-specific (NumPy/CuPy use ``add.at`` and accumulate duplicate indices; PyTorch does not). """ raise NotImplementedError( f"{type(self).__name__} does not implement _scatter_add_inplace." )
[docs] def index_set( self, x: DenseArray, index: Index, values: ArrayLike | float | complex, *, copy: bool = True, ) -> DenseT: """Return ``x`` with ``x[index]`` set to ``values``. With ``copy=True`` a mutable copy is updated and returned; with ``copy=False`` ``x`` is mutated in place. Immutable backends override this method. """ y = self._copy(x) if copy else x y[index] = values return cast(DenseT, y)
[docs] def index_add( self, x: DenseArray, index: Index, values: DenseArray, *, copy: bool = True, ) -> DenseT: """Return ``x`` with ``values`` accumulated into ``x[index]``. With ``copy=True`` a mutable copy is updated and returned; with ``copy=False`` ``x`` is mutated in place. Immutable backends override this method. """ y = self._copy(x) if copy else x self._scatter_add_inplace(y, index, values) return cast(DenseT, y)
[docs] @abstractmethod def ix_(self, *args: Any) -> Any: """Build open-mesh index arrays.""" ...
[docs] @abstractmethod def fori_loop( self, lower: int, upper: int, body_fun: Callable[[int, T], T], init_val: T, ) -> T: """Run a counted loop primitive."""
[docs] @abstractmethod def while_loop( self, cond_fun: Callable[[T], bool], body_fun: Callable[[T], T], init_val: T, ) -> T: """Run a while-loop primitive."""
[docs] @abstractmethod def scan( self, f: Callable[[Carry, X], Tuple[Carry, Y]], init: Carry, xs: X, length: Optional[int] = None, reverse: bool = False, unroll: int = 1, ) -> Tuple[Carry, Y]: """Run a scan primitive."""
[docs] @abstractmethod def cond( self, pred: bool, true_fun: Callable[[T], R], false_fun: Callable[[T], R], *operands: Any, ) -> R: """Run backend-compatible conditional branch selection.""" ...
[docs] @abstractmethod def allclose_sparse( self, a: SparseArray, b: SparseArray, rtol: float = 1e-5, atol: float = 1e-8, ) -> bool: """Compare sparse arrays elementwise within tolerances.""" ...
def _require_two_sparse(self, a: Any, b: Any, *, noun: str = "sparse arrays") -> None: """Raise ``TypeError`` unless both ``a`` and ``b`` are sparse for this backend. Shared guard for :meth:`allclose_sparse`. ``noun`` names the expected operands in the error message (e.g. ``"sparse tensors"``). """ if not self.is_sparse(a) or not self.is_sparse(b): raise TypeError(f"allclose_sparse expects two {noun}.") def _dtype_arg(self, dtype: DType | None) -> DType | None: return None if dtype is None else self.sanitize_dtype(dtype) def _source_is_complex(self, x: Any) -> bool: """Return whether ``x`` carries a complex representation.""" # Arrays report a dtype; else check Python complex, a sparse .data, then sequences. dtype = getattr(x, "dtype", None) if dtype is not None: return getattr(dtype, "kind", None) == "c" or "complex" in str(dtype) if isinstance(x, complex): return True data = getattr(x, "data", None) if data is not None and data is not x: data_dtype = getattr(data, "dtype", None) if data_dtype is not None: return getattr(data_dtype, "kind", None) == "c" or "complex" in str(data_dtype) if isinstance(x, (list, tuple)): return any(self._source_is_complex(value) for value in x) return False def _reject_complex_to_real( self, x: Any, dtype: DType | None, *, operation: str, ) -> None: """Reject implicit loss of a complex representation during conversion.""" if dtype is None: return # Complex to real would silently drop the imaginary part. target_dtype = self.sanitize_dtype(dtype) if not self.is_complex_dtype(target_dtype) and self._source_is_complex(x): raise TypeError( f"{operation} rejected complex-valued input for non-complex dtype " f"{target_dtype}. Explicitly discard the imaginary part first, for " "example with `x.real` or a backend real-part operation, then convert." ) def _to_axis_tuple(self, axis: int | Sequence[int] | None) -> int | tuple[int, ...] | None: if axis is None or isinstance(axis, int): return axis return tuple(axis) def _as_array(self, x: Any) -> DenseT: """Return ``x`` as a backend array, promoting a host scalar to 0-d. SpaceCore's convention is that every array-valued method returns a backend *array*, never a host scalar. NumPy (and CuPy) reductions return ``np.float64``-style scalars for which :meth:`is_array` is False and :meth:`get_dtype` raises; JAX and Torch already return 0-d arrays. Wrapping here makes the four backends agree. The ``is_array`` guard keeps this free on backends that never produce host scalars, and keeps JAX tracers (which are ``jax.Array`` instances) untouched, so the call is safe inside ``jit``. """ return x if self.is_array(x) else self.xp.asarray(x) def _take_along_axis(self, x: DenseArray, indices: DenseArray, axis: int) -> DenseT: """Gather along ``axis`` using per-position ``indices``. Used by the spectral-ordering normalization. Torch spells this ``take_along_dim`` and overrides this method. """ return self.xp.take_along_axis(x, indices, axis=axis) @property def inf(self) -> DenseT: """Positive infinity as a cached backend scalar.""" return self._constant("inf", float("inf")) @property def nan(self) -> DenseT: """NaN as a cached backend scalar.""" return self._constant("nan", float("nan")) @property def pi(self) -> DenseT: """Pi as a cached backend scalar.""" return self._constant("pi", 3.141592653589793) @property def e(self) -> DenseT: """Euler's number as a cached backend scalar.""" return self._constant("e", 2.718281828459045) def _constant(self, name: str, value: float) -> DenseT: if name not in self._constant_cache: self._constant_cache[name] = self.asarray(value) return self._constant_cache[name]
[docs] def eps(self, dtype: DType) -> float: """Machine epsilon for dtype.""" return float(self.xp.finfo(self.sanitize_dtype(dtype)).eps)
[docs] def is_complex_dtype(self, dtype: DType) -> bool: """ Return whether ``dtype`` is a complex floating type. Parameters ---------- dtype: Backend or portable dtype specifier to inspect. Returns ------- bool ``True`` when ``dtype`` represents complex floating values. """ dtype = self.sanitize_dtype(dtype) return getattr(dtype, "kind", None) == "c" or str(dtype).startswith("torch.complex")
[docs] def real_dtype(self, dtype: DType) -> DType: """ Return the real floating dtype with the same precision as ``dtype``. Parameters ---------- dtype: Backend or portable dtype specifier. Returns ------- DType ``dtype`` itself when it is already real-valued; otherwise ``float32`` for complex64 and ``float64`` for complex128. """ dtype = self.sanitize_dtype(dtype) if not self.is_complex_dtype(dtype): return dtype # complex64 pairs with float32 and complex128 with float64; use the name if there is no itemsize. itemsize = getattr(dtype, "itemsize", None) if itemsize is None: dtype_text = str(dtype) if "complex64" in dtype_text: return self.sanitize_dtype("float32") return self.sanitize_dtype("float64") return self.sanitize_dtype("float32" if itemsize <= 8 else "float64")
[docs] def complex_dtype(self, dtype: DType) -> DType: """ Return the complex dtype of the same precision as ``dtype`` (``float32 -> complex64``). Needed because ``result_type(float32, 1j)`` is ``complex128`` on NumPy but ``complex64`` on JAX and torch. """ dtype = self.sanitize_dtype(dtype) if self.is_complex_dtype(dtype): return dtype # The complex dtype whose real part is this dtype; else let promotion decide. for candidate in (self.xp.complex64, self.xp.complex128): if self.real_dtype(candidate) == dtype: return candidate return self.xp.result_type(dtype, self.xp.asarray(1j))
[docs] def get_dtype(self, x: Any) -> DType: """Return x.dtype after verifying x is a backend array.""" if self.is_array(x): return x.dtype raise TypeError(f"Expected {self.family} array, got {type(x)}.")
[docs] def shape(self, x: Any) -> tuple[int, ...]: """Return x.shape as a tuple.""" return tuple(x.shape)
[docs] def ndim(self, x: Any) -> int: """Return the number of dimensions of x.""" return int(x.ndim)
[docs] def size(self, x: Any) -> int: """Return the total number of elements in x.""" return math.prod(map(int, self.shape(x)))
[docs] def asarray(self, x: Any, dtype: DType | None = None, **backend_kwargs: Any) -> DenseT: """Convert input to a dense backend array (delegates to xp.asarray).""" self._reject_complex_to_real(x, dtype, operation="asarray") # Densify sparse input, then Array API asarray or torch's as_tensor. if self.is_sparse(x) and hasattr(x, "to_dense"): x = x.to_dense() return self.xp.asarray(x, dtype=self._dtype_arg(dtype), **backend_kwargs)
[docs] def astype(self, x: DenseArray, dtype: DType | None, **backend_kwargs: Any) -> DenseT: """Cast x to dtype, returning x unchanged when dtype is None.""" if dtype is None: return cast(DenseT, x) self._reject_complex_to_real(x, dtype, operation="astype") return cast(Any, x).astype(self.sanitize_dtype(dtype), **backend_kwargs)
[docs] def empty(self, shape: Tuple[int, ...], dtype: DType | None = None) -> DenseT: """Create an uninitialized array (delegates to xp.empty).""" return self.xp.empty(shape, dtype=self._dtype_arg(dtype))
[docs] def zeros(self, shape: Tuple[int, ...], dtype: DType | None = None) -> DenseT: """Create a zero-filled array (delegates to xp.zeros).""" return self.xp.zeros(shape, dtype=self._dtype_arg(dtype))
[docs] def ones(self, shape: Tuple[int, ...], dtype: DType | None = None) -> DenseT: """Create a one-filled array (delegates to xp.ones).""" return self.xp.ones(shape, dtype=self._dtype_arg(dtype))
[docs] def zeros_like(self, x: DenseArray, dtype: DType | None = None) -> DenseT: """Create a zero-filled array like x (delegates to xp.zeros_like).""" return self.xp.zeros_like(x, dtype=self._dtype_arg(dtype))
[docs] def ones_like(self, x: DenseArray, dtype: DType | None = None) -> DenseT: """Create a one-filled array like x (delegates to xp.ones_like).""" return self.xp.ones_like(x, dtype=self._dtype_arg(dtype))
[docs] def full_like(self, x: DenseArray, value: Any, dtype: DType | None = None) -> DenseT: """Create a value-filled array like x (delegates to xp.full_like).""" return self.xp.full_like(x, value, dtype=self._dtype_arg(dtype))
[docs] def arange( self, start: int, stop: int | None = None, step: int | None = None, dtype: DType | None = None, ) -> DenseT: """Create an evenly spaced range (delegates to xp.arange).""" dtype = self._dtype_arg(dtype) # Mirror the arange(stop), arange(start, stop), arange(start, stop, step) overloads. if stop is None: return self.xp.arange(start, dtype=dtype) if step is None: return self.xp.arange(start, stop, dtype=dtype) return self.xp.arange(start, stop, step, dtype=dtype)
[docs] def full( self, shape: Tuple[int, ...], fill_value: Any, dtype: DType | None = None ) -> DenseT: """Create a value-filled array (delegates to xp.full).""" return self.xp.full(shape, fill_value, dtype=self._dtype_arg(dtype))
[docs] def eye(self, n: int, m: int | None = None, dtype: DType | None = None) -> DenseT: """Create an identity-like matrix (delegates to xp.eye).""" return self.xp.eye(n, m, dtype=self._dtype_arg(dtype))
# -- Flattening order -------------------------------------------------- # # SpaceCore flattens in **C order** (row-major: the last axis varies # fastest), on every backend, always. This is the vec convention that the # coordinate layer is built on -- ``CoordinateSpace.flatten``, # ``LinOp.to_matrix`` and the Kronecker identities all # assume it, and vec(A x) = (I ⊗ A) vec(x) rather than (Aᵀ ⊗ I) is exactly # this choice. It is a *declared* convention rather than a forwarded # ``order=`` argument because Torch has no Fortran-order reshape at all: # offering the parameter would mean offering it on three backends of four. # # The order is over *index* positions, not memory layout, so it is # unaffected by whether the input is C-contiguous, Fortran-contiguous, or # a non-contiguous view -- a transposed array flattens by its logical # index order, not its buffer.
[docs] def ravel(self, x: DenseArray) -> DenseT: """Flatten x to one dimension in C order (last axis varies fastest).""" return self.xp.ravel(x)
[docs] def reshape(self, x: DenseArray, shape: Tuple[int, ...] | int) -> DenseT: """Reshape x in C order (delegates to xp.reshape; see the note above).""" shape_arg = (shape,) if isinstance(shape, int) else shape return self.xp.reshape(x, shape_arg)
[docs] def transpose(self, x: DenseArray, axes: Sequence[int] | None = None) -> DenseT: """Permute dimensions of x.""" if axes is None: axes = tuple(reversed(range(self.ndim(x)))) return self.xp.permute_dims(x, tuple(axes))
[docs] def swapaxes(self, x: DenseArray, axis1: int, axis2: int) -> DenseT: """Swap two axes of x.""" return self.xp.swapaxes(x, axis1, axis2)
[docs] def expand_dims(self, x: DenseArray, axis: int | Sequence[int]) -> DenseT: """Insert singleton dimensions into x.""" if isinstance(axis, int): return self.xp.expand_dims(x, axis=axis) # Several axes: insert one at a time, in increasing final position. out = cast(DenseT, x) ndim = self.ndim(x) + len(axis) for ax in sorted(a + ndim if a < 0 else a for a in axis): out = self.expand_dims(out, ax) return out
[docs] def squeeze(self, x: DenseArray, axis: int | Sequence[int] | None = None) -> DenseT: """Remove singleton dimensions from x.""" if axis is None: # All size-1 axes, passed explicitly since not every backend accepts axis=None. axis = tuple(i for i, dim in enumerate(self.shape(x)) if dim == 1) if not axis: return cast(DenseT, x) axis = (axis,) if isinstance(axis, int) else tuple(axis) return self.xp.squeeze(x, axis=axis)
[docs] def moveaxis( self, x: DenseArray, source: int | Sequence[int], destination: int | Sequence[int], ) -> DenseT: """Move axes of x to new positions.""" return self.xp.moveaxis(x, source, destination)
[docs] def stack(self, arrays: Sequence[DenseArray], axis: int = 0) -> DenseT: """Stack arrays along a new axis (delegates to xp.stack).""" return self.xp.stack(tuple(arrays), axis=axis)
[docs] def hstack(self, arrays: Sequence[DenseArray]) -> DenseT: """Stack arrays horizontally / column-wise (delegates to xp.hstack). Concatenates along axis 0 for 1-D inputs and along axis 1 otherwise, matching NumPy ``hstack`` semantics. """ return self.xp.hstack(tuple(arrays))
[docs] def vstack(self, arrays: Sequence[DenseArray]) -> DenseT: """Stack arrays vertically / row-wise (delegates to xp.vstack). Inputs are promoted to at least 2-D and concatenated along axis 0, matching NumPy ``vstack`` semantics. """ return self.xp.vstack(tuple(arrays))
[docs] def dstack(self, arrays: Sequence[DenseArray]) -> DenseT: """Stack arrays depth-wise along the third axis (delegates to xp.dstack). Inputs are promoted to at least 3-D and concatenated along axis 2, matching NumPy ``dstack`` semantics. """ return self.xp.dstack(tuple(arrays))
[docs] def column_stack(self, arrays: Sequence[DenseArray]) -> DenseT: """Stack 1-D arrays as columns into a 2-D array (delegates to xp.column_stack). 1-D inputs become columns; higher-dimensional inputs are concatenated along axis 1, matching NumPy ``column_stack`` semantics. """ return self.xp.column_stack(tuple(arrays))
[docs] def vmap( self, fn: Callable, in_axes: int | Sequence[int | None] | None = 0, out_axes: int | Sequence[int | None] | None = 0, ) -> Callable: """Vectorize ``fn`` over array axes using a Python-loop fallback.""" # Fallback vmap: loop over the mapped axis in Python and stack the outputs. # in_axes is one axis for all arguments or one per argument. def axis_for_arg(i: int) -> int | Sequence[int | None] | None: if isinstance(in_axes, tuple) or isinstance(in_axes, list): return in_axes[i] return in_axes def normalize_axis(axis: int, ndim: int) -> int: return axis + ndim if axis < 0 else axis # Batch size of the first mapped leaf; tuples are walked recursively. def tree_size(x: Any, axis: Any) -> int | None: if axis is None: return None if isinstance(x, tuple): axes = axis if isinstance(axis, (tuple, list)) else (axis,) * len(x) for xi, ai in zip(x, axes): size = tree_size(xi, ai) if size is not None: return size return None shape = tuple(getattr(x, "shape", ())) axis = normalize_axis(int(axis), len(shape)) return int(cast(Any, shape[axis])) # The i-th slice of every mapped leaf. def tree_take(x: Any, axis: Any, i: int) -> Any: if axis is None: return cast(DenseT, x) if isinstance(x, tuple): axes = axis if isinstance(axis, (tuple, list)) else (axis,) * len(x) return tuple(tree_take(xi, ai, i) for xi, ai in zip(x, axes)) shape = tuple(getattr(x, "shape", ())) axis = normalize_axis(int(axis), len(shape)) index: list[Any] = [slice(None)] * len(shape) index[axis] = i return x[tuple(index)] # Stack the per-index outputs along out_axes, recursing into tuples. def tree_stack(xs: Sequence[Any], axis: Any) -> Any: first = xs[0] if isinstance(first, tuple): axes = axis if isinstance(axis, (tuple, list)) else (axis,) * len(first) return tuple(tree_stack(tuple(x[i] for x in xs), ai) for i, ai in enumerate(axes)) if axis is None: return first return self.stack(xs, axis=int(axis)) def mapped(*args: Any) -> Any: axes = tuple(axis_for_arg(i) for i in range(len(args))) size = None for arg, axis in zip(args, axes): size = tree_size(arg, axis) if size is not None: break # No argument is mapped: one plain call. if size is None: return fn(*args) outputs = tuple( fn(*(tree_take(arg, axis, i) for arg, axis in zip(args, axes))) for i in range(size) ) return tree_stack(outputs, out_axes) return mapped
[docs] def vectorize( self, pyfunc: Callable, *, excluded: Sequence[int] | None = None, signature: str | None = None, ) -> Callable: """ Vectorize a scalar function over broadcast array arguments, like :func:`numpy.vectorize`. Uses the backend's native ``vectorize`` when present; the Python-loop fallback does not support ``signature``. """ # Native vectorize when available; otherwise a Python loop. if hasattr(self.xp, "vectorize"): kwargs: dict[str, Any] = {} if excluded is not None: kwargs["excluded"] = excluded if signature is not None: kwargs["signature"] = signature return self.xp.vectorize(pyfunc, **kwargs) return self._vectorize_loop(pyfunc, excluded=excluded, signature=signature)
def _vectorize_loop( self, pyfunc: Callable, *, excluded: Sequence[int] | None = None, signature: str | None = None, ) -> Callable: """Portable ``vectorize`` fallback for backends without a native one.""" if signature is not None: raise NotImplementedError( "The vectorize fallback does not support gufunc signatures; use a " "backend that provides a native vectorize (NumPy, JAX, CuPy)." ) excluded_set = set() if excluded is None else set(excluded) def vectorized(*args: Any) -> Any: # Broadcast the non-excluded arguments, call pyfunc per element, reshape back. positions = [i for i in range(len(args)) if i not in excluded_set] if not positions: return self.asarray(pyfunc(*args)) broadcast = self.xp.broadcast_arrays(*(self.asarray(args[i]) for i in positions)) out_shape = self.shape(broadcast[0]) flat = {i: self.ravel(b) for i, b in zip(positions, broadcast)} count = math.prod(map(int, out_shape)) outputs = [] for k in range(count): call_args = list(args) for i in positions: call_args[i] = flat[i][k] outputs.append(self.asarray(pyfunc(*call_args))) stacked = self.stack(outputs, axis=0) return self.reshape(stacked, out_shape) return vectorized
[docs] def sum( self, x: DenseArray, axis: int | Sequence[int] | None = None, keepdims: bool = False, dtype: DType | None = None, ) -> DenseT: """Sum over given axes (delegates to xp.sum). Returns a 0-d array when total.""" return self._as_array( self.xp.sum( x, axis=self._to_axis_tuple(axis), dtype=self._dtype_arg(dtype), keepdims=keepdims, ) )
[docs] def mean( self, x: DenseArray, axis: int | Sequence[int] | None = None, keepdims: bool = False, ) -> DenseT: """Mean over given axes (delegates to xp.mean). Returns a 0-d array when total.""" return self._as_array(self.xp.mean(x, axis=self._to_axis_tuple(axis), keepdims=keepdims))
[docs] def min( self, x: DenseArray, axis: int | Sequence[int] | None = None, keepdims: bool = False, ) -> DenseT: """Minimum over given axes (delegates to xp.min). Returns a 0-d array when total.""" return self._as_array(self.xp.min(x, axis=self._to_axis_tuple(axis), keepdims=keepdims))
[docs] def max( self, x: DenseArray, axis: int | Sequence[int] | None = None, keepdims: bool = False, ) -> DenseT: """Maximum over given axes (delegates to xp.max). Returns a 0-d array when total.""" return self._as_array(self.xp.max(x, axis=self._to_axis_tuple(axis), keepdims=keepdims))
[docs] def prod( self, x: DenseArray, axis: int | Sequence[int] | None = None, keepdims: bool = False, dtype: DType | None = None, ) -> DenseT: """Product over given axes (delegates to xp.prod). Returns a 0-d array when total.""" return self._as_array( self.xp.prod( x, axis=self._to_axis_tuple(axis), dtype=self._dtype_arg(dtype), keepdims=keepdims, ) )
[docs] def trace(self, x: DenseArray) -> DenseT: """Trace over the **trailing two axes**; batched input traces each matrix. Deliberately *not* ``xp.trace(x)``: NumPy and JAX default to ``axis1=0, axis2=1``, which on a ``(batch, n, n)`` stack traces across the batch axis and silently returns a length-``n`` vector, while ``torch.trace`` rejects anything but a 2-D input. SpaceCore's batched convention is leading batch axes, so the trailing two are the matrix. """ return self.sum(self.diagonal(x), axis=-1)
[docs] def argsort(self, x: DenseArray, axis: int = -1) -> DenseT: """Return indices that sort ``x`` along an axis.""" return self._as_array(self.xp.argsort(x, axis=axis))
[docs] def sort(self, x: DenseArray, axis: int = -1) -> DenseT: """Sort x along an axis (delegates to xp.sort).""" return self._as_array(self.xp.sort(x, axis=axis))
[docs] def argmin(self, x: DenseArray, axis: int | None = None, keepdims: bool = False) -> DenseT: """Return indices of minima along an axis. Returns a 0-d array when total.""" return self._as_array(self.xp.argmin(x, axis=axis, keepdims=keepdims))
[docs] def argmax(self, x: DenseArray, axis: int | None = None, keepdims: bool = False) -> DenseT: """Return indices of maxima along an axis. Returns a 0-d array when total.""" return self._as_array(self.xp.argmax(x, axis=axis, keepdims=keepdims))
[docs] def vdot(self, x: DenseArray, y: DenseArray) -> DenseT: """Return ``sum(conj(x) * y)`` over flattened inputs. Matches NumPy, JAX, and Torch ``vdot`` semantics. ``DenseLinOp.rapply`` relies on this convention for complex inputs. Returns a 0-d array. """ return self._as_array(self.xp.vdot(self.ravel(x), self.ravel(y)))
[docs] def matmul( self, a: DenseArray, b: DenseArray, backend_kwargs: dict[str, Any] | None = None, ) -> DenseT: """Matrix product (delegates to xp.matmul).""" return self._as_array( self.xp.matmul(a, b, **({} if backend_kwargs is None else backend_kwargs)) )
# -- Spectral presentation conventions --------------------------------- # # A decomposition is determined only up to a choice of ordering and, for # each vector, a sign (real) or unit phase (complex). Four backends are # free to make those choices differently -- and do: Torch returns complex # Hermitian eigenvectors that are -1 times NumPy's and JAX's. SpaceCore # therefore *declares* the presentation and normalizes to it here rather # than inheriting whatever the underlying library returned: # # * eigenvalues ascending, singular values descending; # * each eigenvector / singular vector scaled so that its entry of # largest magnitude is real and positive. # # NOT normalized, and not normalizable: the basis of a degenerate # eigenspace (multiplicity > 1) is arbitrary, so callers must not depend # on it. Ties in "entry of largest magnitude" are broken by first # occurrence, which all backends agree on. # # Everything below is pure array arithmetic -- no data-dependent Python # branch, no host sync -- so it stays safe under jit, vmap and compile. # Cost is O(n^2) on top of an O(n^3) decomposition. def _column_phase(self, vectors: DenseArray) -> DenseT: """Return the unit phase of each column's largest entry, shaped ``(..., 1, k)``.""" # Per column: the entry of largest modulus and its unit phase. magnitude = self.abs(vectors) pivot_row = self.xp.argmax(magnitude, axis=-2) pivot_row = self.expand_dims(pivot_row, -2) # Gather the pivot with a one-hot mask, which stays jittable. rows = self.reshape(self.arange(self.shape(vectors)[-2]), (-1, 1)) selector = self.astype(cast(Any, rows) == pivot_row, self.get_dtype(vectors)) pivot = self.sum(vectors * selector, axis=-2, keepdims=True) scale = self.abs(pivot) # A unit-norm vector cannot be all zeros; the guard only keeps the # division defined for degenerate inputs rather than changing a result. safe_scale = self.where(cast(Any, scale) > 0, scale, self.ones_like(scale)) return self.where(cast(Any, scale) > 0, pivot / safe_scale, self.ones_like(pivot)) def _gauge_columns(self, vectors: DenseArray) -> DenseT: """Scale each column so its largest-magnitude entry is real positive.""" return vectors / self._column_phase(vectors) def _order_eigenpairs( self, eigenvalues: DenseArray, eigenvectors: DenseArray, ) -> tuple[DenseT, DenseT]: """Sort eigenpairs ascending by eigenvalue and gauge the eigenvectors.""" # Ascending eigenvalues, columns permuted to match, then a fixed phase gauge. order = self.xp.argsort(self.real(eigenvalues), axis=-1) eigenvalues = self._take_along_axis(eigenvalues, order, -1) eigenvectors = self._take_along_axis(eigenvectors, self.expand_dims(order, -2), -1) return eigenvalues, self._gauge_columns(eigenvectors)
[docs] def eigh( self, x: DenseArray, backend_kwargs: dict[str, Any] | None = None, ) -> tuple[DenseT, DenseT]: """Eigenpairs of a Hermitian dense matrix, in SpaceCore's presentation. Eigenvalues ascending; each eigenvector scaled so its largest-magnitude entry is real and positive. See the conventions note above -- this is deliberately not raw ``xp.linalg.eigh`` output. """ if self.is_sparse(x): raise TypeError("eigh requires a dense array; sparse input is not supported.") eigenvalues, eigenvectors = self.xp.linalg.eigh( x, **({} if backend_kwargs is None else backend_kwargs) ) return self._order_eigenpairs(eigenvalues, eigenvectors)
[docs] def norm( self, x: DenseArray, ord: int | str | None = None, axis: int | Sequence[int] | None = None, keepdims: bool = False, ) -> DenseT: """Vector or matrix norm (delegates to xp.linalg.norm). Returns a 0-d array when total.""" return self._as_array(self.xp.linalg.norm(x, ord=ord, axis=axis, keepdims=keepdims))
[docs] def solve( self, A: DenseArray, b: DenseArray, backend_kwargs: dict[str, Any] | None = None, ) -> DenseT: """Solve a dense linear system (delegates to xp.linalg.solve).""" return self.xp.linalg.solve(A, b, **({} if backend_kwargs is None else backend_kwargs))
[docs] def eigvalsh( self, A: DenseArray, backend_kwargs: dict[str, Any] | None = None, ) -> DenseT: """Eigenvalues of a Hermitian dense matrix, ascending (SpaceCore convention).""" eigenvalues = self.xp.linalg.eigvalsh( A, **({} if backend_kwargs is None else backend_kwargs) ) return self.sort(self.real(eigenvalues), axis=-1)
[docs] def svd( self, A: DenseArray, full_matrices: bool = True, backend_kwargs: dict[str, Any] | None = None, ) -> tuple[DenseT, DenseT, DenseT]: """Singular value decomposition, in SpaceCore's presentation. Singular values descending; each of the leading ``k = min(m, n)`` columns of ``U`` scaled so its largest-magnitude entry is real and positive, with the compensating phase applied to the matching row of ``Vh`` so that ``U @ diag(s) @ Vh`` still reconstructs ``A`` exactly. With ``full_matrices=True`` the trailing columns of ``U`` (and rows of ``Vh``) span a null space whose basis is arbitrary and backend-specific -- they are left untouched, and are not comparable across backends. Pass ``full_matrices=False`` when you need agreement. """ U, s, Vh = self.xp.linalg.svd( A, full_matrices=full_matrices, **({} if backend_kwargs is None else backend_kwargs), ) return self._order_svd(U, s, Vh)
def _order_svd( self, U: DenseArray, s: DenseArray, Vh: DenseArray, ) -> tuple[DenseT, DenseT, DenseT]: """Sort singular triples descending and gauge them, preserving ``U s Vh``.""" # Sort singular values descending; permute the first k columns of U and rows of Vh. k = self.shape(s)[-1] order = self.xp.argsort(-s, axis=-1) s = self._take_along_axis(s, order, -1) column_order = self.expand_dims(order, -2) U_main = self._take_along_axis(U[..., :, :k], column_order, -1) Vh_main = self._take_along_axis(Vh[..., :k, :], self.expand_dims(order, -1), -2) # Fix the gauge: U columns / phase and Vh rows * phase leave U diag(s) Vh unchanged. phase = self._column_phase(U_main) U_main = U_main / phase Vh_main = Vh_main * self.swapaxes(phase, -1, -2) # Columns beyond k (full_matrices=True) are reattached unchanged. U = self.concatenate([U_main, U[..., :, k:]], axis=-1) Vh = self.concatenate([Vh_main, Vh[..., k:, :]], axis=-2) return U, s, Vh
[docs] def cholesky( self, A: DenseArray, backend_kwargs: dict[str, Any] | None = None, ) -> DenseT: """Cholesky factorization (delegates to xp.linalg.cholesky).""" return self.xp.linalg.cholesky(A, **({} if backend_kwargs is None else backend_kwargs))
[docs] def where( self, condition: DenseArray | bool, x: ArrayLike | float | complex, y: ArrayLike | float | complex, ) -> DenseT: """Select between x and y by condition (delegates to xp.where).""" return self.xp.where(condition, x, y)
[docs] def concatenate( self, arrays: Sequence[DenseArray], axis: int = 0, dtype: DType | None = None, ) -> DenseT: """Concatenate arrays along an existing axis (delegates to xp.concat).""" result = self.xp.concat(tuple(arrays), axis=axis) return self.astype(result, dtype) if dtype is not None else result
[docs] def take( self, x: DenseArray, indices: DenseArray, axis: int | None = None, ) -> DenseT: """Take entries from x by integer indices (delegates to xp.take).""" return self.xp.take(x, indices, axis=axis)
[docs] def diagonal(self, x: DenseArray) -> DenseT: """Return the main diagonal over the **trailing two axes**. Same correction as :meth:`trace`: the array libraries default to ``axis1=0, axis2=1``, which on a ``(batch, n, n)`` stack diagonalizes across the batch axis. Identical to the library default for 2-D input. """ return self.xp.diagonal(x, axis1=-2, axis2=-1)
[docs] def allclose( self, a: DenseArray, b: DenseArray, rtol: float = 1e-5, atol: float = 1e-8, equal_nan: bool = False, ) -> bool: """Return whether dense arrays are close within tolerances.""" return bool(self.xp.allclose(a, b, rtol=rtol, atol=atol, equal_nan=equal_nan))
def __repr__(self) -> str: return f"{type(self).__name__}(family={self.family!r})"
[docs] def conj(self, x: DenseArray, **kwargs: Any) -> DenseT: """Return ``xp.conj(...)`` as a backend array.""" return self._as_array(self.xp.conj(x, **kwargs))
[docs] def real(self, x: DenseArray, **kwargs: Any) -> DenseT: """Return ``xp.real(...)`` as a backend array.""" return self._as_array(self.xp.real(x, **kwargs))
[docs] def imag(self, x: DenseArray, **kwargs: Any) -> DenseT: """Return ``xp.imag(...)`` as a backend array.""" return self._as_array(self.xp.imag(x, **kwargs))
[docs] def abs(self, x: DenseArray, **kwargs: Any) -> DenseT: """Return ``xp.abs(...)`` as a backend array.""" return self._as_array(self.xp.abs(x, **kwargs))
[docs] def sign(self, x: DenseArray, **kwargs: Any) -> DenseT: """Return ``xp.sign(...)`` as a backend array.""" return self._as_array(self.xp.sign(x, **kwargs))
[docs] def sqrt(self, x: DenseArray, **kwargs: Any) -> DenseT: """Return ``xp.sqrt(...)`` as a backend array.""" return self._as_array(self.xp.sqrt(x, **kwargs))
[docs] def exp(self, x: DenseArray, **kwargs: Any) -> DenseT: """Return ``xp.exp(...)`` as a backend array.""" return self._as_array(self.xp.exp(x, **kwargs))
[docs] def log(self, x: DenseArray, **kwargs: Any) -> DenseT: """Return ``xp.log(...)`` as a backend array.""" return self._as_array(self.xp.log(x, **kwargs))
[docs] def isfinite(self, x: DenseArray, **kwargs: Any) -> DenseT: """Return ``xp.isfinite(...)`` as a backend array.""" return self._as_array(self.xp.isfinite(x, **kwargs))
[docs] def isnan(self, x: DenseArray, **kwargs: Any) -> DenseT: """Return ``xp.isnan(...)`` as a backend array.""" return self._as_array(self.xp.isnan(x, **kwargs))
[docs] def broadcast_to(self, x: DenseArray, shape: Sequence[int], **kwargs: Any) -> DenseT: """Return ``xp.broadcast_to(...)`` as a backend array.""" return self._as_array(self.xp.broadcast_to(x, shape, **kwargs))
[docs] def kron(self, a: DenseArray, b: DenseArray, **kwargs: Any) -> DenseT: """Return ``xp.kron(...)`` as a backend array.""" return self._as_array(self.xp.kron(a, b, **kwargs))
[docs] def einsum(self, subscripts: str, *operands: DenseArray, **kwargs: Any) -> DenseT: """Return ``xp.einsum(...)`` as a backend array.""" return self._as_array(self.xp.einsum(subscripts, *operands, **kwargs))
[docs] def maximum(self, x1: DenseArray | float, x2: DenseArray | float, **kwargs: Any) -> DenseT: """Return ``xp.maximum(...)`` as a backend array.""" return self._as_array(self.xp.maximum(x1, x2, **kwargs))
[docs] def minimum(self, x1: DenseArray | float, x2: DenseArray | float, **kwargs: Any) -> DenseT: """Return ``xp.minimum(...)`` as a backend array.""" return self._as_array(self.xp.minimum(x1, x2, **kwargs))
[docs] def clip(self, x: DenseArray, min: Any = None, max: Any = None, **kwargs: Any) -> DenseT: """Return ``xp.clip(...)`` as a backend array.""" return self._as_array(self.xp.clip(x, min, max, **kwargs))
[docs] def diag(self, x: DenseArray, k: int = 0, **kwargs: Any) -> DenseT: """Return ``xp.diag(...)`` as a backend array.""" return self._as_array(self.xp.diag(x, k, **kwargs))
[docs] def tril(self, x: DenseArray, k: int = 0, **kwargs: Any) -> DenseT: """Return ``xp.tril(...)`` as a backend array.""" return self._as_array(self.xp.tril(x, k=k, **kwargs))
[docs] def triu(self, x: DenseArray, k: int = 0, **kwargs: Any) -> DenseT: """Return ``xp.triu(...)`` as a backend array.""" return self._as_array(self.xp.triu(x, k=k, **kwargs))