Skip to content

Commit 3fef6a1

Browse files
fix: stop D19/A11 Mpemba false-positives on symmetric initial states (#77)
Canonical amplitude damping with the default rho_0 = I/2 (and the excited state |1><1|) previously classified as A11 F4 CONFIRMED PUBLICATION_GRADE — a symmetry-protected zero slowest-mode overlap was read as an anomalous Mpemba effect (issue #68). Three coupled fixes: - overlap_c1 (D19) now returns the true biorthogonal coefficient |<l1,rho0>|/|<l1,r1>| instead of |<l1,rho0>|/||l1|| (only the zero test was meaningful before); denominator floored at EPS_DIV. - Non-triviality guard is_trivial_overlap, wired through diagnose() via a new rho_steady_state argument to compute_mpemba_layer. Using the eigenPROJECTORS of rho_ss (robust to a degenerate / maximally mixed steady state, where an eigenvector basis is arbitrary), a rho_0 whose active blocks are disjoint from the slowest mode's cannot populate it — trivially fast relaxation, not Mpemba. MpembaResult gains trivial_overlap. - A single initial state is CANDIDATE-grade, not PUBLICATION_GRADE: A11 confidence capped at 0.70 since confirmation needs a reference family the single-state pipeline does not provide. The README dephasing example (rho_ss = I/2, no protecting symmetry sector) stays A11 but as CANDIDATE/CONFIRMATION, no longer self-certifying. The classifier A11 branch now gates on the guarded is_mpemba_candidate flag. Tests: biorthogonal-coefficient check, guard truth table, a fine-tuned qutrit true-positive oracle, and canonical false-positive regressions in tests/test_mpemba.py; updated A11 fixtures in tests/test_classification.py. Anchors unchanged (21 green); pytest 427 passed; ruff + mypy clean. Refs #68. Claude-Session: https://claude.ai/code/session_01StgHHKB6YGxxjTKdWjcCtq Co-authored-by: Claude <noreply@anthropic.com>
1 parent 62e77f3 commit 3fef6a1

7 files changed

Lines changed: 350 additions & 32 deletions

File tree

CHANGELOG.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,31 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
3535
behaviour change; the semantics are now explicit in code.
3636

3737
### Fixed
38+
- **D19/A11 Mpemba detector no longer false-positives on trivially symmetric
39+
initial states** (issue #68). Three coupled defects were corrected:
40+
- `diagnostics/mpemba.py::overlap_c1` normalised the slowest-mode overlap by
41+
`||l_1||`, so only its *zero test* was meaningful. It now returns the true
42+
**biorthogonal** expansion coefficient `|<l_1, rho_0>| / |<l_1, r_1>|`
43+
(denominator floored at `EPS_DIV`), the physical weight of the slow mode.
44+
- A **non-triviality guard** (`is_trivial_overlap`, wired through `diagnose()`
45+
via the new `rho_steady_state` argument of `compute_mpemba_layer`) now
46+
distinguishes a *symmetry-protected* zero overlap from an anomalous skip. In
47+
the sector decomposition set by the **eigenprojectors of `rho_ss`** (robust
48+
to a degenerate / maximally mixed steady state, unlike an eigenvector basis),
49+
a `rho_0` whose active blocks are disjoint from the slowest mode's can never
50+
populate it — trivially fast relaxation, not Mpemba. Amplitude damping with
51+
the default `rho_0 = I/2` (and the excited state `|1><1|`) previously
52+
returned `A11 F4 CONFIRMED PUBLICATION_GRADE`; both now fall through the A11
53+
branch. `MpembaResult` gains a `trivial_overlap` field.
54+
- A single initial state skipping the slowest mode is now **CANDIDATE-grade,
55+
not PUBLICATION_GRADE**: `A11` confidence is capped at 0.70 because
56+
confirmation needs a reference family (e.g. thermal states across
57+
temperatures) the single-state pipeline does not provide. The README
58+
dephasing example (`rho_ss = I/2`, no protecting symmetry sector) stays
59+
`A11` but as `CANDIDATE`/`CONFIRMATION`, no longer self-certifying.
60+
- New/updated coverage in `tests/test_mpemba.py` (biorthogonal coefficient,
61+
guard truth table, a fine-tuned qutrit true-positive oracle, canonical
62+
false-positive regressions) and `tests/test_classification.py`.
3863
- **CI: pinned `ruff==0.15.20` and reformatted the `_diagnostics` import
3964
block.** Ruff's isort `I001` heuristic for `lines-after-imports` before a
4065
module-level constant changed between 0.15.14 and 0.15.20, flagging a

src/liouscope/_diagnostics.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,9 @@ def diagnose(
132132
)
133133
mpemba: MpembaResult | None = None
134134
if include_mpemba:
135-
mpemba = compute_mpemba_layer(L_super, rho_initial)
135+
mpemba = compute_mpemba_layer(
136+
L_super, rho_initial, rho_steady_state=rho_steady_state
137+
)
136138

137139
classification = classify_mechanism(
138140
spectral=spectral,

src/liouscope/_types.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ class MpembaResult:
116116
overlap_c1: float # D19 slowest-mode overlap
117117
is_mpemba_candidate: bool
118118
expansion_alpha: float # D20 Phi_n scaling exponent
119+
trivial_overlap: bool = False # symmetry-protected zero overlap (not Mpemba)
119120

120121

121122
@dataclass(frozen=True, slots=True, kw_only=True)

src/liouscope/diagnostics/classification.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,8 @@ def _gather_evidence(
7878
if mpemba is not None:
7979
ev["mpemba_overlap_c1"] = float(mpemba.overlap_c1)
8080
ev["mpemba_expansion_alpha"] = float(mpemba.expansion_alpha)
81+
ev["mpemba_is_candidate"] = float(mpemba.is_mpemba_candidate)
82+
ev["mpemba_trivial_overlap"] = float(mpemba.trivial_overlap)
8183
return ev
8284

8385

@@ -87,8 +89,11 @@ def _pick_a_class(
8789
relaxation: RelaxationResult,
8890
) -> tuple[str, str]:
8991
"""Return ``(a_class, f_family)`` based on evidence priorities."""
90-
# F4 Mpemba check first (high salience for current literature risk)
91-
if ev.get("mpemba_overlap_c1", 1.0) < 1.0e-4:
92+
# F4 Mpemba check first (high salience for current literature risk). The
93+
# candidate flag already folds in the non-triviality guard (issue #68), so a
94+
# symmetry-protected zero overlap (diagonal rho_0 vs a coherence slow mode)
95+
# no longer reaches A11 -- only a genuine, fine-tuned skip does.
96+
if ev.get("mpemba_is_candidate", 0.0) > 0.5:
9297
return "A11", "F4"
9398
# F5 phantom relaxation
9499
if ev["pseudospectral_radius"] > 2.0 * ev.get("gap_to_gns_ratio", 1.0) and ev["henrici_eta"] > 1.0:
@@ -152,7 +157,13 @@ def _confidence(ev: dict[str, float], a_class: str) -> float:
152157
elif a_class == "A10":
153158
score = 0.70
154159
elif a_class == "A11" and ev.get("mpemba_overlap_c1", 1.0) < 1.0e-5:
155-
score = 0.90
160+
# A single initial state skipping the slowest mode is a *candidate*, not
161+
# a confirmed anomalous Mpemba effect: confirmation needs a reference
162+
# family (e.g. thermal states at different temperatures), which the
163+
# single-state pipeline does not provide. Capped below the
164+
# PUBLICATION_GRADE threshold so a lone overlap cannot self-certify
165+
# (issue #68); genuine cases still surface as A11 CANDIDATE.
166+
score = 0.70
156167
elif a_class == "A12":
157168
score = 0.20
158169
return float(min(max(score, 0.0), 1.0))
Lines changed: 140 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,28 @@
11
"""Mpemba layer: D19 slowest-mode overlap, D20 expansion-coefficient scaling.
22
33
D19 -- :func:`overlap_c1`. The Mpemba-overlap test (Carollo 2021). Decompose
4-
the initial state in left-eigenvector basis of L and test whether the
4+
the initial state in the left-eigenvector basis of L and test whether the
55
coefficient on the slowest mode (lambda_1 != 0) vanishes. If ``|c_1| << eps``,
6-
the system relaxes anomalously fast (Mpemba candidate).
6+
the state relaxes anomalously fast (strong-Mpemba *candidate*).
7+
8+
The coefficient reported is the **biorthogonal** expansion coefficient
9+
``c_1 = <l_1, rho_0> / <l_1, r_1>`` -- the physically meaningful weight of the
10+
slowest right-mode ``r_1`` in the decomposition of ``rho_0`` (l_1, r_1 the left
11+
and right eigenvectors of the slowest non-zero mode). Normalising by
12+
``<l_1, r_1>`` rather than ``||l_1||`` (the previous behaviour, for which only
13+
the zero test was meaningful) makes ``c_1`` the actual expansion magnitude.
14+
15+
**Non-triviality guard (LIOU-CLS-002, issue #68).** A vanishing ``c_1`` is only
16+
an *anomalous* Mpemba skip when a state that could generically excite the
17+
slowest mode is fine-tuned so it does not. A ``rho_0`` that **commutes with the
18+
steady state** ``rho_ss`` lies in the classical (population) sector of
19+
``rho_ss``; if the slowest mode is a *coherence* in the ``rho_ss`` eigenbasis,
20+
such a ``rho_0`` has ``c_1 = 0`` by symmetry -- it never populates that mode --
21+
which is trivially fast relaxation, not a Mpemba effect. The default
22+
``rho_0 = I/d`` and any diagonal state fall in this class. :func:`is_trivial_overlap`
23+
detects it, and :func:`compute_mpemba_layer` withholds the candidate flag so a
24+
canonical amplitude-damping / dephasing system cannot earn an anomalous-Mpemba
25+
label from its default inputs.
726
827
D20 -- :func:`expansion_alpha`. Scaling exponent of overlap coefficients
928
``|c_n|`` against the index n. Polynomial scaling ``Phi_n ~ exp(alpha L)``
@@ -15,32 +34,119 @@
1534
import numpy as np
1635
import scipy.linalg as sla
1736

18-
from .._consts import EPS_GAP
37+
from .._consts import EPS_DIV, EPS_GAP, EPS_HERMITICITY
1938
from .._types import MpembaResult
2039
from ..numerics.kronecker import vec
2140

2241

42+
def _slowest_mode(
43+
L_super: np.ndarray, *, atol: float = EPS_GAP
44+
) -> tuple[np.ndarray, np.ndarray] | None:
45+
"""Return ``(l_slow, r_slow)`` for the slowest non-zero mode, or ``None``.
46+
47+
The slowest mode is the non-zero eigenvalue with the largest (least
48+
negative) real part. Returns ``None`` when every mode is a zero mode.
49+
"""
50+
L_super = np.asarray(L_super)
51+
eigvals, vl, vr = sla.eig(L_super, left=True, right=True)
52+
nonzero_mask = np.abs(eigvals) > atol
53+
if not np.any(nonzero_mask):
54+
return None
55+
eigvals_nz = eigvals[nonzero_mask]
56+
order = np.argsort(-np.real(eigvals_nz))
57+
slowest_idx = np.where(nonzero_mask)[0][order[0]]
58+
return vl[:, slowest_idx], vr[:, slowest_idx]
59+
60+
2361
def overlap_c1(
2462
L_super: np.ndarray,
2563
rho_initial: np.ndarray,
2664
*,
2765
atol: float = EPS_GAP,
2866
) -> float:
29-
"""Magnitude of the slowest-mode overlap ``|c_1|``."""
30-
L_super = np.asarray(L_super)
31-
eigvals, vl, vr = sla.eig(L_super, left=True, right=True)
32-
# Filter the zero mode
33-
nonzero_mask = np.abs(eigvals) > atol
34-
eigvals_nz = eigvals[nonzero_mask]
35-
if eigvals_nz.size == 0:
67+
"""Magnitude of the biorthogonal slowest-mode overlap ``|c_1|``.
68+
69+
``c_1 = <l_1, vec(rho_0)> / <l_1, r_1>``; the denominator is floored at
70+
:data:`liouscope._consts.EPS_DIV` so a genuinely defective (``<l_1, r_1> = 0``)
71+
slow mode yields a large finite value rather than a spurious NaN.
72+
"""
73+
mode = _slowest_mode(L_super, atol=atol)
74+
if mode is None:
3675
return 0.0
37-
# Slowest non-zero mode = largest real part (least negative)
38-
order = np.argsort(-np.real(eigvals_nz))
39-
slowest_idx = np.where(nonzero_mask)[0][order[0]]
40-
l_slow = vl[:, slowest_idx]
76+
l_slow, r_slow = mode
4177
rho_vec0 = vec(np.asarray(rho_initial))
42-
norm = max(np.linalg.norm(l_slow), 1.0e-12)
43-
return float(abs(np.vdot(l_slow, rho_vec0)) / norm)
78+
denom = abs(np.vdot(l_slow, r_slow))
79+
return float(abs(np.vdot(l_slow, rho_vec0)) / max(denom, EPS_DIV))
80+
81+
82+
def _steady_state_eigenprojectors(
83+
rho_steady_state: np.ndarray, *, tol: float = EPS_HERMITICITY
84+
) -> list[np.ndarray]:
85+
"""Orthogonal projectors onto the distinct eigenspaces of ``rho_ss``.
86+
87+
Grouping degenerate eigenvalues into a single projector makes the sector
88+
decomposition well-defined even when ``rho_ss`` is degenerate or maximally
89+
mixed -- the regime where an individual-eigenvector basis is arbitrary.
90+
"""
91+
rho_steady_state = np.asarray(rho_steady_state, dtype=complex)
92+
w, u = np.linalg.eigh(rho_steady_state)
93+
projectors: list[np.ndarray] = []
94+
i = 0
95+
n = w.size
96+
while i < n:
97+
j = i + 1
98+
while j < n and abs(w[j] - w[i]) <= tol:
99+
j += 1
100+
block = u[:, i:j]
101+
projectors.append(block @ block.conj().T)
102+
i = j
103+
return projectors
104+
105+
106+
def is_trivial_overlap(
107+
L_super: np.ndarray,
108+
rho_initial: np.ndarray,
109+
rho_steady_state: np.ndarray,
110+
*,
111+
atol: float = EPS_GAP,
112+
block_tol: float = 1.0e-9,
113+
) -> bool:
114+
"""Is a vanishing slowest-mode overlap symmetry-protected (not Mpemba)?
115+
116+
Returns ``True`` when the zero overlap is *structural*: in the sector
117+
decomposition set by the eigenprojectors ``{P_a}`` of ``rho_ss``, the slowest
118+
mode ``l_1`` and ``rho_0`` occupy **disjoint** blocks ``P_a (.) P_b``. Then
119+
``rho_0`` can never populate the slowest mode -- its fast relaxation is
120+
trivial, not a fine-tuned Mpemba skip (e.g. a diagonal ``rho_0`` vs a
121+
coherence slowest mode of an amplitude-damping channel).
122+
123+
Using eigen*projectors* (not individual eigenvectors) keeps the test robust
124+
when ``rho_ss`` is degenerate. A maximally mixed ``rho_ss`` collapses to a
125+
single projector (one block), so nothing is structurally decoupled and the
126+
guard does not fire -- such a state has no protecting symmetry sector and is
127+
handled by the (demoted) confidence tier instead.
128+
"""
129+
L_super = np.asarray(L_super)
130+
rho_initial = np.asarray(rho_initial, dtype=complex)
131+
rho_steady_state = np.asarray(rho_steady_state, dtype=complex)
132+
133+
mode = _slowest_mode(L_super, atol=atol)
134+
if mode is None:
135+
return False
136+
l_slow, _ = mode
137+
d = rho_steady_state.shape[0]
138+
l_mat = l_slow.reshape(d, d, order="F")
139+
140+
projectors = _steady_state_eigenprojectors(rho_steady_state)
141+
l_scale = max(float(np.linalg.norm(l_mat)), 1.0)
142+
rho_scale = max(float(np.linalg.norm(rho_initial)), 1.0)
143+
for p_a in projectors:
144+
for p_b in projectors:
145+
l_block = float(np.linalg.norm(p_a @ l_mat @ p_b))
146+
rho_block = float(np.linalg.norm(p_a @ rho_initial @ p_b))
147+
if l_block > block_tol * l_scale and rho_block > block_tol * rho_scale:
148+
return False # a shared block -> rho_0 can reach the slow mode
149+
return True
44150

45151

46152
def expansion_alpha(
@@ -56,7 +162,7 @@ def expansion_alpha(
56162
Returns ``alpha`` (slope). A flat distribution gives ``alpha`` near 0.
57163
"""
58164
L_super = np.asarray(L_super)
59-
eigvals, vl, _ = sla.eig(L_super, left=True, right=True)
165+
eigvals, vl, vr = sla.eig(L_super, left=True, right=True)
60166
mask = np.abs(eigvals) > atol
61167
eigvals_nz = eigvals[mask]
62168
if eigvals_nz.size < 2:
@@ -67,8 +173,8 @@ def expansion_alpha(
67173
cs: list[float] = []
68174
for idx in nz_indices[: min(n_modes, len(nz_indices))]:
69175
l_n = vl[:, idx]
70-
norm = max(np.linalg.norm(l_n), 1.0e-12)
71-
cs.append(float(abs(np.vdot(l_n, rho_vec0)) / norm))
176+
denom = abs(np.vdot(l_n, vr[:, idx]))
177+
cs.append(float(abs(np.vdot(l_n, rho_vec0)) / max(denom, EPS_DIV)))
72178
if len(cs) < 2:
73179
return 0.0
74180
log_cs = np.log(np.clip(np.asarray(cs), 1.0e-30, None))
@@ -81,13 +187,26 @@ def compute_mpemba_layer(
81187
L_super: np.ndarray,
82188
rho_initial: np.ndarray,
83189
*,
190+
rho_steady_state: np.ndarray | None = None,
84191
overlap_threshold: float = 1.0e-4,
85192
) -> MpembaResult:
86-
"""Run D19, D20 and flag Mpemba candidacy."""
193+
"""Run D19, D20 and flag Mpemba candidacy.
194+
195+
``is_mpemba_candidate`` is ``True`` only when ``|c_1| < overlap_threshold``
196+
**and** the vanishing overlap is not symmetry-protected. Triviality can only
197+
be assessed when ``rho_steady_state`` is supplied (the standalone default is
198+
``None``, which preserves the raw overlap test for direct callers).
199+
"""
87200
c1 = overlap_c1(L_super, rho_initial)
88201
alpha = expansion_alpha(L_super, rho_initial)
202+
trivial = (
203+
is_trivial_overlap(L_super, rho_initial, rho_steady_state)
204+
if rho_steady_state is not None
205+
else False
206+
)
89207
return MpembaResult(
90208
overlap_c1=c1,
91-
is_mpemba_candidate=c1 < overlap_threshold,
209+
is_mpemba_candidate=bool(c1 < overlap_threshold and not trivial),
92210
expansion_alpha=alpha,
211+
trivial_overlap=trivial,
93212
)

tests/test_classification.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -203,10 +203,16 @@ def _lep(**kw) -> LepResult:
203203
def _mpemba(**kw) -> MpembaResult:
204204
base = {
205205
"overlap_c1": 0.5,
206-
"is_mpemba_candidate": False,
207206
"expansion_alpha": 1.0,
208207
}
209208
base.update(kw)
209+
# Synthetic evidence carries no steady state, so mirror the real
210+
# (non-trivial) candidate rule: a below-threshold overlap is a candidate
211+
# unless the caller explicitly marks it trivial or overrides the flag.
212+
base.setdefault(
213+
"is_mpemba_candidate",
214+
bool(base["overlap_c1"] < 1.0e-4 and not kw.get("trivial_overlap", False)),
215+
)
210216
return MpembaResult(**base)
211217

212218

@@ -241,11 +247,13 @@ def test_branch_default_fallback_a12():
241247

242248

243249
def test_branch_a11_mpemba():
250+
# issue #68: a single-state Mpemba overlap is CANDIDATE-grade, never
251+
# PUBLICATION_GRADE-confirmed (confirmation needs a reference family).
244252
cls = _classify(mpemba=_mpemba(overlap_c1=1.0e-6))
245253
assert (cls.a_class, cls.f_family) == ("A11", "F4")
246-
assert cls.confidence == pytest.approx(0.90)
247-
assert cls.verdict == VERDICT_CONFIRMED
248-
assert cls.tier == TIER_PUBLICATION
254+
assert cls.confidence == pytest.approx(0.70)
255+
assert cls.verdict == VERDICT_CANDIDATE
256+
assert cls.tier == TIER_CONFIRMATION
249257

250258

251259
def test_branch_a11_mpemba_weak_confidence():
@@ -257,6 +265,15 @@ def test_branch_a11_mpemba_weak_confidence():
257265
assert cls.verdict == VERDICT_NOT_EXCLUDED
258266

259267

268+
def test_branch_a11_trivial_overlap_not_flagged():
269+
# issue #68: a symmetry-protected zero overlap (trivial_overlap=True) must
270+
# NOT reach A11 even though overlap_c1 is below the raw trigger.
271+
cls = _classify(mpemba=_mpemba(overlap_c1=1.0e-6, trivial_overlap=True))
272+
assert cls.a_class != "A11"
273+
assert cls.evidence["mpemba_trivial_overlap"] == 1.0
274+
assert cls.evidence["mpemba_is_candidate"] == 0.0
275+
276+
260277
def test_branch_a10_phantom_relaxation():
261278
cls = _classify(
262279
resolvent=_resolvent(pseudospectral_radius=5.0),

0 commit comments

Comments
 (0)