Skip to content

Field objects for scalars - #69

Merged
Pavlo3P merged 4 commits into
0.4.4from
68-task-field-objects-for-scalars
Sep 12, 2026
Merged

Pavlo3P merged 4 commits into
0.4.4from
68-task-field-objects-for-scalars

Conversation

@Pavlo3P

@Pavlo3P Pavlo3P commented Sep 12, 2026

Copy link
Copy Markdown
Owner

What this PR does

Gives the scalar field an object: Functional gains a Field codomain instead of an implicit string, and out_space="codomain" replaces the parallel out_scalar validation branch.

Closes #68. Follows #67, which bound LinOp endpoints to VectorSpace.

Checklist

  • Tests pass (pytest tests/ -x -q) — 3766 passed, 170 skipped; ruff clean
  • Docstring added or updated, if public behavior changed
  • CHANGELOG entry added under [Unreleased]
  • If this PR touches geometry, adjoints, spectral methods, scalar fields, or batching: describe the mathematical invariant being preserved or added below.

Mathematical invariant

The field is the one-dimensional vector space over itself, with

<a, b> = conj(a) * b,

so it is an inner-product space and a functional is a map into it. It is not a coordinate space: coordinates are a choice of basis for a representation, and a scalar has no such freedom.

This does not reopen ADR-010. Naming the codomain is not the same as making a functional a linear operator into it — the objection was to the class collapse, which erases the Riesz gradient, not to the field having a name. That separation used to be enforced by LinOp's CoordinateSpace bound; since #67 widened it to VectorSpace, and a Field is a VectorSpace, the bound no longer separates them. LinOp.__init__ now rejects a Field codomain explicitly, in the same commit that introduces Field.

Entries and coefficients stay distinct, as ADR-015 decided. Space.scalars binds the coefficient field on the same backend without touching storage: a Hermitian space over complex128 yields a RealField over float64 and remains complex128.

What deliberately did not change

The 4 out_batched_scalar=True sites keep their flag. It asserts shape == (N,); the codomain route goes through _run_checks(..., allow_leading=True) and accepts any leading dimensions, which is strictly weaker. A Field cannot express "exactly one leading axis" without stacked, which it must not have. test_exact_batch_check_is_stronger_than_scalar_field_batch_membership pins the difference, and ADR-010 records the reason.

Breaking

Space.__init__ now rejects integer and Boolean storage, and a complex coefficient field over real storage. The guard binds every space, not only the field-backed numerical ones. Marked breaking in the changelog.

Commits

Each was tested before it was made.

  1. b85608e — Field, RealField, ComplexField, Space.scalars, the storage guards, and the ADR-010 guard
  2. aec8454 — Functional's codomain, the 17 out_scalar conversions, and ContextBound._with_check_level
  3. 73e9072 — ADR-010, ADR-015, changelog

Not in this PR

Mapping and the shared lazy algebra (0.4.5), and spacecore/linalg/. Release-gate items not run here: JIT audit, docs build, backends beyond the default environment.

🤖 Generated with Claude Code

Pavlo3P and others added 3 commits September 12, 2026 17:39
`Field(InnerProductSpace)` is the scalar field as a one-dimensional space
over itself, with `inner(a, b) = conj(a) * b` and Euclidean geometry. It is
pointedly not a `CoordinateSpace`: the surface a scalar cannot honour --
`size`, `flatten`, `stacked` -- lives there, so the hierarchy already cuts
in the right place. `RealField` and `ComplexField` are the concrete forms,
public alongside `Field`.

`ScalarShapeCheck` carries `minimum_level = "standard"` to match the
`out_scalar` branch it will replace; at `cheap` it would tighten validation
rather than preserve it. Under batched membership it permits leading axes,
because ordinary space membership cannot require exactly one.

`Space.scalars` returns the coefficient field on the same backend without
touching the owning space's storage: a Hermitian space over `complex128`
yields a `RealField` over `float64` and stays `complex128` itself.
`Space.__init__` now rejects integer and Boolean storage, and a complex
coefficient field over real storage -- the latter was accepted silently,
since `_check_scalar` only rejects non-real multipliers when the field is
real.

The ADR-010 guard lands here rather than later because this commit opens
the hole: `Field` is a `VectorSpace`, and `LinOp` endpoints have been bound
to `VectorSpace` since #67, so `LinOp[X, Field]` would otherwise satisfy
the bound and reinstate the class collapse ADR-010 rejected. No type bound
expresses "a vector space that is not the field", so `LinOp.__init__`
rejects a `Field` codomain explicitly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`Functional` binds two endpoints, defaulting the codomain to
`dom.scalars` so no existing constructor call changes. The codomain is
independent of the domain's coefficient field: a functional on a real
space may be complex-valued, and conversion preserves that rather than
promoting the domain.

With a codomain object, the 17 `out_scalar=True` decorators become
`out_space="codomain"` and the parallel branch leaves `checked_method`.
Scalar output is now validated by ordinary space membership through the
field's `ScalarShapeCheck`.

The 4 `out_batched_scalar=True` sites stay, deliberately. That flag
asserts `shape == (N,)`; the codomain route goes through
`_run_checks(..., allow_leading=True)` and accepts any leading
dimensions, which is strictly weaker. A `Field` cannot say "exactly one
leading axis" without `stacked`, which it must not have, and ADR-006
models the batch axis as a leading array axis rather than as a space.

Output checks follow the functional's policy, not the field's, which
`out_scalar` did implicitly by reading the functional's check level. That
needs a copy of the codomain at the functional's level, and building it
as `type(cod)(ctx, check_level=...)` breaks any `Field` subclass whose
constructor takes more -- silently, by binding the context to the wrong
parameter. `ContextBound._with_check_level` routes through the subclass's
own `_convert` instead, which is the extension point that already has to
know how to rebuild it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ADR-010 keeps its decision and gains what now enforces it: the codomain
is a `Field`, the type bound no longer separates a functional from a
linear operator into a one-dimensional codomain, and an explicit
construction-time check does. It also records why exact batched scalar
validation keeps its own flag.

ADR-015 gains the object-valued accessor beside the two string ones.

The changelog marks the storage-dtype rejection breaking: it lives on
`Space.__init__`, so it binds every space rather than only the
field-backed numerical ones, and a downstream space over an integer or
Boolean dtype now raises at construction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Pavlo3P Pavlo3P added this to the SpaceCore 0.4.4 milestone Sep 12, 2026
`scripts/docstring_audit.py --check` is a CI gate and it failed on eight
numpydoc issues the change introduced: the `cod` parameter on the four
functional subclasses that now accept it, `ctx` and `check_level` on the
three field classes, and a summary line sharing the opening quotes on
`Field`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Pavlo3P
Pavlo3P merged commit 7d3c0d5 into 0.4.4 Sep 12, 2026
5 checks passed
@Pavlo3P
Pavlo3P deleted the 68-task-field-objects-for-scalars branch September 12, 2026 21:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant