Source code for spacecore.linop._matrix_free

"""Leaf operators defined by actions: identity, matrix-free, and the adjoint view."""

from __future__ import annotations

import math
import warnings
from typing import Any, Callable, cast

from ._base import LinOp, Domain, Codomain, _require_linop
from ._metric import _requires_euclidean_or_riesz, metric_rapply, metric_rvapply
from .._checks import CheckLevel
from .._checks import checked_method
from ..space.base import CoordinateSpace, InnerProductSpace
from ..space._capabilities import _CAP_BATCH, _CAP_COORDINATE, probe_element
from ..contextual import Context



#: Sentinel for "the caller did not mention ``euclidean_adjoint`` at all".
#: Passing ``False`` explicitly is an assertion that ``rapply`` already carries the
#: geometry, so it silences the advisory; omitting it means the question was never
#: considered, which is the only case worth warning about.
_ADJOINT_UNSPECIFIED = object()


[docs] class IdentityLinOp(LinOp[Domain, Domain]): r""" Lazy identity map on ``space``. Parameters ---------- space : Space Domain and codomain. ctx : Context, str, or None, optional Backend context; resolved from ``space`` when omitted. check_level : {"none", "cheap", "standard", "strict"}, optional Validation level; the ambient default when omitted. """ _fields = ("space:domain", "ctx") def _apply_core(self, x: Any) -> Any: return x _rapply_core = _apply_core _vapply_core = _apply_core def __init__( self, space: Domain, ctx: Context | str | None = None, check_level: CheckLevel | None = None, ) -> None: super().__init__(space, space, ctx, check_level=check_level)
[docs] @checked_method(in_space="domain", in_batched=True) def vapply(self, xs: Any) -> Any: """Return ``xs`` after batched domain validation.""" self._require(_CAP_BATCH, "IdentityLinOp.vapply") return xs
[docs] @checked_method(in_space="codomain", in_batched=True) def rvapply(self, xs: Any) -> Any: """Return ``xs`` after batched codomain validation.""" self._require(_CAP_BATCH, "IdentityLinOp.rvapply") return xs
[docs] def to_dense(self) -> Any: """ Return the dense tensor representation of this identity map. The returned array has shape ``self.codomain.shape + self.domain.shape``. """ self._require(_CAP_COORDINATE, "IdentityLinOp.to_dense") # The identity matrix, reshaped to cod.shape + dom.shape. eye = self.ops.eye(math.prod(self.domain.shape), dtype=self.dtype) return self.ops.reshape(eye, tuple(self.codomain.shape) + tuple(self.domain.shape))
[docs] def is_hermitian(self) -> bool: """ Return whether this identity operator is Hermitian. Returns ------- bool Always ``True``. """ return True
def _repr_body(self) -> str: from .._repr import describe_space return describe_space(self.domain)
[docs] class MatrixFreeLinOp(LinOp[Domain, Codomain]): """ Linear operator given by forward and adjoint callables; no matrix is stored. ``rapply`` must be the adjoint in the declared inner products, ``<apply(x), y>_Y = <x, rapply(y)>_X``. It is used as given unless ``euclidean_adjoint=True``, and no adjoint test runs at construction. Parameters ---------- apply : callable Forward map ``apply(x)`` from ``dom`` to ``cod``. rapply : callable Adjoint map ``rapply(y)`` from ``cod`` to ``dom``. dom : Space Domain. cod : Space Codomain. ctx : Context, str, or None, optional Backend context; inferred from ``dom`` and ``cod`` when omitted. vapply : callable or None, optional Batched forward map; ``vmap`` of ``apply`` by default. rvapply : callable or None, optional Batched adjoint; ``vmap`` of ``rapply`` by default. check_level : {"none", "cheap", "standard", "strict"}, optional Validation level; the ambient default when omitted. euclidean_adjoint : bool, optional ``True``: ``rapply`` is the coordinate adjoint and is wrapped into ``R_X^-1 A^T R_Y`` (see :meth:`from_coordinate_adjoint`). ``False``: it is the metric adjoint. Omitting it on a non-Euclidean space warns, because a coordinate adjoint there is silently wrong. hermitian : bool or None, optional Declared self-adjointness, returned by :meth:`is_hermitian` so solvers need no probe; ``None`` (the default) means unknown. At ``strict`` checks a declared ``True`` is probed once against ``rapply``. """ _fields = ("apply:apply_fn", "rapply:rapply_fn", "dom:domain", "cod:codomain", "ctx", "vapply:vapply_fn", "rvapply:rvapply_fn", "hermitian") def _apply_core(self, x: Any) -> Any: return self.apply_fn(x) # Capability requirements hold at every check level: an adjoint needs inner # products, a batch needs batchable spaces. def _rapply_core(self, y: Any) -> Any: self._require(InnerProductSpace, "MatrixFreeLinOp.rapply") return self.rapply_fn(y) def _vapply_core(self, xs: Any) -> Any: # The supplied batched map, else the generic vmap fallback. self._require(_CAP_BATCH, "MatrixFreeLinOp.vapply") return super()._vapply_core(xs) if self.vapply_fn is None else self.vapply_fn(xs) def _rvapply_core(self, ys: Any) -> Any: self._require(_CAP_BATCH, "MatrixFreeLinOp.rvapply") return super()._rvapply_core(ys) if self.rvapply_fn is None else self.rvapply_fn(ys) def __init__( self, apply: Callable[[Any], Any], rapply: Callable[[Any], Any], dom: Domain, cod: Codomain, ctx: Context | str | None = None, vapply: Callable[[Any], Any] | None = None, rvapply: Callable[[Any], Any] | None = None, check_level: CheckLevel | None = None, *, euclidean_adjoint: bool | Any = _ADJOINT_UNSPECIFIED, hermitian: bool | None = None, ) -> None: # Validate the callables, then bind the spaces. if not callable(apply): raise TypeError(f"apply must be callable, got {type(apply).__name__}.") if not callable(rapply): raise TypeError(f"rapply must be callable, got {type(rapply).__name__}.") if vapply is not None and not callable(vapply): raise TypeError(f"vapply must be callable, got {type(vapply).__name__}.") if rvapply is not None and not callable(rvapply): raise TypeError(f"rvapply must be callable, got {type(rvapply).__name__}.") super().__init__(dom, cod, ctx, check_level=check_level) # Does the adjoint need Riesz maps? Only on a non-Euclidean inner-product space. # Without coordinates there is no coordinate adjoint to confuse with, so a # native space never warns. has_inner = all( isinstance(space, InnerProductSpace) for space in (self.domain, self.codomain) ) non_euclidean = has_inner and any( isinstance(space, CoordinateSpace) and not space.is_euclidean for space in (self.domain, self.codomain) ) if euclidean_adjoint is True: # The caller supplied the *coordinate* adjoint A^dagger; wrap it into # the metric adjoint R_X^-1 A^dagger R_Y once, here. try: _requires_euclidean_or_riesz( self.domain, self.codomain, "MatrixFreeLinOp(euclidean_adjoint=True) / " "MatrixFreeLinOp.from_coordinate_adjoint", ) except TypeError as exc: raise ValueError(str(exc)) from exc rapply, rvapply = self._wrap_coordinate_adjoint(rapply, rvapply) elif non_euclidean and euclidean_adjoint is _ADJOINT_UNSPECIFIED: # The callable is stored verbatim and *trusted* as the metric adjoint. # On a non-Euclidean geometry a coordinate adjoint is simply wrong here # and nothing downstream detects it, so say so once, at construction. # Silent on Euclidean spaces (the two coincide) and silent when the # caller passed ``euclidean_adjoint=False`` explicitly, which asserts # that the supplied adjoint already carries the geometry. warnings.warn( "MatrixFreeLinOp stores `rapply` verbatim as the metric adjoint, but " f"{'domain' if not self.domain.is_euclidean else 'codomain'} geometry is " "non-Euclidean, where the metric adjoint R_X^-1 A^dagger R_Y differs from " "the coordinate adjoint. Pass euclidean_adjoint=True to have the coordinate " "adjoint wrapped for you, or confirm `rapply` already carries the geometry.", UserWarning, stacklevel=2, ) # Store the callables; strict mode also dot-tests the adjoint. self.apply_fn = apply self.rapply_fn = rapply self.vapply_fn = vapply self.rvapply_fn = rvapply if self._checks_at_least("strict"): self._check_adjoint_consistency() self.hermitian = hermitian if hermitian and self._checks_at_least("strict"): self._check_declared_hermitian() def _wrap_coordinate_adjoint( self, coordinate_rapply: Callable[[Any], Any], coordinate_rvapply: Callable[[Any], Any] | None, ) -> tuple[Callable[[Any], Any], Callable[[Any], Any] | None]: """Return ``(rapply, rvapply)`` wrapping coordinate adjoints in Riesz maps.""" # Wrap coordinate adjoints into metric ones: R_X^-1 A^T R_Y. dom, cod = self.domain, self.codomain def wrapped_rapply(y: Any) -> Any: return metric_rapply(dom, cod, coordinate_rapply, y) wrapped_rvapply: Callable[[Any], Any] | None = None if coordinate_rvapply is not None: def _wrapped_rvapply(ys: Any) -> Any: return metric_rvapply(dom, cod, coordinate_rvapply, ys) wrapped_rvapply = _wrapped_rvapply return wrapped_rapply, wrapped_rvapply
[docs] def is_hermitian(self) -> bool | None: """Return the declared self-adjointness (``None`` when not declared).""" return self.hermitian
def _check_declared_hermitian(self) -> None: """Probe a declared self-adjoint operator: ``apply`` and ``rapply`` must agree.""" x = probe_element(self.domain, self.ops, self.dtype) if self.domain != self.codomain or ( x is not None and not bool(self.ops.allclose(self.apply_fn(x), self.rapply_fn(x))) ): raise ValueError("MatrixFreeLinOp declared hermitian=True, but apply and rapply differ.") def _check_adjoint_consistency(self) -> None: """Probe the declared adjoint identity on deterministic space elements.""" # Dot test <A x, y> = <x, A* y> on deterministic probe vectors. if not all( isinstance(space, InnerProductSpace) for space in (self.domain, self.codomain) ): return x = probe_element(self.domain, self.ops, self.dtype) y = probe_element(self.codomain, self.ops, self.dtype) if x is None or y is None: # Neither coordinates nor ``ones``: the space cannot build a non-zero # element, so there is nothing to probe with. Construction-time # geometry warnings still apply. return try: ax = self.apply_fn(x) ahy = self.rapply_fn(y) self.codomain._check_member(ax) self.domain._check_member(ahy) lhs = cast(Any, self.codomain).inner(ax, y) rhs = cast(Any, self.domain).inner(x, ahy) consistent = bool(self.ops.allclose(lhs, rhs)) except Exception as exc: raise ValueError( "Strict matrix-free adjoint consistency check could not be completed." ) from exc if not consistent: raise ValueError( "Strict matrix-free adjoint consistency check failed: " "<A x, y> != <x, A* y> for the deterministic probe." )
[docs] @classmethod def from_coordinate_adjoint( cls, apply: Callable[[Any], Any], coordinate_rapply: Callable[[Any], Any], dom: Domain, cod: Codomain, ctx: Context | str | None = None, vapply: Callable[[Any], Any] | None = None, coordinate_rvapply: Callable[[Any], Any] | None = None, ) -> MatrixFreeLinOp: r""" Build a matrix-free operator from a Euclidean coordinate adjoint. The stored adjoint is the metric one, ``R_X^-1 A^T R_Y``, which is the given callable itself on Euclidean spaces. A non-Euclidean space without usable Riesz maps is rejected. Parameters ---------- apply : callable Forward map from ``dom`` to ``cod``. coordinate_rapply : callable Euclidean coordinate adjoint of ``apply``. dom, cod : Space Domain and codomain. ctx : Context, str, or None, optional Backend context. vapply : callable or None, optional Batched forward map. coordinate_rvapply : callable or None, optional Batched coordinate adjoint, wrapped the same way; ``vmap`` of the wrapped adjoint by default. """ # Validate the callables, then build via __init__ with euclidean_adjoint=True. if not callable(apply): raise TypeError(f"apply must be callable, got {type(apply).__name__}.") if not callable(coordinate_rapply): raise TypeError( f"coordinate_rapply must be callable, got {type(coordinate_rapply).__name__}." ) if coordinate_rvapply is not None: if not callable(coordinate_rvapply): raise TypeError( f"coordinate_rvapply must be callable, got {type(coordinate_rvapply).__name__}." ) # The wrapping now lives in __init__ behind euclidean_adjoint=True, so the # two entry points cannot drift apart, and this path raises no warning: # passing the coordinate adjoint is exactly what it is documented to take. return cls( apply, coordinate_rapply, dom, cod, ctx, vapply, coordinate_rvapply, euclidean_adjoint=True, )
[docs] def fuse(self, *, materialize: bool = False) -> LinOp: """Stay matrix-free unless ``materialize=True`` is requested (ADR-021/ADR-008). By default a matrix-free operator is never densified, so it returns itself. With ``materialize=True`` the caller explicitly opts into densification: the operator is probed into a dense tensor (via ``to_dense``, the basis sweep) and returned as a :class:`DenseLinOp`, letting an enclosing expression collapse to a single dense operator. """ # Matrix-free stays lazy unless densifying was requested. if not materialize: return self from ._dense import DenseLinOp return DenseLinOp(self.to_dense(), self.domain, self.codomain, self.ctx)
def _convert(self, new_ctx: Context) -> MatrixFreeLinOp: """ Convert this matrix-free operator to ``new_ctx``. Parameters ---------- new_ctx: Concrete target context for converted domain and codomain spaces. Returns ------- MatrixFreeLinOp Operator with converted spaces and the same user-supplied callables. """ # ``self.rapply_fn`` is already the metric adjoint by construction -- either # supplied that way, or wrapped once at __init__ under # ``euclidean_adjoint=True``. Declaring ``False`` here is therefore both # correct (re-wrapping would apply the Riesz maps twice) and quiet: the # construction advisory belongs to the original call, not to every convert. # The stored rapply is already the metric adjoint, so no re-wrap. return MatrixFreeLinOp( self.apply_fn, self.rapply_fn, self.domain.convert(new_ctx), self.codomain.convert(new_ctx), new_ctx, self.vapply_fn, self.rvapply_fn, euclidean_adjoint=False, hermitian=self.hermitian, )
class _AdjointViewLinOp(LinOp[Codomain, Domain]): """ Adjoint view ``op.H``: ``apply`` is ``op.rapply`` and ``rapply`` is ``op.apply``; ``op.H.H is op``. Parameters ---------- op : LinOp Operator whose adjoint is viewed. check_level : {"none", "cheap", "standard", "strict"}, optional Validation level; the ambient default when omitted. """ _fields = ("op",) _dynamic = ("op",) def _apply_core(self, y: Any) -> Any: return self.op._rapply_core(y) def _rapply_core(self, x: Any) -> Any: return self.op._apply_core(x) def _vapply_core(self, ys: Any) -> Any: return self.op.rvapply(ys) def __init__( self, op: LinOp[Domain, Codomain], check_level: CheckLevel | None = None, ) -> None: # The view swaps domain and codomain. op = _require_linop(op, "op") if check_level is None: check_level = op.check_level super().__init__(op.codomain, op.domain, op.ctx, check_level=check_level) self.op = op def vapply(self, ys: Any) -> Any: """Return ``op.rvapply(ys)`` over a batch.""" self._require(_CAP_BATCH, "_AdjointViewLinOp.vapply") return self.op.rvapply(ys) def rvapply(self, xs: Any) -> Any: """Return ``op.vapply(xs)`` over a batch.""" self._require(_CAP_BATCH, "_AdjointViewLinOp.rvapply") return self.op.vapply(xs) def fuse(self, *, materialize: bool = False) -> LinOp: """Fuse the wrapped operand; return the adjoint of the fused operator (ADR-021). ``A.H.fuse()`` is ``A.fuse().H`` — the inner expression is fused (e.g. a composition collapses to one dense operator) and the adjoint view wraps the result, which keeps the metric adjoint correct on any geometry. A matrix-free operand stays matrix-free under its adjoint (or is densified when ``materialize=True``). """ return self.op.fuse(materialize=materialize).H @property def H(self) -> LinOp[Domain, Codomain]: """Original operator viewed as the adjoint of this adjoint view.""" return self.op def _repr_class_name(self) -> str: """Present a clean public label for the private adjoint-view class.""" return "AdjointLinOp" def _repr_body(self) -> str: return f"{self.op._short_repr()}.H"