Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 51 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,14 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
pipeline is untouched). The classifier now exposes `beta_D`, `beta_D_linear`,
`gap` and the implied multiplier `d17_metric_multiplier` in the evidence dict
(the factor is explicit and auditable, not hidden), and a genuine single-mode
gap-controlled system can now earn A1 "gap-controlled" (F1). The A1 early-branch
gap-controlled system can now earn A1 "gap-controlled" (family "none" -- see
issue #70 A6 below, which corrected the A1 family from F1). The A1 early-branch
is ordered *after* the F1-F5 gap-failure families and only *before* the
relative-entropy-shape branches (M2/M3a/M3b): `gap_rate_consistency` +
`linear_fit_model` are initial-state-dependent, so a strongly non-normal
phantom/skin operator with an `rho_0` that excites only the slow gap mode must
NOT shadow its true (operator-intrinsic) F5/F2 mechanism -- it stays A10/F5
(resp. A4/F2), not A1/F1 (Equalita #79 review). No V1-V5 mechanism label changes
(resp. A4/F2), not A1 (Equalita #79 review). No V1-V5 mechanism label changes
(golden-pinned end-to-end against baseline `3fef6a1`); only the D17 *number* is
corrected. New end-to-end regression
`tests/test_validation_systems/test_d17_gap_coherence.py` (20 tests, incl. V1-V5
Expand All @@ -65,6 +66,54 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
conservative *placeholder* (not a measured residual) unless the caller runs
an ODE-tolerance sweep -- which `diagnose()` does not. Same numeric value, no
behaviour change; the semantics are now explicit in code.
- **Classifier semantics debt cleared: A1 family, F5 dimensional coherence,
LEP degeneracy, dead EXCLUDED verdict** (issue #70, A5/A6/A8/A9). Four
Comment on lines +69 to +70

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Update CITATION for methodology changes

This entry changes result-facing methodology/semantics for the classifier (A1 family assignment, the F5 rule, and D16 degeneracy handling), but the commit leaves CITATION.cff untouched. The repo instructions in AGENTS.md require changes touching results or methodology to update CITATION.cff; otherwise the citable metadata still describes the previous public surface while labels/verdict evidence have changed. Please update the citation metadata or explicitly move non-methodology-only changes out of this PR.

Useful? React with 👍 / 👎.

semantic corrections to the A1-A12 classifier, each with a physics rationale:
- **A6 -- A1 now maps to family `"none"`, not `F1`.** `F1` is the Mori-Shirai
*overlap gap-FAILURE* mechanism (PRL 125, 230604); A1 (asymptotic-gap-
controlled, primitive QMS) is precisely the *no-gap-failure* case. Tagging
the healthy gap-controlled label with a gap-FAILURE family was a category
error. `"none"` = "No gap-failure mechanism flagged" is A1's correct family.
(`tests/test_classification.py` + `test_d17_gap_coherence.py` updated to pin
A1/`"none"`; the #69 dimension-coherence logic is untouched.)
- **A8 -- the F5 phantom-relaxation rule is now dimension-coherent and
scale-invariant.** The old rule `pseudospectral_radius > 2 * gap_to_gns_ratio`
compared a RATE (the D13 radius `max{|z|: z in sigma_eps(L)}`) against a
dimensionless ratio, so rescaling the Liouvillian `L -> cL` (a pure change of
time unit) flipped the A10/F5 verdict. The rule now compares the
dimensionless pseudospectral *reach* `radius / gap` (how far the
eps-pseudospectrum extends relative to the asymptotic decay rate `Delta`,
the physical phantom signature, Znidaric 2023) against `2 * gap_to_gns_ratio`
-- both sides dimensionless, scale-invariant to leading order. A vanishing
gap is treated as infinite reach (the gapless/critical phantom limit). New
metamorphic rescale tests over `c in [1e-3, 1e3]` pin no verdict flip, plus a
regression proving the old bare-radius rule *would* have flipped.
- **A9 -- `lep_proximity` (D16) no longer blind to exact degeneracies.** The
min-separation scan skipped every pair with `sep <= atol`, so an exactly
degenerate eigenvalue pair -- the STRONGEST exceptional-point signal (two
eigenvalues coalescing) -- was invisible, and a fully degenerate spectrum
returned `inf` ("maximally far from an EP"), the exact inverse of the
physics. Coalescence now yields proximity `0.0`; the candidate-count loop
uses the same data and a `max(10*min_sep, atol)` window, so the two loops are
mutually consistent. (D16 measures eigenvalue proximity only; genuine
defective EP vs semisimple degeneracy is disambiguated by the Petermann
factor D9, as before.) New edge tests cover exact/full/sub-atol degeneracy.
- **A5 -- the unreachable `EXCLUDED` verdict was removed** from the `Verdict`
Literal (and `_consts.VERDICT_EXCLUDED`). A single-pass, maximum-evidence
classifier reports the best-fit A-class with its support and never reports a
class it is simultaneously ruling out, so a per-class active-rejection
verdict is not expressible in this architecture -- the value was permanently
unreachable through `diagnose()`. The old `confidence < 0.30 -> EXCLUDED`
branch was also semantically wrong (low confidence = epistemic "unresolved"
= `NOT_EXCLUDED`, not counter-evidence); low confidence now correctly yields
`NOT_EXCLUDED`. **API note:** `Verdict` narrows from 5 to 4 members; this is
a type-surface narrowing only (runtime `diagnose()` output is unchanged --
it never emitted `EXCLUDED`). Genuine active exclusion is deferred to a
future per-hypothesis scoring mode. B3 (unreachable classes A6/A7/A9) and B4
(unused D16/D18/D11/D12/D20 evidence) are analysed but NOT wired in this PR
(see PR body) -- both are class/verdict-influencing design decisions that can
introduce false positives and are deferred to dedicated PRs with anchor
coverage.

### Fixed
- **D19/A11 Mpemba detector no longer false-positives on trivially symmetric
Expand Down
4 changes: 3 additions & 1 deletion src/liouscope/_consts.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,9 @@
EPS_DIV: Final[float] = 1.0e-300

VERDICT_CONFIRMED: Final[str] = "CONFIRMED"
VERDICT_EXCLUDED: Final[str] = "EXCLUDED"
# issue #70 A5: VERDICT_EXCLUDED removed -- it was unreachable through
# ``diagnose()`` and semantically inexpressible in a single-pass best-class
# classifier (active exclusion needs per-hypothesis scoring; deferred).
VERDICT_CANDIDATE: Final[str] = "CANDIDATE"
VERDICT_NOT_EXCLUDED: Final[str] = "NOT_EXCLUDED"
VERDICT_UNDEFINED: Final[str] = "UNDEFINED"
Expand Down
7 changes: 6 additions & 1 deletion src/liouscope/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@

from ._consts import DIAGNOSTIC_SCHEMA_VERSION, TAXONOMY_VERSION

Verdict = Literal["CONFIRMED", "EXCLUDED", "CANDIDATE", "NOT_EXCLUDED", "UNDEFINED"]
# issue #70 A5: "EXCLUDED" was removed. A single-pass maximum-evidence
# classifier reports the best-fit A-class with its support and never emits a
# genuine active-rejection verdict, so the value was permanently unreachable
# through ``diagnose()`` (advertised a capability the pipeline lacks). Active
# exclusion requires a per-hypothesis scoring mode (deferred). See CHANGELOG.
Verdict = Literal["CONFIRMED", "CANDIDATE", "NOT_EXCLUDED", "UNDEFINED"]
Tier = Literal["PUBLICATION_GRADE", "CONFIRMATION", "EXPLORATION"]
QualityLabel = Literal["stable", "moderate", "exploratory"]
SolverPath = Literal["dense", "sparse_arpack"]
Expand Down
44 changes: 35 additions & 9 deletions src/liouscope/diagnostics/classification.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@
TIER_PUBLICATION,
VERDICT_CANDIDATE,
VERDICT_CONFIRMED,
VERDICT_EXCLUDED,
VERDICT_NOT_EXCLUDED,
VERDICT_UNDEFINED,
)
Expand Down Expand Up @@ -118,8 +117,26 @@ def _pick_a_class(
# no longer reaches A11 -- only a genuine, fine-tuned skip does.
if ev.get("mpemba_is_candidate", 0.0) > 0.5:
return "A11", "F4"
# F5 phantom relaxation
if ev["pseudospectral_radius"] > 2.0 * ev.get("gap_to_gns_ratio", 1.0) and ev["henrici_eta"] > 1.0:
# F5 phantom relaxation (issue #70 A8). The rule must be dimension-coherent
# AND scale-invariant. ``pseudospectral_radius`` (D13) is the max modulus
# max{|z| : z in sigma_eps(L)} -- a RATE-dimensioned quantity that scales
# ~linearly under a uniform Liouvillian rescale L -> cL (all eigenvalues,
# and the bracketing grid, scale by c). ``gap_to_gns_ratio`` is a pure
# dimensionless number (Delta / Delta_s), invariant under L -> cL. Comparing
# a rate directly against a dimensionless ratio (the pre-#70 rule) was
# incoherent: rescaling L -> cL flipped the A10/F5 verdict even though the
# physics is unchanged. Normalising the radius by the spectral gap Delta
# yields the dimensionless pseudospectral reach (radius / Delta) -- how far
# the eps-pseudospectrum extends relative to the asymptotic decay rate --
# which is the physically meaningful phantom-relaxation signature (Znidaric
# 2023) and is scale-invariant to leading order (both radius and gap scale
# as c). A vanishing gap (no spectral gap) is treated as inf reach: a
# gapless, strongly non-normal operator is the phantom/critical limit.
_gap = ev.get("gap", 0.0)
_psr_reach = (
ev["pseudospectral_radius"] / _gap if _gap > 0.0 else float("inf")
)
if _psr_reach > 2.0 * ev.get("gap_to_gns_ratio", 1.0) and ev["henrici_eta"] > 1.0:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make the F5 non-normality gate scale-free

For the rescale scenario this change is meant to fix, the condition still gates on raw henrici_eta > 1.0. henrici_eta_n is the Frobenius norm of the Schur off-diagonal, so henrici_eta_n(cL) scales linearly with the time-unit rescale c; only the pseudospectral radius was normalized here. As a result, a strongly non-normal phantom system near this threshold can fall through to A1/A12 when expressed in slower units even though the physics is unchanged, and the new synthetic rescale tests miss it because they keep Henrici fixed by hand. Please normalize this non-normality signal or use a scale-free gate before claiming/reserving scale-invariant F5 classification.

Useful? React with 👍 / 👎.

return "A10", "F5"
# F1 overlap/eigenvector amplification (Mori-Shirai 2020): non-normal
# amplification flagged by high Kreiss constant + Petermann factor
Expand All @@ -141,14 +158,14 @@ def _pick_a_class(
# DEPENDENT trace-distance curve, whereas pseudospectral_radius / henrici /
# trans_amplitude / kreiss / petermann are operator-INTRINSIC. A strongly
# non-normal phantom/skin operator with an rho_0 that excites only the slow
# gap mode yields a clean single-exp at the gap rate; awarding A1/F1
# gap mode yields a clean single-exp at the gap rate; awarding A1/"none"
# CONFIRMED there would shadow the true A10/F5 (or A3/A4) mechanism. So the
# gap-failure families are decided first; A1 is reached only when none fire.
if (
ev.get("gap_rate_consistency", float("inf")) < 0.05
and ev.get("d17_linear_single_exp", 0.0) > 0.5
):
return "A1", "F1"
return "A1", "none"
# Oscillatory transient
if ev["has_complex_pairs"] > 0 and relaxation.aicc_model == "M3b":
return "A8", "none"
Expand All @@ -160,9 +177,9 @@ def _pick_a_class(
return "A5", "none"
# Strong gap consistency (M0 winner, beta_D close to Delta)
if ev["gap_rate_consistency"] < 0.05 and relaxation.aicc_model == "M0":
return "A1", "F1"
return "A1", "none"
if ev["gap_rate_consistency"] < 0.20:
return "A1", "F1"
return "A1", "none"
return "A12", "none"


Expand All @@ -180,8 +197,17 @@ def _pick_verdict_tier(
return VERDICT_CONFIRMED, TIER_PUBLICATION
if confidence >= 0.60:
return VERDICT_CANDIDATE, TIER_CONFIRMATION
if confidence < 0.30:
return VERDICT_EXCLUDED, TIER_EXPLORATION
# issue #70 A5: no genuine EXCLUDED (active-rejection) branch here. A
# single-pass, maximum-evidence classifier reports the BEST-fit A-class with
# its support -- it never reports a class it is simultaneously ruling out, so
# a per-class "EXCLUDED" verdict is not expressible in this architecture
# (active exclusion needs per-hypothesis scoring, deferred; see PR body). The
# old ``confidence < 0.30 -> EXCLUDED`` branch was also SEMANTICALLY wrong:
# low confidence in the best-fit class is epistemic uncertainty ("unresolved"
# = NOT_EXCLUDED), not positive counter-evidence ("ruled out"). It was
# additionally unreachable (the only sub-0.30 confidence, A12 = 0.20,
# short-circuits to NOT_EXCLUDED above). Low confidence now correctly falls
# through to NOT_EXCLUDED.
return VERDICT_NOT_EXCLUDED, TIER_EXPLORATION


Expand Down
33 changes: 27 additions & 6 deletions src/liouscope/diagnostics/lep.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,25 +21,46 @@ def lep_proximity(eigenvalues: np.ndarray, *, atol: float = EPS_GAP) -> tuple[fl
"""Minimum eigenvalue-pair separation, including complex-conjugate pairs.

Returns ``(min_sep, candidate_count)`` where ``candidate_count`` is the
number of pairs within ``10 * min_sep`` of each other.
number of pairs within a proximity window of the closest pair.

Issue #70 A9: a Liouvillian exceptional point (LEP) is where two eigenvalues
(and their eigenvectors) coalesce, so eigenvalue separation -> 0 is the
signature of *approaching* an EP and an EXACT degeneracy is the STRONGEST
possible proximity signal. The pre-#70 scan skipped every pair with
``sep <= atol`` (``if sep > atol``), which discarded exactly that signal:
an exactly (or numerically) degenerate pair was invisible, and a fully
degenerate spectrum returned ``inf`` -- "maximally FAR from an EP" -- the
exact inverse of the physics. The scan below keeps all ``i < j`` pairs; a
minimum separation at or below ``atol`` is clamped to ``0.0`` (coalesced =
proximity 0, not inf). The candidate-count loop uses the SAME data and a
proximity window ``max(10 * min_sep, atol)`` so that when ``min_sep == 0``
the window is ``atol`` (counting the coalesced cluster) rather than a
degenerate zero-width window -- the two loops are now mutually consistent.

D16 measures eigenvalue proximity only; it cannot by itself distinguish a
genuine (defective) EP from a semisimple / symmetry-protected degeneracy.
That disambiguation is the job of the non-normality layer (Petermann factor
D9), which the classifier combines with this signal.
"""
eigenvalues = np.asarray(eigenvalues)
n = eigenvalues.size
if n < 2:
return float("inf"), 0
min_sep = float("inf")
pairs_close = 0
for i in range(n):
for j in range(i + 1, n):
sep = float(abs(eigenvalues[i] - eigenvalues[j]))
if sep < min_sep and sep > atol:
if sep < min_sep:
min_sep = sep
if not np.isfinite(min_sep):
return float("inf"), 0
# Exact / numerically-degenerate closest pair == strongest EP signal.
if min_sep <= atol:
min_sep = 0.0
window = max(10.0 * min_sep, atol)
pairs_close = 0
for i in range(n):
for j in range(i + 1, n):
sep = float(abs(eigenvalues[i] - eigenvalues[j]))
if sep <= 10.0 * min_sep:
if sep <= window:
pairs_close += 1
return min_sep, pairs_close

Expand Down
67 changes: 59 additions & 8 deletions tests/test_classification.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@
TIER_PUBLICATION,
VERDICT_CANDIDATE,
VERDICT_CONFIRMED,
VERDICT_EXCLUDED,
VERDICT_NOT_EXCLUDED,
VERDICT_UNDEFINED,
)
Expand Down Expand Up @@ -320,6 +319,52 @@ def test_branch_a8_oscillatory_transient():
assert (cls.a_class, cls.f_family) == ("A8", "none")


@pytest.mark.parametrize("c", [0.001, 0.01, 0.1, 1.0, 10.0, 1000.0])
def test_a8_f5_rule_is_scale_invariant_under_rescale(c):
# issue #70 A8 (metamorphic): rescaling the Liouvillian L -> cL rescales
# every RATE-dimensioned quantity by c -- the spectral gap, the GNS gap and
# the pseudospectral radius -- while dimensionless quantities (gap_to_gns
# ratio, henrici) are invariant. A dimension-coherent F5 rule must NOT flip
# the A10/F5 verdict under this rescale (the physics is unchanged; only the
# unit of time changed). The fixed rule compares the dimensionless reach
# radius/gap, which is scale-invariant.
spectral = _spectral(gap=0.5 * c, gns_gap=0.5 * c, kms_gap=0.5 * c)
resolvent = _resolvent(pseudospectral_radius=5.0 * c)
nonnorm = _nonnorm(henrici_eta=2.0)
cls = _classify(spectral=spectral, resolvent=resolvent, nonnorm=nonnorm)
assert (cls.a_class, cls.f_family) == ("A10", "F5")


def test_a8_old_bare_radius_rule_would_have_flipped_under_rescale():
# Load-bearing regression: the pre-#70 rule compared the BARE radius (a rate)
# against 2 * gap_to_gns (dimensionless). Under a strong down-scale the bare
# radius drops below the threshold and the OLD rule would have dropped A10/F5,
# whereas the same physical system stays A10/F5 with the dimension-coherent
# rule. This pins that the fix is real, not cosmetic.
c = 0.01
spectral = _spectral(gap=0.5 * c, gns_gap=0.5 * c, kms_gap=0.5 * c)
resolvent = _resolvent(pseudospectral_radius=5.0 * c)
nonnorm = _nonnorm(henrici_eta=2.0)
cls = _classify(spectral=spectral, resolvent=resolvent, nonnorm=nonnorm)
ev = cls.evidence
# New (dimension-coherent) rule fires:
assert ev["pseudospectral_radius"] / ev["gap"] > 2.0 * ev["gap_to_gns_ratio"]
assert (cls.a_class, cls.f_family) == ("A10", "F5")
# Old (bare-radius) rule would NOT have fired on the same rescaled system:
assert not (ev["pseudospectral_radius"] > 2.0 * ev["gap_to_gns_ratio"])


def test_a8_f5_fires_on_gapless_strongly_nonnormal_operator():
# issue #70 A8 edge: a vanishing spectral gap with strong non-normality is
# the phantom/critical limit -> infinite pseudospectral reach -> F5 fires.
cls = _classify(
spectral=_spectral(gap=0.0),
resolvent=_resolvent(pseudospectral_radius=5.0),
nonnorm=_nonnorm(henrici_eta=2.0),
)
assert (cls.a_class, cls.f_family) == ("A10", "F5")


def test_branch_a10_jordan_block_m3a():
cls = _classify(relaxation=_relaxation(aicc_model="M3a"))
assert (cls.a_class, cls.f_family) == ("A10", "F5")
Expand All @@ -331,19 +376,22 @@ def test_branch_a5_biexponential_m2():


def test_branch_a1_strong_gap_consistency():
# issue #70 A6: A1 (gap-controlled, primitive QMS) is the NO-gap-failure case
# and must map to family "none", not F1 (Mori-Shirai overlap gap-FAILURE).
cls = _classify(
relaxation=_relaxation(aicc_model="M0"),
lep=_lep(gap_rate_consistency=0.01),
)
assert (cls.a_class, cls.f_family) == ("A1", "F1")
assert (cls.a_class, cls.f_family) == ("A1", "none")
assert cls.confidence == pytest.approx(0.95)
assert cls.verdict == VERDICT_CONFIRMED


def test_branch_a1_moderate_gap_consistency():
# gap_rate_consistency < 0.20 but not the M0 + < 0.05 strong combo.
# issue #70 A6: A1 -> family "none" (no gap-failure mechanism flagged).
cls = _classify(lep=_lep(gap_rate_consistency=0.1))
assert (cls.a_class, cls.f_family) == ("A1", "F1")
assert (cls.a_class, cls.f_family) == ("A1", "none")
assert cls.confidence == pytest.approx(0.5)
assert cls.verdict == VERDICT_NOT_EXCLUDED

Expand All @@ -356,7 +404,7 @@ def test_a1_early_branch_reached_when_no_gap_failure_family():
relaxation=_relaxation(aicc_model="M2", linear_fit_model="M0"),
lep=_lep(gap_rate_consistency=0.01),
)
assert (cls.a_class, cls.f_family) == ("A1", "F1")
assert (cls.a_class, cls.f_family) == ("A1", "none") # issue #70 A6
assert cls.verdict == VERDICT_CONFIRMED # confidence 0.95 (D17 < 0.05)


Expand Down Expand Up @@ -400,9 +448,11 @@ def test_undefined_when_beta_not_finite():
assert cls.tier == TIER_EXPLORATION


# Direct unit tests for the verdict/tier mapping, including branches that are
# unreachable through the natural confidence values (e.g. EXCLUDED requires a
# non-A12 class with confidence < 0.30).
# Direct unit tests for the verdict/tier mapping. issue #70 A5: there is no
# longer an EXCLUDED branch -- low confidence in the best-fit class is epistemic
# uncertainty ("unresolved" = NOT_EXCLUDED), not active counter-evidence. The
# sub-0.30 row below now PINS that corrected semantics (previously it wrongly
# expected EXCLUDED, a verdict diagnose() could never emit anyway).
@pytest.mark.parametrize(
"confidence,expected_verdict,expected_tier",
[
Expand All @@ -411,7 +461,8 @@ def test_undefined_when_beta_not_finite():
(0.70, VERDICT_CANDIDATE, TIER_CONFIRMATION),
(0.60, VERDICT_CANDIDATE, TIER_CONFIRMATION),
(0.45, VERDICT_NOT_EXCLUDED, TIER_EXPLORATION),
(0.20, VERDICT_EXCLUDED, TIER_EXPLORATION),
(0.20, VERDICT_NOT_EXCLUDED, TIER_EXPLORATION),
(0.0, VERDICT_NOT_EXCLUDED, TIER_EXPLORATION),
],
)
def test_pick_verdict_tier_thresholds(confidence, expected_verdict, expected_tier):
Expand Down
Loading
Loading