Current task: —

E-014-cor16-diagnostics.py

Experiments: E-014 · script updated 08-18-2026

E-014-cor16-diagnostics.csv — 2.2 KB, updated 08-18-2026

page 1 of 1 · 500/page · 80 rows
# partatheta_or_jvalue
2 P1_count 6.0 0.000000 -76
3 P1_count 6.0 0.392699 -76
4 P1_count 6.0 0.785398 -76
5 P1_count 6.0 1.178097 -76
6 P1_count 6.0 1.570796 -76
7 P1_count 6.0 1.963495 -75
8 P1_count 6.0 2.356194 -75
9 P1_count 6.0 2.748894 -75
10 P1_count 6.0 3.141593 -75
11 P1_count 6.0 3.534292 -76
12 P1_count 6.0 3.926991 -76
13 P1_count 6.0 4.319690 -76
14 P1_count 6.0 4.712389 -76
15 P1_count 6.0 5.105088 -76
16 P1_count 6.0 5.497787 -76
17 P1_count 6.0 5.890486 -76
18 P2_loc_T 4.0 - 0.81133522
19 P2_loc_L2 4.0 - 0.80424634
20 P3_zero 4.0 1 0.7867814646
21 P3_zero 4.0 2 1.5735649911
22 P3_zero 4.0 3 2.3603526899
23 P3_zero 4.0 4 3.1471467731
24 P3_zero 4.0 5 3.9339496133
25 P3_zero 4.0 6 4.7207638123
26 P3_zero 4.0 7 5.5075922862
27 P3_zero 4.0 8 6.2944384014
28 P3_zero 4.0 9 7.0813061251
29 P3_zero 4.0 10 7.8682003010
30 P4_best_theta 4.0 - 3.141593
31 P4_best_resid 4.0 - 0.01771437
32 P4_median_resid 4.0 - 0.02234287
33 P4_phase_agree 4.0 - 1.000000
34 P2_loc_T 6.0 - 0.81550223
35 P2_loc_L2 6.0 - 0.80966596
36 P3_zero 6.0 1 0.5240776625
37 P3_zero 6.0 2 1.0481554188
38 P3_zero 6.0 3 1.5722334747
39 P3_zero 6.0 4 2.0963122442
40 P3_zero 6.0 5 2.6203915296
41 P3_zero 6.0 6 3.1444719403
42 P3_zero 6.0 7 3.6685540308
43 P3_zero 6.0 8 4.1926361833
44 P3_zero 6.0 9 4.7167188682
45 P3_zero 6.0 10 5.2408032910
46 P4_best_theta 6.0 - 3.141593
47 P4_best_resid 6.0 - 0.02150857
48 P4_median_resid 6.0 - 0.02446875
49 P4_phase_agree 6.0 - 1.000000
50 P2_loc_T 8.0 - 0.82304677
51 P2_loc_L2 8.0 - 0.81862142
52 P3_zero 8.0 1 0.3929737960
53 P3_zero 8.0 2 0.7859477352
54 P3_zero 8.0 3 1.1789218684
55 P3_zero 8.0 4 1.5718960385
56 P3_zero 8.0 5 1.9648711681
57 P3_zero 8.0 6 2.3578469144
58 P3_zero 8.0 7 2.7508231961
59 P3_zero 8.0 8 3.1438000155
60 P3_zero 8.0 9 3.5367773732
61 P3_zero 8.0 10 3.9297553515
62 P4_best_theta 8.0 - 3.141593
63 P4_best_resid 8.0 - 0.02322519
64 P4_median_resid 8.0 - 0.02538998
65 P4_phase_agree 8.0 - 1.000000
66 P2_loc_T 10.0 - 0.82898990
67 P2_loc_L2 10.0 - 0.82514491
68 P3_zero 10.0 1 0.3143148208
69 P3_zero 10.0 2 0.6286296644
70 P3_zero 10.0 3 0.9429444923
71 P3_zero 10.0 4 1.2572595983
72 P3_zero 10.0 5 1.5715742918
73 P3_zero 10.0 6 1.8858897312
74 P3_zero 10.0 7 2.2002047584
75 P3_zero 10.0 8 2.5145198428
76 P3_zero 10.0 9 2.8288362901
77 P3_zero 10.0 10 3.1431521899
78 P4_best_theta 10.0 - 3.141593
79 P4_best_resid 10.0 - 0.02419232
80 P4_median_resid 10.0 - 0.02589723
81 P4_phase_agree 10.0 - 1.000000

E-014-cor16-diagnostics.json — 3.1 KB, updated 08-18-2026

JSON sidecar (click to expand)
{
  "experiment": "E-014",
  "task": "Q-S3: Corollary 1.6 diagnostics (Suzuki line)",
  "description": "theta-independence of zero count; v± localization; zero-flow map vs a; imaginary-axis least-squares read-off of theta(a), phi(a,z)",
  "script": "research/numerics/scripts/E-014-cor16-diagnostics.py",
  "parameters": {
    "a_sweep": [
      4.0,
      6.0,
      8.0,
      10.0
    ],
    "h": 0.01,
    "lambda": -1.0,
    "y_grid": [
      1.0,
      20.0,
      96
    ],
    "phi_deg": 4
  },
  "checks": {
    "V1_theta_count_interlacing": true,
    "V2_lattice_persists": true,
    "V3_xi_crosscheck_and_readoff": true,
    "V4_vmirror_symmetry": true
  },
  "key_values": {
    "P1_counts": [
      -76,
      -76,
      -76,
      -76,
      -76,
      -75,
      -75,
      -75,
      -75,
      -76,
      -76,
      -76,
      -76,
      -76,
      -76,
      -76
    ],
    "P1_spread": 1,
    "P2_localization": {
      "4.0": [
        0.8113352211750133,
        0.8042463400966671
      ],
      "6.0": [
        0.815502234769262,
        0.8096659632054103
      ],
      "8.0": [
        0.8230467697557222,
        0.8186214210851552
      ],
      "10.0": [
        0.8289899043479302,
        0.8251449076417641
      ]
    },
    "P3_first_zeros": {
      "4.0": [
        0.78678146,
        1.57356499,
        2.36035269,
        3.14714677,
        3.93394961
      ],
      "6.0": [
        0.52407766,
        1.04815542,
        1.57223347,
        2.09631224,
        2.62039153
      ],
      "8.0": [
        0.3929738,
        0.78594774,
        1.17892187,
        1.57189604,
        1.96487117
      ],
      "10.0": [
        0.31431482,
        0.62862966,
        0.94294449,
        1.2572596,
        1.57157429
      ]
    },
    "P4_readoff": {
      "4.0": {
        "best_theta": 3.141592653589793,
        "best_resid": 0.017714374721797224,
        "best_phase_agree": 1.0,
        "median_resid": 0.022342865357109994
      },
      "6.0": {
        "best_theta": 3.141592653589793,
        "best_resid": 0.021508567165581402,
        "best_phase_agree": 1.0,
        "median_resid": 0.0244687465471507
      },
      "8.0": {
        "best_theta": 3.141592653589793,
        "best_resid": 0.023225193635352376,
        "best_phase_agree": 1.0,
        "median_resid": 0.025389981806922614
      },
      "10.0": {
        "best_theta": 3.141592653589793,
        "best_resid": 0.024192319237001752,
        "best_phase_agree": 1.0,
        "median_resid": 0.025897228710069387
      }
    },
    "xi_flint_crosscheck_rel": 4.721803879534242e-17
  },
  "caveats": [
    "Galerkin discretization h=0.01 (E-010 h-convergence: z1 rel drift 4e-4 between h=0.01 and 0.005 at a=4)",
    "read-off on the imaginary axis tests only the modulus shape + phase of e^phi W vs F away from zeros; zero-density gap (F4 of D-020) means a good fit is NOT expected at large a — the residual trend is the observable"
  ],
  "elapsed_seconds": 20.4,
  "timestamp_utc": "2026-08-18T09:22:09.120726+00:00",
  "row_count": 81
}

Source code

Python script (click to expand)
"""E-014 — Q-S3 attack: Corollary 1.6 diagnostics on the Suzuki line.

Theory frame (D-023): Cor 1.6 demands e^{phi}W(a,theta;z) -> z^2 xi/xi'
uniformly on compacts. W has exponential type 2a with ALL REAL zeros
(Thm 1.5); at a <= 8 (E-010) the zeros form a maximal-density lattice
(spacing pi/a, i.e. ~2aT/pi zeros in [0,T]), while the target has
~T log T/2pi zeros. For the limit to hold, the zero density must COLLAPSE
at larger a — zeros stay real (Thm 1.5) so they must FLOW OUT to infinity
as a grows. Since theta only rotates the boundary phase (de Branges
interlacing), the count in [0,T] is theta-independent up to O(1); the
thinning, if it happens, lives in the a-dependence of the deficiency
vectors v± = T_a^{-1} e^{±x}. Four diagnostics:

  P1. theta-independence: zero count in [0, Z_TH] at a=6 for 16 thetas.
  P2. v± localization: edge-mass fraction of v± vs a (thinning channel:
      if v± concentrate at ±a, W's zeros can thin below maximal density).
  P3. zero-flow map: first 10 zeros of W at theta=0 for a in A_SWEEP;
      lattice law z_j ~ (pi/a)(1+rho)j and flow direction (inflow toward
      0 expected while the lattice persists; reversal = signal).
  P4. imaginary-axis read-off: F(iy) = -y^2 xi(1/2+y)/xi'(1/2+y) is real
      for real y. Fit log|W(a,theta;iy)| + p(y) ~ log|F(iy)| over
      y in [1, Y_MAX], p polynomial deg <= 4 (lstsq), theta on a 24-grid.
      Reports best theta(a) and residual vs a; phase-agreement score.

Checks:
  V1. P1: max count - min count <= 2 across theta (interlacing picture).
  V2. P3: lattice law persists: |z_1(a) - pi/a| / (pi/a) < 0.03 for all a.
  V3. P4: pipeline sanity — F(iy) cross-checked vs flint xi (rel 1e-8);
      read-off produces finite residuals with best <= median theta cost.
  V4. P2: v+ = mirror of v- (symmetry, rel 1e-6), machinery regression.

Outputs: research/numerics/data/E-014-cor16-diagnostics.csv (+ .json)
"""
import json
import sys
import time
from datetime import datetime, timezone
from pathlib import Path

import mpmath as mp
import numpy as np
import scipy.linalg
import scipy.optimize
from numpy.polynomial.legendre import leggauss

REPO = Path(__file__).resolve().parents[3]
DATA = REPO / "research" / "numerics" / "data"
CSV = DATA / "E-014-cor16-diagnostics.csv"
JSON = DATA / "E-014-cor16-diagnostics.json"

T_MAXG = 20.0            # sieve to e^20 covers 2a <= 20 (a <= 10)
T_LERCH = 0.05
GL_N = 8
A_SWEEP = [4.0, 6.0, 8.0, 10.0]
H_MAIN = 0.01
LAM_SHIFT = -1.0
Z_TH = 40.0              # P1 window
N_GRID = 20000
N_ZEROS_TRACK = 10
THETAS_P1 = np.linspace(0, 2 * np.pi, 16, endpoint=False)
THETAS_P4 = np.linspace(0, 2 * np.pi, 24, endpoint=False)
Y_GRID = np.linspace(1.0, 20.0, 96)
PHI_DEG = 4


# ---------------------------------------------------------- g (as E-010)
def mangoldt_sieve_fast(n_max: int) -> np.ndarray:
    """Lambda(n)=log p for prime powers; vectorized Eratosthenes +
    vectorized prime-power assignment."""
    is_comp = np.zeros(n_max + 1, dtype=bool)
    for p in range(2, int(n_max ** 0.5) + 1):
        if not is_comp[p]:
            is_comp[p * p::p] = True
    lam_log = np.zeros(n_max + 1)
    primes = np.flatnonzero(~is_comp[2:]) + 2
    lp = np.log(primes)
    pk = primes.copy()
    while pk.size and pk[0] <= n_max:
        lam_log[pk] = lp
        pk = pk * primes[: pk.size]
        keep = pk <= n_max
        pk = pk[keep]
        lp = lp[keep]
    return lam_log


print("sieving von Mangoldt to e^20 ...", flush=True)
t_sv = time.time()
N_MAXG = int(np.exp(T_MAXG)) + 2
LAM_LOG = mangoldt_sieve_fast(N_MAXG)
NS = np.arange(2, N_MAXG + 1, dtype=np.float64)
LAM = LAM_LOG[2:] / np.sqrt(NS)
LOG_NS = np.log(NS)
C1 = np.cumsum(LAM)
C0 = np.cumsum(LAM * LOG_NS)
del LAM, LOG_NS, NS
print(f"  sieve done: {time.time()-t_sv:.0f}s", flush=True)

mp.mp.dps = 30
DIG_COEFF = float(-(mp.digamma(mp.mpf(1) / 4) - mp.log(mp.pi)) / 2)
PHI1 = float(mp.lerchphi(1, 2, mp.mpf(1) / 4))
KK = np.arange(0, 400)
DENOM = (KK + 0.25) ** 2


def prime_sum_vec(t):
    """sum_{n <= e^t} Lambda(n)/sqrt(n) * (t - log n), via C1/C0.
    #{2 <= n <= e^t} = ceil(e^t - eps) - 1 (searchsorted-right equivalent)."""
    m = np.floor(np.exp(t) + 1e-9).astype(np.int64) - 1
    out = np.zeros_like(t)
    pos = m > 0
    idx = m[pos] - 1
    out[pos] = C1[idx] * t[pos] - C0[idx]
    return out


def lerch_comb(t):
    out = np.empty_like(t)
    fast = t > T_LERCH
    if fast.any():
        z = np.exp(-2.0 * t[fast])
        phi = (np.power.outer(z, KK) / DENOM).sum(axis=1)
        out[fast] = -0.25 * (PHI1 - np.exp(-t[fast] / 2) * phi)
    if (~fast).any():
        for i in np.flatnonzero(~fast):
            ti = mp.mpf(float(t[i]))
            out[i] = float(-0.25 * (mp.lerchphi(1, 2, mp.mpf(1) / 4)
                                    - mp.exp(-ti / 2)
                                    * mp.lerchphi(mp.exp(-2 * ti), 2,
                                                  mp.mpf(1) / 4)))
    return out


def g_float(t):
    t = np.asarray(t, dtype=float)
    g = -4.0 * (np.exp(t / 2) + np.exp(-t / 2) - 2.0)
    g += prime_sum_vec(t)
    g += DIG_COEFF * t
    g += lerch_comb(t)
    g[t == 0.0] = 0.0
    return g


A_CONST = 0.5 * (np.log(2 * np.pi) + np.euler_gamma - 1.0)


def sigma_vec(t):
    t = np.asarray(t, dtype=float)
    with np.errstate(divide="ignore", invalid="ignore"):
        return np.where(t > 0, 0.5 * t * np.log(t) + A_CONST * t, 0.0)


def F_sing(t):
    if t <= 0:
        return 0.0
    return (A_CONST * t**3 / 6.0 + t**3 * np.log(t) / 12.0
            - 5.0 * t**3 / 72.0)


def S_sing(k, h):
    return (F_sing((k + 1) * h) - 2.0 * F_sing(k * h)
            + F_sing(abs(k - 1) * h))


XN, XW = leggauss(GL_N)


def S_full(k, h):
    """E-010's S with an adaptive cap: prime-point splitting only when the
    window holds <= 64 log-points (small t, where derivative jumps are
    relatively large); otherwise plain two-panel Gauss (g is C^0, jumps in
    derivative are O(Lambda/sqrt(n)) — negligible at large t)."""
    def quad_plain(lo, hi, peak):
        tot = 0.0
        for a, b in ((lo, peak), (peak, hi)):
            if b - a <= 0:
                continue
            mid, half = 0.5 * (a + b), 0.5 * (b - a)
            xs = mid + half * XN
            rho = g_float(xs) - sigma_vec(xs)
            tot += half * float(np.dot(XW, (h - np.abs(xs - peak)) * rho))
        return tot

    def quad(lo, hi, peak):
        n_lo = int(np.ceil(np.exp(lo)))
        n_hi = int(np.floor(np.exp(hi)))
        if n_hi - n_lo > 64:
            return quad_plain(lo, hi, peak)
        pts = sorted({lo, peak, hi} |
                     {float(np.log(n))
                      for n in range(max(n_lo, 2), n_hi + 1)
                      if lo < np.log(n) < hi})
        tot = 0.0
        for p0, p1 in zip(pts, pts[1:]):
            if p1 - p0 <= 0:
                continue
            mid, half = 0.5 * (p0 + p1), 0.5 * (p1 - p0)
            xs = mid + half * XN
            rho = g_float(xs) - sigma_vec(xs)
            tot += half * float(np.dot(XW, (h - np.abs(xs - peak)) * rho))
        return tot

    if k == 0:
        return S_sing(0, h) + 2.0 * quad(0.0, h, 0.0)
    return S_sing(k, h) + quad((k - 1) * h, (k + 1) * h, k * h)


def stiffness_first_col(n_elem, h):
    n_q = n_elem - 1
    s = np.array([S_full(k, h) for k in range(n_elem)])
    q = np.empty(n_q)
    q[0] = (2.0 * s[0] - 2.0 * s[1]) / h**2
    for k in range(1, n_q):
        q[k] = (2.0 * s[k] - s[k + 1] - s[k - 1]) / h**2
    return q


_BUILD = {}


def build(a, h):
    if (a, h) in _BUILD:
        return _BUILD[(a, h)]
    n_elem = int(round(2 * a / h))
    n = n_elem - 1
    q = stiffness_first_col(n_elem, h)
    Q = scipy.linalg.toeplitz(q)
    M = scipy.linalg.toeplitz(np.r_[2 * h / 3, h / 6,
                                    np.zeros(max(n - 2, 0))])
    ii = np.arange(1, n_elem)
    x = -a + ii * h
    xg, wg = leggauss(8)
    b = {}
    for sgn in (+1.0, -1.0):
        mid1 = x - h / 2
        xs1 = mid1[:, None] + (h / 2) * xg[None, :]
        phi1 = (xs1 - (x - h)[:, None]) / h
        mid2 = x + h / 2
        xs2 = mid2[:, None] + (h / 2) * xg[None, :]
        phi2 = ((x + h)[:, None] - xs2) / h
        b[sgn] = ((h / 2) * (np.exp(sgn * xs1) * phi1) @ wg
                  + (h / 2) * (np.exp(sgn * xs2) * phi2) @ wg)
    _BUILD[(a, h)] = (x, h, Q, M, b)
    return _BUILD[(a, h)]


def solve_v(Q, M, b_s, lam, n):
    T = Q - lam * M
    e = np.ones(n)
    B = np.zeros((n + 1, n + 1))
    B[:n, :n] = T
    B[:n, n] = e
    B[n, :n] = e
    sol = scipy.linalg.solve(B, np.r_[b_s, 0.0], assume_a="sym")
    return sol[:n], T


def hat_FT(z, h):
    return h * np.sinc(z * h / (2 * np.pi)) ** 2


def V_of_z(z, c, x, h):
    return hat_FT(z, h) * (np.exp(1j * np.outer(z, x)) @ c)


def W_complex(z, Vp, Vm, theta):
    return (z - 1j) * Vp + np.exp(1j * theta) * (z + 1j) * Vm


def zeros_theta0(a, h, lam, z_max, n_grid):
    """Zeros of W(a,0;z) in (0, z_max] — real gauge 2(z Re V + Im V)."""
    x, hh, Q, M, b = build(a, h)
    n = len(x)
    cp, T = solve_v(Q, M, b[+1.0], lam, n)
    zg = np.linspace(0.01, z_max, n_grid)
    Vp = V_of_z(zg, cp, x, hh)
    Wf = 2 * (zg * Vp.real + Vp.imag)

    def Wdirect(z):
        V = hat_FT(np.array([z]), hh)[0] * complex(np.exp(1j * z * x) @ cp)
        return 2 * (z * V.real + V.imag)

    idx = np.flatnonzero(np.sign(Wf[:-1]) * np.sign(Wf[1:]) < 0)
    roots = []
    for i in idx:
        try:
            roots.append(scipy.optimize.brentq(Wdirect, zg[i], zg[i + 1],
                                               xtol=1e-13, rtol=1e-13))
        except ValueError:
            pass
    return roots, cp, x, hh, Q, M, b, T


def count_zeros(a, h, lam, theta, z_max, n_grid):
    """Zero count in (0, z_max] via argument-principle-free |W| minima."""
    x, hh, Q, M, b = build(a, h)
    n = len(x)
    cp, T = solve_v(Q, M, b[+1.0], lam, n)
    cm, _ = solve_v(Q, M, b[-1.0], lam, n)
    np_ = float(cp @ T @ cp)
    nm = float(cm @ T @ cm)
    cm = cm * np.sqrt(np_ / nm)
    # argument-principle count on the rectangle [0.01, z_max] x [-eps, eps]
    # (all zeros real by Thm 1.5, so this counts real zeros in (0, z_max])
    eps = 0.02
    n_side = 4000
    xs = np.linspace(0.01, z_max, n_side)
    ys = np.linspace(-eps, eps, 41)
    contour = np.concatenate([
        xs + 1j * eps,          # top, left->right
        ys[::-1] * 1j + z_max,  # right, top->bottom
        xs[::-1] - 1j * eps,    # bottom, right->left
        ys * 1j + 0.01,         # left, bottom->top
    ])
    Vp = V_of_z(contour, cp, x, hh)
    Vm = V_of_z(contour, cm, x, hh)
    W = W_complex(contour, Vp, Vm, theta)
    darg = np.angle(W[1:] * np.conj(W[:-1])) + np.angle(W[0] * np.conj(W[-1]))
    count = int(round(float(darg.sum()) / (2 * np.pi)))
    return count, W


# ----------------------------------------------------------- F on i*y
def xi_real(s):
    """xi(s) for real s > 1 via mpmath (real-axis, safe)."""
    s = mp.mpf(s)
    return 0.5 * s * (s - 1) * mp.pi ** (-s / 2) * mp.gamma(s / 2) * mp.zeta(s)


mp.mp.dps = 40


def F_imag(y):
    """F(iy) = -y^2 xi(1/2+y)/xi'(1/2+y), real."""
    s = mp.mpf(0.5) + mp.mpf(y)
    xiv = xi_real(s)
    dx = mp.diff(xi_real, s)
    return float(-mp.mpf(y) ** 2 * xiv / dx)


def main() -> int:
    if CSV.exists() and "--force" not in sys.argv:
        print(f"cached: {CSV} (use --force to recompute)")
        return 0
    t0 = time.time()
    rows = []

    # ---- V3 pre-check: flint cross-check of xi at two points
    import flint
    flint.ctx.prec = 200
    def xi_flint(s):
        a = flint.arb(str(s))
        return float(0.5 * a * (a - 1) * (a / 2).gamma()
                     * flint.arb.pi() ** (-a / 2) * a.zeta())
    xerr = float(max(abs(xi_real(2.5) - xi_flint(2.5)) / abs(xi_flint(2.5)),
                     abs(xi_real(10.5) - xi_flint(10.5))
                     / abs(xi_flint(10.5))))
    print(f"xi cross-check vs flint: rel err {xerr:.2e}", flush=True)

    Fv = np.array([F_imag(y) for y in Y_GRID])
    logF = np.log(np.abs(Fv))
    signF = np.sign(Fv)

    # ---- P1: theta-independence at a=6
    print("P1: theta sweep at a=6 ...", flush=True)
    counts = []
    for th in THETAS_P1:
        c, _ = count_zeros(6.0, H_MAIN, LAM_SHIFT, th, Z_TH, N_GRID)
        counts.append(c)
        print(f"  theta={th:.3f}  count={c}", flush=True)
    count_spread = max(counts) - min(counts)
    check_v1 = bool(count_spread <= 2)

    # ---- P2/P3/P4 over a-sweep
    flow = {}
    localization = {}
    readoff = {}
    sym_err = 0.0
    for a in A_SWEEP:
        print(f"a={a}: zeros + profiles + read-off ...", flush=True)
        roots, cp, x, hh, Q, M, b, T = zeros_theta0(a, H_MAIN, LAM_SHIFT,
                                                    max(Z_TH, 60.0), N_GRID)
        cm, _ = solve_v(Q, M, b[-1.0], LAM_SHIFT, len(x))
        np_ = float(cp @ T @ cp)
        nm = float(cm @ T @ cm)
        cm = cm * np.sqrt(np_ / nm)
        # V4: mirror symmetry v-(x) vs v+(-x)
        sym_err = max(sym_err,
                      float(np.max(np.abs(cm - cp[::-1]))
                            / max(1.0, float(np.max(np.abs(cp))))))
        # P2 localization: edge mass in T-norm and L2
        w_t = np.abs(T @ cp)
        edge = np.abs(x) > (a - 1.0)
        loc_t = float(np.sum(np.abs(cp[edge] * w_t[edge]))
                      / np.sum(np.abs(cp * w_t)))
        l2 = np.abs(cp) ** 2
        loc_l2 = float(l2[edge].sum() / l2.sum())
        localization[a] = (loc_t, loc_l2)
        # P3 flow
        flow[a] = roots[:N_ZEROS_TRACK]
        # P4 read-off
        ziy = 1j * Y_GRID
        Vp = V_of_z(ziy, cp, x, hh)
        Vm = V_of_z(ziy, cm, x, hh)
        best = None
        costs = []
        for th in THETAS_P4:
            W = W_complex(ziy, Vp, Vm, th)
            lw = np.log(np.abs(W))
            A = np.vander(Y_GRID, PHI_DEG + 1, increasing=True)
            coef, *_ = np.linalg.lstsq(A, logF - lw, rcond=None)
            resid = float(np.sqrt(np.mean((logF - lw - A @ coef) ** 2)))
            # phase agreement: arg(W) vs sign(F)
            phase = float(np.mean(np.cos(np.angle(W)) * signF > 0))
            costs.append((resid, th, phase))
        costs.sort()
        best = costs[0]
        med = float(np.median([c[0] for c in costs]))
        readoff[a] = {"best_theta": best[1], "best_resid": best[0],
                      "best_phase_agree": best[2], "median_resid": med}
        print(f"  loc(T)={loc_t:.4f} loc(L2)={loc_l2:.4f}  "
              f"z1={flow[a][0]:.4f} (pi/a={np.pi/a:.4f})  "
              f"read-off: theta*={best[1]:.3f} resid={best[0]:.4f} "
              f"(median {med:.4f}) phase={best[2]:.3f}", flush=True)

    # ---- checks
    lat_ok = all(abs(flow[a][0] - np.pi / a) / (np.pi / a) < 0.03
                 for a in A_SWEEP)
    check_v2 = bool(lat_ok)
    check_v3 = bool(xerr < 1e-8 and
                    all(readoff[a]["best_resid"]
                        <= readoff[a]["median_resid"] for a in A_SWEEP))
    check_v4 = bool(sym_err < 1e-6)
    checks = {
        "V1_theta_count_interlacing": check_v1,
        "V2_lattice_persists": check_v2,
        "V3_xi_crosscheck_and_readoff": check_v3,
        "V4_vmirror_symmetry": check_v4,
    }

    with CSV.open("w", encoding="utf-8", newline="") as f:
        f.write("part,a,theta_or_j,value\n")
        for th, c in zip(THETAS_P1, counts):
            f.write(f"P1_count,6.0,{th:.6f},{c}\n")
        for a in A_SWEEP:
            lt, l2 = localization[a]
            f.write(f"P2_loc_T,{a},-,{lt:.8f}\n")
            f.write(f"P2_loc_L2,{a},-,{l2:.8f}\n")
            for j, z in enumerate(flow[a], 1):
                f.write(f"P3_zero,{a},{j},{z:.10f}\n")
            ro = readoff[a]
            f.write(f"P4_best_theta,{a},-,{ro['best_theta']:.6f}\n")
            f.write(f"P4_best_resid,{a},-,{ro['best_resid']:.8f}\n")
            f.write(f"P4_median_resid,{a},-,{ro['median_resid']:.8f}\n")
            f.write(f"P4_phase_agree,{a},-,{ro['best_phase_agree']:.6f}\n")

    meta = {
        "experiment": "E-014",
        "task": "Q-S3: Corollary 1.6 diagnostics (Suzuki line)",
        "description": "theta-independence of zero count; v± localization; "
                       "zero-flow map vs a; imaginary-axis least-squares "
                       "read-off of theta(a), phi(a,z)",
        "script": "research/numerics/scripts/E-014-cor16-diagnostics.py",
        "parameters": {"a_sweep": A_SWEEP, "h": H_MAIN, "lambda": LAM_SHIFT,
                       "y_grid": [1.0, 20.0, 96], "phi_deg": PHI_DEG},
        "checks": checks,
        "key_values": {
            "P1_counts": counts, "P1_spread": count_spread,
            "P2_localization": {str(a): localization[a] for a in A_SWEEP},
            "P3_first_zeros": {str(a): [round(z, 8) for z in flow[a][:5]]
                               for a in A_SWEEP},
            "P4_readoff": {str(a): readoff[a] for a in A_SWEEP},
            "xi_flint_crosscheck_rel": float(xerr),
        },
        "caveats": [
            "Galerkin discretization h=0.01 (E-010 h-convergence: z1 rel "
            "drift 4e-4 between h=0.01 and 0.005 at a=4)",
            "read-off on the imaginary axis tests only the modulus shape "
            "+ phase of e^phi W vs F away from zeros; zero-density gap "
            "(F4 of D-020) means a good fit is NOT expected at large a — "
            "the residual trend is the observable",
        ],
        "elapsed_seconds": round(time.time() - t0, 1),
        "timestamp_utc": datetime.now(timezone.utc).isoformat(),
        "row_count": None,
    }
    meta["row_count"] = sum(1 for _ in CSV.open())
    JSON.write_text(json.dumps(meta, indent=2), encoding="utf-8")
    ok = all(checks.values())
    print(f"wrote {CSV.name}, {JSON.name} — "
          f"{'ALL CHECKS PASS' if ok else 'CHECK FAILURE: ' + str(checks)}")
    return 0 if ok else 1


if __name__ == "__main__":
    raise SystemExit(main())