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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
18 changes: 15 additions & 3 deletions docs/dev/adr/010_functional_contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<c, x>`. `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 `<c, x>`. `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

Expand All @@ -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`.
24 changes: 22 additions & 2 deletions docs/dev/adr/015_dtype_default_vs_scalar_field.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
6 changes: 6 additions & 0 deletions spacecore/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@
DTypeCheck,
FieldCheck,
HermitianCheck,
Field,
RealField,
ComplexField,
CoordinateSpace,
DenseCoordinateSpace,
DenseVectorSpace,
Expand Down Expand Up @@ -233,6 +236,9 @@
"TreeElement",
"TreeSpace",
"TreeSpectralDecomposition",
"Field",
"RealField",
"ComplexField",
"CoordinateSpace",
"InnerProductSpace",
"StarSpace",
Expand Down
27 changes: 6 additions & 21 deletions spacecore/_checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]]:
"""
Expand All @@ -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
-------
Expand All @@ -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.
Expand Down Expand Up @@ -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

Expand Down
13 changes: 13 additions & 0 deletions spacecore/contextual/_bound.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loading
Loading