Source code for spacecore.space.base._space

from __future__ import annotations

from typing import Any, ClassVar, Literal

from ..._checks import CheckLevel, check_level_at_least
from ...mapping._lazy_algebra import is_recognizably_nonreal
from ...contextual import ContextBound
from ..._repr import field_symbol
from ...contextual import Context
from ..checks import SpaceCheck, SpaceValidationError, _run_checks


[docs] class Space(ContextBound): """ General space capability: context ownership and membership checks. Parameters ---------- ctx : Context, str, or None, optional Context specification used for elements and validation checks. check_level : {"none", "cheap", "standard", "strict"}, optional Validation level; the ambient default when omitted. """ checks: ClassVar[tuple[SpaceCheck, ...]] = () def __init__( self, ctx: Context | str | None = None, check_level: CheckLevel | None = None, ) -> None: super().__init__(ctx, check_level=check_level) # Spaces hold floating data; a complex scalar field needs complex storage. if not self.ops.xp.isdtype(self.dtype, ("real floating", "complex floating")): raise TypeError("Spaces require floating storage, not integer or Boolean dtype.") if self.scalar_field == "complex" and not self.ops.is_complex_dtype(self.dtype): raise TypeError("A complex scalar field requires complex floating storage.") # Lazy caches. Populated on first call to member_checks() / # _checks_for_level() and reused for every subsequent membership # validation. Spaces are immutable after construction (`convert` # returns a fresh instance), so caching the MRO-walked check list # is safe. self._cached_member_checks: tuple[SpaceCheck, ...] | None = None self._cached_checks_by_level: dict[CheckLevel, tuple[SpaceCheck, ...]] = {} #: Scalar field this space is closed under, when that is *narrower* than the #: field implied by the dtype. ``None`` (the default) means "same as #: :attr:`field`" — the common case, where entries and scalars agree. #: Subclasses override it with a literal; see :attr:`scalar_field`. declared_scalar_field: ClassVar[Literal["real", "complex"] | None] = None @property def field(self) -> Literal["real", "complex"]: """Return the field the *entries* live in, derived from the context dtype. ``Context.dtype`` controls array representation. This property records only whether those entries are real or complex. This is a fact about representation, and it is **not** always the field of scalars the space is a vector space over — see :attr:`scalar_field`. Equality and repr use this one: a real-dtype and a complex-dtype space of the same shape are different spaces regardless of what they are closed under. """ return "complex" if self.ops.is_complex_dtype(self.dtype) else "real" @property def scalar_field(self) -> Literal["real", "complex"]: """Return the field of scalars this space is closed under. Defaults to :attr:`field` — entries and scalars agree for coordinate spaces. A subclass whose structure survives only real scaling declares otherwise via :attr:`declared_scalar_field`: the complex Hermitian matrices have complex entries but form a **real** vector space, because ``i·H`` is anti-Hermitian for Hermitian ``H``. Declared, never inferred. The dtype cannot imply this — it is a property of the structure the space enforces, not of how elements are stored. """ return self.declared_scalar_field or self.field def _check_scalar(self, a: Any) -> None: """Raise if ``a`` is not an admissible multiplier for this space.""" # Only a provably non-real scalar is rejected, so traced scalars pass. if self.scalar_field == "real" and is_recognizably_nonreal(a): raise SpaceValidationError( f"{self._space_descriptor()} is a vector space over ℝ, so scaling by " f"the non-real scalar {a!r} leaves the space. Multiply the underlying " "array directly if you intend to exit this space." )
[docs] def check_scalar(self, a: Any) -> None: """Raise if ``a`` is not an admissible multiplier, unless checks are off. Only rejects a multiplier that is *provably* inadmissible, so a traced scalar under ``jax.jit`` always passes (see :func:`spacecore._lazy_algebra.is_recognizably_nonreal`). """ if self.check_level != "none": self._check_scalar(a)
def __eq__(self, other: Any) -> bool: # Tier 1: backend compatibility (type + ops family + dtype, ignoring # check_level). Tier 2/3: per-subclass algebraic comparison. if not self.same_math(other): return NotImplemented return self._eq_algebra(other) def __hash__(self) -> int: # Only what equality also requires: equal spaces share type, backend, dtype and # shape. Geometry weights compare numerically, so they cannot enter the hash. if not hasattr(self, "_ctx"): return object.__hash__(self) # not yet bound (e.g. hashed while torch traces __init__) return hash((type(self), self.ops.family, str(self.dtype), getattr(self, "shape", None))) def _eq_algebra(self, other: Any) -> bool: """Algebraic-equality comparison, run only after the backend gate passes. Subclasses extend this via ``super()._eq_algebra(other) and ...`` so each adds its own structural/numerical checks. The base contributes the scalar field (real vs complex): two spaces over different fields are never equal. """ return self.field == other.field def _field_symbol(self) -> str: """Return the scalar-field glyph (``ℝ``/``ℂ``) for this space.""" try: return field_symbol(self.field) except Exception: return "?" def _space_descriptor(self) -> str: """Return a compact math descriptor used in reprs and operator arrows. The base descriptor is just the scalar-field glyph; coordinate, Hermitian, stacked, and tree spaces refine it with shape/structure. """ return self._field_symbol() def _repr_body(self) -> str: return self._space_descriptor()
[docs] def member_checks(self) -> tuple[SpaceCheck, ...]: """Return every ``SpaceCheck`` this instance must satisfy. Walks the MRO and collects ``checks`` class attributes plus any instance-state-driven ``_local_checks`` factories. The result is cached on first access because spaces are immutable post-init. Subclasses that depend on mutable state (none in 0.4.0) must clear ``self._cached_member_checks`` themselves. """ # Computed once: spaces are immutable after construction. cached = self._cached_member_checks if cached is not None: return cached checks: list[SpaceCheck] = [] # Base classes first: each contributes its class checks and instance checks. for klass in reversed(type(self).__mro__): checks.extend(klass.__dict__.get("checks", ())) local_checks = klass.__dict__.get("_local_checks") if local_checks is not None: checks.extend(local_checks(self)) result = tuple(checks) # No write under a tracer: torch.compile breaks the graph on a dict mutated mid-trace. if not self.ops.is_tracing(): self._cached_member_checks = result return result
def _checks_for_level(self, level: CheckLevel) -> tuple[SpaceCheck, ...]: """Return the subset of ``member_checks`` that apply at ``level``. Cached per level. This is the hot-path validator used by ``_check_member`` and ``_run_checks``; precomputing it removes per-call MRO walks and per-check ``minimum_level`` comparisons. """ # Filter the checks this level runs, once per level. cached = self._cached_checks_by_level.get(level) if cached is not None: return cached result = tuple( check for check in self.member_checks() if check_level_at_least(level, check.minimum_level) ) if not self.ops.is_tracing(): # see member_checks self._cached_checks_by_level[level] = result return result def _check_member(self, x: Any) -> None: """Raise if ``x`` is not a valid element of this space.""" _run_checks(self, x, allow_leading=False)
[docs] def check_member(self, x: Any) -> None: if self.check_level != "none": self._check_member(x)