Source code for spacecore.linalg._utils

from __future__ import annotations

from math import prod
from typing import Any, NamedTuple

from ..linop import LinOp
from .._repr import summarize_value

DEFAULT_CONVERGENCE_CHECK_INTERVAL = 64


def require_linop(A: Any) -> LinOp:
    """Return ``A`` as a ``LinOp`` or raise a clear type error."""
    if not isinstance(A, LinOp):
        raise TypeError(f"A must be a LinOp, got {type(A).__name__}.")
    return A


def require_square(A: LinOp, name: str) -> None:
    """Raise if ``A`` is not a square operator."""
    if A.domain != A.codomain:
        raise ValueError(f"{name} requires a square LinOp; got {A.domain!r} -> {A.codomain!r}.")


def require_strict_cg_preconditions(A: LinOp) -> None:
    """Run expensive development-time Hermitian and positive-curvature probes."""
    # Strict only: probe positive curvature <x, A x> > 0 on one vector.
    if not A._checks_at_least("strict"):
        return
    if A.is_hermitian() is False:
        raise ValueError("cg strict checks require A to be Hermitian/self-adjoint.")

    x = default_initial_vector(A)
    curvature = A.domain.real_inner(x, A.apply(x))
    try:
        positive = bool(curvature > 0)
    except Exception as exc:
        raise ValueError("cg strict positive-definiteness probe could not be evaluated.") from exc
    if not positive:
        raise ValueError("cg strict checks require positive curvature on the probe vector.")


def default_maxiter(A: LinOp) -> int:
    """Return the default Krylov iteration count for ``A``."""
    from ..space import CoordinateSpace
    from .._errors import CapabilityError

    if not isinstance(A.domain, CoordinateSpace):
        raise CapabilityError("Default iteration count requires CoordinateSpace; supply maxiter explicitly.")
    return max(1, prod(A.domain.shape))


def check_maxiter(maxiter: int | None, A: LinOp) -> int:
    """Validate an optional iteration count."""
    if maxiter is None:
        return default_maxiter(A)
    maxiter = int(maxiter)
    if maxiter < 0:
        raise ValueError("maxiter must be nonnegative.")
    return maxiter


def check_interval(interval: int, name: str = "check_every") -> int:
    """Validate a positive integer parameter, such as a convergence-check interval."""
    interval = int(interval)
    if interval < 1:
        raise ValueError(f"{name} must be positive.")
    return interval


def require_hermitian_square(A: Any, name: str, message: str | None = None) -> Any:
    """Return ``A`` as a square LinOp, raising if it is provably not self-adjoint."""
    A = require_linop(A)
    require_square(A, name)
    if A._checks_at_least("standard") and A.is_hermitian() is False:
        raise ValueError(message or f"{name} requires A to be Hermitian/self-adjoint.")
    return A


def should_check_iteration(k: Any, maxiter: int, interval: int) -> Any:
    """Return whether iteration ``k`` should refresh convergence diagnostics."""
    return (k >= maxiter) | ((k % interval) == 0)


def threshold(norm_b: Any, tol: float, atol: float) -> Any:
    """Return the absolute-plus-relative convergence threshold."""
    return max(float(atol), 0.0) + max(float(tol), 0.0) * norm_b


# ---------------------------------------------------------------------------
# Check-free primitives for solver hot loops.
#
# Iterative solvers validate their operator and right-hand side once, at entry.
# Their iterates (search directions, residuals, Krylov vectors) are then
# generated internally and are valid by construction, so the per-iteration
# membership checks the public ``apply`` / space ops run are pure overhead
# (measured at well over half of CG's NumPy runtime). These helpers route the
# hot loop through the operators' and spaces' check-free ``_*_core`` kernels.
#
# Safety: a core is used only when it is *consistent* with the public method —
# i.e. the public method is not overridden by a subclass below the class that
# defines the core. That guards against a user space that overrides ``inner``
# (custom geometry) while inheriting a built-in ``_inner_core``; there the
# override wins and the checked public method is used. Resolution is done once,
# at solver entry, so the hot loop pays neither the check nor the lookup.
# ---------------------------------------------------------------------------
def is_converged(residual_norm: Any, threshold_value: Any) -> Any:
    """Return backend-compatible convergence predicate."""
    return residual_norm <= threshold_value


def safe_inverse_nonneg(ops: Any, value: Any) -> Any:
    """
    Return ``1 / value`` where ``value > 0`` and zero otherwise.

    This helper is intended for norms and nonnegative residual magnitudes. It
    is not a general scalar inverse: for example, ``-2`` maps to ``0``, not
    ``-0.5``.
    """
    positive = value > 0
    safe_value = ops.where(positive, value, ops.ones_like(value))
    return ops.where(positive, 1.0 / safe_value, ops.zeros_like(value))


def normalize(space: Any, x: Any) -> tuple[Any, Any]:
    """Normalize a space member and return ``(unit, norm)``."""
    norm = space._norm_core(x)
    return space._scale_core(safe_inverse_nonneg(space.ops, norm), x), norm


def default_initial_vector(A: LinOp) -> Any:
    """Return a deterministic unit vector in ``A.domain`` using its geometry."""
    from ..space._capabilities import probe_element
    from .._errors import CapabilityError

    v = probe_element(A.domain, A.ops, A.dtype)
    if v is None:
        raise CapabilityError(
            "Default initialization requires space.ones() or CoordinateSpace; "
            "supply an explicit initial element (x0)."
        )
    return normalize(A.domain, v)[0]


def result_repr(name: str, fields: dict[str, Any]) -> str:
    """Return a compact result-object representation."""
    body = ", ".join(f"{key}={summarize_value(value)}" for key, value in fields.items())
    return f"{name}({body})"


[docs] class LinalgResult(NamedTuple): """ Result of every :mod:`spacecore.linalg` solver. Fields a solver does not produce are ``None``. Parameters ---------- x : array-like The computed element: the solution (:func:`cg`, :func:`lsqr`), the eigenvector estimate (:func:`power_iteration`, :func:`lanczos_smallest`), or the action ``exp(t * A) @ v`` (:func:`expm_multiply`). converged : bool-like Whether the solver's stopping test was met. num_iters : int-like Iterations executed; for the Krylov methods :func:`lanczos_smallest` and :func:`expm_multiply`, the Krylov dimension reached. residual_norm : scalar The solver's residual measure: ``norm(b - A x)`` for :func:`cg` and :func:`lsqr` (exact or estimated per ``residual_mode``), ``norm(A x - eigenvalue x)`` for :func:`power_iteration`, the Ritz residual estimate for :func:`lanczos_smallest`, and for :func:`expm_multiply` the projection indicator ``abs(beta[m] phi[m-1])``, which omits ``norm(v)`` and does not bound the error. eigenvalue : scalar or None Eigenvalue estimate (:func:`power_iteration`, :func:`lanczos_smallest`). normal_residual_norm : scalar or None ``norm(A.H (A x - b))`` (:func:`lsqr`). """ x: Any converged: Any num_iters: Any residual_norm: Any eigenvalue: Any = None normal_residual_norm: Any = None def __repr__(self) -> str: """Summarize the fields this solver produced, without printing full arrays.""" fields = {k: v for k, v in self._asdict().items() if v is not None} return result_repr(type(self).__name__, fields)