You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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:
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.
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.apply — always available — plus the two metric verbs the
wrapper already calls:
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.
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.
#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.
The defect, in one line
A.His a dead end in the algebra: the adjoint wrapper declines to expose its owntranspose, so
A.H.His unreachable — even though(A*)ᵀis a four-line composition ofthings the wrapper already holds.
Measured at
463c6325[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_adjointabledefaults toFalse("an operator with a working
apply_transposeoverrides"), and_AdjointOperatordoesnot override it — so the wrapper reports
is_adjointable = Falseand 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:
A raising stub where ABSENCE is the house spelling.
LinearOperatordeclares noapply_transposeat all —[M]LinearOperator.apply_transposeraisesAttributeErroron the class. So the house pattern for "this operator has notranspose" is to not define the method (
TraceRestrictionOperator,operator.py:2521, is the precedent), and the baseis_adjointable = Falsethenadvertises it.
_AdjointOperatorinstead adds a method that raises — the namedanti-pattern (a harmful stub), and it makes the absence look deliberate-but-blocked
rather than simply absent.
The algebra does not close, and it could.
.His one of the algebra's fiveoperations (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 isliterally what
_AdjointOperator.applycomputes (:1305-1312). Therefore, metricsbeing symmetric:
which needs only
inner.apply— always available — plus the two metric verbs thewrapper already calls:
With that,
is_adjointablebecomes honestlyTrue,A.H.Hresolves, and the stubretires. Note this is the exact mirror of the existing
applybody — which is alsothe tell that the hole is accidental rather than principled.
Verification this owes
⚠ Shared numerics:
_AdjointOperatoris what every.Hin the tree constructs.[M]no code outsideoperator.pynames it directly (the ~20 tree-wide hits are all:class:docstring cross-references), but that is a statement about the name, not theobjects — the wrapper is reachable from every adjointable leaf.
A.H.H ≡ Afor a non-trivial metric. ⛔ Not ona constant metric:
vv-principlesMode 12's commutator criterion —A* = Aᵀwhenever
[G, Aᵀ] = 0, so a uniform-Gfixture certifies nothing about the metriclegs. Use a genuinely non-commuting pair; a diagonal operator will NOT do (it commutes
with every diagonal
G).is_adjointableflips toTrueand that the flip is load-bearing.vv-principlesMOC: Degenerate ray guard in _ray_box_intersections #17's per-arm rule) — awhole-body mutation would certify only the first reachable leg.
.Hconsumer 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 thegauge's build +
apply+gauge+apply_transposegives 0 constructions:LossKernelGaugeimplementsapply_transposedirectly, becauseΠᵀ = Πis a theoremthere 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 anadjointable operator is adjointable by class, so the gap cannot be spelled. ⚠ #213's
body is partly stale; see the correcting comment there. Sibling: #374.