diff --git a/CHANGELOG.md b/CHANGELOG.md index 58514ee..2cba822 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,18 @@ and the project adheres to [Semantic Versioning](https://semver.org/). ### Changed +- Functionals now expose a `Field` codomain, defaulting to `domain.scalars`, + and validate scalar output through ordinary space membership. Explicit field + codomains can differ from the domain's coefficient field; exact batched + scalar checks remain unchanged. +- `LinOp` rejects a `Field` codomain at construction, preserving the separate + functional and Riesz-gradient contract from ADR-010. +- **Spaces reject integer/Boolean storage and complex coefficients over real + storage at construction** (breaking). The guard lives on `Space.__init__`, so + it applies to every space rather than only the field-backed numerical ones. A + space over an integer or Boolean dtype now raises; represent such data in a + floating dtype, or model it outside the vector-space hierarchy. + - **`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 @@ -38,6 +50,11 @@ and the project adheres to [Semantic Versioning](https://semver.org/). ### Added +- Public `Field`, `RealField`, and `ComplexField` scalar spaces with Euclidean + geometry, and `Space.scalars` for accessing the coefficient field on the + same backend. Real coefficients over complex storage use the corresponding + real dtype without changing the owning space's context. + - `CapabilityError`, exported from `spacecore` and `spacecore.space`, raised when a space lacks a capability the requested operation needs. diff --git a/docs/dev/adr/010_functional_contract.md b/docs/dev/adr/010_functional_contract.md index 0b514f0..bceaca1 100644 --- a/docs/dev/adr/010_functional_contract.md +++ b/docs/dev/adr/010_functional_contract.md @@ -10,11 +10,22 @@ Functionals are scalar-valued maps on spaces. They share context and validation ## Current design -`Functional` owns one domain space and a context. Subclasses implement `value(x)`. `vvalue(xs)` evaluates over a leading batch axis through backend `vmap` unless overridden. `LinearFunctional` adds a Riesz-gradient contract through `representer`, `grad`, and `vgrad`. `InnerProductFunctional` stores a representer and evaluates ``. `MatrixFreeLinearFunctional` trusts user evaluation callables and has no stored representer. `QuadraticForm` defines optional `grad`, `vgrad`, and `hess_apply`; `LinOpQuadraticForm` stores a Hermitian operator, optional linear term, and scalar offset. Pull-backs are built by composing with a LinOp and have specializations for inner-product and quadratic functionals. +`Functional` owns a domain space, a scalar `Field` codomain, and a context. The codomain defaults to `domain.scalars`; an explicit `cod=` may independently select a real or complex field. Subclasses implement `value(x)`. `vvalue(xs)` evaluates over a leading batch axis through backend `vmap` unless overridden. `LinearFunctional` adds a Riesz-gradient contract through `representer`, `grad`, and `vgrad`. `InnerProductFunctional` stores a representer and evaluates ``. `MatrixFreeLinearFunctional` trusts user evaluation callables and has no stored representer. `QuadraticForm` defines optional `grad`, `vgrad`, and `hess_apply`; `LinOpQuadraticForm` stores a Hermitian operator, optional linear term, and scalar offset. Pull-backs are built by composing with a LinOp and have specializations for inner-product and quadratic functionals. ## Decision -A functional is not a LinOp with a one-dimensional codomain. It owns a domain and scalar-valued evaluation, and gradients are space elements with respect to the domain geometry when implemented. +A functional is not a LinOp with a one-dimensional codomain. It owns a domain, a `Field` codomain and scalar-valued evaluation, and gradients are space elements with respect to the domain geometry when implemented. + +`Field` inherits `InnerProductSpace` (and therefore `VectorSpace`), but not +`CoordinateSpace`. Since `LinOp` now accepts `VectorSpace` endpoints, the type +bound alone cannot maintain this separation. `LinOp.__init__` explicitly rejects +a `Field` codomain with an error citing ADR-010. Use a `Functional` for that map. + +Scalar output validation uses `out_space="codomain"` and the field's +`ScalarShapeCheck`, active only at `standard` and `strict`. Exact batch output +validation deliberately retains `out_batched_scalar`: ordinary batched space +membership cannot require exactly `(N,)`. The matrix-free explicit batch +callback retains its existing check of all leading axes (ADR-006). ## Rationale @@ -30,7 +41,8 @@ New functionals must validate domain inputs and scalar output shape when checks ## Contributor invariants -- `value` returns a scalar-like backend value, not an element of a codomain space. +- `value` returns a scalar-like backend value in its `Field` codomain. +- Codomain conversion and pytree reconstruction preserve the mathematical field; a complex codomain must not promote a real domain to complex storage. - `grad` returns a domain element when implemented. - Matrix-free functionals do not imply a stored representer. - Functional composition requires `A.codomain == F.domain`. diff --git a/docs/dev/adr/015_dtype_default_vs_scalar_field.md b/docs/dev/adr/015_dtype_default_vs_scalar_field.md index 7f164f0..6e93df5 100644 --- a/docs/dev/adr/015_dtype_default_vs_scalar_field.md +++ b/docs/dev/adr/015_dtype_default_vs_scalar_field.md @@ -16,12 +16,31 @@ Some mathematical constraints are already dtype-sensitive. `EuclideanElementwise ## Decision -`Context.dtype` is a representation default, not the full mathematical scalar-field contract. `Space.field` describes whether membership is over a real or complex scalar field. Dtype checks remain representation checks. Field-level checks decide whether complex values are mathematically allowed, while exact dtype checks continue to decide representation compatibility. +`Context.dtype` is a representation default, not the full mathematical scalar-field contract. `Space.field` describes real or complex entries; `Space.scalar_field` describes the allowed coefficients. Dtype checks remain representation checks. Field-level checks decide whether complex values are mathematically allowed, while exact dtype checks continue to decide representation compatibility. For 0.4.0 Stage 1, `Space.field` is derived from `Context.dtype`; no constructor-level override is provided. `FieldCheck` makes scalar-field compatibility explicit while `DTypeCheck` continues to enforce exact representation dtype. Conversion rejects complex-to-real narrowing unless the caller explicitly extracts a real part first. Exact dtype equality and cross-precision compatibility remain strict until concrete workloads justify relaxing them. Solver workspaces should be operand-driven: real diagnostics for complex operators use `ops.real_dtype(ctx.dtype)`, while vector workspaces use the operator/context dtype. +`Space.scalars` returns a public `RealField` or `ComplexField` on the same +backend. It is separate from both string accessors and does not mutate the +owning space's context. Fields have Euclidean inner products and scalar shape +membership, but no coordinate or batching capability. + +| Coefficient field | Element storage | Scalar field storage | +| --- | --- | --- | +| real | real floating | same dtype | +| real | complex floating | corresponding real dtype | +| complex | real floating | rejected at construction | +| complex | complex floating | same dtype | + +Integer and Boolean space storage are rejected. For example, a +`HermitianSpace` over `complex128` has a `RealField` over `float64`, while the +Hermitian space retains `complex128`. A functional's explicit field codomain +is validated independently: a real domain can have complex-valued output. +Field conversion preserves its real/complex identity and uses the target +backend and corresponding precision. + ## Rationale Dtype and scalar field answer different questions. Dtype controls storage and backend kernels; field controls mathematical membership and valid operations. Conflating them makes complex membership and precision compatibility ambiguous. @@ -37,6 +56,7 @@ Using exact dtype as the only field proxy was rejected because real-vs-complex m ## Contributor invariants - `Context.dtype` controls representation defaults and constructors. -- `Space.field` is the mathematical contract for real vs complex membership and is derived from the context dtype in 0.4.0. +- `Space.field` describes entries and is derived from the context dtype. +- `Space.scalar_field` describes coefficients; `Space.scalars` is their object-valued field. - Current dtype membership remains exact and strict. - Solver scalar diagnostics should use real workspaces when mathematically real, especially for complex operators. diff --git a/spacecore/__init__.py b/spacecore/__init__.py index a8d76e6..16c73e0 100644 --- a/spacecore/__init__.py +++ b/spacecore/__init__.py @@ -91,6 +91,9 @@ DTypeCheck, FieldCheck, HermitianCheck, + Field, + RealField, + ComplexField, CoordinateSpace, DenseCoordinateSpace, DenseVectorSpace, @@ -233,6 +236,9 @@ "TreeElement", "TreeSpace", "TreeSpectralDecomposition", + "Field", + "RealField", + "ComplexField", "CoordinateSpace", "InnerProductSpace", "StarSpace", diff --git a/spacecore/_checks.py b/spacecore/_checks.py index 963ba6f..37b2963 100644 --- a/spacecore/_checks.py +++ b/spacecore/_checks.py @@ -46,7 +46,6 @@ def checked_method( arg_positions: int | tuple[int, ...] | None = None, in_batched: bool = False, out_batched: bool = False, - out_scalar: bool = False, out_batched_scalar: bool = False, ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: """ @@ -71,16 +70,12 @@ def checked_method( Validate inputs as leading-axis batches instead of single elements. out_batched : bool, optional Validate outputs as leading-axis batches instead of single elements. - out_scalar : bool, optional - Validate that the output is scalar-shaped (``shape == ()``). This is the - codomain check for a :class:`~spacecore.functional.Functional`, whose - codomain is the scalar field rather than a :class:`Space` object and so - cannot be expressed through ``out_space``. out_batched_scalar : bool, optional Validate that the output is a vector of scalars, one per input element: ``shape == (N,)`` for a leading batch of size ``N``. The batch size is read from the *input* named by ``in_space`` at the first checked - position, so this flag requires ``in_space``. + position, so this flag requires ``in_space``. Deliberately retained: + batched Field membership cannot enforce exactly one leading axis. Returns ------- @@ -101,9 +96,6 @@ def checked_method( hand-written ``_checks_at_least("standard")`` gating they replace. Shape is static under ``jax.jit``, so they are safe to run while tracing. """ - require_mutually_exclusive( - "out_scalar", out_scalar or None, "out_batched_scalar", out_batched_scalar or None - ) if out_batched_scalar and in_space is None: # TypeError, matching ``require_mutually_exclusive``: both are misuse of # the decorator's argument combination, not a bad runtime value. @@ -156,18 +148,11 @@ def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any: else: out_target._check_member(y) - if (out_scalar or out_batched_scalar) and check_level_at_least(level, "standard"): - from ._batching import _check_scalar_shape + if out_batched_scalar and check_level_at_least(level, "standard"): + from ._batching import _check_scalar_shape, _leading_batch_size - if out_scalar: - _check_scalar_shape(y, ()) - else: - from ._batching import _leading_batch_size - - batch_target = self if in_space == "self" else getattr(self, in_space) - _check_scalar_shape( - y, (_leading_batch_size(batch_target, args[batch_pos]),) - ) + batch_target = self if in_space == "self" else getattr(self, in_space) + _check_scalar_shape(y, (_leading_batch_size(batch_target, args[batch_pos]),)) return y diff --git a/spacecore/contextual/_bound.py b/spacecore/contextual/_bound.py index 294b938..0f25d16 100644 --- a/spacecore/contextual/_bound.py +++ b/spacecore/contextual/_bound.py @@ -115,6 +115,19 @@ def _bind_context( self._check_level = minimum_check_level(tuple(levels)) if levels else get_check_level() return tuple(child.convert(resolved) for child in children) + def _with_check_level(self, level: CheckLevel | bool) -> Self: + """Return a copy of this object validating at ``level``. + + Routes through the subclass's own :meth:`_convert` rather than calling + ``type(self)(...)``, so a subclass whose constructor takes more than a + context keeps working — ``_convert`` is the extension point that already + has to know how to rebuild it. Mirrors :meth:`convert`, which likewise + assigns ``_check_level`` onto a freshly built object. + """ + result = self._convert(self.ctx) + result._check_level = normalize_check_level(level) + return result + @property def ops(self) -> BackendOps: """Return backend operations associated with this object's context.""" diff --git a/spacecore/functional/__pycache__/__init__.cpython-310.pyc b/spacecore/functional/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..220f0cb Binary files /dev/null and b/spacecore/functional/__pycache__/__init__.cpython-310.pyc differ diff --git a/spacecore/functional/__pycache__/__init__.cpython-311.pyc b/spacecore/functional/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..54ee258 Binary files /dev/null and b/spacecore/functional/__pycache__/__init__.cpython-311.pyc differ diff --git a/spacecore/functional/__pycache__/__init__.cpython-312.pyc b/spacecore/functional/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..72876f8 Binary files /dev/null and b/spacecore/functional/__pycache__/__init__.cpython-312.pyc differ diff --git a/spacecore/functional/__pycache__/__init__.cpython-313.pyc b/spacecore/functional/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..82bc6f1 Binary files /dev/null and b/spacecore/functional/__pycache__/__init__.cpython-313.pyc differ diff --git a/spacecore/functional/__pycache__/_algebra.cpython-313.pyc b/spacecore/functional/__pycache__/_algebra.cpython-313.pyc new file mode 100644 index 0000000..b2412cc Binary files /dev/null and b/spacecore/functional/__pycache__/_algebra.cpython-313.pyc differ diff --git a/spacecore/functional/__pycache__/_base.cpython-310.pyc b/spacecore/functional/__pycache__/_base.cpython-310.pyc new file mode 100644 index 0000000..0bb2c9b Binary files /dev/null and b/spacecore/functional/__pycache__/_base.cpython-310.pyc differ diff --git a/spacecore/functional/__pycache__/_base.cpython-311.pyc b/spacecore/functional/__pycache__/_base.cpython-311.pyc new file mode 100644 index 0000000..f47cf35 Binary files /dev/null and b/spacecore/functional/__pycache__/_base.cpython-311.pyc differ diff --git a/spacecore/functional/__pycache__/_base.cpython-312.pyc b/spacecore/functional/__pycache__/_base.cpython-312.pyc new file mode 100644 index 0000000..61b4255 Binary files /dev/null and b/spacecore/functional/__pycache__/_base.cpython-312.pyc differ diff --git a/spacecore/functional/__pycache__/_base.cpython-313.pyc b/spacecore/functional/__pycache__/_base.cpython-313.pyc new file mode 100644 index 0000000..e33f913 Binary files /dev/null and b/spacecore/functional/__pycache__/_base.cpython-313.pyc differ diff --git a/spacecore/functional/__pycache__/_battery.cpython-313-pytest-9.0.2.pyc b/spacecore/functional/__pycache__/_battery.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..d9d9d03 Binary files /dev/null and b/spacecore/functional/__pycache__/_battery.cpython-313-pytest-9.0.2.pyc differ diff --git a/spacecore/functional/__pycache__/_battery.cpython-313.pyc b/spacecore/functional/__pycache__/_battery.cpython-313.pyc new file mode 100644 index 0000000..c9526f3 Binary files /dev/null and b/spacecore/functional/__pycache__/_battery.cpython-313.pyc differ diff --git a/spacecore/functional/__pycache__/_composed.cpython-310.pyc b/spacecore/functional/__pycache__/_composed.cpython-310.pyc new file mode 100644 index 0000000..83169c0 Binary files /dev/null and b/spacecore/functional/__pycache__/_composed.cpython-310.pyc differ diff --git a/spacecore/functional/__pycache__/_composed.cpython-311.pyc b/spacecore/functional/__pycache__/_composed.cpython-311.pyc new file mode 100644 index 0000000..b353a8a Binary files /dev/null and b/spacecore/functional/__pycache__/_composed.cpython-311.pyc differ diff --git a/spacecore/functional/__pycache__/_composed.cpython-312.pyc b/spacecore/functional/__pycache__/_composed.cpython-312.pyc new file mode 100644 index 0000000..f840677 Binary files /dev/null and b/spacecore/functional/__pycache__/_composed.cpython-312.pyc differ diff --git a/spacecore/functional/__pycache__/_composed.cpython-313.pyc b/spacecore/functional/__pycache__/_composed.cpython-313.pyc new file mode 100644 index 0000000..4822cfd Binary files /dev/null and b/spacecore/functional/__pycache__/_composed.cpython-313.pyc differ diff --git a/spacecore/functional/__pycache__/_least_squares.cpython-313-pytest-9.0.2.pyc b/spacecore/functional/__pycache__/_least_squares.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..0c768a3 Binary files /dev/null and b/spacecore/functional/__pycache__/_least_squares.cpython-313-pytest-9.0.2.pyc differ diff --git a/spacecore/functional/__pycache__/_least_squares.cpython-313.pyc b/spacecore/functional/__pycache__/_least_squares.cpython-313.pyc new file mode 100644 index 0000000..8837365 Binary files /dev/null and b/spacecore/functional/__pycache__/_least_squares.cpython-313.pyc differ diff --git a/spacecore/functional/__pycache__/_linear.cpython-310.pyc b/spacecore/functional/__pycache__/_linear.cpython-310.pyc new file mode 100644 index 0000000..e664fe5 Binary files /dev/null and b/spacecore/functional/__pycache__/_linear.cpython-310.pyc differ diff --git a/spacecore/functional/__pycache__/_linear.cpython-311.pyc b/spacecore/functional/__pycache__/_linear.cpython-311.pyc new file mode 100644 index 0000000..ab99e9f Binary files /dev/null and b/spacecore/functional/__pycache__/_linear.cpython-311.pyc differ diff --git a/spacecore/functional/__pycache__/_linear.cpython-312.pyc b/spacecore/functional/__pycache__/_linear.cpython-312.pyc new file mode 100644 index 0000000..a3ebeb4 Binary files /dev/null and b/spacecore/functional/__pycache__/_linear.cpython-312.pyc differ diff --git a/spacecore/functional/__pycache__/_linear.cpython-313.pyc b/spacecore/functional/__pycache__/_linear.cpython-313.pyc new file mode 100644 index 0000000..2234480 Binary files /dev/null and b/spacecore/functional/__pycache__/_linear.cpython-313.pyc differ diff --git a/spacecore/functional/__pycache__/_proximal.cpython-313-pytest-9.0.2.pyc b/spacecore/functional/__pycache__/_proximal.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..a4f261e Binary files /dev/null and b/spacecore/functional/__pycache__/_proximal.cpython-313-pytest-9.0.2.pyc differ diff --git a/spacecore/functional/__pycache__/_proximal.cpython-313.pyc b/spacecore/functional/__pycache__/_proximal.cpython-313.pyc new file mode 100644 index 0000000..32c4b34 Binary files /dev/null and b/spacecore/functional/__pycache__/_proximal.cpython-313.pyc differ diff --git a/spacecore/functional/__pycache__/_quadratic.cpython-310.pyc b/spacecore/functional/__pycache__/_quadratic.cpython-310.pyc new file mode 100644 index 0000000..30d5014 Binary files /dev/null and b/spacecore/functional/__pycache__/_quadratic.cpython-310.pyc differ diff --git a/spacecore/functional/__pycache__/_quadratic.cpython-311.pyc b/spacecore/functional/__pycache__/_quadratic.cpython-311.pyc new file mode 100644 index 0000000..3271539 Binary files /dev/null and b/spacecore/functional/__pycache__/_quadratic.cpython-311.pyc differ diff --git a/spacecore/functional/__pycache__/_quadratic.cpython-312.pyc b/spacecore/functional/__pycache__/_quadratic.cpython-312.pyc new file mode 100644 index 0000000..e958542 Binary files /dev/null and b/spacecore/functional/__pycache__/_quadratic.cpython-312.pyc differ diff --git a/spacecore/functional/__pycache__/_quadratic.cpython-313.pyc b/spacecore/functional/__pycache__/_quadratic.cpython-313.pyc new file mode 100644 index 0000000..82708a0 Binary files /dev/null and b/spacecore/functional/__pycache__/_quadratic.cpython-313.pyc differ diff --git a/spacecore/functional/__pycache__/_realified.cpython-313.pyc b/spacecore/functional/__pycache__/_realified.cpython-313.pyc new file mode 100644 index 0000000..c2163ac Binary files /dev/null and b/spacecore/functional/__pycache__/_realified.cpython-313.pyc differ diff --git a/spacecore/functional/_algebra.py b/spacecore/functional/_algebra.py index 5619601..719a5d0 100644 --- a/spacecore/functional/_algebra.py +++ b/spacecore/functional/_algebra.py @@ -24,6 +24,7 @@ from typing import Any from ._base import Functional +from ..space import Field from .._checks import checked_method from .._check_policy import CheckLevel, minimum_check_level from ..contextual import Context @@ -88,11 +89,12 @@ def __init__( # result inherits the least-strict operand independently of order. if check_level is None: check_level = functional.check_level - super().__init__(functional.domain, functional.ctx, check_level=check_level) + super().__init__(functional.domain, functional.ctx, check_level=check_level, + cod=functional.codomain) self.scalar = scalar self.functional = functional.convert(self.ctx) - @checked_method(in_space="domain", out_scalar=True) + @checked_method(in_space="domain", out_space="codomain") def value(self, x: Any, *args: Any, **kwargs: Any) -> Any: """Return ``scalar * functional.value(x)``.""" return self._value_core(x, *args, **kwargs) @@ -171,7 +173,7 @@ def make_scaled_functional(scalar: Any, functional: Functional) -> Functional: unwrap_scaled=lambda f: ( (f.scalar, f.functional) if isinstance(f, ScaledFunctional) else None ), - make_zero=lambda: ZeroFunctional(functional.domain, functional.ctx), + make_zero=lambda: ZeroFunctional(functional.domain, functional.ctx, cod=functional.codomain), make_scaled_node=ScaledFunctional, ) @@ -202,7 +204,8 @@ def __init__(self, terms: Any, check_level: CheckLevel | bool | None = None) -> _require_same_domain(parts) if check_level is None: check_level = minimum_check_level(tuple(term.check_level for term in parts)) - super().__init__(parts[0].domain, parts[0].ctx, check_level=check_level) + super().__init__(parts[0].domain, parts[0].ctx, check_level=check_level, + cod=parts[0].codomain) self.terms = tuple(term.convert(self.ctx) for term in parts) @property @@ -210,7 +213,7 @@ def parts(self) -> tuple[Functional, ...]: """Return the summed terms in order.""" return self.terms - @checked_method(in_space="domain", out_scalar=True) + @checked_method(in_space="domain", out_space="codomain") def value(self, x: Any, *args: Any, **kwargs: Any) -> Any: """Return the sum of the term values at ``x``.""" return self._value_core(x, *args, **kwargs) @@ -302,7 +305,7 @@ def make_functional_sum(terms: Any) -> Functional: return finalize_sum( flat, is_zero=lambda f: isinstance(f, ZeroFunctional), - make_zero=lambda: ZeroFunctional(flat[0].domain, flat[0].ctx), + make_zero=lambda: ZeroFunctional(flat[0].domain, flat[0].ctx, cod=flat[0].codomain), make_sum_node=SumFunctional, ) @@ -323,6 +326,9 @@ class ZeroFunctional(Functional): check_level : {{"none", "cheap", "standard", "strict"}}, optional Runtime validation policy for this object. When omitted, the ambient default (see :func:`spacecore.get_check_level`) is used. + cod : Field or None, optional + Scalar codomain, defaulting to the domain's ``scalars``. Its real or + complex field is independent of the domain's storage dtype. """ def __init__( @@ -330,17 +336,19 @@ def __init__( dom: Any, ctx: Context | str | None = None, check_level: CheckLevel | bool | None = None, + *, + cod: Field | None = None, ) -> None: - super().__init__(dom, ctx, check_level=check_level) + super().__init__(dom, ctx, check_level=check_level, cod=cod) - @checked_method(in_space="domain", out_scalar=True) + @checked_method(in_space="domain", out_space="codomain") def value(self, x: Any, *args: Any, **kwargs: Any) -> Any: """Return the scalar zero.""" return self._value_core(x, *args, **kwargs) def _value_core(self, x: Any, *args: Any, **kwargs: Any) -> Any: - """Check-free scalar zero in the domain dtype.""" - return self.ctx.asarray(0.0) + """Check-free scalar zero in the codomain dtype.""" + return self.codomain.ctx.asarray(0.0) def grad(self, x: Any, *args: Any, **kwargs: Any) -> Any: """Return the domain's zero element.""" @@ -354,21 +362,22 @@ def __eq__(self, other: Any) -> bool: """Return whether another zero functional has the same domain.""" if not self.same_math(other): return NotImplemented - return self.domain == other.domain + return self.domain == other.domain and self.codomain == other.codomain def tree_flatten(self): """Flatten this functional for pytree registration.""" - return (), (self.domain, self.ctx) + return (), (self.domain, self.ctx, self.codomain, self.check_level) @classmethod def tree_unflatten(cls, aux, children): """Rebuild this functional from pytree data.""" - dom, ctx = aux - return cls(dom, ctx) + dom, ctx, cod, check_level = aux + return cls(dom, ctx, check_level=check_level, cod=cod) def _convert(self, new_ctx: Context) -> "ZeroFunctional": """Convert the zero functional to ``new_ctx``.""" - return ZeroFunctional(self.domain.convert(new_ctx), new_ctx) + return ZeroFunctional(self.domain.convert(new_ctx), new_ctx, + check_level=self.check_level, cod=self.codomain) class ConstantFunctional(Functional): @@ -394,6 +403,9 @@ class ConstantFunctional(Functional): Runtime validation policy for this functional. When omitted, the ambient default (see :func:`spacecore.get_check_level`) is used. Validation policy is a property of the functional, not of the ``Context``. + cod : Field or None, optional + Scalar codomain, defaulting to the domain's ``scalars``. Its real or + complex field is independent of the domain's storage dtype. Attributes ---------- @@ -407,20 +419,22 @@ def __init__( constant: Any, ctx: Context | str | None = None, check_level: CheckLevel | bool | None = None, + *, + cod: Field | None = None, ) -> None: if not is_scalar_like(constant): raise TypeError(f"constant must be scalar-like, got {type(constant).__name__}.") - super().__init__(dom, ctx, check_level=check_level) + super().__init__(dom, ctx, check_level=check_level, cod=cod) self.constant = constant - @checked_method(in_space="domain", out_scalar=True) + @checked_method(in_space="domain", out_space="codomain") def value(self, x: Any, *args: Any, **kwargs: Any) -> Any: """Return the stored constant.""" return self._value_core(x, *args, **kwargs) def _value_core(self, x: Any, *args: Any, **kwargs: Any) -> Any: - """Check-free constant in the domain dtype (mirrors ``ZeroFunctional``).""" - return self.ctx.asarray(self.constant) + """Check-free constant in the codomain dtype (mirrors ``ZeroFunctional``).""" + return self.codomain.ctx.asarray(self.constant) def grad(self, x: Any, *args: Any, **kwargs: Any) -> Any: """Return the domain's zero element (a constant has zero derivative).""" @@ -434,22 +448,23 @@ def __eq__(self, other: Any) -> bool: """Return whether another constant functional has the same domain and value.""" if not self.same_math(other): return NotImplemented - return self.domain == other.domain and scalar_eq(self.constant, other.constant) + return self.domain == other.domain and self.codomain == other.codomain and scalar_eq(self.constant, other.constant) def tree_flatten(self): """Flatten this functional for pytree registration (constant is a traced child).""" - return (self.constant,), (self.domain, self.ctx) + return (self.constant,), (self.domain, self.ctx, self.codomain, self.check_level) @classmethod def tree_unflatten(cls, aux, children): """Rebuild this functional from pytree data.""" - dom, ctx = aux + dom, ctx, cod, check_level = aux (constant,) = children - return cls(dom, constant, ctx) + return cls(dom, constant, ctx, check_level=check_level, cod=cod) def _convert(self, new_ctx: Context) -> "ConstantFunctional": """Convert the constant functional to ``new_ctx``.""" - return ConstantFunctional(self.domain.convert(new_ctx), self.constant, new_ctx) + return ConstantFunctional(self.domain.convert(new_ctx), self.constant, new_ctx, + check_level=self.check_level, cod=self.codomain) def make_constant_functional( @@ -514,11 +529,12 @@ def __init__( raise TypeError(f"offset must be scalar-like, got {type(offset).__name__}.") if check_level is None: check_level = functional.check_level - super().__init__(functional.domain, functional.ctx, check_level=check_level) + super().__init__(functional.domain, functional.ctx, check_level=check_level, + cod=functional.codomain) self.functional = functional.convert(self.ctx) self.offset = offset - @checked_method(in_space="domain", out_scalar=True) + @checked_method(in_space="domain", out_space="codomain") def value(self, x: Any, *args: Any, **kwargs: Any) -> Any: """Return ``functional.value(x) + offset``.""" return self._value_core(x, *args, **kwargs) @@ -648,7 +664,7 @@ def __init__( # (mirrors SumFunctional). if check_level is None: check_level = minimum_check_level((left.check_level, right.check_level)) - super().__init__(left.domain, left.ctx, check_level=check_level) + super().__init__(left.domain, left.ctx, check_level=check_level, cod=left.codomain) self.left = left.convert(self.ctx) self.right = right.convert(self.ctx) @@ -657,7 +673,7 @@ def factors(self) -> tuple[Functional, Functional]: """Return the two factors in order.""" return (self.left, self.right) - @checked_method(in_space="domain", out_scalar=True) + @checked_method(in_space="domain", out_space="codomain") def value(self, x: Any, *args: Any, **kwargs: Any) -> Any: """Return ``left.value(x) * right.value(x)``.""" return self._value_core(x, *args, **kwargs) @@ -754,7 +770,7 @@ def make_functional_product(left: Functional, right: Functional) -> Functional: _require_same_domain((left, right), node="ProductFunctional") if isinstance(left, ZeroFunctional) or isinstance(right, ZeroFunctional): - return ZeroFunctional(left.domain, left.ctx) + return ZeroFunctional(left.domain, left.ctx, cod=left.codomain) if isinstance(left, ConstantFunctional): return make_scaled_functional(left.constant, right) if isinstance(right, ConstantFunctional): diff --git a/spacecore/functional/_base.py b/spacecore/functional/_base.py index 83995d0..9c96786 100644 --- a/spacecore/functional/_base.py +++ b/spacecore/functional/_base.py @@ -15,11 +15,11 @@ ) from .._checks import checked_method from ..backend import PyTreeNode -from .._repr import describe_space, field_symbol +from .._repr import describe_space from ..contextual import ContextBound -from ..contextual import Context +from ..contextual import Context, resolve_context_priority from .._check_policy import CheckLevel -from ..space import CoordinateSpace +from ..space import CoordinateSpace, Field if TYPE_CHECKING: from ..linop import LinOp @@ -48,6 +48,10 @@ class Functional(PyTreeNode, ContextBound, Generic[Domain]): default (see :func:`spacecore.get_check_level`) is used. Validation policy is a property of the functional, not of the ``Context``. + cod : Field or None, optional + Scalar codomain, defaulting to the converted domain's ``scalars``. + Its real or complex field is independent of the domain storage dtype. + Attributes ---------- dom : Space @@ -61,8 +65,24 @@ def __init__( dom: Domain, ctx: Context | str | None = None, check_level: CheckLevel | bool | None = None, + *, + cod: Field | None = None, ) -> None: - (self.dom,) = self._bind_context(ctx, dom, check_level=check_level) + resolved = resolve_context_priority(ctx, dom) + dom = dom.convert(resolved) + if cod is None: + cod = dom.scalars + if not isinstance(cod, Field): + raise TypeError("Functional codomain must be a Field.") + self.dom, self.cod = self._bind_context(resolved, dom, cod, check_level=check_level) + # Output checks follow the functional's policy, even when its domain + # or explicitly supplied field uses a different validation level. + self.cod = self.cod._with_check_level(self.check_level) + + @property + def codomain(self) -> Field: + """The independently bound scalar field of this functional's values.""" + return self.cod @property def domain(self) -> Domain: @@ -253,11 +273,7 @@ def __eq__(self, other: Any) -> bool: def _arrow(self) -> str: """Return the ``domain → scalar-field`` descriptor for this functional.""" - try: - codomain = field_symbol(self.dom.field) - except Exception: - codomain = "?" - return f"{describe_space(self.dom)} → {codomain}" + return f"{describe_space(self.dom)} → {describe_space(self.cod)}" def _repr_body(self) -> str: return self._arrow() diff --git a/spacecore/functional/_composed.py b/spacecore/functional/_composed.py index 3fb301c..d5a68fd 100644 --- a/spacecore/functional/_composed.py +++ b/spacecore/functional/_composed.py @@ -80,11 +80,11 @@ def __init__( _require_composable(F, A) if check_level is None: check_level = minimum_check_level((F.check_level, A.check_level)) - super().__init__(A.domain, A.ctx, check_level=check_level) + super().__init__(A.domain, A.ctx, check_level=check_level, cod=F.codomain) self.F = F.convert(A.ctx) self.A = A - @checked_method(in_space="domain", out_scalar=True) + @checked_method(in_space="domain", out_space="codomain") def value(self, x: Any) -> Any: """ Evaluate ``F(A x)``. diff --git a/spacecore/functional/_linear.py b/spacecore/functional/_linear.py index 68ef2fb..9eedb8d 100644 --- a/spacecore/functional/_linear.py +++ b/spacecore/functional/_linear.py @@ -3,6 +3,7 @@ from abc import abstractmethod from typing import Any, Callable +from ..space import Field from ._base import Domain, Functional from .._batching import _check_scalar_shape from .._checks import checked_method @@ -34,7 +35,7 @@ def _convert_space_element(space: Space, value: Any) -> Any: @core_kernels("functional-linear") class LinearFunctional(Functional[Domain]): - r""" + """ Represent a linear scalar-valued map. Parameters @@ -46,6 +47,9 @@ class LinearFunctional(Functional[Domain]): check_level : {{"none", "cheap", "standard", "strict"}}, optional Runtime validation policy for this object. When omitted, the ambient default (see :func:`spacecore.get_check_level`) is used. + cod : Field or None, optional + Scalar codomain, defaulting to the domain's ``scalars``. Its real or + complex field is independent of the domain's storage dtype. """ @property @@ -118,7 +122,7 @@ def representer(self) -> Any: """Stored domain element ``c`` defining ``ell_c(x) = ``.""" return self._c - @checked_method(in_space="domain", out_scalar=True) + @checked_method(in_space="domain", out_space="codomain") def value(self, x: Any) -> Any: """Return ``domain.inner(representer, x)``.""" return self._value_core(x) @@ -183,6 +187,10 @@ class MatrixFreeLinearFunctional(LinearFunctional[Domain]): Runtime validation policy for this object. When omitted, the ambient default (see :func:`spacecore.get_check_level`) is used. + cod : Field or None, optional + Scalar codomain, independent of the domain's coefficient field. + Defaults to ``dom.scalars``. + Returns ------- MatrixFreeLinearFunctional @@ -197,6 +205,8 @@ def __init__( ctx: Context | str | None = None, vvalue: Callable[[Any], Any] | None = None, check_level: CheckLevel | bool | None = None, + *, + cod: Field | None = None, ) -> None: """ Initialize a matrix-free linear functional. @@ -225,7 +235,7 @@ def __init__( raise TypeError(f"value must be callable, got {type(value).__name__}.") if vvalue is not None and not callable(vvalue): raise TypeError(f"vvalue must be callable, got {type(vvalue).__name__}.") - super().__init__(dom, ctx, check_level=check_level) + super().__init__(dom, ctx, check_level=check_level, cod=cod) self.value_fn = value self.vvalue_fn = vvalue @@ -245,7 +255,7 @@ def representer(self) -> Any: """ raise NotImplementedError(f"{type(self).__name__} does not store a Riesz representer.") - @checked_method(in_space="domain", out_scalar=True) + @checked_method(in_space="domain", out_space="codomain") def value(self, x: Any) -> Any: """ Evaluate the scalar functional. @@ -298,7 +308,7 @@ def __eq__(self, other: Any) -> bool: """Return whether another matrix-free functional uses the same callables.""" if not self.same_math(other): # Tier 1: backend return NotImplemented - if self.domain != other.domain: # Tier 2: domain + if self.domain != other.domain or self.codomain != other.codomain: # Tier 2: endpoints return False # Callable identity: extensional equality of callables is undecidable. return self.value_fn is other.value_fn and self.vvalue_fn is other.vvalue_fn @@ -306,14 +316,14 @@ def __eq__(self, other: Any) -> bool: def tree_flatten(self): """Flatten this functional for pytree registration.""" children = () - aux = (self.value_fn, self.domain, self.ctx, self.vvalue_fn) + aux = (self.value_fn, self.domain, self.ctx, self.vvalue_fn, self.codomain, self.check_level) return children, aux @classmethod def tree_unflatten(cls, aux, children): """Rebuild this functional from pytree data.""" - value_fn, domain, ctx, vvalue_fn = aux - return cls(value_fn, domain, ctx, vvalue_fn) + value_fn, domain, ctx, vvalue_fn, cod, check_level = aux + return cls(value_fn, domain, ctx, vvalue_fn, check_level=check_level, cod=cod) def _convert(self, new_ctx: Context) -> MatrixFreeLinearFunctional: """ @@ -335,4 +345,6 @@ def _convert(self, new_ctx: Context) -> MatrixFreeLinearFunctional: self.domain.convert(new_ctx), new_ctx, self.vvalue_fn, + check_level=self.check_level, + cod=self.codomain, ) diff --git a/spacecore/functional/_quadratic.py b/spacecore/functional/_quadratic.py index 7696c40..fd3872a 100644 --- a/spacecore/functional/_quadratic.py +++ b/spacecore/functional/_quadratic.py @@ -30,6 +30,9 @@ class QuadraticForm(Functional[Domain]): check_level : {{"none", "cheap", "standard", "strict"}}, optional Runtime validation policy for this object. When omitted, the ambient default (see :func:`spacecore.get_check_level`) is used. + cod : Field or None, optional + Scalar codomain, defaulting to the domain's ``scalars``. Its real or + complex field is independent of the domain's storage dtype. """ def hess_apply(self, x: Any) -> Any: @@ -127,7 +130,7 @@ def _check_hermitian_structure(Q: LinOp[Domain, Domain]) -> None: if result is False: raise ValueError("LinOpQuadraticForm requires Q to be Hermitian/self-adjoint.") - @checked_method(in_space="domain", out_scalar=True) + @checked_method(in_space="domain", out_space="codomain") def value(self, x: Any) -> Any: """Return ``1/2 * + linear(x) + a``.""" return self._value_core(x) diff --git a/spacecore/functional/_realified.py b/spacecore/functional/_realified.py index 883f86c..77411b9 100644 --- a/spacecore/functional/_realified.py +++ b/spacecore/functional/_realified.py @@ -142,7 +142,7 @@ def __init__(self, base: Functional) -> None: dom = DenseCoordinateSpace( (2 * X.size,), real_ctx, check_level=X.check_level ) - super().__init__(dom, real_ctx, check_level=X.check_level) + super().__init__(dom, real_ctx, check_level=X.check_level, cod=base.codomain) self.base = base self.complex_space = X @@ -161,7 +161,7 @@ def from_real(self, w: Any) -> Any: # Output-only: the input is validated by ``base.value`` against the *complex* # domain after ``from_real``, so an ``in_space`` here would check ``w`` # against the wrong space. - @checked_method(out_scalar=True) + @checked_method(out_space="codomain") def value(self, w: Any, *args: Any, **kwargs: Any) -> Any: """Return ``base.value`` at the complex element ``w`` encodes.""" return self.base.value(self.from_real(w), *args, **kwargs) diff --git a/spacecore/functional/tools/__pycache__/__init__.cpython-313.pyc b/spacecore/functional/tools/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..9945de0 Binary files /dev/null and b/spacecore/functional/tools/__pycache__/__init__.cpython-313.pyc differ diff --git a/spacecore/functional/tools/__pycache__/_coordinate.cpython-313.pyc b/spacecore/functional/tools/__pycache__/_coordinate.cpython-313.pyc new file mode 100644 index 0000000..7c3d900 Binary files /dev/null and b/spacecore/functional/tools/__pycache__/_coordinate.cpython-313.pyc differ diff --git a/spacecore/functional/tools/__pycache__/_entropy.cpython-313.pyc b/spacecore/functional/tools/__pycache__/_entropy.cpython-313.pyc new file mode 100644 index 0000000..f36f64d Binary files /dev/null and b/spacecore/functional/tools/__pycache__/_entropy.cpython-313.pyc differ diff --git a/spacecore/functional/tools/__pycache__/_huber.cpython-313.pyc b/spacecore/functional/tools/__pycache__/_huber.cpython-313.pyc new file mode 100644 index 0000000..2663337 Binary files /dev/null and b/spacecore/functional/tools/__pycache__/_huber.cpython-313.pyc differ diff --git a/spacecore/functional/tools/__pycache__/_least_squares.cpython-313.pyc b/spacecore/functional/tools/__pycache__/_least_squares.cpython-313.pyc new file mode 100644 index 0000000..c413958 Binary files /dev/null and b/spacecore/functional/tools/__pycache__/_least_squares.cpython-313.pyc differ diff --git a/spacecore/functional/tools/__pycache__/_norms.cpython-313.pyc b/spacecore/functional/tools/__pycache__/_norms.cpython-313.pyc new file mode 100644 index 0000000..8007d2f Binary files /dev/null and b/spacecore/functional/tools/__pycache__/_norms.cpython-313.pyc differ diff --git a/spacecore/functional/tools/__pycache__/_proximal.cpython-313.pyc b/spacecore/functional/tools/__pycache__/_proximal.cpython-313.pyc new file mode 100644 index 0000000..8cb5aa9 Binary files /dev/null and b/spacecore/functional/tools/__pycache__/_proximal.cpython-313.pyc differ diff --git a/spacecore/functional/tools/__pycache__/_spectral.cpython-313.pyc b/spacecore/functional/tools/__pycache__/_spectral.cpython-313.pyc new file mode 100644 index 0000000..5b843f0 Binary files /dev/null and b/spacecore/functional/tools/__pycache__/_spectral.cpython-313.pyc differ diff --git a/spacecore/functional/tools/_entropy.py b/spacecore/functional/tools/_entropy.py index 04dd6d5..65e9a38 100644 --- a/spacecore/functional/tools/_entropy.py +++ b/spacecore/functional/tools/_entropy.py @@ -70,7 +70,7 @@ def __init__( ) -> None: super().__init__(dom, ctx, check_level=check_level) - @checked_method(in_space="domain", out_scalar=True) + @checked_method(in_space="domain", out_space="codomain") def value(self, x: Any) -> Any: """Return ``sum_i x_i log x_i`` with ``0 log 0 = 0``.""" o = self.ops @@ -152,7 +152,7 @@ def target(self) -> Any: """Stored reference element ``t``.""" return self._target - @checked_method(in_space="domain", out_scalar=True) + @checked_method(in_space="domain", out_space="codomain") def value(self, x: Any) -> Any: """Return ``sum_i x_i log(x_i / t_i)`` with ``0 log 0 = 0``.""" o = self.ops diff --git a/spacecore/functional/tools/_huber.py b/spacecore/functional/tools/_huber.py index 211e96b..25725de 100644 --- a/spacecore/functional/tools/_huber.py +++ b/spacecore/functional/tools/_huber.py @@ -74,7 +74,7 @@ def __init__( raise ValueError(f"HuberFunctional requires a finite delta > 0, got {delta}.") self.delta = delta - @checked_method(in_space="domain", out_scalar=True) + @checked_method(in_space="domain", out_space="codomain") def value(self, x: Any) -> Any: """Return ``sum_i h_delta(x_i)``.""" o = self.ops diff --git a/spacecore/functional/tools/_norms.py b/spacecore/functional/tools/_norms.py index 9fde0d0..ec7c29b 100644 --- a/spacecore/functional/tools/_norms.py +++ b/spacecore/functional/tools/_norms.py @@ -52,7 +52,7 @@ def __init__( ) -> None: super().__init__(dom, ctx, check_level=check_level) - @checked_method(in_space="domain", out_scalar=True) + @checked_method(in_space="domain", out_space="codomain") def value(self, x: Any) -> Any: """Return ``1/2 _X`` as a real scalar.""" return 0.5 * self.ops.real(_inner_core(self.domain, x, x)) @@ -128,7 +128,7 @@ def __init__( raise ValueError(f"LpNormFunctional requires a finite p >= 1, got {p}.") self.p = p - @checked_method(in_space="domain", out_scalar=True) + @checked_method(in_space="domain", out_space="codomain") def value(self, x: Any) -> Any: """Return ``(sum_i |x_i|^p)^{1/p}``.""" return lp_value(self.ops, x, self.p) diff --git a/spacecore/functional/tools/_spectral.py b/spacecore/functional/tools/_spectral.py index c148a2d..3fb574c 100644 --- a/spacecore/functional/tools/_spectral.py +++ b/spacecore/functional/tools/_spectral.py @@ -143,7 +143,7 @@ def __init__( ctx: Context | str | None = None, check_level: CheckLevel | bool | None = None, ) -> None: - super().__init__(dom, ctx, check_level=check_level) + super().__init__(dom, ctx, check_level=check_level, cod=base.codomain) domain = cast(Any, self.domain) if not isinstance(domain, JordanAlgebraSpace): raise TypeError( @@ -170,7 +170,7 @@ def __init__( ) self.base = base - @checked_method(in_space="domain", out_scalar=True) + @checked_method(in_space="domain", out_space="codomain") def value(self, x: Any) -> Any: """Return ``f(lambda(x))``.""" return self.base.value(cast(Any, self.domain).spectrum(x)) diff --git a/spacecore/linop/_base.py b/spacecore/linop/_base.py index fdc30e9..161755e 100644 --- a/spacecore/linop/_base.py +++ b/spacecore/linop/_base.py @@ -13,7 +13,7 @@ from ..space._capabilities import _CAP_BATCH, _CAP_COORDINATE, require from ..backend import PyTreeNode from .._repr import describe_space -from ..space import VectorSpace +from ..space import VectorSpace, Field from ..contextual import Context from ..contextual import ContextBound @@ -96,6 +96,11 @@ def __init__( ctx: Context | str | None = None, check_level: CheckLevel | bool | None = None, ): + if isinstance(cod, Field): + raise TypeError( + "LinOp codomain cannot be a Field (ADR-010); use Functional " + "for scalar-valued maps and Riesz gradients." + ) self.dom, self.cod = self._bind_context(ctx, dom, cod, check_level=check_level) @property diff --git a/spacecore/space/__init__.py b/spacecore/space/__init__.py index 9f60be9..376cd58 100644 --- a/spacecore/space/__init__.py +++ b/spacecore/space/__init__.py @@ -12,6 +12,9 @@ SquareMatrixCheck, ) from .base import ( + Field, + RealField, + ComplexField, CoordinateSpace, EuclideanInnerProduct, EuclideanJordanAlgebraSpace, @@ -44,6 +47,9 @@ "InnerProduct", "EuclideanInnerProduct", "WeightedInnerProduct", + "Field", + "RealField", + "ComplexField", "CoordinateSpace", "InnerProductSpace", "StarSpace", diff --git a/spacecore/space/base/__init__.py b/spacecore/space/base/__init__.py index 634c4aa..2016a3b 100644 --- a/spacecore/space/base/__init__.py +++ b/spacecore/space/base/__init__.py @@ -9,10 +9,14 @@ InnerProductSpace, WeightedInnerProduct, ) +from ._field import Field, RealField, ComplexField from ._star import StarSpace from ._jordan import JordanAlgebraSpace, EuclideanJordanAlgebraSpace __all__ = [ + "Field", + "RealField", + "ComplexField", "Space", "VectorSpace", "CoordinateSpace", diff --git a/spacecore/space/base/_field.py b/spacecore/space/base/_field.py new file mode 100644 index 0000000..5f18d2a --- /dev/null +++ b/spacecore/space/base/_field.py @@ -0,0 +1,128 @@ +"""Scalar fields, independent of finite coordinate representations.""" +from __future__ import annotations + +from abc import abstractmethod +from dataclasses import dataclass +from typing import ClassVar + +from ..._check_policy import CheckLevel +from ...contextual import Context +from ..checks import SpaceCheck +from ._inner_product import EuclideanInnerProduct, InnerProductSpace + + +@dataclass(frozen=True) +class ScalarShapeCheck(SpaceCheck): + """Require a single scalar at standard validation and above.""" + + name: str = "scalar_shape" + minimum_level: ClassVar[CheckLevel] = "standard" + + def validate(self, space, x, *, allow_leading): + # Scalars have no trailing axes; ordinary batched membership permits + # arbitrary leading axes. Exact (N,) checks remain at the caller. + return allow_leading or self.is_valid(space, x) + + def is_valid(self, space, x): + return tuple(getattr(x, "shape", ())) == () + + def error_message(self, space, x): + return f"Expected scalar output with shape (), got {tuple(getattr(x, 'shape', ()))}." + + +class Field(InnerProductSpace): + """ + Euclidean scalar field with no coordinate or batching capability. + + Field membership checks scalar shape at standard and strict levels. + Its context records the representation of coefficients, independently of + the storage dtype of a vector space using those coefficients. + + Parameters + ---------- + ctx : Context, str, or None, optional + Backend context specification. Default is the ambient context. + check_level : {"none", "cheap", "standard", "strict"}, optional + Runtime validation policy for this space. When omitted, the ambient + default (see :func:`spacecore.get_check_level`) is used. + """ + + checks = (ScalarShapeCheck(),) + geometry = EuclideanInnerProduct() + + @property + @abstractmethod + def declared_scalar_field(self): + """The real or complex coefficient field.""" + + def zeros(self): + """Return scalar zero in the field's context.""" + return self.ctx.asarray(0.) + + def ones(self): + """Return scalar one for deterministic probes.""" + return self.ctx.asarray(1.) + + def add(self, a, b): + """Return the sum of two scalars.""" + return a + b + + def scale(self, s, a): + """Return the product of two scalars.""" + return s * a + + def inner(self, a, b): + """Return ``conj(a) * b``.""" + return self.ops.conj(a) * b + + def _convert(self, new_ctx): + # Endpoint binding chooses a backend and precision, not a different + # mathematical field. In particular complex codomains survive binding + # to functionals whose domains use real storage. + dtype = (new_ctx.ops.real_dtype(new_ctx.dtype) if self.scalar_field == "real" + else new_ctx.ops.complex_dtype(new_ctx.dtype)) + return type(self)(Context(new_ctx.ops, dtype), check_level=self.check_level) + + +class RealField(Field): + """ + Real scalars, represented in a real floating dtype. + + Parameters + ---------- + ctx : Context, str, or None, optional + Backend context specification. Default is the ambient context. + check_level : {"none", "cheap", "standard", "strict"}, optional + Runtime validation policy for this space. When omitted, the ambient + default (see :func:`spacecore.get_check_level`) is used. + """ + + declared_scalar_field = "real" + + def __init__(self, ctx=None, check_level=None): + super().__init__(ctx, check_level=check_level) + self._ctx = Context(self.ops, self.ops.real_dtype(self.dtype)) + + +class ComplexField(Field): + """ + Complex scalars; explicit contexts must use complex floating storage. + + Parameters + ---------- + ctx : Context, str, or None, optional + Backend context specification. Default is the ambient context. + check_level : {"none", "cheap", "standard", "strict"}, optional + Runtime validation policy for this space. When omitted, the ambient + default (see :func:`spacecore.get_check_level`) is used. + """ + + declared_scalar_field = "complex" + + def __init__(self, ctx=None, check_level=None): + if ctx is None: + from ...contextual import normalize_context + + default = normalize_context(None) + ctx = Context(default.ops, default.ops.complex_dtype(default.dtype)) + super().__init__(ctx, check_level=check_level) diff --git a/spacecore/space/base/_space.py b/spacecore/space/base/_space.py index d65e8b0..433bfa9 100644 --- a/spacecore/space/base/_space.py +++ b/spacecore/space/base/_space.py @@ -33,6 +33,10 @@ def __init__( check_level: CheckLevel | bool | None = None, ) -> None: super().__init__(ctx, check_level=check_level) + 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` @@ -77,6 +81,20 @@ def scalar_field(self) -> Literal["real", "complex"]: """ return self.declared_scalar_field or self.field + @property + def scalars(self): + """Return the coefficient field on this backend without changing storage. + + Real coefficients over complex storage use the corresponding real + precision; entry and coefficient fields remain separate properties. + """ + from ._field import RealField, ComplexField + + if self.scalar_field == "real": + ctx = Context(self.ops, self.ops.real_dtype(self.dtype)) + return RealField(ctx, check_level=self.check_level) + return ComplexField(self.ctx, check_level=self.check_level) + def _check_scalar(self, a: Any) -> None: """Raise if ``a`` is not an admissible multiplier for this space.""" if self.scalar_field == "real" and is_recognizably_nonreal(a): diff --git a/tests/functional/test_field_codomain.py b/tests/functional/test_field_codomain.py new file mode 100644 index 0000000..d17a893 --- /dev/null +++ b/tests/functional/test_field_codomain.py @@ -0,0 +1,139 @@ +"""Functional fields remain independent across binding, algebra and round trips.""" +import numpy as np +import pytest + +import spacecore as sc +from spacecore._checks import checked_method + + +@pytest.fixture +def complex_valued(): + ctx = sc.Context(sc.NumpyOps(), dtype=np.float64) + dom = sc.DenseCoordinateSpace((2,), ctx) + return sc.MatrixFreeLinearFunctional( + lambda x: (1 + 2j) * x.sum(), dom, + cod=sc.ComplexField(sc.Context(ctx.ops, dtype=np.complex128)), + ) + + +def test_real_domain_complex_codomain(complex_valued): + f = complex_valued + assert f.domain.dtype == np.dtype('float64') + assert f.ctx.dtype == np.dtype('float64') + assert isinstance(f.codomain, sc.ComplexField) + assert f.codomain.dtype == np.dtype('complex128') + assert f.value(np.array([1., 2.])) == 3 + 6j + assert 'ℝ' in repr(f) and 'ℂ' in repr(f) + + +@pytest.mark.parametrize('build', [lambda f: f, lambda f: 2 * f, lambda f: f + f, + lambda f: f + 1, lambda f: f * f, + lambda f: 0 * f, + lambda f: f.compose(sc.IdentityLinOp(f.domain))]) +def test_codomain_survives_algebra_conversion_and_pytree(complex_valued, build): + f = build(complex_valued) + x = np.array([1., 2.]) + children, aux = f.tree_flatten() + restored = type(f).tree_unflatten(aux, children) + converted = f.convert(sc.Context(sc.NumpyOps(), dtype=np.float32)) + for obj in (f, restored, converted): + assert isinstance(obj.codomain, sc.ComplexField) + assert obj.domain.field == 'real' + np.testing.assert_allclose(obj.value(obj.ctx.asarray(x)), f.value(x)) + assert converted.codomain.dtype == np.dtype('complex64') + assert converted.domain.dtype == np.dtype('float32') + + +def test_explicit_codomain_participates_in_equality(complex_valued): + f = complex_valued + real = sc.MatrixFreeLinearFunctional(f.value_fn, f.domain) + assert f != real + + +def test_default_hermitian_codomain_is_real(): + ctx = sc.Context(sc.NumpyOps(), dtype=np.complex128) + dom = sc.HermitianSpace(2, ctx=ctx) + f = sc.InnerProductFunctional(dom.zeros(), dom) + assert isinstance(f.codomain, sc.RealField) + assert f.codomain.dtype == np.dtype('float64') + assert f.domain.dtype == np.dtype('complex128') + + +def test_codomain_must_be_a_field(): + dom = sc.DenseCoordinateSpace((2,), sc.Context(sc.NumpyOps(), dtype=np.float64)) + with pytest.raises(TypeError, match='must be a Field'): + sc.MatrixFreeLinearFunctional(lambda x: x.sum(), dom, cod=dom) + + +@pytest.mark.parametrize('level,raises', [('none', False), ('cheap', False), + ('standard', True), ('strict', True)]) +def test_functional_policy_controls_field_output_check(level, raises): + # Deliberately different policies on both supplied endpoints. + ctx = sc.Context(sc.NumpyOps(), dtype=np.float64) + dom = sc.DenseCoordinateSpace((2,), ctx, check_level='none') + cod = sc.RealField(ctx, check_level='strict') + f = sc.MatrixFreeLinearFunctional(lambda x: x, dom, cod=cod, check_level=level) + assert cod.check_level == 'strict' + for obj in (f, f.convert(sc.Context(sc.NumpyOps(), dtype=np.float32))): + if raises: + with pytest.raises(sc.SpaceValidationError, match='Expected scalar output'): + obj.value(obj.ctx.asarray([1., 2.])) + else: + obj.value(obj.ctx.asarray([1., 2.])) + + +def test_exact_batch_check_is_stronger_than_scalar_field_batch_membership(): + class InvalidBatch(sc.MatrixFreeLinearFunctional): + def value(self, x): + return x.sum() + + @checked_method(in_space='domain', in_batched=True, out_batched_scalar=True) + def vvalue(self, xs): + return self.ctx.asarray(np.ones((len(xs), 1))) + + ctx = sc.Context(sc.NumpyOps(), dtype=np.float64) + f = InvalidBatch(lambda x: x.sum(), sc.DenseCoordinateSpace((2,), ctx)) + from spacecore.space.checks import _run_checks + + _run_checks(f.codomain, np.ones((3, 1)), allow_leading=True) + with pytest.raises(ValueError, match='scalar batch output'): + f.vvalue(np.ones((3, 2))) + + +def test_complex_constant_and_zero_round_trip(): + ctx = sc.Context(sc.NumpyOps(), dtype=np.float64) + dom = sc.DenseCoordinateSpace((2,), ctx) + field = sc.ComplexField() + for f in (sc.ConstantFunctional(dom, 1j, cod=field), sc.ZeroFunctional(dom, cod=field)): + children, aux = f.tree_flatten() + restored = type(f).tree_unflatten(aux, children) + assert restored == f + assert isinstance(restored.codomain, sc.ComplexField) + np.testing.assert_allclose(restored.value(dom.zeros()), f.value(dom.zeros())) + + +def test_field_subclass_with_extra_constructor_args_survives_binding(): + """The codomain is rebuilt through `_convert`, not `type(cod)(ctx, ...)`.""" + + class TaggedField(sc.RealField): + """A public-Field subclass whose constructor takes more than a context.""" + + def __init__(self, tag, ctx=None, check_level=None): + super().__init__(ctx, check_level=check_level) + self.tag = tag + + def _convert(self, new_ctx): + return type(self)(self.tag, new_ctx, check_level=self.check_level) + + ctx = sc.Context(sc.NumpyOps(), dtype=np.float64) + cod = TaggedField('t', ctx, check_level='cheap') + f = sc.MatrixFreeLinearFunctional( + lambda x: x.sum(), sc.DenseCoordinateSpace((2,), ctx), + cod=cod, check_level='strict', + ) + + assert isinstance(f.codomain, TaggedField) + assert f.codomain.tag == 't' + assert f.codomain.check_level == 'strict' # follows the functional's policy + assert cod.check_level == 'cheap' # the caller's object is untouched + assert f.value(np.array([1., 2.])) == 3. diff --git a/tests/functional/test_functional_base.py b/tests/functional/test_functional_base.py index 10629db..803f533 100644 --- a/tests/functional/test_functional_base.py +++ b/tests/functional/test_functional_base.py @@ -284,7 +284,7 @@ def test_message_distinguishes_single_from_batched(self): # =========================================================================== -# Functional codomain contract: out_scalar / out_batched_scalar +# Functional codomain and exact batched scalar contracts # =========================================================================== class _NonScalar(sc.Functional): """Violates the ``F : X -> K`` contract by returning its input.""" @@ -308,12 +308,7 @@ def _convert(self, new_ctx): class TestScalarOutputContract: - """``out_scalar`` is the codomain check ``out_space`` cannot express. - - A ``Functional``'s codomain is the scalar field, reported only as a string - via ``domain.field`` — there is no ``Space`` object to bind ``out_space`` to, - which is why the output side went unchecked while every input was validated. - """ + """A Field codomain validates scalar outputs through ordinary space checks.""" def _domain(self, ctx, check_level=None): return sc.DenseCoordinateSpace((2,), ctx, check_level=check_level) @@ -367,10 +362,6 @@ def test_batched_output_must_be_one_scalar_per_element(self, numpy_ctx): class TestCheckedMethodScalarFlags: """Guards on the decorator itself.""" - def test_flags_are_mutually_exclusive(self): - with pytest.raises(TypeError): - checked_method(in_space="domain", out_scalar=True, out_batched_scalar=True) - def test_batched_scalar_requires_in_space(self): with pytest.raises(TypeError, match="requires in_space"): checked_method(out_batched_scalar=True) diff --git a/tests/generators/spaces.py b/tests/generators/spaces.py index 39cf62e..2759f8f 100644 --- a/tests/generators/spaces.py +++ b/tests/generators/spaces.py @@ -591,4 +591,15 @@ def vector_space_law_cases(*, seed: int | None = DEFAULT_SEED) -> tuple[SpaceCas for case in jordan_space_cases(check_level="standard", seed=seed) if case.reference["kind"] in {"elementwise", "stacked", "tree"} ) - return tuple(dense) + tuple(vectors) + tuple(trees) + jordan + fields = tuple( + GeneratedCase( + obj=field, + reference={name: field.ctx.asarray(value) for name, value in + zip(("x", "y", "z", "a", "b"), (1., -2., 3., .5, -1.25))}, + capabilities=frozenset({"vector", "inner_product", field.field}), + id=f"field-{field.field}", + ) + for field in (sc.RealField(_context(np.float64)), + sc.ComplexField(_context(np.complex128))) + ) + return tuple(dense) + tuple(vectors) + tuple(trees) + jordan + fields diff --git a/tests/spaces/_generated_helpers.py b/tests/spaces/_generated_helpers.py index a98eaf8..11150ff 100644 --- a/tests/spaces/_generated_helpers.py +++ b/tests/spaces/_generated_helpers.py @@ -18,13 +18,13 @@ def tolerances(dtype: Any) -> tuple[float, float]: return (2e-5, 2e-6) if real_dtype == np.dtype(np.float32) else (1e-10, 1e-11) -def assert_allclose(space: sc.CoordinateSpace, actual: Any, expected: Any) -> None: +def assert_allclose(space: sc.VectorSpace, actual: Any, expected: Any) -> None: rtol, atol = tolerances(space.dtype) if not isinstance(space, sc.TreeSpace) and not space.ops.is_dense(expected): expected = space.ctx.asarray(expected) np.testing.assert_allclose( - to_numpy(space.flatten(actual)), - to_numpy(space.flatten(expected)), + to_numpy(space.flatten(actual) if isinstance(space, sc.CoordinateSpace) else actual), + to_numpy(space.flatten(expected) if isinstance(space, sc.CoordinateSpace) else expected), rtol=rtol, atol=atol, ) diff --git a/tests/spaces/test_fields.py b/tests/spaces/test_fields.py new file mode 100644 index 0000000..173c103 --- /dev/null +++ b/tests/spaces/test_fields.py @@ -0,0 +1,123 @@ +"""Scalar fields preserve coefficient mathematics independently of storage.""" +import numpy as np +import pytest + +import spacecore as sc +from spacecore.contextual import normalize_context +from spacecore._batching import _batched_inner +from spacecore.space._capabilities import probe_element, require + + +class RealCoefficients(sc.DenseCoordinateSpace): + declared_scalar_field = 'real' + + +class ComplexCoefficients(sc.DenseCoordinateSpace): + declared_scalar_field = 'complex' + + +@pytest.mark.parametrize('dtype', ['float32', 'float64', 'complex64', 'complex128']) +@pytest.mark.parametrize('declared', ['real', 'complex']) +def test_scalar_storage_table(dtype, declared): + ctx = sc.Context(sc.NumpyOps(), dtype=dtype) + cls = RealCoefficients if declared == 'real' else ComplexCoefficients + if declared == 'complex' and not dtype.startswith('complex'): + with pytest.raises(TypeError, match='complex floating storage'): + cls((2,), ctx) + return + space = cls((2,), ctx) + original_ctx = space.ctx + field = space.scalars + assert isinstance(field, sc.RealField if declared == 'real' else sc.ComplexField) + assert field.dtype == (ctx.ops.real_dtype(ctx.dtype) if declared == 'real' else ctx.dtype) + assert field.ops == ctx.ops + assert space.ctx is original_ctx + assert space.dtype == np.dtype(dtype) + if declared == 'real': + with pytest.raises(sc.SpaceValidationError): + space.check_scalar(1j) + + +@pytest.mark.parametrize('dtype', ['int32', 'int64', 'bool']) +@pytest.mark.parametrize('cls', [sc.RealField, sc.ComplexField, sc.DenseCoordinateSpace]) +def test_integer_boolean_storage_rejected(dtype, cls): + ctx = sc.Context(sc.NumpyOps(), dtype=dtype) + with pytest.raises(TypeError, match='floating storage'): + cls((2,), ctx) if cls is sc.DenseCoordinateSpace else cls(ctx) + + +@pytest.mark.parametrize('cls,dtype', [(sc.RealField, 'float64'), (sc.ComplexField, 'complex128')]) +def test_field_laws_geometry_probe_and_missing_coordinates(cls, dtype): + field = cls(sc.Context(sc.NumpyOps(), dtype=dtype)) + assert isinstance(field, sc.VectorSpace) + assert isinstance(field, sc.InnerProductSpace) + assert not isinstance(field, sc.CoordinateSpace) + for name in ('shape', 'size', 'flatten', 'stacked'): + assert not hasattr(field, name) + a = field.ctx.asarray(2 + 3j if cls is sc.ComplexField else 2.) + b = field.ctx.asarray(4.) + np.testing.assert_allclose(field.inner(a, b), np.conj(a) * b) + assert field.riesz(a) is a + assert field.riesz_inverse(a) is a + assert field.is_euclidean + assert field.zeros().shape == () + assert probe_element(field, field.ops, field.dtype) == 1 + np.testing.assert_allclose(field.axpy(2, a, b), 2 * a + b) + with pytest.raises(sc.CapabilityError, match='CoordinateSpace'): + require(field, sc.CoordinateSpace, 'flatten') + with pytest.raises(sc.CapabilityError, match='CoordinateSpace'): + _batched_inner(field, np.ones(2), np.ones(2)) + + +@pytest.mark.parametrize('level,raises', [('none', False), ('cheap', False), + ('standard', True), ('strict', True)]) +def test_scalar_member_check_levels(level, raises): + field = sc.RealField(check_level=level) + field.check_member(2.) + field.check_member(field.ones()) + if raises: + with pytest.raises(sc.SpaceValidationError, match='Expected scalar output'): + field.check_member(np.ones(2)) + else: + field.check_member(np.ones(2)) + + +def test_hermitian_scalars_do_not_mutate_storage(): + ctx = sc.Context(sc.NumpyOps(), dtype=np.complex128) + space = sc.HermitianSpace(2, ctx=ctx) + original_ctx = space.ctx + field = space.scalars + assert isinstance(field, sc.RealField) + assert field.dtype == np.dtype('float64') + assert space.dtype == np.dtype('complex128') + assert space.ctx is original_ctx + assert space.field == 'complex' and space.scalar_field == 'real' + + +@pytest.mark.parametrize('family', ['numpy', 'torch', 'jax']) +def test_fields_preserve_backend_and_precision(family): + if family != 'numpy': + pytest.importorskip(family) + ops = normalize_context(family).ops + ctx = sc.Context(ops, dtype='complex64') + space = sc.HermitianSpace(2, ctx=ctx) + assert space.scalars.ops == ops + assert space.scalars.dtype == ops.sanitize_dtype('float32') + assert space.scalars.ones().dtype == ops.sanitize_dtype('float32') + assert sc.ComplexField(ctx).ones().dtype == ops.sanitize_dtype('complex64') + + +def test_default_fields_and_conversion_keep_mathematical_field(): + assert sc.RealField().field == 'real' + assert sc.ComplexField().field == 'complex' + ctx = sc.Context(sc.NumpyOps(), dtype='float32') + assert sc.ComplexField().convert(ctx).dtype == np.dtype('complex64') + assert sc.RealField().convert(sc.Context(sc.NumpyOps(), 'complex64')).dtype == np.dtype('float32') + + +def test_linop_rejects_field_codomain_at_construction(): + ctx = sc.Context(sc.NumpyOps(), dtype=np.float64) + dom = sc.DenseCoordinateSpace((2,), ctx) + for field in (sc.RealField(ctx), sc.ComplexField()): + with pytest.raises(TypeError, match='ADR-010'): + sc.MatrixFreeLinOp(lambda x: x.sum(), lambda y: np.ones(2) * y, dom, field)