Skip to content

numerics: A.H is a dead end — _AdjointOperator declines a transpose it can compute in four lines #375

Description

@deOliveira-R

The defect, in one line

A.H is a dead end in the algebra: the adjoint wrapper declines to expose its own
transpose, so A.H.H is unreachable — even though (A*)ᵀ is a four-line composition of
things the wrapper already holds.

Measured at 463c6325

$ .venv/bin/python
>>> from orpheus.numerics.operator import IdentityOperator
>>> import numpy as np
>>> I = IdentityOperator[np.ndarray]()
>>> type(I.H).__name__
'_AdjointOperator'
>>> I.H.is_adjointable
False
>>> I.H.apply_transpose(np.ones(3))
NotImplementedError: apply_transpose on an _AdjointOperator wrapper is not
supported in 9.6; if a consumer needs it, take the adjoint of the original
inner operator's transpose directly.

[M] orpheus/numerics/operator.py:1239 (class _AdjointOperator), raising stub at
:1313-1318.

⚠ What this is NOT — a correction to how it was first characterised

It was first written up as "claims an adjoint it does not have". That is false, and
the measurement above is why.
LinearOperator.is_adjointable defaults to False
("an operator with a working apply_transpose overrides"), and _AdjointOperator does
not override it — so the wrapper reports is_adjointable = False and then raises.
The advertisement and the behaviour agree. This is an honest capability gap, not a
mis-claim, and it is strictly less severe than the sibling issue filed alongside it.

Recording the correction because the wrong framing would send a fixer looking for a
predicate bug that does not exist.

What the defect actually is

Two things, both real:

  1. A raising stub where ABSENCE is the house spelling. LinearOperator declares no
    apply_transpose at all — [M] LinearOperator.apply_transpose raises
    AttributeError on the class. So the house pattern for "this operator has no
    transpose" is to not define the method (TraceRestrictionOperator,
    operator.py:2521, is the precedent), and the base is_adjointable = False then
    advertises it. _AdjointOperator instead adds a method that raises — the named
    anti-pattern (a harmful stub), and it makes the absence look deliberate-but-blocked
    rather than simply absent.

  2. The algebra does not close, and it could. .H is one of the algebra's five
    operations (Pattern 1: the code IS the math). An operation whose result cannot be fed
    back into the same operation is a hole in the notation. A* = G_D⁻¹ Aᵀ G_C — that is
    literally what _AdjointOperator.apply computes (:1305-1312). Therefore, metrics
    being symmetric:

    (A*)ᵀ = (G_D⁻¹ Aᵀ G_C)ᵀ = G_Cᵀ A G_D⁻ᵀ = G_C · A · G_D⁻¹
    

    which needs only inner.applyalways available — plus the two metric verbs the
    wrapper already calls:

    def apply_transpose(self, x):
        z = inner_domain.apply_inverse_metric(x) if inner_domain is not None else x
        result = self.inner.apply(z)
        return inner_codomain.apply_metric(result) if inner_codomain is not None else result

    With that, is_adjointable becomes honestly True, A.H.H resolves, and the stub
    retires. Note this is the exact mirror of the existing apply body — which is also
    the tell that the hole is accidental rather than principled.

Verification this owes

⚠ Shared numerics: _AdjointOperator is what every .H in the tree constructs.
[M] no code outside operator.py names it directly (the ~20 tree-wide hits are all
:class: docstring cross-references), but that is a statement about the name, not the
objects — the wrapper is reachable from every adjointable leaf.

  • A gate on the double-adjoint law A.H.H ≡ A for a non-trivial metric. ⛔ Not on
    a constant metric: vv-principles Mode 12's commutator criterion — A* = Aᵀ
    whenever [G, Aᵀ] = 0, so a uniform-G fixture certifies nothing about the metric
    legs. Use a genuinely non-commuting pair; a diagonal operator will NOT do (it commutes
    with every diagonal G).
  • A gate that is_adjointable flips to True and that the flip is load-bearing.
  • Mutation-verify each metric leg separately (vv-principles MOC: Degenerate ray guard in _ray_box_intersections #17's per-arm rule) — a
    whole-body mutation would certify only the first reachable leg.
  • A wide re-run (≥90 min, SERIAL), since every .H consumer is downstream.

Provenance — why this was found and not fixed here

Surfaced during the #344 loss-kernel-gauge campaign. The campaign's plan asserted the
gauge would be this hole's first consumer ("a self-adjoint projector is that
consumer"
). ⛔ REFUTED[M] instrumenting _AdjointOperator.__init__ across the
gauge's build + apply + gauge + apply_transpose gives 0 constructions:
LossKernelGauge implements apply_transpose directly, because Πᵀ = Π is a theorem
there and routing through the adjoint machinery would be slower and no more correct.

So the campaign has no consumer for this, and the fix is shared numerics needing its
own gate set plus a wide re-run. Deferred by user ruling rather than bundled into an
unrelated merge.

Relationship to #213

#213 is the principled home — under Iso ⊂ PartialIso ⊂ General, the adjoint of an
adjointable operator is adjointable by class, so the gap cannot be spelled. ⚠ #213's
body is partly stale; see the correcting comment there. Sibling: #374.

Metadata

Metadata

Assignees

No one assigned

    Labels

    level:L1Equation verification (analytical/MMS)module:numericsnumerics/ shared solver infrastructuretype:improvementEnhancement to existing functionality

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions