Skip to content
Merged
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
34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,40 @@ and the project adheres to [Semantic Versioning](https://semver.org/).

## [Unreleased]

### Changed

- **`LinOp` endpoints are bound to `VectorSpace`** rather than `CoordinateSpace`.
A linear operator needs only `zeros`/`add`/`scale` from its endpoints;
coordinates are a representation, not part of the contract. Operators can now
be defined over any vector space, including one with no `shape`, `size`,
`flatten` or `stacked`. Concrete operators are unaffected — `SparseLinOp` and
`DiagonalLinOp` declare `LinOp[CoordinateSpace, CoordinateSpace]` themselves.

`VectorSpace` and not `InnerProductSpace`: the two are siblings, so binding to
the latter would have *narrowed* the accepted set and dropped bare `TreeSpace`
and `StackedSpace`.

- **Operations that need more than a vector space now say so.** Materialization
(`to_dense`, `to_matrix`, `fuse`, basis probes) requires coordinates, batching
(`vapply`, `rvapply`) requires the batched coordinate surface, and `rapply`
requires an inner product for its metric-adjoint contract. Each raises
`CapabilityError` naming the operation and the missing capability, where
previously an unsupported endpoint surfaced as an `AttributeError`.

- `MatrixFreeLinOp`'s strict adjoint-consistency probe no longer requires
coordinates. It builds its probe element from the space's own `ones` when one
exists, so a non-coordinate space is checked too, and is skipped only when no
non-zero element can be built at all.

- Operator endpoint compatibility no longer compares `shape`. Type identity,
context agreement and convert-equality already decide it, and
`CoordinateSpace` equality compares shape.

### Added

- `CapabilityError`, exported from `spacecore` and `spacecore.space`, raised when
a space lacks a capability the requested operation needs.

## [0.4.3] — 2026-09-12

### Removed
Expand Down
2 changes: 2 additions & 0 deletions spacecore/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@
Space,
StarSpace,
SpaceCheck,
CapabilityError,
SpaceValidationError,
SquareMatrixCheck,
VectorSpace,
Expand Down Expand Up @@ -243,6 +244,7 @@
"EuclideanElementwiseJordanSpace",
"Space",
"SpaceCheck",
"CapabilityError",
"SpaceValidationError",
"SquareMatrixCheck",
"DenseArray",
Expand Down
3 changes: 3 additions & 0 deletions spacecore/_batching.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from typing import Any

from .space.checks import _run_checks
from .space._capabilities import _CAP_BATCH, _CAP_INNER, require

# Shared batched-evaluation helpers used by both LinOp and Functional. They live
# here (rather than in functional/_base) so the linop and functional batched
Expand Down Expand Up @@ -59,6 +60,8 @@ def _warn_vmap_fallback_once(obj: Any, method: str, batch_size: int) -> None:

def _batched_inner(space: Any, xs: Any, ys: Any) -> Any:
"""Return ``space.inner(xs[i], ys[i])`` for a leading-axis batch."""
require(space, _CAP_BATCH, "_batched_inner")
require(space, _CAP_INNER, "_batched_inner")
xs_flat = space.flatten_batch(xs)
ys_dual = ys if space.is_euclidean else space.riesz(ys)
ys_flat = space.flatten_batch(ys_dual)
Expand Down
4 changes: 4 additions & 0 deletions spacecore/_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,7 @@ class ContextConflictError(ContextError):

class UnknownBackendError(ContextError):
"""No backend is registered under the requested family name."""


class CapabilityError(TypeError):
"""A space lacks a capability the requested operation needs."""
67 changes: 53 additions & 14 deletions spacecore/linop/_algebra.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
from __future__ import annotations

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

from ._base import LinOp, Domain, Codomain
from ._metric import _requires_euclidean_or_riesz, metric_rapply, metric_rvapply
from .._check_policy import CheckLevel, minimum_check_level
from .._checks import checked_method
from ..space.base import InnerProductSpace
from ..space._capabilities import _CAP_BATCH, _CAP_COORDINATE, probe_element, require
from .._repr import summarize_value
from ..contextual import Context
from ..kernels import core_kernels
Expand Down Expand Up @@ -58,8 +59,6 @@ def _same_space_for_algebra(left: Any, right: Any) -> bool:
return True
if type(left) is not type(right):
return False
if tuple(left.shape) != tuple(right.shape):
return False
if not left.ctx.same_math(right.ctx):
return False
try:
Expand Down Expand Up @@ -269,10 +268,14 @@ def rapply(self, y: Any) -> Any:
@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)``."""
require(self.domain, _CAP_BATCH, "ScaledLinOp.vapply")
require(self.codomain, _CAP_BATCH, "ScaledLinOp.vapply")
return self._vapply_core(xs)

def rvapply(self, ys: Any) -> Any:
"""Return ``conj(scalar) * op.rvapply(ys)``."""
require(self.domain, _CAP_BATCH, "ScaledLinOp.rvapply")
require(self.codomain, _CAP_BATCH, "ScaledLinOp.rvapply")
xs = self.op.rvapply(ys)
return self.domain.scale_batch(_conjugate_scalar(self.scalar), xs)

Expand Down Expand Up @@ -419,11 +422,15 @@ def rapply(self, y: Any) -> Any:
@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)``."""
require(self.domain, _CAP_BATCH, "SumLinOp.vapply")
require(self.codomain, _CAP_BATCH, "SumLinOp.vapply")
return self._vapply_core(xs)

@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)``."""
require(self.domain, _CAP_BATCH, "SumLinOp.rvapply")
require(self.codomain, _CAP_BATCH, "SumLinOp.rvapply")
add_batch = self.domain.add_batch
acc = self.ops_tuple[0].rvapply(ys)
for op in self.ops_tuple[1:]:
Expand Down Expand Up @@ -578,10 +585,14 @@ def rapply(self, z: Any) -> Any:
@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))``."""
require(self.domain, _CAP_BATCH, "ComposedLinOp.vapply")
require(self.codomain, _CAP_BATCH, "ComposedLinOp.vapply")
return self._vapply_core(xs)

def rvapply(self, zs: Any) -> Any:
"""Return ``right.rvapply(left.rvapply(zs))``."""
require(self.domain, _CAP_BATCH, "ComposedLinOp.rvapply")
require(self.codomain, _CAP_BATCH, "ComposedLinOp.rvapply")
return self.right.rvapply(self.left.rvapply(zs))

def fuse(self, *, materialize: bool = False) -> LinOp:
Expand Down Expand Up @@ -705,11 +716,15 @@ def rapply(self, y: Any) -> Any:
@checked_method(in_space="domain", in_batched=True)
def vapply(self, xs: Any) -> Any:
"""Return the batched zero element of the codomain."""
require(self.domain, _CAP_BATCH, "ZeroLinOp.vapply")
require(self.codomain, _CAP_BATCH, "ZeroLinOp.vapply")
return self._vapply_core(xs)

@checked_method(in_space="codomain", in_batched=True)
def rvapply(self, ys: Any) -> Any:
"""Return the batched zero element of the domain."""
require(self.domain, _CAP_BATCH, "ZeroLinOp.rvapply")
require(self.codomain, _CAP_BATCH, "ZeroLinOp.rvapply")
return _batched_zeros(self.domain, _leading_shape(self.codomain, ys))

def to_dense(self) -> Any:
Expand All @@ -718,6 +733,8 @@ def to_dense(self) -> Any:

The returned array has shape ``self.codomain.shape + self.domain.shape``.
"""
require(self.domain, _CAP_COORDINATE, "ZeroLinOp.to_dense")
require(self.codomain, _CAP_COORDINATE, "ZeroLinOp.to_dense")
return self.ops.zeros(
tuple(self.codomain.shape) + tuple(self.domain.shape), dtype=self.dtype
)
Expand Down Expand Up @@ -802,11 +819,15 @@ def rapply(self, x: Any) -> Any:
@checked_method(in_space="domain", in_batched=True)
def vapply(self, xs: Any) -> Any:
"""Return ``xs`` after batched domain validation."""
require(self.domain, _CAP_BATCH, "IdentityLinOp.vapply")
require(self.codomain, _CAP_BATCH, "IdentityLinOp.vapply")
return xs

@checked_method(in_space="codomain", in_batched=True)
def rvapply(self, xs: Any) -> Any:
"""Return ``xs`` after batched codomain validation."""
require(self.domain, _CAP_BATCH, "IdentityLinOp.rvapply")
require(self.codomain, _CAP_BATCH, "IdentityLinOp.rvapply")
return xs

def to_dense(self) -> Any:
Expand All @@ -815,6 +836,8 @@ def to_dense(self) -> Any:

The returned array has shape ``self.codomain.shape + self.domain.shape``.
"""
require(self.domain, _CAP_COORDINATE, "IdentityLinOp.to_dense")
require(self.codomain, _CAP_COORDINATE, "IdentityLinOp.to_dense")
size = 1
for dim in self.domain.shape:
size *= dim
Expand Down Expand Up @@ -999,7 +1022,12 @@ def __init__(
raise TypeError(f"rvapply must be callable, got {type(rvapply).__name__}.")
super().__init__(dom, cod, ctx, check_level=check_level)

non_euclidean = not (self.domain.is_euclidean and self.codomain.is_euclidean)
has_inner = all(
isinstance(space, InnerProductSpace) for space in (self.domain, self.codomain)
)
non_euclidean = has_inner and not (
self.domain.is_euclidean and self.codomain.is_euclidean
)
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.
Expand Down Expand Up @@ -1062,20 +1090,21 @@ def _wrapped_rvapply(ys: Any) -> Any:

def _check_adjoint_consistency(self) -> None:
"""Probe the declared adjoint identity on deterministic space elements."""
if not all(hasattr(space, "inner") for space in (self.domain, self.codomain)):
if not all(
isinstance(space, InnerProductSpace)
for space in (self.domain, self.codomain)
):
return

def probe(space: Any) -> Any:
if hasattr(space, "ones"):
return space.ones()
if hasattr(space, "unflatten") and hasattr(space, "shape"):
flat = self.ops.ones((prod(space.shape),), dtype=self.dtype)
return space.unflatten(flat)
raise TypeError(f"{type(space).__name__} cannot build a strict probe element.")
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:
x = probe(self.domain)
y = probe(self.codomain)
ax = self.apply_fn(x)
ahy = self.rapply_fn(y)
self.codomain._check_member(ax)
Expand Down Expand Up @@ -1204,6 +1233,8 @@ def rapply(self, y: Any) -> Any:
Any
Element of ``self.domain`` returned by ``rapply_fn``.
"""
require(self.domain, InnerProductSpace, "MatrixFreeLinOp.rapply")
require(self.codomain, InnerProductSpace, "MatrixFreeLinOp.rapply")
return self._rapply_core(y)

@checked_method(in_space="domain", out_space="codomain", in_batched=True, out_batched=True)
Expand All @@ -1222,6 +1253,8 @@ def vapply(self, xs: Any) -> Any:
Batched element of ``self.codomain`` produced by ``vapply_fn`` or
by the fallback batching implementation.
"""
require(self.domain, _CAP_BATCH, "MatrixFreeLinOp.vapply")
require(self.codomain, _CAP_BATCH, "MatrixFreeLinOp.vapply")
if self.vapply_fn is None:
return super().vapply(xs)
return self.vapply_fn(xs)
Expand All @@ -1242,6 +1275,8 @@ def rvapply(self, ys: Any) -> Any:
Batched element of ``self.domain`` produced by ``rvapply_fn`` or by
the fallback batching implementation.
"""
require(self.domain, _CAP_BATCH, "MatrixFreeLinOp.rvapply")
require(self.codomain, _CAP_BATCH, "MatrixFreeLinOp.rvapply")
if self.rvapply_fn is None:
return super().rvapply(ys)
return self.rvapply_fn(ys)
Expand Down Expand Up @@ -1379,10 +1414,14 @@ def rapply(self, x: Any) -> Any:

def vapply(self, ys: Any) -> Any:
"""Return ``op.rvapply(ys)`` over a batch."""
require(self.domain, _CAP_BATCH, "_AdjointViewLinOp.vapply")
require(self.codomain, _CAP_BATCH, "_AdjointViewLinOp.vapply")
return self.op.rvapply(ys)

def rvapply(self, xs: Any) -> Any:
"""Return ``op.vapply(xs)`` over a batch."""
require(self.domain, _CAP_BATCH, "_AdjointViewLinOp.rvapply")
require(self.codomain, _CAP_BATCH, "_AdjointViewLinOp.rvapply")
return self.op.vapply(xs)

def fuse(self, *, materialize: bool = False) -> LinOp:
Expand Down
15 changes: 12 additions & 3 deletions spacecore/linop/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,15 @@
from .._batching import _leading_batch_size, _warn_vmap_fallback_once
from .._check_policy import CheckLevel
from .._checks import checked_method
from ..space._capabilities import _CAP_BATCH, _CAP_COORDINATE, require
from ..backend import PyTreeNode
from .._repr import describe_space
from ..space import CoordinateSpace
from ..space import VectorSpace
from ..contextual import Context
from ..contextual import ContextBound

Domain = TypeVar("Domain", bound=CoordinateSpace)
Codomain = TypeVar("Codomain", bound=CoordinateSpace)
Domain = TypeVar("Domain", bound=VectorSpace)
Codomain = TypeVar("Codomain", bound=VectorSpace)


class LinOp(PyTreeNode, ContextBound, Generic[Domain, Codomain]):
Expand Down Expand Up @@ -183,12 +184,16 @@ def is_hermitian(self) -> bool | None:
@checked_method(in_space="domain", in_batched=True)
def vapply(self, xs: Any) -> Any:
"""Apply over a leading batch axis. Input must have shape ``(N,) + domain.shape``; use ``moveaxis`` for other layouts."""
require(self.domain, _CAP_BATCH, "LinOp.vapply")
require(self.codomain, _CAP_BATCH, "LinOp.vapply")
_warn_vmap_fallback_once(self, "vapply", _leading_batch_size(self.domain, xs))
return self.ops.vmap(self.apply, in_axes=0, out_axes=0)(xs)

@checked_method(in_space="codomain", in_batched=True)
def rvapply(self, ys: Any) -> Any:
"""Apply the adjoint over a leading batch axis. Input must have shape ``(N,) + codomain.shape``; use ``moveaxis`` for other layouts."""
require(self.domain, _CAP_BATCH, "LinOp.rvapply")
require(self.codomain, _CAP_BATCH, "LinOp.rvapply")
_warn_vmap_fallback_once(self, "rvapply", _leading_batch_size(self.codomain, ys))
return self.ops.vmap(self.rapply, in_axes=0, out_axes=0)(ys)

Expand Down Expand Up @@ -327,6 +332,8 @@ def to_dense(self) -> Any:
that already store a dense or sparse matrix should override this method
for efficiency.
"""
require(self.domain, _CAP_COORDINATE, "LinOp.to_dense")
require(self.codomain, _CAP_COORDINATE, "LinOp.to_dense")
return self.ops.reshape(
self.to_matrix(), tuple(self.codomain.shape) + tuple(self.domain.shape)
)
Expand All @@ -346,6 +353,8 @@ def to_matrix(self) -> Any:
for small/testing use; concrete storage-backed subclasses should
override it when they can expose a matrix directly.
"""
require(self.domain, _CAP_COORDINATE, "LinOp.to_matrix")
require(self.codomain, _CAP_COORDINATE, "LinOp.to_matrix")
domain_size = prod(self.domain.shape)
codomain_size = prod(self.codomain.shape)
eye = self.ops.eye(domain_size, dtype=self.dtype)
Expand Down
7 changes: 6 additions & 1 deletion spacecore/linop/_metric.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

import warnings

from ..space.base import InnerProduct, InnerProductSpace
from ..space.base import CoordinateSpace, InnerProduct, InnerProductSpace
from ..space._capabilities import require


_METRIC_BATCH_FALLBACK_ERRORS = (TypeError, ValueError, NotImplementedError)
Expand Down Expand Up @@ -50,6 +51,8 @@ def _requires_euclidean_or_riesz(dom, cod, opname: str) -> None:

def _metric_is_hermitian_by_basis(op) -> bool | None:
"""Check self-adjointness by comparing forward and adjoint basis actions."""
if not isinstance(op.domain, CoordinateSpace):
return None
if op.domain != op.codomain:
return False
if op.domain.size > _METRIC_HERMITIAN_BASIS_CHECK_MAX_SIZE:
Expand Down Expand Up @@ -95,6 +98,8 @@ def metric_rapply(domain, codomain, euclidean_rapply, y):
Getting this wrong is silent: a coordinate transpose satisfies the identity on
every Euclidean space, so only a non-Euclidean test can detect it.
"""
require(domain, InnerProductSpace, "metric_rapply")
require(codomain, InnerProductSpace, "metric_rapply")
if domain.is_euclidean and codomain.is_euclidean:
return euclidean_rapply(y)
return domain.riesz_inverse(euclidean_rapply(codomain.riesz(y)))
Expand Down
2 changes: 2 additions & 0 deletions spacecore/space/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Vector space abstractions, concrete spaces, and validation checks."""

from .._errors import CapabilityError
from .checks import (
BackendCheck,
DTypeCheck,
Expand Down Expand Up @@ -59,6 +60,7 @@
"TreeSpectralDecomposition",
"Space",
"SpaceCheck",
"CapabilityError",
"SpaceValidationError",
"SquareMatrixCheck",
"VectorSpace",
Expand Down
Loading
Loading