Geometry and Riesz Maps#

Every Space has an InnerProduct geometry. The geometry defines the inner product and the Riesz maps used to convert between coordinate elements and dual coordinates. Matrix-backed adjoints use those maps:

\[A^\sharp y = R_X^{-1} A^\dagger R_Y y.\]

Here A^\dagger is the Euclidean coordinate adjoint, while A^\sharp is the true adjoint for the declared domain and codomain geometries.

Matrix-Free Adjoints#

MatrixFreeLinOp does not own a coordinate matrix, so it does not derive or correct adjoints with Riesz maps. Its rapply callable is the user-supplied implementation of the metric adjoint \(A^\sharp\). The adjoint view A.H delegates directly to those callables: A.H.apply(y) calls A.rapply(y), and A.H.rapply(x) calls A.apply(x).

If a matrix-free reverse callable is only a Euclidean coordinate transpose, it can still pass construction and shape validation. It will fail the mathematical adjoint dot-test on non-Euclidean spaces:

\[\langle A x, y\rangle_Y = \langle x, A^\sharp y\rangle_X.\]

Automatic application of \(R_X^{-1} A^\dagger R_Y\) is reserved for matrix-backed operators where SpaceCore owns the coordinate matrix.

Weighted Geometry#

The common diagonal-metric case is built in:

import numpy as np
import spacecore as sc

ctx = sc.Context(sc.NumpyOps(), dtype=np.float64)
weights = ctx.asarray([2.0, 5.0, 11.0])
X = sc.DenseCoordinateSpace((3,), ctx, geometry=sc.WeightedInnerProduct(weights))

x = ctx.asarray([1.0, 2.0, 3.0])
y = ctx.asarray([4.0, 5.0, 6.0])
value = X.inner(x, y)        # vdot(x, weights * y)
dual = X.riesz(x)            # weights * x
primal = X.riesz_inverse(dual)

Custom Geometry#

To define a custom geometry, subclass InnerProduct and implement inner. For non-Euclidean geometries, also implement riesz and riesz_inverse and return False from is_euclidean:

class MyGeometry(sc.InnerProduct):
    def inner(self, ops, x, y):
        ...

    def riesz(self, ops, x):
        ...

    def riesz_inverse(self, ops, x):
        ...

    @property
    def is_euclidean(self):
        return False

    def convert(self, ctx):
        # Convert any stored arrays into ctx.
        return self

The Riesz maps should broadcast over leading batch axes. For example, if x can have shape (N,) + space.shape, then riesz(ops, x) should return the corresponding batch of dual coordinates. This lets batched adjoints stay on the fast path.

Matrix-backed operators refuse non-Euclidean spaces that do not provide usable Riesz maps. This avoids silently using a Euclidean coordinate adjoint as if it were a metric adjoint. If a geometry cannot expose Riesz maps, use MatrixFreeLinOp and provide an explicit metric adjoint instead.

Solvers and Functionals#

Solvers such as cg, lsqr, power_iteration, and lanczos_smallest use domain.inner and domain.norm (or codomain.norm for residuals that live in the codomain). Gradients returned by functionals are space elements: they satisfy

\[\langle \nabla f(x), v \rangle_X = Df(x)[v].\]

This contract is correct on non-Euclidean geometries when spaces supply Riesz maps and operators use metric-aware adjoints.