File size: 24,845 Bytes
1062742
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
"""Streamlit app for QK/OV equilibrium observables.

Treats the QK circuit as an interaction (W_QK = symmetric Hamiltonian S +
antisymmetric circulating part A) and the OV circuit as a state
(rho_OV = W_OV W_OV^T / Tr), and visualizes how far each head sits from
equilibrium. Three sections mirror the observable families:

  1. Cross-circuit equilibrium β€” the commutator C = [rho_OV, W_QK] (Theta).
  2. QK irreversibility β€” antisymmetric fraction f_A and non-normality nu.
  3. Spectra & element statistics of S and A.

Discovers runs automatically from the output directory:
  - Output root: $DATA_PATH if set, otherwise ./outputs
  - Runs: any `*_equilibrium_spectra.npz` under the root (with a sibling
    `*_scalars.csv`), produced by `run_correlations.py --equilibrium`.

Usage:
    streamlit run apps/equilibrium_app.py
    DATA_PATH=eq_outputs streamlit run apps/equilibrium_app.py
"""

import os
from pathlib import Path

import numpy as np
import pandas as pd
import plotly.graph_objects as go
from plotly.subplots import make_subplots
from scipy.stats import gaussian_kde
import streamlit as st

st.set_page_config(
    page_title="QK/OV Equilibrium",
    page_icon="βš–οΈ",
    layout="wide",
    initial_sidebar_state="expanded",
)

DEFAULT_OUTPUT_DIR = Path(os.environ.get("DATA_PATH", "outputs"))
SUFFIX = "_equilibrium_spectra.npz"

# Plotly default qualitative palette (matches the other study apps)
BLUE, RED, GREEN, PURPLE, ORANGE, CYAN, PINK = (
    "#636EFA", "#EF553B", "#00CC96", "#AB63FA", "#FFA15A", "#19D3F3", "#FF6692")


# ── Discovery & loading ───────────────────────────────────────────────

def discover_runs(root: Path):
    return sorted(root.rglob(f"*{SUFFIX}")) if root.is_dir() else []


@st.cache_data(show_spinner="Loading equilibrium outputs...")
def load_run(npz_path: str):
    p = Path(npz_path)
    run_key = p.name[: -len(SUFFIX)]
    z = np.load(p)
    arrays = {k: z[k] for k in z.files}
    df = (pd.read_csv(p.parent / f"{run_key}_scalars.csv")
          .sort_values(["layer", "head"]).reset_index(drop=True))
    return run_key, df, arrays


# ── Plot helpers ──────────────────────────────────────────────────────

def _grid(df, col, n_layers, n_heads):
    g = df.pivot_table(index="layer", columns="head", values=col)
    return g.reindex(index=range(n_layers), columns=range(n_heads)).values


def _heatmap(z, title, colorscale="Viridis", zmid=None, cbar=""):
    fig = go.Figure(go.Heatmap(
        z=z, colorscale=colorscale, zmid=zmid, colorbar_title=cbar,
        hovertemplate="layer %{y}, head %{x}<br>%{z:.4g}<extra></extra>"))
    fig.update_layout(title=title, xaxis_title="head", yaxis_title="layer",
                      height=380, yaxis=dict(autorange="reversed"),
                      margin=dict(l=10, r=10, t=40, b=10))
    return fig


def _hist(vals, title, xtitle, color, vlines=(), log_y=False):
    vals = np.asarray(vals, float)
    vals = vals[np.isfinite(vals)]
    fig = go.Figure(go.Histogram(x=vals, nbinsx=40, marker_color=color))
    for x in vlines:
        fig.add_vline(x=x, line_dash="dash", line_color="#888")
    fig.update_layout(title=title, xaxis_title=xtitle, yaxis_title="heads",
                      height=340, bargap=0.02, margin=dict(l=10, r=10, t=40, b=10),
                      yaxis_type="log" if log_y else "linear")
    return fig


def _spectral_hist(vals, title, xtitle, color):
    """Histogram of a spectrum; clip the y-scale to the non-zero bins and
    annotate the pile-up bin (the small/near-zero modes otherwise wipe out
    the scale of the modes that carry the structure)."""
    vals = np.asarray(vals, float)
    vals = vals[np.isfinite(vals)]
    fig = go.Figure()
    if vals.size == 0:
        return fig.update_layout(title=title, height=320)
    counts, edges = np.histogram(vals, bins=40)
    centers = 0.5 * (edges[:-1] + edges[1:])
    zbin = int(np.argmin(np.abs(centers)))           # bin nearest zero
    others = np.delete(counts, zbin)
    ymax = float(others.max()) if others.size and others.max() > 0 else float(counts.max())
    fig.add_bar(x=centers, y=counts, width=edges[1] - edges[0], marker_color=color)
    fig.add_vline(x=0, line_dash="dash", line_color="#aaa")
    if counts[zbin] > ymax:
        fig.add_annotation(x=centers[zbin], y=ymax, ay=-26,
                           text=f"{int(counts[zbin])} @β‰ˆ{centers[zbin]:.2g}",
                           font=dict(size=11, color=color), arrowhead=2)
        fig.update_yaxes(range=[0, ymax * 1.15])
    fig.update_layout(title=title, xaxis_title=xtitle, yaxis_title="modes",
                      height=320, bargap=0.02, margin=dict(l=10, r=10, t=40, b=10))
    return fig


# Spectral stats reuse head_metrics' singular-value battery; for S the values
# fed in are |Ξ»(S)| and for A they are Ο‰_k (rotation rates = singular values of
# A). The `sv_*` columns are those magnitudes β€” relabel for clarity.
SPEC_BASE = {"S": "|Ξ»|", "A": "Ο‰"}
SUFFIX_NICE = {
    "sv_mean": "mean", "sv_variance": "variance", "sv_skewness": "skewness",
    "sv_kurtosis": "kurtosis", "sv_sum": "sum", "sv_sum_squares": "sum of squares",
    "participation_ratio": "participation ratio",
    "normalized_participation_ratio": "norm. participation ratio",
    "spectral_entropy": "spectral entropy", "condition_number": "condition number",
    "stable_rank": "stable rank",
    "sum": "element sum", "mean": "element mean", "std": "element std",
    "max": "element max", "min": "element min", "skew": "element skew",
    "kurtosis": "element kurtosis", "differential_entropy": "element diff. entropy",
    "entropy": "element entropy", "fit_mu": "fit ΞΌ", "fit_sigma": "fit Οƒ",
    "kl_vs_empirical_normal": "KL vs normal",
}


def _suffix_label(suf):
    return SUFFIX_NICE.get(suf, suf.replace("_", " "))


def _overlay_label(suf):
    base = _suffix_label(suf)
    return f"{base} of |Ξ»|/Ο‰" if suf.startswith("sv_") else base


def _stat_label(col):
    part, suf = col[0], col[2:]
    base = _suffix_label(suf)
    return f"{part} Β· {base} of {SPEC_BASE[part]}" if suf.startswith("sv_") \
        else f"{part} Β· {base}"


SCALAR_LABEL = {
    "Theta": "Θ", "Theta_supp": "Θ_supp", "Theta_curr": "Θ_curr",
    "Theta_full": "Θ_full", "f_A": "f_A", "nu": "ν", "nu_norm": "ν_norm",
    "nu_align": "Ξ½_align", "norm_C": "β€–Cβ€–", "norm_C_anti": "β€–C_antiβ€–",
    "norm_C_sym": "β€–C_symβ€–", "norm_rho_OV": "‖ρ_OVβ€–", "norm_W_QK": "β€–W_QKβ€–",
    "norm_S": "β€–Sβ€–", "norm_A": "β€–Aβ€–", "norm_PSP": "β€–PSPβ€–",
    "decomp_within": "decomp within", "decomp_cross": "decomp cross",
}


def _var_label(col):
    return _stat_label(col) if col.startswith(("S_", "A_")) else SCALAR_LABEL.get(col, col)


# ── Sections ──────────────────────────────────────────────────────────

def section_overview(run_key, df, arrays):
    n_layers, n_heads = int(df.layer.max()) + 1, int(df["head"].max()) + 1
    d_model = arrays["lambda_S"].shape[1]
    st.title("QK/OV equilibrium")
    st.caption(
        "Per head, QK is read as an interaction (W_QK = S + A, a symmetric "
        "Hamiltonian S plus an antisymmetric circulating part A) and OV as a state "
        "(rho_OV = W_OV W_OVα΅€ / Tr). Distance from equilibrium is the failure of "
        "rho_OV to commute with the QK energy. All quantities are weight-only.")
    c = st.columns(5)
    c[0].metric("Heads", f"{len(df)}")
    c[1].metric("Layers Γ— heads", f"{n_layers} Γ— {n_heads}")
    c[2].metric("d_model", f"{d_model}")
    c[3].metric("median Θ", f"{np.nanmedian(df['Theta']):.3f}",
                help="Energy mismatch β€–[ρ_OV,S]β€– / (‖ρ_OVβ€–β€–Sβ€–); 0 = equilibrium")
    c[4].metric("median f_A", f"{np.nanmedian(df['f_A']):.3f}",
                help="Antisymmetric (non-reciprocal) fraction of W_QK")
    return n_layers, n_heads, d_model


def section_commutator(df, n_layers, n_heads):
    st.header("1 Β· Cross-circuit equilibrium")
    st.caption(
        "C = [ρ_OV, W_QK], split by symmetry into C_anti = [ρ_OV, S] (energy "
        "mismatch) and C_sym = [ρ_OV, A] (current alignment). **Θ** = β€–C_antiβ€– / "
        "(‖ρ_OVβ€–β€–Sβ€–) is distance from equilibrium: Θ = 0 means ρ_OV and the energy S "
        "are simultaneously diagonalizable (Gibbs). But Θ is dominated by the trivial "
        "**support↔null leakage** β€” ρ_OV is rank ≀ head_dim, so S mostly couples OV's "
        "tiny used subspace to the huge unused complement (a random S gives the same "
        "β‰ˆ0.98 leakage fraction). **Θ_supp** restricts to ρ_OV's support β€” equilibrium "
        "*within the subspace OV actually uses* β€” and is the part that carries real, "
        "weights-specific signal. NaN denotes a head with β€–Sβ€– or β€–Aβ€– β‰ˆ 0.")

    df = df.copy()
    denom = df["decomp_within"] + df["decomp_cross"]
    df["null_leak_frac"] = np.where(denom > 0, df["decomp_cross"] / denom, np.nan)
    has_supp = "Theta_supp" in df.columns

    k = st.columns(4)
    k[0].metric("median Θ", f"{np.nanmedian(df['Theta']):.3f}",
                help="Full energy mismatch; rank-mismatch dominated")
    if has_supp:
        k[1].metric("median Θ_supp", f"{np.nanmedian(df['Theta_supp']):.3f}",
                    help="Equilibrium within ρ_OV's support β€” the meaningful part")
    k[2].metric("median Θ_full", f"{np.nanmedian(df['Theta_full']):.3f}")
    k[3].metric("median null-leak", f"{np.nanmedian(df['null_leak_frac']):.3f}",
                help="Fraction of β€–[ρ_OV,S]β€–Β² leaking to OV's null space (β‰ˆ0.98 "
                     "is the generic rank-deficiency baseline)")

    choices = {"Θ β€” energy mismatch (full)": ("Theta", "Viridis", None)}
    if has_supp:
        choices["Θ_supp β€” equilibrium within OV's support"] = ("Theta_supp", "Viridis", None)
    choices.update({
        "Θ_curr β€” current alignment": ("Theta_curr", "Viridis", None),
        "Θ_full β€” full commutator": ("Theta_full", "Viridis", None),
        "Null-leakage fraction of β€–[ρ_OV,S]β€–Β²": ("null_leak_frac", "Magma", None),
    })
    default_idx = 1 if has_supp else 0
    label = st.selectbox("Map", list(choices), index=default_idx, key="f1_map")
    col, cs, zmid = choices[label]

    a, b = st.columns([3, 2])
    a.plotly_chart(_heatmap(_grid(df, col, n_layers, n_heads), label,
                            colorscale=cs, zmid=zmid, cbar=col),
                   use_container_width=True)
    b.plotly_chart(_hist(df[col], f"P({col})", col, BLUE),
                   use_container_width=True)
    if col == "null_leak_frac":
        st.caption("Splits β€–[ρ_OV,S]β€–Β² into reorganization *within* the directions OV "
                   "uses vs. leakage into OV's unused null directions. β‰ˆ0.98 for every "
                   "head β€” and a random S reproduces it β€” so this is rank-deficiency "
                   "geometry, not learned structure. Θ_supp isolates the within part.")

    st.plotly_chart(
        go.Figure(go.Scatter(
            x=df["layer"], y=df["Theta"], mode="markers",
            marker=dict(color=df["f_A"], colorscale="Plasma", showscale=True,
                        colorbar_title="f_A", size=7),
            text=[f"L{l}H{h}" for l, h in zip(df.layer, df["head"])],
            hovertemplate="%{text}<br>layer=%{x}<br>Θ=%{y:.4f}<extra></extra>"))
        .update_layout(title="Θ vs layer (colored by f_A)", xaxis_title="layer",
                       yaxis_title="Θ", height=340, margin=dict(l=10, r=10, t=40, b=10)),
        use_container_width=True)


def section_irreversibility(df, n_layers, n_heads):
    st.header("2 Β· QK irreversibility (weight-only)")
    st.caption(
        "**f_A** = β€–Aβ€–Β²/β€–W_QKβ€–Β² is the non-reciprocal fraction of the interaction: "
        "0 = fully reciprocal, Β½ = equipartition, 1 = pure circulation. **Ξ½** = "
        "β€–[W_QK, W_QKα΅€]β€– is non-normality. Since [W_QK, W_QKα΅€] = 2[A, S] (the SΒ² and "
        "AΒ² parts cancel), Ξ½ = 2β€–[A, S]β€– is purely an S–A interaction: it needs both "
        "parts present *and* non-commuting. So **Ξ½_align** = β€–[A, S]β€–/(β€–Aβ€–β€–Sβ€–) is the "
        "magnitude-free piece independent of f_A β€” as f_A β†’ 1, S β†’ 0 and Ξ½ itself "
        "collapses (the pure-rotation limit), so over the full range Ξ½ is "
        "non-monotonic in f_A, not simply correlated.")

    df = df.copy()
    if "nu_align" not in df.columns:
        denom = 2 * df["norm_A"] * df["norm_S"]
        df["nu_align"] = np.where(denom > 0, df["nu"] / denom, np.nan)

    k = st.columns(3)
    k[0].metric("median f_A", f"{np.nanmedian(df['f_A']):.3f}")
    k[1].metric("median Ξ½_norm", f"{np.nanmedian(df['nu_norm']):.3f}")
    k[2].metric("median Ξ½_align", f"{np.nanmedian(df['nu_align']):.3f}",
                help="β€–[A,S]β€–/(β€–Aβ€–β€–Sβ€–); 0 ⇔ A and S commute (W_QK normal)")

    nu_choices = {"Ξ½_align β€” A↔S alignment (magnitude-free)": "nu_align",
                  "Ξ½ / β€–W_QKβ€–Β² β€” non-normality": "nu_norm"}
    nu_label = st.selectbox("Non-normality measure", list(nu_choices), key="f2_nu")
    nu_col = nu_choices[nu_label]

    a, b = st.columns(2)
    a.plotly_chart(_heatmap(_grid(df, "f_A", n_layers, n_heads),
                            "f_A β€” antisymmetric fraction", colorscale="RdBu",
                            zmid=0.5, cbar="f_A"), use_container_width=True)
    b.plotly_chart(_heatmap(_grid(df, nu_col, n_layers, n_heads), nu_label,
                            colorscale="Cividis", cbar=nu_col),
                   use_container_width=True)

    a, b = st.columns(2)
    a.plotly_chart(
        go.Figure(go.Scatter(
            x=df["f_A"], y=df[nu_col], mode="markers",
            marker=dict(color=df["layer"], colorscale="Turbo", showscale=True,
                        colorbar_title="layer", size=7),
            text=[f"L{l}H{h}" for l, h in zip(df.layer, df["head"])],
            hovertemplate="%{text}<br>f_A=%{x:.3f}<br>"
                          + nu_col + "=%{y:.3f}<extra></extra>"))
        .update_layout(title=f"f_A vs {nu_col} (independence check)",
                       xaxis_title="f_A", yaxis_title=nu_col, height=360,
                       margin=dict(l=10, r=10, t=40, b=10)),
        use_container_width=True)
    b.plotly_chart(_hist(df["f_A"], "P(f_A)", "f_A", GREEN, vlines=(0.5,)),
                   use_container_width=True)


def section_spectra(df, arrays, n_layers, n_heads):
    st.header("3 Β· Spectra & element statistics of S and A")
    st.caption(
        "S is symmetric indefinite β€” its **signed** eigenvalues are the energy "
        "density of states (asymmetry about 0 is the element-mean/skew of W_QK, which "
        "lives entirely in S). A is antisymmetric β€” its spectrum is the set of "
        "rotation rates Ο‰_k. Element distributions: A has zero diagonal and zero "
        "mean by construction.")

    labels = [f"L{int(l)}H{int(h)}" for l, h in zip(df.layer, df["head"])]
    arr_idx = {(int(l), int(h)): i for i, (l, h) in enumerate(arrays["head_index"])}
    sel = st.selectbox("Head", labels, key="f3_head")
    l, h = (int(x) for x in sel[1:].split("H"))
    i = arr_idx[(l, h)]

    lam = arrays["lambda_S"][i]
    omega = arrays["omega_A"][i]
    tol = 1e-6 * max(np.abs(lam).max(), 1e-30)
    lam_nz = lam[np.abs(lam) > tol]
    omega_nz = omega[omega > 1e-6 * max(omega.max(), 1e-30)]
    st.caption(f"{sel}: rank(S) β‰ˆ {len(lam_nz)}, active current loops β‰ˆ "
               f"{len(omega_nz) // 2} (of d_model = {len(lam)}). Spectral histograms "
               "below clip the near-zero pile-up bin (its count is annotated) so the "
               "rest of the spectrum is visible.")

    c = st.columns(3)
    c[0].plotly_chart(_spectral_hist(lam_nz, "signed eig(S) β€” energy DOS", "Ξ»", RED),
                      use_container_width=True)
    c[1].plotly_chart(_spectral_hist(omega_nz, "Ο‰_k(A) β€” rotation rates", "Ο‰", PURPLE),
                      use_container_width=True)

    bins = arrays["w_bins"]
    centers = 0.5 * (bins[:-1] + bins[1:])
    figE = go.Figure()
    figE.add_scatter(x=centers, y=arrays["P_w_S"][i], name="S", line_shape="hv",
                     fill="tozeroy", line=dict(color=RED),
                     fillcolor="rgba(239,85,59,0.30)")
    figE.add_scatter(x=centers, y=arrays["P_w_A"][i], name="A", line_shape="hv",
                     fill="tozeroy", line=dict(color=BLUE),
                     fillcolor="rgba(99,110,250,0.30)")
    figE.update_layout(title="element density P(w)", xaxis_title="matrix element",
                       yaxis_title="density", height=320, yaxis_type="log",
                       margin=dict(l=10, r=10, t=40, b=10),
                       legend=dict(x=0.01, y=0.99))
    c[2].plotly_chart(figE, use_container_width=True)

    # ── Per-head statistic map (2D) ──
    st.subheader("Per-head statistic map")
    stat_cols = [c for c in df.columns if c.startswith(("S_", "A_"))]
    default = stat_cols.index("S_spectral_entropy") if "S_spectral_entropy" in stat_cols else 0
    metric = st.selectbox("Statistic", stat_cols, index=default, key="f3_stat",
                          format_func=_stat_label)
    st.plotly_chart(_heatmap(_grid(df, metric, n_layers, n_heads), _stat_label(metric),
                             colorscale="Viridis", cbar=metric),
                    use_container_width=True)

    # ── Unrolled headΓ—layer views ──
    st.subheader("Unrolled (head Γ— layer)")
    st.caption("Heads in a single sequence ordered by (layer, head); dashed lines mark "
               "layer boundaries. Lets you compare element distributions across every "
               "head at once, and overlay an S vs A statistic at fixed head.")
    layer_starts = list(range(0, len(df), n_heads))
    layer_ticks = [f"L{k // n_heads}" for k in layer_starts]

    part = st.radio("P(w) matrix", ["S", "A"], horizontal=True, key="f3_pw_part")
    Pw = arrays[f"P_w_{part}"]
    figpw = go.Figure(go.Heatmap(
        x=centers, y=list(range(len(Pw))), z=np.log10(Pw + 1e-6),
        colorscale="Inferno", colorbar_title="log₁₀ P(w)",
        hovertemplate="w=%{x:.3f}<br>headΓ—layer %{y}<br>log₁₀P=%{z:.2f}<extra></extra>"))
    for k in layer_starts[1:]:
        figpw.add_hline(y=k - 0.5, line_color="rgba(255,255,255,0.18)", line_width=1)
    figpw.update_yaxes(tickvals=layer_starts, ticktext=layer_ticks,
                       autorange="reversed")
    figpw.update_layout(title=f"P(w) for {part} across heads", height=460,
                        xaxis_title="matrix element w", yaxis_title="head Γ— layer",
                        margin=dict(l=10, r=10, t=40, b=10))
    st.plotly_chart(figpw, use_container_width=True)

    suffixes = sorted({c[2:] for c in df.columns if c.startswith("S_")}
                      & {c[2:] for c in df.columns if c.startswith("A_")})
    sd = suffixes.index("stable_rank") if "stable_rank" in suffixes else 0
    suf = st.selectbox("Statistic (S vs A overlay)", suffixes, index=sd,
                       key="f3_overlay", format_func=_overlay_label)
    figov = go.Figure()
    figov.add_scatter(x=list(range(len(df))), y=df[f"S_{suf}"], mode="lines",
                      name=_stat_label(f"S_{suf}"), line=dict(color=RED))
    figov.add_scatter(x=list(range(len(df))), y=df[f"A_{suf}"], mode="lines",
                      name=_stat_label(f"A_{suf}"), line=dict(color=BLUE))
    for k in layer_starts[1:]:
        figov.add_vline(x=k - 0.5, line_color="rgba(128,128,128,0.30)", line_width=1)
    figov.update_xaxes(tickvals=layer_starts, ticktext=layer_ticks)
    figov.update_layout(title=f"S vs A Β· {_overlay_label(suf)} (unrolled)",
                        xaxis_title="head Γ— layer", yaxis_title=_overlay_label(suf),
                        height=380, margin=dict(l=10, r=10, t=40, b=10),
                        legend=dict(x=0.01, y=0.99))
    st.plotly_chart(figov, use_container_width=True)


def section_corner(df):
    st.header("4 Β· Corner plot")
    st.caption(
        "Pairwise relationships across heads for any four observables: lower "
        "triangle is the per-head scatter (colored by layer), the diagonal is the "
        "marginal distribution. Use it to read off correlations and outliers β€” e.g. "
        "Θ vs f_A vs ν_align, or a spectral statistic against the commutator.")

    num_cols = [c for c in df.columns if c not in ("layer", "head")
                and np.issubdtype(df[c].dtype, np.number)]
    defaults = [c for c in ("Theta", "f_A", "nu_align", "Theta_full") if c in num_cols]
    while len(defaults) < 4:
        defaults.append(num_cols[len(defaults) % len(num_cols)])

    N = 4
    sel_cols = st.columns(N)
    vars_ = [sel_cols[k].selectbox(f"Variable {k + 1}", num_cols,
                                   index=num_cols.index(defaults[k]),
                                   format_func=_var_label, key=f"corner_var_{k}")
             for k in range(N)]
    diag_mode = st.radio("Diagonal", ["KDE", "Histogram"], horizontal=True,
                         key="corner_diag")

    labels = [f"L{int(l)}H{int(h)}" for l, h in zip(df.layer, df["head"])]
    layer = df["layer"].values

    fig = make_subplots(rows=N, cols=N, horizontal_spacing=0.04, vertical_spacing=0.04)
    for i in range(N):
        for j in range(N):
            if j > i:
                fig.update_xaxes(visible=False, row=i + 1, col=j + 1)
                fig.update_yaxes(visible=False, row=i + 1, col=j + 1)
                continue
            if i == j:
                x = df[vars_[i]].values.astype(float)
                x = x[np.isfinite(x)]
                if len(x) == 0:
                    continue
                if diag_mode == "KDE" and np.ptp(x) > 0:
                    xr = np.linspace(x.min(), x.max(), 200)
                    fig.add_trace(go.Scatter(
                        x=xr, y=gaussian_kde(x)(xr), mode="lines", showlegend=False,
                        line=dict(color=BLUE, width=1.5), fill="tozeroy",
                        fillcolor="rgba(99,110,250,0.15)"), row=i + 1, col=j + 1)
                else:
                    fig.add_trace(go.Histogram(
                        x=x, marker_color=BLUE, opacity=0.7, nbinsx=30,
                        histnorm="probability density", showlegend=False),
                        row=i + 1, col=j + 1)
            else:
                fig.add_trace(go.Scatter(
                    x=df[vars_[j]].values, y=df[vars_[i]].values, mode="markers",
                    marker=dict(color=layer, coloraxis="coloraxis", size=5, opacity=0.6),
                    text=labels, showlegend=False,
                    hovertemplate=f"%{{text}}<br>{_var_label(vars_[j])}=%{{x:.3g}}<br>"
                                  f"{_var_label(vars_[i])}=%{{y:.3g}}<extra></extra>"),
                    row=i + 1, col=j + 1)

    for k in range(N):
        fig.update_xaxes(title_text=_var_label(vars_[k]), row=N, col=k + 1)
        if k > 0:
            fig.update_yaxes(title_text=_var_label(vars_[k]), row=k + 1, col=1)
    if diag_mode == "Histogram":
        fig.update_layout(barmode="overlay")
    fig.update_layout(
        height=760, hovermode="closest",
        margin=dict(l=10, r=10, t=20, b=10),
        coloraxis=dict(colorscale="Turbo",
                       colorbar=dict(title="layer", x=0.82, y=0.80, len=0.45,
                                     thickness=12)))
    st.plotly_chart(fig, use_container_width=True)


# ── Main ──────────────────────────────────────────────────────────────

def main():
    runs = discover_runs(DEFAULT_OUTPUT_DIR)
    run_map = {p.name[: -len(SUFFIX)]: p for p in runs}
    with st.sidebar:
        st.title("QK/OV Equilibrium")
        if not run_map:
            st.warning(f"No `*{SUFFIX}` under `{DEFAULT_OUTPUT_DIR}`. Run "
                       "`run_correlations.py --equilibrium`, or set `DATA_PATH`.")
            return
        sel = st.selectbox("Run", list(run_map))
        npz = run_map[sel]
        st.caption(f"`{npz.parent}`")

    run_key, df, arrays = load_run(str(npz))
    n_layers, n_heads, _ = section_overview(run_key, df, arrays)
    st.divider()
    section_commutator(df, n_layers, n_heads)
    st.divider()
    section_irreversibility(df, n_layers, n_heads)
    st.divider()
    section_spectra(df, arrays, n_layers, n_heads)
    st.divider()
    section_corner(df)


if __name__ == "__main__":
    main()