Skip to content

refactor: create invert tags helper (#224)#5

Open
jpbrodrick89 wants to merge 16 commits into
jpb/refactor-AbstractLinearSolverfrom
claude/hevd-solver-hermitian-1paoqv
Open

refactor: create invert tags helper (#224)#5
jpbrodrick89 wants to merge 16 commits into
jpb/refactor-AbstractLinearSolverfrom
claude/hevd-solver-hermitian-1paoqv

Conversation

@jpbrodrick89

Copy link
Copy Markdown
Owner

No description provided.

claude added 6 commits June 14, 2026 11:42
Add a Hermitian eigenvalue decomposition solver (`lineax.HEVD`) built on
`jax.numpy.linalg.eigh`, mirroring `lineax.SVD` for self-adjoint operators.
Like SVD it handles singular operators by returning the pseudoinverse
solution, and honours `MaxRankTag` by statically truncating to the largest
eigenvalues. Unlike SVD's descending singular values, `eigh` returns
eigenvalues in ascending order and they are signed, so the decomposition is
reordered by descending magnitude before the (shared) rcond/truncation logic.

Add a `hermitian_tag` and `is_hermitian` check mirroring the symmetric ones,
including dispatch for all built-in operators, tag propagation through
transpose/inversion/composition/scaling, and the real-vs-complex
relationships between symmetry and Hermitian-ness.
…rmitian

`compute` masks small eigenvalues by magnitude regardless of ordering, so the
argsort + column gather (O(n^2)) is only needed to enable the static low-rank
truncation. Move it inside the `MaxRankTag` branch so the common path keeps
`eigh`'s native order at no cost, and have `compute` take `max|w|` directly.

Dispatch `AutoLinearSolver(well_posed=False)` to `HEVD` for Hermitian
operators (a Hermitian eigendecomposition is cheaper than a general SVD, with
the same pseudoinverse handling of ill-posed systems).
Add tests building `Q diag(eigvals) Q^H` with zero eigenvalues placed between
large eigenvalues of both signs. After eigh's ascending sort the discarded
eigenpairs sit in the interior of the spectrum, exercising the magnitude-based
masking (pseudoinverse) and the magnitude-based MaxRankTag truncation, for
both real and complex dtypes.
`AbstractLinearOperator.H` returns the Hermitian adjoint: a no-op (`self`) for
a Hermitian operator (since `Aᴴ = A`), otherwise `conj(operator).transpose()`.
This mirrors the existing `.T`, whose `transpose()` already no-ops for
symmetric operators.

Add `AbstractLinearSolver.conj_transpose(state, options)` with a default that
composes `conj` then `transpose` (identical to the previous inline computation
in the linear-solve JVP). Self-adjoint solvers (`Cholesky`, `HEVD`) override it
as a no-op since `Aᴴ = A` -- note their `transpose` must NOT no-op, as
`Aᵀ = conj(A) != A` for complex operators. `AutoLinearSolver` delegates so the
selected solver's override applies.

Wire both into the JVP rule: `operator.H` / `t_operator.H` and
`solver.conj_transpose(state)` replace the explicit conj+transpose, skipping
redundant work on the common self-adjoint paths.
Overriding the concrete `conj_transpose` in Cholesky/HEVD/AutoLinearSolver
violated equinox's abstract/final convention. Instead make `conj_transpose` a
single final method on `AbstractLinearSolver` that no-ops when a new abstract
`assume_hermitian()` returns True (else composes `conj` then `transpose`), and
implement `assume_hermitian` on every solver -- mirroring the existing
`assume_full_rank`. Cholesky and HEVD return True; all others return False
(`AutoLinearSolver` conservatively False, since its solver is chosen at runtime).
…_transpose

Whether the conjugate-transpose state can be reused is fundamentally an operator
property: for a Hermitian operator `Aᴴ = A`, so `init(Aᴴ) == init(A)` for *any*
solver. So the JVP now skips forming the adjoint state when `is_hermitian(operator)`
and reuses the existing state directly -- a single operator-keyed check that
applies uniformly (including `AutoLinearSolver`, CG, etc.), rather than a
per-solver flag.

This removes the need for both the `assume_hermitian` abstract method (reverted
from every solver) and the `conj_transpose` solver method (its only caller was the
JVP, which now inlines `conj` then `transpose` for the non-Hermitian case, exactly
as before). The operator-level `.H` no-op is retained.
@jpbrodrick89
jpbrodrick89 changed the base branch from main to dev June 14, 2026 12:49
claude added 6 commits June 14, 2026 20:06
A PSD/NSD operator is Hermitian (real or complex), and a real symmetric/diagonal
operator is Hermitian too. The generic tag loop for TaggedLinearOperator only
checked `hermitian_tag in tags or is_hermitian(inner)`, so a `TaggedLinearOperator`
carrying e.g. `positive_semidefinite_tag` (as produced by composing operators)
reported `is_hermitian == False`, causing HEVD to reject it.

Give TaggedLinearOperator an explicit `is_hermitian` mirroring the cross-implications
encoded for the core operators. Fixes the test_singular failures for HEVD on
make_composed_operator (PSD/NSD, real and complex).
HEVD is not special enough to warrant a bespoke test module. Instead:

- Make the generic `zero` singular construction yield a genuinely rank-deficient
  *indefinite* Hermitian matrix (re-zero the leading row and column after the
  symmetric/Hermitian build), so HEVD with `hermitian_tag` can join the standard
  pseudoinverse scan (forward solve, transpose, linearise, materialise, jvp, vmap,
  real and complex). This removes the need for bespoke indefinite/singular tests.
- Move the MaxRankTag truncation and over-truncation tests to test_max_rank.py,
  alongside the SVD equivalents.
- Move the is_hermitian checks and hermitian-tag propagation to test_operator.py,
  alongside is_symmetric.
- Move the `.H` (conjugate-transpose) and adjoint-state-reuse tests to
  test_transpose.py.
- Drop the redundant solve/pseudoinverse/jit/grad/rejection tests (covered by the
  generic scans) and delete test_hevd.py.
For a PSD (resp. NSD) operator all eigenvalues are >= 0 (resp. <= 0), so in eigh's
ascending order the largest-magnitude `r` are a contiguous trailing (resp. leading)
slice. Querying the PSD/NSD tags lets MaxRankTag truncation take that slice directly,
avoiding the argsort + column gather that the indefinite case still needs (slices are
much cheaper than gathers, especially on accelerators). The branching is entirely in
`init` (static, at trace time); `compute` is unchanged.

The over-truncation check now tests the largest *discarded* magnitude rather than the
boundary eigenvalue, which also guards against a mistagged PSD/NSD operator whose true
large eigenvalues sit on the dropped side. Parametrise the max_rank tests over all
three branches.
Every non-well-posed solver has a "gram partner": a solver representing the
(pseudo)inverse of the gram matrix AᴴA, obtainable from the existing factorisation
with no further decomposition (QR -> Cholesky with R; SVD/HEVD -> HEVD with σ²/w²;
Normal(tall) -> its inner solver, which already factorises AᴴA).

The `not assume_independent_rows` term of the JVP is `A⁺(Aᴴ)⁺w = (AᴴA)⁺w`. Use the
gram partner to compute this in a single gram solve instead of nesting an adjoint
solve inside the outer A⁺ solve. Routing it back through `linear_solve_p` (rather
than applying the factors directly) keeps it correct under higher-order autodiff,
since the gram solve then uses lineax's pseudoinverse-aware adjoint rather than
differentiating through the factorisation.

Contained in `_solve.py` via a `_gram_partner` dispatch (lazy imports to avoid the
solver<->_solve cycle; `_HasGramPartner` type alias for readability). The row-space
projection term is deliberately left untouched.
The gram operator AᴴA depends only on `operator`, so construct it inline at the JVP
call site (where `operator` is in scope) and drop the `_gram_operator` helper and the
operator from `_gram_partner`'s return (now `(gram_solver, gram_state)`); the SVD/HEVD
branch takes it as an argument for `pack_structures`.

QR only reaches the gram fast path for tall operators (`not assume_independent_rows`
requires `rows > columns`), where it stored the QR of `A`, so the wide/transposed case
is unreachable -- raise instead of returning NotImplemented. (Normal's wide case, by
contrast, is genuinely reachable for a rank-deficient inner solver, so it keeps the
NotImplemented fallback.)
…r' into claude/hevd-solver-hermitian-1paoqv

# Conflicts:
#	lineax/_solve.py
@jpbrodrick89
jpbrodrick89 changed the base branch from dev to jpb/refactor-AbstractLinearSolver June 18, 2026 20:57
claude added 4 commits June 18, 2026 21:15
…cate

Now that the AbstractLinearSolver refactor lets `_solve.py` import the solver classes
at module scope, gate the gram fast path on a `_has_gram_partner(solver, state)`
predicate instead of the `NotImplemented` dance:

- `_MaybeHasGramPartner = QR | SVD | HEVD | Normal` names the candidate solver *types*
  (renamed from `_HasGramPartner`, since Normal only conditionally has one).
- `_has_gram_partner(solver, state)` is the definitive runtime check (True for
  QR/SVD/HEVD; for Normal, only when tall).
- `_gram_partner` is typed `_MaybeHasGramPartner -> tuple[...]`, always returns a pair,
  and raises ValueError on the wide cases as guards (unreachable given the predicate).

Also only construct the (lazy) gram operator when the fast path is actually taken.
Unlike the other property checks, `is_hermitian` is an optional optimisation hint
(dispatch to HEVD; shortcut the linear-solve JVP adjoint state), and `False` is always
the safe answer. Defaulting to `False` rather than `_default_not_implemented` means a
custom `AbstractLinearOperator` written before `is_hermitian` existed keeps working
without registering it -- otherwise it would raise in `__check_init__`, the JVP, and
`AutoLinearSolver`. Built-in operators all register `is_hermitian`, so they are
unaffected.
A plain `return False` default was wrong for custom operators: a PSD custom operator
would report `is_hermitian == False`, inconsistent with built-in PSD operators (which
are Hermitian) -- so it would miss HEVD dispatch and the JVP shortcut that an
equivalent built-in gets.

Instead default to the same logic the built-in operators use (minus the `hermitian_tag`
check, which needs operator-specific `.tags`): Hermitian if PSD/NSD, or if real and
symmetric. This defers to checks a complete custom operator already registers (and that
`__check_init__`/`Cholesky` already require), so it stays non-breaking while keeping
`is_hermitian` correct -- the graceful-default pattern also used by `max_rank`.
It is now used across several files in the `_operator` package (the symmetric/Hermitian
checks), so drop the leading underscore to mark it as a shared package helper.
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.

2 participants