Skip to content

0.4.1#49

Merged
Pavlo3P merged 56 commits into
masterfrom
0.4.1
Jun 28, 2026
Merged

0.4.1#49
Pavlo3P merged 56 commits into
masterfrom
0.4.1

Conversation

@Pavlo3P

@Pavlo3P Pavlo3P commented Jun 28, 2026

Copy link
Copy Markdown
Owner

No description provided.

Pavlo3P and others added 30 commits June 25, 2026 20:58
The spec's correctness_ref named a module-level node id
(::test_composed_chain_apply_matches_generic) that no longer exists;
the real test is TestComposedChainApply::test_matches_generic. ADR-016
requires the ref pin an existing test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Introduce spacecore/kernels/specs/_batched.py: the shared 'stack uniform
flat-dense matrices -> one batched matmul' fast path (uniformity guard +
matvec/right-matmul primitives + shape-only cost), so the block-diagonal and
stacked folds are thin wrappers over one body rather than near-duplicate specs.

Refactor the shipped apply spec to use the helper (behaviour-preserving;
covered by TestBlockDiagonalUniformBatched) and add the dispatch-eligible
block-diagonal rapply spec: stack _A2H -> one batched matmul, guarded to
EUCLIDEAN_FLAT (a non-Euclidean adjoint uses a metric/Riesz path) and the NumPy
backend (bit-exactness verified there only), with a shape-only memory cost.

Wire BlockDiagonalLinOp._rapply_unchecked through dispatch() mirroring
_apply_unchecked; the generic fallback (parts[i]._rapply_core) is byte-identical
to the prior _rapply_parts loop, so dispatch-off is result-identical.

Verified: optimized == generic == NumPy ground truth (array_equal) across a
block-count/size grid; off==on bit-exact and verify mode green end-to-end.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Batched twins of the apply/rapply folds for uniform flat-dense blocks: stack the
cached matrices and issue one batched matmul in the dense core's transpose-on-
right orientation (X @ A2T for vapply, Y @ A2H.T for rvapply, matching
dense_vapply_core / dense_euclidean_rvapply_core). EUCLIDEAN_FLAT + NumPy guards
and shape-only costs as for rapply.

Wire BlockDiagonalLinOp vapply/rvapply through dispatch() via new
_vapply_unchecked/_rvapply_unchecked; the generic now pins the check-free
_vapply_core/_rvapply_core (mirroring the apply path) instead of the public
checked op.vapply/op.rvapply, which is result-identical for valid inputs (the
boundary @checked_method already validated the batch) and removes the redundant
per-block revalidation.

Verified: optimized == generic == NumPy ground truth (array_equal) across a
block/size/batch grid; off==on bit-exact and verify mode green end-to-end.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Same defect as the composed-chain-apply ref: the explicit-entry
block-diagonal-dense-apply spec named a non-existent module-level node id
(::test_block_diagonal_dense_apply_matches_generic) instead of the real
TestBlockDiagonalDenseApply::test_matches_generic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The broadcast-no-sum directions of the single-domain/single-codomain tree
operators apply one shared operand through K components and return a tuple (no
reduction), so a stacked batched matmul is exactly bit-equal to the per-component
loop. Add a shared-broadcast helper (batched_matvec_shared + matvec_shared_cost)
to _batched.py and two thin specs in stacked_batched.py:
  - linop.stacked.apply       (stack _A2, broadcast x)
  - linop.sum_to_single.rapply (stack _A2H, broadcast y, EUCLIDEAN_FLAT only)

Uniform-matrix-shape guard means heterogeneous-codomain (stacked) or
heterogeneous-domain (sum_to_single) operators fall through to generic. Wire the
two previously-unoptimized call sites through dispatch(); the reduction
directions (which carry their own inline flat-dense fast paths) are deliberately
left untouched.

Verified: optimized == generic == NumPy ground truth (array_equal); off==on
bit-exact and verify mode green end-to-end; the reduction paths still work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Evaluated the reserved linop.matvec.sparse key: every SparseLinOp direction is
already a single optimal backend call (apply/rapply one SpMV; vapply/rvapply one
batched SpMV over the stacked RHS, not an O(batch) loop). There is no faster
bit-exact path, so wiring a spec would add only dispatch overhead with no win.
Record the decision so the reserved key is not redundantly activated later.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Document the five new dispatch-eligible specs (block-diagonal rapply/vapply/
rvapply, stacked.apply, sum_to_single.rapply) and their shared helper, the
SparseLinOp no-spec decision, and the two stale correctness_ref fixes under
Unreleased.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add LinOp.fuse() (Tier-2 lazy-algebra simplification): collapse maximal subtrees
of densely-fusible operators into one materialized DenseLinOp. The base method
returns self (leaves are atomic); ComposedLinOp.fuse() fuses its operands and,
when both are dense, replaces A @ B with a single DenseLinOp holding the matrix
product M_A @ M_B.

Correctness is up to floating-point rounding (fusion reassociates the
arithmetic), not bit-exact, per ADR-021 — tests use allclose. The fused matrix
is adjoint-consistent on any geometry: the shared middle-space Riesz maps cancel,
so the fused operator's metric adjoint equals B* @ A* (verified on a weighted,
non-Euclidean space). A matrix-free operand is never densified: a non-dense
fused operand keeps the composition lazy (verified).

Lives in spacecore.linop, not spacecore.kernels, and is not gated on the
ADR-016 dispatch rail.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- ScaledLinOp.fuse(): fold the scalar into the dense matrix (c·A -> DenseLinOp
  with c·M_A); the adjoint stays conjugated (verified with a complex scalar).
- SumLinOp.fuse(): combine the densely-fusible terms into one DenseLinOp via
  matrix addition, keeping matrix-free/structured terms lazy.
- _AdjointViewLinOp.fuse(): fuse the wrapped operand and take its adjoint, so
  (A @ B).H collapses its inner composition.

All keep matrix-free operands un-densified and are adjoint-consistent;
correctness via allclose (Tier-2 reassociates).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
BlockDiagonalLinOp, BlockMatrixLinOp, StackedLinOp, and SumToSingleLinOp fuse
each of their component operators and rebuild, preserving the tree/block layout,
domain/codomain, and context. A composed-dense block collapses to a single
DenseLinOp -- which also makes it newly eligible for the block-diagonal dispatch
fold. Correctness via allclose; matrix-free blocks stay un-densified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Complete the ADR-021 matrix-free rail: fuse() gains a keyword-only
materialize=False parameter, threaded through every override. The safe default
never densifies a matrix-free operand (it stays a lazy leaf and breaks a fusible
run); materialize=True is the explicit caller opt-in that densifies a
MatrixFreeLinOp via its to_dense() basis probe, letting the enclosing expression
collapse to a single dense operator.

Verified: default keeps matrix-free lazy; materialize=True densifies and lets a
dense @ matrix-free chain collapse to one DenseLinOp (allclose).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A runnable notebook covering the two performance/algebra layers:
- ADR-016 dispatch: the two kernel layers, dispatch_mode off/on/verify, the
  exactness + memory-gate + matrix-free rails, and the live dispatch-key catalog,
  demonstrated on a uniform block-diagonal operator (off==on bit-exact).
- ADR-021 fuse(): Tier-1 auto rewrites at construction, Tier-2 fuse() multiplying
  dense operators into one matrix (allclose, adjoint-consistent), the matrix-free
  rail, and fuse(materialize=True). Closes by showing the two layers compose
  (a fused composed-block becomes dispatch-foldable).

Executed on NumPy; converted to RST and wired into the tutorials index and
README under a new 'Performance and internals' section. Sphinx renders it clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add Part 4 demonstrating the actual benefits:
- Fusion: timing a depth-16 dense composition shows ~10x faster per apply once
  fused (16 matvecs -> 1) -- the 'fuse once, apply many' win, on any backend.
- Dispatch: timing the block-diagonal apply off vs on is honest about CPU being
  break-even/slightly slower (NumPy's per-block loop is already cheap), with the
  measure-first framing -- the batched fold is a GPU win and is gated on a
  per-backend benchmark, which is why dispatch stays off by default.

Executed on NumPy (0 errors), reconverted to RST; Sphinx renders clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Context inference joined inferred-context dtypes through numpy.result_type,
which cannot interpret a torch (or jax) dtype:

    TypeError: Cannot interpret 'torch.float64' as a data type

It surfaced when constructing an operator that infers a TreeSpace from its
operands on the torch backend — e.g. BlockDiagonalLinOp/StackedLinOp/
SumToSingleLinOp.from_operators, and ADR-021 fuse() on such an operator. Join the
dtypes through the operands' own array-API namespace (ops.xp.result_type)
instead, which promotes each backend's dtypes correctly. NumPy behaviour is
unchanged.

Adds a torch regression test exercising the from_operators inference path and
fuse() on torch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The uniform block-diagonal / stacked / sum-to-single dispatch folds rebuilt
ops.stack([matrix(p) for p in parts]) on every apply, even though that stacked
block-matrix array is fixed by the (immutable) blocks. ADR-022 makes it a
build-time-amortized value instead of per-call work.

- Add CachedStackParts(tuple) + stacked_block_matrices(parts, matrix) in
  kernels.specs._batched: the helper memoizes the stack per matrix accessor
  (_A2 apply, _A2H adjoint, _A2T / _A2H.T batched) when parts carries the cache,
  and falls back to a fresh stack (byte-identical) for plain tuples.
- Route batched_matvec / batched_matvec_shared / batched_right_matmul through it.
- Wrap self.parts in CachedStackParts once in BlockDiagonalLinOp / StackedLinOp /
  SumToSingleLinOp __init__ so the memo persists across applies.

The cache is a derived value: built lazily on first optimized use (NumPy-only,
dispatch on/verify only — the default off path is untouched), excluded from
operator identity (__eq__/__hash__) and from the pytree (tree_flatten
re-normalizes parts to a plain tuple, so a round-trip rebuilds an empty cache),
and a matrix-free operand is never cache-materialized (the fold is inapplicable).
The memory gate is honored via the dispatcher's existing affordability check and
the dispatcher stays stateless. Dispatch-decision caching stays out of scope.

Adds tests/kernels/test_materialized_cache.py pinning the invariants; full suite
green (3289 passed). Updates CHANGELOG and the ADR index.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two findings from the adversarial review of the materialized-form cache:

- Drop CachedStackParts from the public __all__ of spacecore.kernels and
  spacecore.kernels.specs. It is an internal ADR-022 detail the fold operators
  wrap their parts in, not a user-facing type; it stays importable for that
  cross-module use but is no longer part of the blessed public API surface.
- Add __getstate__/__setstate__ so copy/deepcopy/pickle rebuild an independent,
  empty cache instead of aliasing one shared dict (shallow copy) or restoring a
  populated one (deepcopy/pickle). This matches the pytree round-trip contract:
  the memo is derived and reconstructable, never carried across reconstruction.

Adds a copy/deepcopy/pickle regression test. Full suite green (3290 passed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Both are implemented and shipped (fuse() in spacecore.linop; the materialized-
form fold cache across the block/stacked/sum-to-single operators), so flip their
status from Proposed to Accepted in the ADR headers and the index table.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a "Caching the materialized fold" section that shows the cache in action:
the per-accessor stack cache appearing on the operator's parts (empty -> one
(K, m, m) slot after a fold routes), and a measurement that isolates the
stacking cost from the dispatcher's selection overhead. Re-stacking every apply
the fold loses to the per-block loop (0.66x); with the stack cached it wins
(1.31x) — the re-stack is ~half the uncached fold's time.

Make the timing comparisons robust: per_apply_us now reports the median across
repeated batches (timeit.repeat) instead of a single timed run, so the fusion,
dispatch, and caching numbers are averaged and outlier-resistant. Intro and
takeaways updated to cover the caching layer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
per_apply_us now returns the arithmetic mean over all repeat x number timed
calls (np.mean) rather than the median, so every figure in the notebook is an
average over many runs. Re-executed; the story is unchanged (fusion ~11x,
dispatch ~0.92x break-even, caching flips the fold from 0.65x to 1.25x).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Re-render docs/source/tutorials/09_kernels_and_fusion.rst from the committed
notebook (nbconvert --to rst) so the published page matches the notebook: adds
§5 "Caching the materialized fold (ADR-022)" and the averaged timings. Update
the tutorials index entry to mention fold-stack caching alongside dispatch and
fusion. Figure asset unchanged (deterministic seeded plot).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The materializing folds only route when the memory gate has a budget, which on
NumPy comes from free_memory_bytes() (psutil-backed). The "Minimal install
tests" CI job has no psutil -> None -> "no budget, no fuse", so the fold never
routes and the cache stays empty, failing the six operator-level cache tests
that assert it fills.

Add a `routable` fixture that pins a large free-memory budget (monkeypatching
NumpyOps.free_memory_bytes) for the tests that exercise a routed fold, making
them deterministic and independent of whether psutil is installed — which is
what they actually mean to test ("once a fold routes, the cache fills"). The
direct-spec and dispatch-off tests are untouched (they don't depend on routing).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The dispatcher's memory gate sizes a materializing fast path against
NumpyOps.free_memory_bytes(), which is psutil-backed. psutil was undeclared
(effectively optional): a minimal install returned None -> "no budget, no fuse",
so materializing folds never routed there. That silently disables a core
dispatch rail and broke the ADR-022 cache tests on the minimal-install CI job.

Declare psutil as a core dependency and import it unconditionally in
free_memory_bytes(), so the CPU memory gate always has a real budget. This
reverts the prior test-only `routable` workaround (no longer needed now that
the budget is always available). Updates the CHANGELOG wording.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CachedStackParts is re-exported for internal cross-module use (the fold
operators import it) but deliberately kept out of __all__, which ruff flagged as
an unused import (F401). Use the redundant-alias form (`import X as X`) — the
standard explicit-re-export marker that ruff and pyright both honor — so it stays
importable and out of the public surface without a noqa.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Brings the existing micro + macro benchmark suite onto the feature
branch as the baseline for the ADR-023 work:

- micro layer: factory probes (bench/_probes.py), fixed-seed runner
  (bench/_run.py, seeds 0-3), timing/peak-memory harness, verdict and
  overhead diagnosis, JSON persistence, and a self-contained Plotly
  dashboard.
- macro layer (bench/macro/): algorithm-level workloads (CG, Lanczos,
  PDHG, QOT, density pipeline, operator stress, JAX full loop).
- tests/bench/ smoke + macro coverage (115 passing).

This is the starting point the 0.4.1 benchmark-surface spec
(docs/dev/0.4.1-bench-surface.md) and ADR-023 build on; the
configuration-regime axis, ragged-block probes, and HTML aggregation
land in follow-up commits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Agreed micro-overhead surface for ADR-023: which operations get probed
and what each is compared against (hand-optimal pure-array-library
bare). Records scope decisions (linalg/kernel/check_member/tree out;
block operators tested uniform AND ragged), the configuration-regime
model (baseline / dispatch / dispatch+cache / verify), and the
implementation phases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pavlo3P and others added 26 commits June 26, 2026 17:05
ADR-023 phase 1. A *regime* is a named bundle of SpaceCore optimization
toggles the runner activates around an otherwise-identical probe, so the
same operation on the same (seed, size) inputs is timed under each:

- bench/_regimes.py: the regime vocabulary (baseline / dispatch /
  dispatch_cache / verify), benchmark_regime() activating the real
  spacecore.kernels.dispatch_mode, dispatch_eligible() (linop only), and
  regimes_for() resolving the per-family sweep (baseline always included
  as the regime_speedup reference). Default sweep for linop probes is
  baseline + dispatch_cache; dispatch (cold cache) is deferred until
  probes expose their routed operator, and verify is opt-in.
- ProbeResult gains `regime` + `regime_speedup` (within-run ratio vs the
  baseline cell at the same coordinate); _io round-trips both.
- run_probes() sweeps regimes alongside check_levels, wraps the timed
  section in benchmark_regime, and pairs regime_speedup in the final
  pass; `python -m bench run --regime ...` exposes it.

Non-dispatch (space/functional) probes run baseline only, so the default
path and existing artifacts are unchanged. tests/bench/test_regimes.py
covers the vocabulary, dispatch-mode activation, and an end-to-end run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ADR-023 / 0.4.1 surface: block operators are measured uniform AND
ragged. The existing block_diagonal / stacked / sum_to_single probes
stack same-shape blocks (uniform) — the ADR-016
block-diagonal-uniform-dense-batched fold's target. This adds the
ragged counterparts (mixed block shapes) where no uniform fold applies,
so both bare and SpaceCore stay a per-block loop and the probe isolates
the abstraction's own cost when there is no fast path:

- linop.block_diagonal.apply.ragged / .rapply.ragged
- linop.stacked.apply.ragged       (same domain, differently-shaped codomains)
- linop.sum_to_single.apply.ragged (differently-shaped domains, one codomain)

Each ragged bare is the idiomatic per-block tuple; all four reproduce
the reference exactly. Uniform probes are now labelled "dispatch fold
target", ragged "no uniform fold", so the dashboard's regime view reads
the contrast directly (dispatch routes the uniform fold; ragged dispatch
is a near no-op).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ADR-023 baseline contract: a probe's `bare` must compute the same value
as its `reference`, so a fast-but-wrong baseline cannot inflate a
speedup. Parametrized over every numpy probe, asserting bare-vs-reference
error within the family tolerance. 84 probes pass — no strawman bares
in the current suite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow the 0.4.1 benchmark-surface table (docs/dev/0.4.1-bench-surface.md)
by removing the out-of-scope families/probes from the suite, leaving
space / linop / functional only (56 probes):

- linalg probes removed completely (cg, power_iteration, lsqr,
  lanczos_smallest).
- kernel-comparison probes removed (the optimized folds are still
  measured on the real linop operators under the dispatch regimes, so
  no coverage is lost); dropped the _KERNEL_PROBE_TO_* mappings and the
  kernel_benchmark_ids / kernel_probe_cases helpers.
- space.check_member, the space.tree.* probes, and
  linop.generated_{dense,diagonal} removed. Kept
  functional.generated_linear.value and the elementwise-Jordan probes.

Threaded the removal through every consumer so nothing dangles:
- OperationFamily Literal narrowed to space/linop/functional; _verdict
  _FAMILY_TOL and _dashboard family colors trimmed to match.
- _diagnose: dropped the now-dead KERNEL_WIN / KERNEL_NEUTRAL /
  SOLVER_FIXED_ITERATIONS reasons, their heuristics, summaries, and the
  kernel_wins rollup; _dashboard top-wins generalized from the
  kernel-only `optimized` gate to the backend-agnostic optimized_speedup.
- CLI --family choices (run, list, run_micro) reduced; run_micro gains
  --regime and _cmd_run reads device/regime defensively.
- README coverage table + module layout updated; stale _policy.py
  docstring pointer to the deleted helper removed.

ruff + pyright clean on the touched files; tests/bench 196 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-ups from the removal audit:
- ADR-023: drop linalg/kernel from the dashboard `family` grouping
  vocabulary (those values can no longer appear as a group key); the
  conceptual ADR-016 kernel/dispatch discussion is untouched.
- _dashboard.py: fix the stale family-checkbox docstring to list only
  space/linop/functional.
- _run.py: remove the dead solver-result-shape branches (.x /
  .eigenvalue / .result / .value) in _error_vs_reference — they existed
  only for the removed linalg solver probes.

ruff clean; tests/bench 196 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
HermitianSpace.spectrum is `ops.eigh(x)[0]` and spectral_decompose is
`ops.eigh(x)` — identical SpaceCore work, spectrum just discards the
eigenvectors (and its eigvalsh bare understated the eigh the impl
actually runs). ElementwiseJordanSpace.spectral_decompose is likewise
`spectrum` wrapped as `(x, None)`. Keep spectral_decompose (its eigh
bare matches the work), drop the redundant spectrum probe in both. 54
probes now: space(18)/linop(31)/functional(5).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…plots

Both dashboard charts were diagnostics, not insights: the per-seed
jitter scatter just re-renders measurement noise already captured by
speedup_std, and the overhead-persistence chart overlaid every
(operation, backend, check_level) series into unreadable spaghetti.
Removed both plots (scalingCurves / perSeedJitter JS, their render
calls, divs, and docstring entries) and renumbered the remaining charts.

Kept the concise "Overhead persistence" diagnosis card (a short ranked
list of size-persistent-overhead cases) — that is the actionable form
of the concept. The clean regime-aware speedup-by-size view will come
with the Phase 5 interactive aggregation (size + regime are group-by
dimensions there).

ruff clean; tests/bench 192 passed; dashboard renders end-to-end.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
HermitianSpace.spectral_decompose is correct on Torch — eigenvalues and
the reconstruction U diag(λ) Uᴴ match numpy to float32 precision — but
torch and numpy eigh return eigenvectors with flipped signs (and
arbitrary bases on degenerate eigenspaces). The probe compared
eigenvectors element-wise, so every Torch case tripped the correctness
gate with error ~1.5 (6 of 8 cells flagged CORRECTNESS_FAILURE).

Compare only the (ascending) eigenvalues — the basis-invariant part;
eigenvector validity is already covered by the from_spectrum
reconstruction probe. The bare still runs the full eigh for fair timing.

`python -m bench run --backend torch --max-size 128` now reports 0
correctness failures (was 6). The other near-tolerance torch cases
(from_spectrum ~1e-7, dense.apply.device on MPS ~4e-6) were already
within the existing float32 widening (1e-4); heavy torch-CPU matmuls are
bit-identical to numpy, so no tolerance change is needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The dashboard always rendered a summary card and filter chip for every
Status, including CORRECTNESS_FAILURE and REGRESSION at count 0 — so a
clean run (e.g. torch after the spectral_decompose fix, 0 failures) still
showed a "CORRECTNESS_FAILURE" card/chip and read as if failures existed.

Only render a status card and filter chip when that status has at least
one case, so the label appears only when there is a real failure to look
at. Performance statuses (WIN/NEUTRAL/LOSS/HEAVY_LOSS) are unaffected when
present. The embedded status_colors/enum (non-visible JSON) are untouched,
so the JS filter vocabulary still works.

Test now covers all four performance statuses and asserts a zero-count
status gets no chip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ayer

The verdict status widened the correctness tolerance for float32 backends
(Torch) to 1e-4, but bench._diagnose still used the raw 1e-9 _FAMILY_TOL.
So a Torch case could be classified WIN/NEUTRAL by the verdict yet carry a
CORRECTNESS_FAILURE *diagnosis reason* — rendered as a brown badge next to
the green status badge. spectral_decompose (eigenvalue error ~1e-6, well
within float32) tripped this on every Torch row.

Extract correctness_tol(result) in _verdict as the single source of truth
(family tol + float32 widening) and consult it from both categorize() and
the diagnosis heuristics. Verdict and diagnosis can no longer disagree;
the Torch run now shows no CORRECTNESS_FAILURE in either column (reasons
fall through to BARE_SATURATES_OP / NEUTRAL).

ruff clean; tests/bench 192 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The table marks each case with a status badge and a diagnosis-reason
badge, but nothing explained what they mean. Add a "Tag legend" section
between the table and the footer that lists every status and reason tag
present in the run with a one-line explanation, colored to match the
badges. Only tags that actually occur are shown, so the legend tracks the
page (and never explains a CORRECTNESS_FAILURE that did not happen).

ruff clean; tests/bench green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Alongside the backend, family, check-mode, and status filters, the
dashboard now offers a "Problem size" chip group — one checkbox per
distinct size present in the run. Unchecking a size hides those rows from
both the table and the charts (the filter runs through the same filtered()
path). Wired into state.sizes, the change/reset handlers, and the reset
button; chips render only for sizes actually present.

Test exercises multiple sizes (64/256/1024) and asserts the chips render
and the filter is wired; _mock_result gains a size kwarg.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the per-size checkbox chips with a two-thumb range slider (min
and max) over the indices of the sorted distinct sizes, so the handles
snap to actual sizes present and stay evenly spaced even though sizes
span orders of magnitude (8 … 65536). A readout shows the selected
"n ∈ [low, high]". Filtering keeps rows with low ≤ size ≤ high through
the same filtered() path (table + charts). Handles cannot cross
(each clamps to the other), single-size and empty-size runs degrade
gracefully, and Reset restores the full range.

Standard overlapping-range CSS (pointer-events on the thumbs) keeps the
page self-contained — no slider library. Test updated to assert the
slider + readout render and the index range matches the distinct sizes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adversarial review found a real UX trap in the dual-range slider: the two
range inputs overlap with no z-index, so the later-painted max thumb sits
on top of the min thumb. When the range collapses at the top edge (min
dragged fully right, or a single-size run), the max thumb has nowhere up
to go and the buried min thumb cannot be grabbed back — the range gets
stuck closed.

Add setSizeZ(): max stays on top by default (grabbable to re-open a
collapse anywhere below the top), but when min reaches the top index the
min thumb is lifted above max so it can be dragged back down. Called from
both input handlers, on init, and on reset; CSS sets the default stacking.
Every collapse case now re-opens. (Integration lens of the review passed:
composes with all filters, table + charts honor it, 192 tests pass.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The headline count cards and the whole "What's causing overhead and
wins" section were computed once in Python and baked in, so they ignored
the filters. Move the rollup client-side: computeOverall(rows) mirrors
bench._diagnose (dominant reasons, worst overhead, persistence, top wins,
JAX summary, narrative) and renderSummaryCards(rows) rebuilds the count
cards — both now run from filtered() on every refresh(), so the summary
tracks whatever the backend/family/status/size/check/search/speedup
filters select.

To count reasons exactly as the server does, each row now carries its
full diagnosis_reasons list (a case can have several); the dominant-
reasons tally sums those. renderDiagnosisSection takes the computed
rollup as an argument; the one-time init render is dropped (refresh()
covers it). Verified the client rollup reproduces the server's on the
full set (matching to a pre-existing ±1 boundary case, and consistent
with the per-row badges).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Torch defaulted to float32 while NumPy and (x64-enabled) JAX ran float64,
so the bare-vs-SpaceCore comparison was unfair on Torch and the float32
rounding tripped the correctness gate. Mirror the JAX treatment: add
enable_torch_x64() (torch.set_default_dtype(float64)) and call it from the
runner alongside enable_jax_x64().

Apple MPS is float32-only hardware and cannot take float64 tensors, so the
device-aware probe builds its MPS case at float32 explicitly, and the
correctness tolerance keeps a float32 width for MPS only — every other
backend/device now gets the strict 1e-9 gate. Verified: a Torch run shows
CPU max error 1.95e-14 (was ~1e-6 at float32) and 0 correctness failures.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…uild)

The CI docs build (`sphinx-build -W --keep-going`) failed with 13
"Pygments lexer name 'ipython3' is not known" warnings — turned into
errors by -W — all in docs/source/tutorials/09_kernels_and_fusion.rst.
That file was regenerated from its notebook with nbconvert's default
`.. code:: ipython3` directives, whereas the other eight tutorials use
`.. code:: python` (a universally-known lexer). The `ipython3` lexer is
only registered when IPython is installed, which the `[docs]` extra does
not pull in, so it builds locally (IPython present) but fails in CI.

Switch all 13 blocks to `.. code:: python`, matching the convention.
Strict build now passes with zero warnings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
nbconvert-generated tutorial RST can use `.. code:: ipython3`, whose
Pygments lexer is only registered when IPython is installed. The strict
docs CI (`sphinx-build -W`) installs `.[docs]`, which lacked it, so such
a tutorial failed the build. Pull IPython into the docs extra so the
`ipython3` lexer is always available — a backstop against recurrence on
top of normalizing tutorial 09 to `python`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
JAX was benchmarked eagerly (and only the SpaceCore call was optionally
jitted), so the comparison didn't reflect how JAX is actually run and the
abstraction "overhead" was really trace overhead. Now, on JAX, the runner
jits BOTH the SpaceCore call and the bare reference and times their
post-compile steady state — the time_op warmup absorbs compilation, so
the reported bare/sc medians (and hence the speedup) are jitted-vs-jitted
with compilation excluded. Each side's first-call compile latency is
measured separately via time_op_first_call.

- bench/_run.py: `_timed_callable(backend, fn)` jits on JAX and returns
  (jitted, compile_ns), falling back to eager (compile None) for a
  non-jittable probe; used for both sc and bare. Drops the jit_compatible
  gate — every JAX probe is jitted where possible.
- Data model: SeedTiming/ProbeResult gain `bare_compile_ns(_median)`;
  `compile_ns(_median)` is now explicitly the sc compile; the redundant
  jit_* fields are removed. _io round-trips the new shape.
- Reporting: the dashboard table and the CLI per-case table get two
  columns — `sc compile` and `bare compile` — and the JAX compile summary
  panel shows both medians. The `jit_compatible` Probe flag is removed.

Verified on a real JAX run: space.add steady state sc≈bare (jit erases
the eager abstraction overhead), with sc/bare compile reported separately;
248/256 sc and 254/256 bare jitted, the rest gracefully eager. Full suite
green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…mmetric

The review of the jit-both-sides change flagged a real hazard: when one
side jits and the other falls back to eager, the speedup is an
eager-vs-jitted comparison and gets mislabeled (e.g. HEAVY_LOSS) with no
signal. Two fixes:

- Restrict JAX to check_level="none". Under "cheap" the membership
  validation is value-dependent and not jittable, so the SpaceCore call
  would be eager while the jnp bare jits. The runner now drops "cheap"
  for the JAX backend (other backends keep none + cheap).
- Make the jit resolution symmetric. _resolve_timed_pair jits both sc and
  bare, but if EITHER is not jittable both are timed eagerly — so a row is
  either both-jitted or both-eager, never mixed. (3 functional `.value`
  probes have a non-jittable sc even under "none"; they now compare
  eager-vs-eager fairly instead of eager-vs-jitted.)

Verified: a jax+numpy run shows JAX only at none, 0 mixed rows
(124 both-jitted, 4 both-eager). Tests assert none-only and the
both-or-neither invariant. Full bench suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
functional.inner_product.value/grad, quadratic.value, and
matrix_free.value used a 2-point (256, 4096) grid while the analogous
space vector ops (add/scale/inner/norm/zeros) use the 3-point
(256, 4096, 65536). Match them so the functional family scales over the
same grid as the rest. (generated_linear.value stays at (3,) — it is a
fixed small generator-built instance, not a scaling probe.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per review, the functional scaling probes (inner_product.value/grad,
quadratic.value, matrix_free.value) move to an even x4 geometric grid
capped at 1024 — every point is runnable without a huge-size run, unlike
the x16 (256, 4096, 65536) grid that yielded only one usable point under
--max-size 1024. (generated_linear.value stays at (3,).)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Every scaling probe now uses the same x4 geometric grid (64, 256, 1024) —
previously the suite mixed x16 (256,4096,65536 for the vector/diagonal/
sparse/matrix-free ops), x4 (64,256,1024 and 32,128,512), and x2
(8,16,32,64 hermitian). The x16 grids gave only one runnable point under
--max-size 1024; everything is now consistently paced and fully runnable
on a <=1024 machine.

The one exception is the Hermitian family (spectral_decompose,
from_spectrum, symmetrize, inner): eigh is O(n^3) and a 1024x1024
eigendecomposition would take many minutes per probe, so it keeps the
same x4 pacing but cost-capped at (16, 64, 256). generated_linear.value
stays (3,). Largest size anywhere is now 1024.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…odule-python--m-bench

47 add the unified benchmark submodule python  m bench
- Bump spacecore/_version.py to 0.4.1.
- CHANGELOG: promote [Unreleased] to [0.4.1] — 2026-06-28, open a fresh
  empty [Unreleased].
- release_notes.rst: add the Version 0.4.1 section (dispatch / fuse /
  cache / bench).
- test_public_api: pin the version assertion to 0.4.1.

Dispatch/fuse/cache (ADR-016/021/022) and the unified bench submodule
ship in 0.4.1, all off the default path. Built wheel+sdist clean
(twine PASSED); bench is excluded from the wheel (tooling only).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Pavlo3P
Pavlo3P merged commit 43dc71b into master Jun 28, 2026
7 checks passed
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