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
196 changes: 196 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,202 @@ and the project adheres to [Semantic Versioning](https://semver.org/).

## [Unreleased]

## [0.4.3] — 2026-09-12

### Removed

- **`jax_pytree_class` is removed from the public API** (breaking). Pytree
registration is no longer a JAX-named decorator applied by hand, but a
backend-neutral registry: containers inherit `PyTreeNode` and each backend
installs its own tree protocol (see *Added*). No replacement decorator is
exported — a class becomes a pytree node by being a concrete `LinOp`,
`Functional`, or one of the three pytree `Space` types. Code that decorated its
own class should inherit `spacecore.backend.PyTreeNode` instead.
- **`SpectralLpNormFunctional` is removed** (breaking). A per-formula spectral
class duplicates the value, the gradient, the pytree methods and the validation
of its coordinate twin — it re-checked `p >= 1` that `LpNormFunctional` already
enforces. The Schatten `p`-norm is now the spectral *lift* of the coordinate
`p`-norm:

```python
# before
f = sc.SpectralLpNormFunctional(X, p)
# after
f = sc.spectralize(X, lambda s: sc.LpNormFunctional(s, p))
```

`NuclearNormFunctional(X)` is unchanged as a named constructor and now returns a
`SpectralFunctional` whose `base` is `LpNormFunctional(s, 1.0)`; code reading
`f.p` should read `f.base.p`.

### Added

- **Backend-neutral pytree registration.** `PyTreeNode` (the capability mixin
owning the `tree_flatten` / `tree_unflatten` contract), `PyTreeRegistry`, and
`BackendOps.install_pytree_protocol()` — a plugin hook, no-op by default, that a
backend implements to teach its transform machinery to see inside SpaceCore
containers. The registry wires the class × backend cross-product with
back-fill in both directions, so registration no longer depends on import order.
`LinOp` and `Functional` now carry the capability on their bases, so every
concrete subclass — including user-defined ones — is registered automatically.
- **Torch is now a transform-capable backend.** `TorchOps.install_pytree_protocol`
registers containers with `torch.utils._pytree`, so a Torch-backed operator can
flow through `torch.compile` and `torch.func` as a traced argument rather than an
opaque leaf. One registration also covers `torch.utils._cxx_pytree`.
- **`SpectralFunctional`, `spectralize`, `eigenvalue_space`** — lift **any**
symmetric coordinate functional onto a Jordan spectrum (Lewis's theorem), instead
of hand-writing a class per formula. `spectralize(X, NegativeEntropyFunctional)`
is the von Neumann entropy; `SquaredL2NormFunctional` lifts to the squared
Frobenius norm, `HuberFunctional` to its spectral analogue. The base functional
must be symmetric — eigenvalues have no canonical order, so a non-symmetric `f`
makes `f(lambda(X))` ill-defined rather than merely mis-differentiated.
- **`RealifiedFunctional` and `realify`** — view a complex-domain functional over
stacked real coordinates `(Re v, Im v)`, for optimizers that assume a real vector
space. The real gradient is `(Re g_v, Im g_v)` with `g_v` the *coordinate*
gradient, so the metric correction stays where ADR-010 put it. `realify` is a
no-op on an already-real domain.
- **`OpsRegistry`** — the backend registry extracted out of `Contextual`, which now
holds ambient policy only. Instantiable, so registration is testable without the
process-wide singleton; `register_ops` still raises `ContextConflictError` on a
duplicate family.
- **`BackendOps.complex_dtype`** — the inverse of `real_dtype`. Deriving it at the
call site is not portable: NumPy promotes `float32` against a Python complex to
`complex128`, while JAX and Torch give `complex64`.

- **`ProductFunctional` and `make_functional_product`** — the functional algebra
becomes multiplicative. `F * G` is the pointwise product `F(x) * G(x)` on a
shared domain, with the product-rule Riesz gradient

```
grad(F·G)(x) = conj(G(x))·grad F(x) + conj(F(x))·grad G(x)
```

combined through the domain's own `scale`/`add` (a domain element may be a
pytree), and a `value_and_grad` that evaluates each factor once. Deliberately
**binary**: the product rule is a two-factor law, and `(F*G)*H` expresses the
n-ary case at the same cost. The conjugations are identities for the usual
real-valued factors.
- **`ConstantFunctional` and `make_constant_functional`** — the constant map
`x -> c`, with zero gradient. This is the embedding of a scalar into the
functional algebra, previously unrepresentable: the algebra had a zero element
and an affine shift but no constant node. `make_constant_functional` collapses
`c = 0` to `ZeroFunctional` so the additive identity keeps one representation.
- **`spacecore.opfamily`: `OperatorFamily`, `FunctionalScaledOperator`,
`make_functional_scaled_operator`** — `F * A` for a `Functional` and a `LinOp`
is the functional-weighted map `m(x) = F(x) A x`. That map is **not linear**
(both the scale and the direction move with `x`), so it is deliberately *not* a
`LinOp`; it is a point-indexed *family* `x -> A_x`, of which a `LinOp` is the
constant case. Freezing the point recovers linearity, and each point carries
two different operators:

- `m.at(x)` — the frozen member `F(x) · A`, an ordinary `ScaledLinOp` that
composes, sums and has an adjoint;
- `m.linearize_at(x)` — the derivative `Dm(x)[h] = <grad F(x), h>·Ax + F(x)·A h`,
with metric adjoint `<Ax, w>_Y·grad F(x) + conj(F(x))·A^# w`, for Newton-type
steps.

They differ by exactly a rank-one term and coincide only when `F` is constant.
A `ConstantFunctional` weight collapses to a plain `ScaledLinOp`, so the linear
case is never forced through the non-linear type. The module is top-level
because it depends on both `linop` and `functional`, and neither depends on it.
- **`checked_method(out_scalar=True)` / `out_batched_scalar=True`** — the codomain
check for a `Functional`. `out_space=` names an attribute holding a `Space`, and
a functional's codomain is the scalar *field*, reported only as a string, so the
decorator that guards every `LinOp` output had nothing to bind to and the check
was hand-written per subclass. `out_scalar` asserts `shape == ()`;
`out_batched_scalar` asserts `(N,)` with `N` read from the input named by
`in_space`, which it therefore requires.
- **`Space.scalar_field`, `Space.declared_scalar_field`, `Space.check_scalar`** —
the field of scalars a space is closed under, *declared* rather than inferred
from the dtype. It defaults to `field`; `HermitianSpace` declares `"real"`,
because complex Hermitian matrices have complex entries but form a **real**
vector space (`i·H` is anti-Hermitian).

### Changed

- **`check_level` is a property of the bound object, not of `Context`** (breaking).
`Context(ops, dtype=..., check_level=...)` and the deprecated `enable_checks=`
argument are gone; `Context` is now exactly `(ops, dtype)`. Pass `check_level=`
to the space / operator / functional constructor, or set it ambiently with
`set_check_level` / `use_check_level`. Two contexts differing only in strictness
are now correctly the same context.
- **`spacecore._contextual` is now `spacecore.contextual`** (breaking), and
`Context` moved out of `spacecore.backend`. The top-level `spacecore.Context`
re-export is unchanged.
- **Ambient context and check level are scoped with `contextvars`.**
`use_context` / `use_check_level` install a `ContextVar` override unwound by
`Token`, so nesting is exact and concurrent threads or async tasks cannot clobber
one another. `set_context` / `set_check_level` still write the process-wide
baseline. The backend registry deliberately stays global — scoping it would make
a backend registered inside a `with` block vanish on exit.
- **`available_ops()` is memoized and returns a tuple** rather than a list.
Discovery attempts a real import per optional backend and scans entry-point
metadata; it ran three times per `import spacecore`. A tuple because the cached
value is shared.

- **`Functional.__mul__` / `__rmul__` now dispatch on the operand type.** A scalar
still gives `ScaledFunctional`; a `Functional` now gives the pointwise product
and a `LinOp` the functional-weighted family. Previously both returned
`NotImplemented`, so `F * G` and `F * A` raised `TypeError`. Operands that are
neither scalar-like nor `Functional` nor `LinOp` still defer to the reflected
operation.
- **Functional outputs are checked as scalars at `standard` and above.** 17
`value` and 3 `vvalue` implementations carry the new decorator flags, replacing
four hand-written `_checks_at_least("standard")` / `_check_scalar_shape` bodies
with one implementation. The level matches what those call sites already used —
`cheap` deliberately does not run it. Cost is ~0.5 µs per `value` call, and
nothing at `check_level="none"`, which still short-circuits before any check.
`MatrixFreeLinearFunctional.vvalue` keeps its own check: it permits several
leading batch axes, which is broader than the single-axis contract `vvalue`
documents. `RealifiedFunctional.value` is checked on its output only — its input
is validated against the *complex* domain by the inner functional.
- **`_check_scalar_shape` distinguishes single from batched output** in its error
message ("Expected scalar output" vs "Expected scalar batch output"). It said
"batch" unconditionally, which was harmless while it guarded four batched paths
and misleading now that it guards ~20 mostly single-element ones.
- **`HermitianSpace.scale` / `scale_batch` reject a non-real multiplier**
(breaking, at `standard` and above). `scale(1j, H)` returned a skew-Hermitian
array still typed as an element of `Herm(n)`; the error surfaced at some later
membership check, or never at `check_level="none"`. Only a *provably* non-real
multiplier is refused, so a traced scalar under `jax.jit` still passes. `field`
remains dtype-derived and still drives equality and repr: a real-dtype and a
complex-dtype `Herm(n)` are genuinely different spaces.

### Fixed

- **A present-but-broken optional backend no longer aborts `import spacecore`.**
`spacecore.backend` eagerly imported the JAX subpackage to reach
`jax_pytree_class`, so an installed-but-unimportable backend raising anything
other than `ModuleNotFoundError` — a shadowed `cupy`, a partially-installed
`jax` — propagated out of the import. Discovery is now the only path into a
backend package, and it warns and skips instead.
- **One broken backend now warns once, not once per discovery call.**

- **`ComposedFunctional` now has a gradient.** `F.compose(A).grad(x)` raised
`NotImplementedError`: the node implemented `value` but not the chain rule,
though `LinOp.rapply` already provides the metric adjoint it needs. Added
`grad`, a fused `value_and_grad`, and a batched `vgrad`, with cores registered
in the `composed-functional` kernel set:

```
grad(F o A)(x) = A^#(grad F(A x))
```

`rapply` **is** `A^#` (ADR-009), so no Riesz map is applied on top of it —
that would count the geometry twice — and no explicit conjugation appears,
because the adjoint identity absorbs it. `value_and_grad` applies `A` once and
shares the image, where the inherited default applied it twice. The typed
specializations in `make_functional_composed` (`InnerProductFunctional`,
`LinOpQuadraticForm`) were unaffected, and are used as a cross-check on the
generic node.
- **`scalar_eq` no longer swallows every exception.** It wrapped its comparison in
a bare `except Exception: return False`, so a raising `__eq__` was silently
reported as inequality. Narrowed to `TypeError` — the base class of JAX's
`TracerBoolConversionError` and of any "cannot reduce to a concrete bool"
failure — which keeps the intended verdict for an abstract scalar (undecidable,
so canonicalization is skipped and the expression tree stays unfolded but
correct) while letting a genuinely broken `__eq__` propagate.

## [0.4.2] — 2026-07-01

### Added
Expand Down
31 changes: 20 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,8 @@ level) adds named constructors over that machinery, with no new core types:
`least_squares` for `½‖Ax−b‖²`, coordinate norms (`SquaredL2NormFunctional`,
`LpNormFunctional`, `L1NormFunctional`), `NegativeEntropyFunctional`,
`KLDivergenceFunctional`, `HuberFunctional`, the spectral
`SpectralLpNormFunctional`/`NuclearNormFunctional`, and the metric-aware
`NuclearNormFunctional` (and `spectralize` / `SpectralFunctional` to lift any
coordinate functional to a spectral one), and the metric-aware
proximal primitive `generalized_shrinkage` with the wrappers `prox_l1`,
`prox_l2sq`, and `project_nonneg`. Each objective's gradient is the metric
(Riesz) gradient under the domain geometry, and the proximal step is taken in
Expand Down Expand Up @@ -214,21 +215,29 @@ and [deviation catalog](https://pavlo3p.github.io/SpaceCore/design/backend_devia

## Validation Policy

A `Context` carries a `check_level` that determines how aggressively spaces,
operators, functionals, and solver preconditions validate their inputs. The
ordered levels are `CHECK_LEVELS = ("none", "cheap", "standard", "strict")`:
`cheap` covers shape/dtype/backend/tree-structure, `standard` adds membership
and Hermitian checks, and `strict` adds bounded expensive probes. Checks are
opt-in per context, so hot paths can run unvalidated while development and tests
run strict.
Every context-bound object — space, operator, functional — carries a
`check_level` that determines how aggressively it validates its inputs, together
with solver preconditions. The ordered levels are
`CHECK_LEVELS = ("none", "cheap", "standard", "strict")`: `cheap` covers
shape/dtype/backend/tree-structure, `standard` adds membership and Hermitian
checks, and `strict` adds bounded expensive probes. Checks are opt-in per
object, so hot paths can run unvalidated while development and tests run strict.

The level is a property of the bound object rather than of the `Context`: a
context fixes backend ops and dtype, while two objects sharing that backend and
dtype may legitimately validate at different strictness. Pass `check_level=` to
a constructor, or move the ambient default with `sc.set_check_level(...)` /
`sc.use_check_level(...)`.

```python
import numpy as np
import spacecore as sc

ctx = sc.Context(sc.NumpyOps(), dtype=np.float64, check_level="standard")
X = sc.DenseCoordinateSpace((2,), ctx)
A = sc.DenseLinOp(ctx.asarray([[2.0, 0.0], [0.0, 3.0]]), X, X, ctx)
ctx = sc.Context(sc.NumpyOps(), dtype=np.float64)
X = sc.DenseCoordinateSpace((2,), ctx, check_level="standard")
A = sc.DenseLinOp(
ctx.asarray([[2.0, 0.0], [0.0, 3.0]]), X, X, ctx, check_level="standard"
)

try:
A.apply(ctx.asarray([1.0, 2.0, 3.0])) # wrong shape
Expand Down
9 changes: 7 additions & 2 deletions bench/_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
import webbrowser
from pathlib import Path
from statistics import median
from typing import Iterable
from typing import Any, Iterable

from ._io import _metadata
from ._probes import ProbeResult
Expand Down Expand Up @@ -173,6 +173,7 @@ def render_dashboard(
results: Iterable[ProbeResult],
out_path: str | Path,
baseline: Iterable[ProbeResult] | None = None,
meta: dict[str, Any] | None = None,
) -> Path:
"""Render an interactive HTML dashboard for a bench run.

Expand Down Expand Up @@ -213,7 +214,11 @@ def render_dashboard(
)

summary = _summary(rows)
meta = _metadata()
# Prefer the metadata recorded in the source artifact (the machine that
# *ran* the benchmark); fall back to this machine only when the artifact
# carried none. Rendering on a different host must not overwrite the
# processor/platform/versions of the run.
meta = meta or _metadata()
overall = _build_overall(results, diagnoses)

backends = sorted({r["backend"] for r in rows})
Expand Down
Loading
Loading