Source code for spacecore.linop._algebra

from __future__ import annotations

from functools import reduce
from typing import Any, Sequence

from ._base import LinOp, Domain, Codomain, _require_linop
from ..mapping import (
    Scalar, ScaledNode, SumNode, ZeroNode, make_scaled_of, make_sum_of, register,
    same_space_for_algebra as _same_space_for_algebra,
)
from ..space import DenseElement, Element
from .._checks import CheckLevel, minimum_check_level
from .._checks import checked_method
from ..space._capabilities import _CAP_BATCH, _CAP_COORDINATE
from .._repr import summarize_value
from ..mapping._lazy_algebra import conjugate_scalar as _conjugate_scalar
from ._matrix_free import IdentityLinOp


def _compose_chain(op: Any) -> tuple:
    """Flatten ``op`` into its leaf operators in application order.

    This is the *fusion rule*: a composed operator exposes a precomputed
    ``_apply_chain`` (its leaves, first applied first); every other operator
    contributes itself as a single-element chain. Concatenating the chains of a
    composition's operands fuses an arbitrarily deep ``A @ B @ C @ ...`` into one
    flat sequence, so the composed core loops once instead of re-walking the
    nested binary tree.
    """
    chain = getattr(op, "_apply_chain", None)
    return chain if chain is not None else (op,)


def _leading_shape(space: Any, value: Any) -> tuple[int, ...]:
    """Infer the leading batch dimensions of ``value`` relative to ``space``."""
    # Product spaces: batch shape of the first component. Else strip the trailing space shape.
    parts = getattr(space, "spaces", None)
    if parts is not None and isinstance(value, tuple) and value:
        return _leading_shape(parts[0], value[0])
    shape = tuple(getattr(value, "shape", ()))
    base = tuple(space.shape)
    return shape if not base else shape[: len(shape) - len(base)]


def _batched_zeros(space: Any, leading: tuple[int, ...]) -> Any:
    """Return a batched zero element of ``space`` with leading shape ``leading``."""
    # Product spaces get a tuple of per-component zero batches.
    parts = getattr(space, "spaces", None)
    if parts is not None:
        return tuple(_batched_zeros(part, leading) for part in parts)
    return space.ops.zeros(leading + tuple(space.shape), dtype=space.dtype)



[docs] def make_sum(ops: Sequence[LinOp]) -> LinOp: """ Return the lazy sum of operators, with nested sums flattened and zero terms dropped. Parameters ---------- ops : sequence of LinOp Operators sharing context, domain and codomain. Returns ------- LinOp The sum, the single remaining operand, or a zero operator. """ return make_sum_of(LinOp, ops)
[docs] def make_scaled(scalar: Any, op: LinOp) -> LinOp: """ Return ``scalar * op``: ``0`` gives zero, ``1`` gives ``op``, nested scalars fold. Parameters ---------- scalar : scalar-like Coefficient. op : LinOp Operator to scale. Returns ------- LinOp The scaled operator. """ return make_scaled_of(LinOp, scalar, op)
def _check_composable(left: LinOp, right: LinOp) -> tuple[LinOp, LinOp]: """Validate ``left @ right``: both LinOps, same context, matching middle space.""" left = _require_linop(left, "left") right = _require_linop(right, "right") if not left.ctx.same_math(right.ctx): raise ValueError( "All LinOp operands in an algebraic expression must have the same ctx; " f"operand 0 has ctx {left.ctx!r}, operand 1 has ctx {right.ctx!r}." ) if not _same_space_for_algebra(right.codomain, left.domain): raise ValueError( "ComposedLinOp requires right.codomain == left.domain; " f"got {right.codomain!r} and {left.domain!r}." ) return left, right
[docs] def make_composed(left: LinOp, right: LinOp) -> LinOp: """ Return ``left @ right``, dropping identity factors and collapsing zero factors. Parameters ---------- left : LinOp Operator applied second. right : LinOp Operator applied first; ``right.codomain`` must equal ``left.domain``. Returns ------- LinOp The composition. """ left, right = _check_composable(left, right) # Local rewrites: identity factors vanish, zero factors give a zero map. if isinstance(right, IdentityLinOp): return left if isinstance(left, IdentityLinOp): return right if isinstance(left, ZeroLinOp) or isinstance(right, ZeroLinOp): return ZeroLinOp(right.domain, left.codomain, left.ctx) return ComposedLinOp(left, right)
[docs] class ScaledLinOp(ScaledNode, LinOp[Domain, Codomain]): r""" Lazy ``scalar * op``; its adjoint is ``conj(scalar) * op.H``. Parameters ---------- scalar : scalar-like Coefficient. op : LinOp Operator to scale. check_level : {"none", "cheap", "standard", "strict"}, optional Validation level; the ambient default when omitted. """ _fields = ("scalar", "op:operand") _dynamic = ("scalar", "op") def _apply_core(self, x: Any) -> Any: y = self.op._apply_core(x) scale = self.codomain._scale_core return scale(self.scalar, y) def _rapply_core(self, y: Any) -> Any: x = self.op._rapply_core(y) scale = self.domain._scale_core return scale(_conjugate_scalar(self.scalar), x) def _vapply_core(self, xs: Any) -> Any: ys = self.op._vapply_core(xs) return self.codomain._scale_batch_core(self.scalar, ys) def __init__(self, scalar: Any, op: LinOp[Domain, Codomain], check_level: CheckLevel | None = None) -> None: super().__init__(scalar, op, check_level) @property def op(self) -> LinOp[Domain, Codomain]: """The scaled operand.""" return self.operand
[docs] @checked_method(in_space="domain", out_space="codomain", in_batched=True, out_batched=True) def vapply(self, xs: Any) -> Any: """Return ``scalar * op.vapply(xs)``.""" self._require(_CAP_BATCH, "ScaledLinOp.vapply") return self._vapply_core(xs)
def rvapply(self, ys: Any) -> Any: """Return ``conj(scalar) * op.rvapply(ys)``.""" self._require(_CAP_BATCH, "ScaledLinOp.rvapply") # Adjoint of s A is conj(s) A*. xs = self.op.rvapply(ys) return self.domain._scale_batch_core(_conjugate_scalar(self.scalar), xs)
[docs] def fuse(self, *, materialize: bool = False) -> LinOp: r"""Fuse the operand and fold the scalar into a dense matrix (ADR-021). When the fused operand is dense, replace ``c · A`` with one :class:`DenseLinOp` holding ``c · M_A``; otherwise keep the scaling lazy, so a matrix-free operand is never densified. Adjoint-consistent: the fused operator's adjoint is ``conj(c) · M_A^*`` (with the metric), exactly the lazy ``ScaledLinOp`` adjoint. """ from ._dense import DenseLinOp # Fuse the operand; a dense result absorbs the scalar into its matrix. op = self.op.fuse(materialize=materialize) if isinstance(op, DenseLinOp): matrix = self.scalar * op.to_matrix() tensor = self.ops.reshape( matrix, tuple(op.codomain.shape) + tuple(op.domain.shape) ) return DenseLinOp(tensor, op.domain, op.codomain, self.ctx) return make_scaled(self.scalar, op)
[docs] def is_hermitian(self) -> bool | None: """ Return ``op.is_hermitian()`` for a real scalar, else ``None`` (undecided). Returns ------- bool | None Structural Hermiticity, or ``None`` when unknown. """ if _conjugate_scalar(self.scalar) == self.scalar: return self.op.is_hermitian() return None
def _repr_body(self) -> str: return f"{summarize_value(self.scalar)} · {self.op._short_repr()}"
[docs] class SumLinOp(SumNode, LinOp[Domain, Codomain]): r""" Lazy sum of operators sharing context, domain and codomain. Parameters ---------- ops : sequence of LinOp The summands. check_level : {"none", "cheap", "standard", "strict"}, optional Validation level; the ambient default when omitted. """ _fields = ("ops:parts",) _dynamic = ("ops",) def _apply_core(self, x: Any) -> Any: # A1 x + ... + Ak x, with the codomain's unchecked add when it has one. return reduce(self.codomain._add_core, (part._apply_core(x) for part in self.ops_tuple)) def _rapply_core(self, y: Any) -> Any: # A1* y + ... + Ak* y, added in the domain. return reduce(self.domain._add_core, (part._rapply_core(y) for part in self.ops_tuple)) def _vapply_core(self, xs: Any) -> Any: # Row-wise sum of the batched applies. return reduce(self.codomain._add_batch_core, (part._vapply_core(xs) for part in self.ops_tuple)) def __init__(self, ops: Sequence[LinOp[Domain, Codomain]], check_level: CheckLevel | None = None) -> None: super().__init__(ops, check_level) @property def ops_tuple(self) -> tuple[LinOp[Domain, Codomain], ...]: """The summed operators (alias of ``parts``).""" return self.parts
[docs] @checked_method(in_space="domain", out_space="codomain", in_batched=True, out_batched=True) def vapply(self, xs: Any) -> Any: """Return ``sum_i ops[i].vapply(xs)``.""" self._require(_CAP_BATCH, "SumLinOp.vapply") return self._vapply_core(xs)
[docs] @checked_method(in_space="codomain", out_space="domain", in_batched=True, out_batched=True) def rvapply(self, ys: Any) -> Any: """Return ``sum_i ops[i].rvapply(ys)``.""" self._require(_CAP_BATCH, "SumLinOp.rvapply") # Adjoint of a sum is the sum of adjoints. return reduce(self.domain._add_batch_core, (op.rvapply(ys) for op in self.ops_tuple))
[docs] def fuse(self, *, materialize: bool = False) -> LinOp: r"""Fuse each term and combine the dense terms into one ``DenseLinOp`` (ADR-021). Fuse every term, sum the matrices of the densely-fusible ones into a single :class:`DenseLinOp`, and keep the remaining (matrix-free or structured) terms as lazy summands — so a matrix-free term is never densified. Adjoint-consistent and additive: ``(A + B)^* = A^* + B^*``. Combining reassociates the term order, so equality holds up to rounding. """ from ._dense import DenseLinOp # Fuse each term, then add up the dense ones into one matrix; # matrix-free terms stay as lazy summands. fused = [p.fuse(materialize=materialize) for p in self.parts] dense = [p for p in fused if isinstance(p, DenseLinOp)] if len(dense) < 2: return make_sum(fused) matrix = dense[0].to_matrix() for d in dense[1:]: matrix = matrix + d.to_matrix() ref = dense[0] tensor = self.ops.reshape( matrix, tuple(ref.codomain.shape) + tuple(ref.domain.shape) ) combined = DenseLinOp(tensor, ref.domain, ref.codomain, self.ctx) others = [p for p in fused if not isinstance(p, DenseLinOp)] return make_sum([combined, *others])
[docs] def is_hermitian(self) -> bool | None: """ Return whether this lazy sum is structurally Hermitian. The adjoint is additive, ``(A1 + ... + Ak)* = A1* + ... + Ak*``, so a sum is self-adjoint when every term is. If all operands are provably Hermitian this returns ``True``; otherwise the verdict is not cheaply decidable (a sum of non-Hermitian terms may still be Hermitian) and ``None`` is returned. ``False`` is never returned. Returns ------- bool | None ``True`` when every part is provably Hermitian, otherwise ``None``. """ if all(op.is_hermitian() is True for op in self.parts): return True return None
def _repr_body(self) -> str: from .._repr import truncated_join return truncated_join((op._short_repr() for op in self.ops_tuple), " + ")
[docs] class ComposedLinOp(LinOp[Domain, Codomain]): r""" Lazy composition ``left @ right``; the adjoint is ``right.H @ left.H``. Parameters ---------- left : LinOp Operator applied second. right : LinOp Operator applied first; ``right.codomain`` must equal ``left.domain``. check_level : {"none", "cheap", "standard", "strict"}, optional Validation level; the ambient default when omitted. """ _fields = ("left", "right") _dynamic = ("left", "right") def _apply_core(self, x: Any) -> Any: for leaf in self._apply_chain: x = leaf._apply_core(x) return x def _rapply_core(self, z: Any) -> Any: for leaf in reversed(self._apply_chain): z = leaf._rapply_core(z) return z def _vapply_core(self, xs: Any) -> Any: for leaf in self._apply_chain: xs = leaf._vapply_core(xs) return xs def __init__( self, left: LinOp, right: LinOp, check_level: CheckLevel | None = None, ) -> None: left, right = _check_composable(left, right) # Bind with right's domain and left's codomain. if check_level is None: check_level = minimum_check_level((left.check_level, right.check_level)) super().__init__(right.domain, left.codomain, left.ctx, check_level=check_level) self.left = left self.right = right # Fuse the (possibly nested) composition into one flat chain of leaf # operators in application order — right applied first, then left. # Cached at construction so every apply runs a single check-free loop # instead of re-walking the binary ComposedLinOp tree. self._apply_chain = _compose_chain(right) + _compose_chain(left)
[docs] @checked_method(in_space="domain", out_space="codomain", in_batched=True, out_batched=True) def vapply(self, xs: Any) -> Any: """Return ``left.vapply(right.vapply(xs))``.""" self._require(_CAP_BATCH, "ComposedLinOp.vapply") return self._vapply_core(xs)
def rvapply(self, zs: Any) -> Any: """Return ``right.rvapply(left.rvapply(zs))``.""" self._require(_CAP_BATCH, "ComposedLinOp.rvapply") return self.right.rvapply(self.left.rvapply(zs))
[docs] def fuse(self, *, materialize: bool = False) -> LinOp: r"""Fuse a composition of dense operators into one ``DenseLinOp`` (ADR-021). Fuse each operand first, then — when both fused operands are dense — replace ``A @ B`` with a single :class:`DenseLinOp` holding the matrix product :math:`M_A M_B`. Any operand that does not fuse to a dense operator (matrix-free leaves, sparse, structured) keeps the composition lazy, so a matrix-free operand is never densified. The fused matrix is adjoint-consistent on any geometry: the shared middle-space Riesz maps cancel, so the fused operator's metric adjoint equals ``B* @ A*`` up to floating-point rounding. """ from ._dense import DenseLinOp # Two dense factors multiply into one matrix; otherwise stay lazy. left = self.left.fuse(materialize=materialize) right = self.right.fuse(materialize=materialize) if isinstance(left, DenseLinOp) and isinstance(right, DenseLinOp): ops = self.ops matrix = ops.matmul(left.to_matrix(), right.to_matrix()) tensor = ops.reshape( matrix, tuple(left.codomain.shape) + tuple(right.domain.shape) ) return DenseLinOp(tensor, right.domain, left.codomain, self.ctx) return make_composed(left, right)
[docs] def is_hermitian(self) -> bool | None: """ Return ``True`` for an adjoint-palindromic chain (``R.H @ R``, ``L @ V @ L.H`` with ``V`` self-adjoint), else ``None``. Returns ------- bool | None ``True`` when structurally Hermitian, ``None`` when unknown. """ # The chain c_k ... c_0 is self-adjoint when c_i# == c_(k-i) for every i and the # middle factor, if any, is self-adjoint: R# R, L V L# and their nestings. chain = self._apply_chain k = len(chain) if all(chain[i].H == chain[k - 1 - i] for i in range(k // 2)) and ( k % 2 == 0 or chain[k // 2].is_hermitian() is True ): return True return None
def _repr_body(self) -> str: return f"{self.left._short_repr()} ∘ {self.right._short_repr()}"
[docs] class ZeroLinOp(ZeroNode, LinOp[Domain, Codomain]): r""" Lazy zero map from ``dom`` to ``cod``. Parameters ---------- dom : Space Domain. cod : Space Codomain. ctx : Context, str, or None, optional Backend context; resolved from the spaces when omitted. check_level : {"none", "cheap", "standard", "strict"}, optional Validation level; the ambient default when omitted. """ def _apply_core(self, x: Any) -> Any: return self.codomain.zeros() def _rapply_core(self, y: Any) -> Any: return self.domain.zeros() def _vapply_core(self, xs: Any) -> Any: return _batched_zeros(self.codomain, _leading_shape(self.domain, xs))
[docs] @checked_method(in_space="domain", in_batched=True) def vapply(self, xs: Any) -> Any: """Return the batched zero element of the codomain.""" self._require(_CAP_BATCH, "ZeroLinOp.vapply") return self._vapply_core(xs)
[docs] @checked_method(in_space="codomain", in_batched=True) def rvapply(self, ys: Any) -> Any: """Return the batched zero element of the domain.""" self._require(_CAP_BATCH, "ZeroLinOp.rvapply") return _batched_zeros(self.domain, _leading_shape(self.codomain, ys))
[docs] def to_dense(self) -> Any: """ Return the dense tensor representation of the zero map. The returned array has shape ``self.codomain.shape + self.domain.shape``. """ self._require(_CAP_COORDINATE, "ZeroLinOp.to_dense") return self.ops.zeros( tuple(self.codomain.shape) + tuple(self.domain.shape), dtype=self.dtype )
[docs] def is_hermitian(self) -> bool: """ Return whether the zero map is Hermitian. Returns ------- bool ``True`` exactly when domain and codomain are the same space. """ return self.domain == self.codomain
LinOp._Sum, LinOp._Scaled, LinOp._Zero = SumLinOp, ScaledLinOp, ZeroLinOp register("add", LinOp, LinOp, lambda a, b: make_sum((a, b))) register("mul", Scalar, LinOp, make_scaled) register("mul", LinOp, Scalar, lambda op, s: make_scaled(s, op)) register("matmul", LinOp, LinOp, make_composed) register("matmul", LinOp, Element, lambda A, x: DenseElement._make(A.codomain, A.apply(x.densify())))