#!/usr/bin/env python3
r"""sunrise-row11-evaluate.py -- two-loop sunrise, unequal squared masses (1,1,4), eps^0:
STANDALONE evaluator (row 11 of the paper's table of integrals).

Two-loop sunrise S_111(2-2eps, t), one external scale t = p^2 (Euclidean t < 0), three
massive propagators with squared masses (m1^2, m2^2, m3^2) = (1, 1, 4), mu = 1 --
the PSEUDOTHRESHOLD-DEGENERATE row: m3 = m1 + m2 (mu1 = -m1+m2+m3 ... mu3 =
m1+m2-m3 = 0), so one curve root pins and the two punctures coincide, z1 = z2
EXACTLY.  Object: the eps^0 coefficient J[1,1,1]^(0)(t) of the (1,1,1,0,0) master
(d = 2 - 2 eps, AMFlow normalization), and E^(0) = J^(0)/psihat1.

CLOSED FORM (uniform per-puncture Kronecker-eMPL formula, zero free parameters):

    J^(0)/psihat1 = -C_{4,2}(t) - (6/(2 pi)^3) * (1/3) * sum_{j=1}^{3} [ W(z_j,1) - 8 W(z_j,2) ]

  * z1 = z2 degeneracy: handled exactly by multiplicity grouping
    (sunrise_empl.marked_z_mults) -- each distinct W computed once, weighted 2.
  * Abel-Jacobi BRANCH REPAIR (z3 -> 1 - z1 - z2) is LOAD-BEARING for this row:
    the ellipf/asin principal branch reflects z3 across the half-period for
    -2 < t < 0 (raw z-sum = 0.903 at t = -1); the repair lives in the shared
    layer's fcurve and is gated here against 289-digit independent AMFlow
    references at t = -1 and t = -1/2.
  * W(z,N) = I(1, g^(3)(z, N tau_C); q_C): depth-two Kronecker-eMPL word,
    convergent q-series with the PROVEN tail bound |b_n| <= C n^2,
    C = (2 pi)^3 zeta(2) = 408.03, certified + ENFORCED at runtime by
    sunrise_empl.Nq_for (fail-closed at the depth cap); the achieved bound is
    propagated to value level and printed (BOUND, not estimate).
  * C_{4,2} = sum_j (1/2i)[Li2(w_j) - Li2(1/w_j)]: elliptic-dilogarithm boundary.
  * Per-puncture coefficients [-1, -1/3, +8/3]: PSLQ-exact rationals, identified
    on the 4 fit points t = -5, -2, -1, -1/2 (two of them, t = -1/2 and t = -1,
    are reference points below and agree to 289 digits at the default
    precision); the point t = -3 was held out of the identification.  ZERO free
    parameters at runtime.

TWO KINDS OF REFERENCE POINT (the data file flags each one):
  * in_fit: t = -1/2 and t = -1, two of the 4 PSLQ fit points (in-sample
    agreement; the fit saw them); 289-digit AMFlow references (goal 280);
  * held_out_in_pslq_fit: 5 points the fit NEVER saw -- t = -3, the point held
    out of the PSLQ identification, whose goal-280 AMFlow value (ball radius
    4.30e-290, 289 digits in the ball) was computed after the closed form and
    its coefficients were fixed (post-closure); t = -9 and t = -11, whose
    goal-150 AMFlow values (159 digits in each ball) were computed after this
    evaluator's predictions at those points had been filed (prediction-first);
    and t = -5/2 and t = -7, fit-era verify-grid points never in the fit
    (goal-150 AMFlow values, 159 and 160 digits in the ball, computed during
    the June 2026 fit before its verifier ran).  Each entry carries the sha256
    of its AMFlow input configuration and output file, its ball radius and the
    digits that radius certifies.
  The two kinds are gated SEPARATELY below and both must clear the bar.

All machinery lives in the shared module sunrise_empl.py (same directory): pure
mpmath, self-contained q-series from the printed kernel definitions, no IBP
reduction, no differential-equation solver, no network at runtime.  This script
adds no arithmetic to the value: the held-out gate calls the same predict_J.

WHAT THE DEFAULT RUN CHECKS (exit 0 only if every leg passes):
  1. positive control: the same marked-point machinery at equal mass (1,1,1) vs
     the independent classical Gamma_1(6) route (Eisenstein series -> newform f3
     -> I(1,f3;q_C), boundary (3/2) sqrt3 L(chi_{-3},2) via Hurwitz zeta);
  2. fit-point gate: J^(0) at the 2 fit points t = -1/2, -1 (both inside the
     branch-repair window -2 < t < 0) vs their 289-digit AMFlow reference values
     (comparison only, never in the evaluation; in-sample: they were PSLQ fit
     points); bar: worst agreement STRICTLY > 30 digits and relative error
     < 10^-30/2;
  3. held-out gate: J^(0) at t = -5/2, -3, -7, -9 and -11 vs the 159-, 289-,
     160-, 159- and 159-digit AMFlow values the fit never saw; same bar, gated
     and reported on its own line ('held-out: ...'), with the fit points on
     their own ('fit-point: ...');
  4. the digits the paper prints: E^(0)(-7) = -3.3260720... (8 digits, trailing
     digits truncated) must be matched to within one unit in the last printed place;
  5. the digit counts the paper states for the held-out points ("held out to
     289 digits at t=-3", "held out to 159 digits at t=-9", "held out to 159
     digits at t=-11"): each measured here as the agreement at the point its
     claim names (cap min(claimed digits, working precision)); tested when the
     run's precision reaches it (the default, dps 300, does for all three;
     --dps 150 SKIPs the t = -3 claim).

FILES (beside this script; sha256-pinned below and REFUSED on any mismatch):
  sunrise_empl.py             shared Kronecker-eMPL layer (curve, marked points, words, gates)
  sunrise-row11-data.json     reference values (2 fit points + 5 held-out points with their AMFlow provenance) + the paper's printed digits

DOMAIN: Euclidean t < 0.  |q_C| -> 0 at the soft point t -> 0^-; |q_C| >= 0.8995
(astronomically deep |t|; keeps 1/(1-|q_C|) < 10, one budgeted guard digit) is
refused explicitly.  Gate-verified window -11 <= t <= -1/2 (straddles the
branch-repair boundary t = -2).  Physical t > 0 is NOT wired (analytic
continuation of the q-series frame not vendored).

EXIT CODES: 0 every gate passes; 1 a gate fails (this is what --mutate and
--mutate-heldout must produce); 2 usage error or --point domain refusal; 3 a
pinned file's sha256 does not match (refused before any computation); 4 a
required file or mpmath is missing.

USAGE
  python3 sunrise-row11-evaluate.py                    # default: dps 300, all checks (seconds)
  python3 sunrise-row11-evaluate.py --dps 60           # lower precision (paper claim then SKIPs)
  python3 sunrise-row11-evaluate.py --point=-3/2       # any Euclidean t < 0 (use --point= for negatives)
  python3 sunrise-row11-evaluate.py --check            # two-precision rule: rerun all gate points at dps+60, diff
  python3 sunrise-row11-evaluate.py --mutate           # control: +8 -> 8(1+1e-12) everywhere; MUST exit nonzero
  python3 sunrise-row11-evaluate.py --mutate-heldout   # control: the same perturbation in the HELD-OUT
                                                       #   evaluation only (fit table untouched); MUST exit nonzero

mp.dps is set inside main() after argparse (module-level mpf footgun avoided).
Dependency: python3 + mpmath (pip install mpmath).
"""
import argparse
import hashlib
import json
import os
import sys

EXIT_PASS, EXIT_FAIL, EXIT_USAGE, EXIT_PIN, EXIT_MISSING = 0, 1, 2, 3, 4

ROW = 11
HERE = os.path.dirname(os.path.abspath(__file__))
EMPL_FILE = os.path.join(HERE, "sunrise_empl.py")
DATA_FILE = os.path.join(HERE, "sunrise-row11-data.json")
EMPL_SHA256 = "1928e35f0edd8197fc10550ea439acaa21e5c4ae4136396b0493fe49331f62bf"
DATA_SHA256 = "c1eb77df0da2f182b98bdcea7d2616cf02e2d758774679f53dc585f5258af7f0"


def _pinned_bytes(path, pin):
    """Read a companion file and refuse it unless its sha256 matches the pin."""
    name = os.path.basename(path)
    if not os.path.exists(path):
        print(f"MISSING: {name} must sit beside this script (download it from the same page); "
              f"nothing computed")
        sys.exit(EXIT_MISSING)
    raw = open(path, "rb").read()
    sha = hashlib.sha256(raw).hexdigest()
    if sha != pin:
        print(f"REFUSED: {name} sha256 {sha} ({len(raw)} bytes) does not match the pin "
              f"{pin} -- the file was altered or is not the released version; nothing computed")
        sys.exit(EXIT_PIN)
    return raw


def _t_label(gp):
    """'-3' or '-1/2': the rational t of a reference point as printed in the summaries."""
    return f"{gp['t_num']}" if gp["t_den"] == 1 else f"{gp['t_num']}/{gp['t_den']}"


def _agree_digits(mp, J_pred, oracle):
    """Agreement digits and relative error of a computed value against a reference midpoint
    (the same measurement the shared layer's gate table prints)."""
    rel = abs(J_pred - oracle) / abs(oracle)
    d = int(-mp.log10(rel)) if rel > 0 else mp.mp.dps
    return d, rel


def heldout_gate(mp, R, data, held, args, masses):
    """Gate the held-out reference points SEPARATELY from the fit points.

    held: the data file's gate points flagged held_out_in_pslq_fit (t = -5/2, -3, -7, -9, -11: the fit never
    saw them).  Each is evaluated with the SAME predict_J the fit table used (no new arithmetic on
    the value; args.mutate or args.mutate_heldout threads the shared layer's coefficient perturbation
    into these evaluations only through predict_J's own mutate hook), compared with its AMFlow
    midpoint, and printed per point with its agreement digits, cap and provenance.  Bar: worst
    agreement STRICTLY > 30 digits AND relative error < 10^-30/2 (the fit table's bar).  Then the
    paper's stated held-out digit count is measured at the held-out point the claim names
    (at_t_num/at_t_den; without a named point, as the worst agreement over these points), SKIP, not
    FAIL, when the precision cannot reach it, and --check reruns them at dps+60.  Returns
    (worst_digits, wall, failures) where failures is a list of messages (empty when every leg passes)."""
    import time
    t0 = time.time()
    mutate = bool(getattr(args, "mutate", False) or getattr(args, "mutate_heldout", False))
    failures = []
    if getattr(args, "mutate_heldout", False):
        print("\n[mutate-heldout] CONTROL RUN: per-puncture coefficient +8 -> 8 (1 + 1e-12) in the "
              "HELD-OUT evaluation only (the fit table above is untouched); the held-out gate "
              "MUST fail and this run MUST exit nonzero")
    names = ", ".join(_t_label(gp) for gp in held)
    print(f"\nheld-out reference points (t = {names}; never in the PSLQ fit -- the coefficients "
          f"never saw them):")
    worst = None
    worst_rel = mp.mpf(0)
    maxcert = mp.mpf(0)
    results = []
    for gp in held:
        t = mp.mpf(gp["t_num"]) / mp.mpf(gp["t_den"])
        diag = {}
        J_pred = R.predict_J(t, masses, diag=diag, mutate=mutate)
        oracle = mp.mpf(gp["oracle_mid"])
        d, rel = _agree_digits(mp, J_pred, oracle)
        cap = min(gp["oracle_digits"], mp.mp.dps)
        results.append((t, J_pred, d, cap, gp))
        worst = d if worst is None else min(worst, d)
        worst_rel = max(worst_rel, rel)
        maxcert = max(maxcert, diag["err_J"])
        print(f"  t = {mp.nstr(t, 8):>10}  J_pred = {mp.nstr(J_pred, 24)}")
        print(f"    vs {gp['source']}: agree {d} digits "
              f"(cap: {cap} = min(oracle {gp['oracle_digits']}d, dps))  "
              f"|q_C| = {mp.nstr(abs(diag['qC']), 5)}  Nq = {diag['Nq']}")
        print(f"    provenance: AMFlow goal {gp['amflow_goal_digits']} digits, eps order "
              f"{gp['amflow_eps_order']}, ball radius {gp['amflow_ball_radius']} "
              f"({gp['amflow_ball_digits']} digits in the ball); input configuration sha256 "
              f"{gp['amflow_config_sha256'][:16]}..., output sha256 {gp['amflow_output_sha256'][:16]}... "
              f"(full values in the data file)")
    bar_rel = mp.mpf(10) ** (-30) / 2
    ok = (worst > 30) and (worst_rel < bar_rel)
    if not ok:
        failures.append(f"held-out gate: worst agree {worst}d not STRICTLY > 30d, or rel error "
                        f"{mp.nstr(worst_rel, 3)} not < 10^-30/2 = {mp.nstr(bar_rel, 3)}")
    per_point = "; ".join(f"t = {_t_label(r[4])}: {r[2]} digits (cap {r[3]})" for r in results)
    print(f"\n  held-out: t = {names} (never in the PSLQ fit): {per_point}; worst {worst} digits  "
          f"[{'PASS' if ok else 'FAIL'}: strict bar > 30 d AND worst-rel {mp.nstr(worst_rel, 3)} "
          f"< 10^-30/2, directed-rounding slack]")
    print(f"  [certified] q-series truncation error |Delta J| <= {mp.nstr(maxcert, 3)} at every "
          f"held-out point (the same per-word proven tail bound as the fit table; BOUND, not estimate)")

    # the digit count the paper's own sentence STATES (quoted in cl["text"]), measured at the held-out
    # point the claim names (cl["at_t_num"]/["at_t_den"]; the row's claim is about ONE point) or, with no
    # named point, as the worst agreement over the held-out points (cl["measured_over"] names the subset;
    # only the held-out subset is wired -- anything else is refused as a data error, never silently passed)
    for cl in data.get("paper_claims", []):
        if cl.get("measured_over") != "held_out_points":
            failures.append(f"paper claim '{cl['text']}': measured_over = {cl.get('measured_over')!r} "
                            f"is not 'held_out_points' (the only subset this script measures)")
            continue
        need = int(cl["held_out_digits"])
        if "at_t_num" in cl:
            at = [r for r in results if (r[4]["t_num"], r[4]["t_den"]) == (cl["at_t_num"], cl["at_t_den"])]
            if len(at) != 1:
                failures.append(f"paper claim '{cl['text']}': the named point t = {cl['at_t_num']}/{cl['at_t_den']} "
                                f"is not one of the held-out points (t = {names})")
                continue
            meas, cap = at[0][2], min(at[0][4]["oracle_digits"], mp.mp.dps)
            over = f"the held-out point the claim names, t = {_t_label(at[0][4])} (never in the fit)"
        else:
            meas, cap = worst, min(min(gp["oracle_digits"] for gp in held), mp.mp.dps)
            over = f"the {len(held)} held-out points above (t = {names}; never in the fit)"
        if cap >= need:
            cl_ok = meas >= need - 1   # one digit slack: the reference's own last digit
            if not cl_ok:
                failures.append(f"paper claim '{cl['text']}': held-out agreement {meas}d < {need - 1}d")
            print(f"  paper claim '{cl['text']}' [{cl['where']}]: measured here as this run's "
                  f"agreement at {over} = {meas} digits (cap {cap} = min(held-out reference digits, dps); "
                  f"bar {need} - 1) [{'PASS' if cl_ok else 'FAIL'}]")
        else:
            print(f"  paper claim '{cl['text']}' [{cl['where']}]: not testable at this precision "
                  f"(cap {cap} = min(held-out reference digits, dps) < {need}; rerun with --dps {need}) [SKIP]")

    if args.check:
        print(f"\n--check (held-out points): rerunning at dps {mp.mp.dps + 60} ...")
        old_dps = mp.mp.dps
        mp.mp.dps = old_dps + 60
        masses_hi = R.masses_from_sq(data["masses_sq"])   # re-sqrt at the new dps
        okall = True
        for t, J_lo, _, _, gp in results:
            J_hi = R.predict_J(t, masses_hi, mutate=mutate)
            dd = abs(J_hi - J_lo) / abs(J_hi)
            stab = int(-mp.log10(dd)) if dd > 0 else mp.mp.dps
            okk = stab >= old_dps - 12
            okall = okall and okk
            print(f"  t = {mp.nstr(t, 8):>10}: low-dps value stable to {stab} digits "
                  f"(target ~{old_dps})  [{'ok' if okk else 'DRIFT'}]")
        mp.mp.dps = old_dps
        print(f"--check (held-out) verdict: {'PASS' if okall else 'FAIL'}")
        if not okall:
            failures.append("--check two-precision DRIFT on a held-out point (see table)")

    wall = time.time() - t0
    print(f"\nheld-out gate wall time: {wall:.2f} s")
    return worst, wall, failures


def main():
    ap = argparse.ArgumentParser(
        description="Row 11: unequal-mass sunrise (1,1,4) eps^0, "
                    "uniform per-puncture Kronecker-eMPL closed form "
                    "(z1 = z2 degeneracy)")
    ap.add_argument("--dps", type=int, default=300,
                    help="reported decimal digits (default 300; +10 guard digits internally)")
    ap.add_argument("--point", action="append", default=[],
                    help="extra Euclidean t < 0 (rational -7/2 or decimal; "
                         "use --point=-7/2 syntax for negatives); repeatable")
    ap.add_argument("--check", action="store_true",
                    help="two-precision rule: rerun all gate points (fit and held-out) at dps+60 and diff")
    ap.add_argument("--mutate", action="store_true",
                    help="control: perturb the exact coefficient +8 to 8(1+1e-12) in every gate "
                         "evaluation; run must exit nonzero")
    ap.add_argument("--mutate-heldout", action="store_true",
                    help="control: the same perturbation in the held-out evaluation ONLY (fit table "
                         "untouched); the held-out gate must fail and the run exit nonzero")
    args = ap.parse_args()
    if args.dps < 30:
        ap.error("--dps must be >= 30 (the gate bar is 30 digits)")   # argparse exits 2

    try:
        import mpmath as mp
    except ImportError:
        print("MISSING: python3 module mpmath (pip install mpmath); nothing computed")
        return EXIT_MISSING

    raw_data = _pinned_bytes(DATA_FILE, DATA_SHA256)   # refused before any computation
    _pinned_bytes(EMPL_FILE, EMPL_SHA256)
    sys.path.insert(0, HERE)
    import sunrise_empl as R  # noqa: E402
    print(f"[pins] {os.path.basename(DATA_FILE)} sha256 {DATA_SHA256[:16]}... and "
          f"sunrise_empl.py sha256 {EMPL_SHA256[:16]}... match")

    mp.mp.dps = args.dps + 10  # guard digits; gate digits reported vs the references
    data = json.loads(raw_data)
    if data["row"] != ROW:
        print(f"REFUSED: data file is for row {data['row']}, not row {ROW}")
        return EXIT_PIN
    # split the reference points by the data file's own flags: the shared layer's gate table
    # sees ONLY the fit points (in_fit; its printed values are unchanged), the held-out points
    # are gated separately below; the paper claim is measured over the held-out points only
    fit = [gp for gp in data["gate_points"] if gp["in_fit"] and not gp["held_out_in_pslq_fit"]]
    held = [gp for gp in data["gate_points"] if gp["held_out_in_pslq_fit"] and not gp["in_fit"]]
    if len(fit) + len(held) != len(data["gate_points"]) or not fit or not held:
        print(f"REFUSED: data file flags are inconsistent ({len(fit)} in_fit, {len(held)} held out, "
              f"{len(data['gate_points'])} points)")
        return EXIT_PIN
    data_fit = dict(data, gate_points=fit, paper_claims=[])
    print(f"reference points: {len(fit)} fit points (t = {', '.join(_t_label(g) for g in fit)}; in-sample) "
          f"+ {len(held)} held-out points (t = {', '.join(_t_label(g) for g in held)}; never in the fit)")
    failures = []
    worst_fit = None
    try:
        worst_fit, wall = R.run_row(ROW, data_fit, args)   # SystemExit(2) on a --point refusal
    except RuntimeError as e:
        failures.append(str(e))
        print(f"\n[gate] fit-point table FAIL: {e}")
    masses = R.masses_from_sq(data["masses_sq"])   # same call, same dps as the table above
    worst_held, wall_held, held_failures = heldout_gate(mp, R, data, held, args, masses)
    failures += held_failures
    fit_names = ", ".join(_t_label(g) for g in fit)
    if worst_fit is None:
        print(f"  fit-point: t = {fit_names} (the PSLQ fit points; in-sample): FAIL (see the table above)")
    else:
        print(f"  fit-point: t = {fit_names} (the PSLQ fit points; in-sample): worst agreement "
              f"{worst_fit} digits  [PASS: strict bar > 30 d]")
    if failures:
        print(f"\n[gate] FAIL (exit {EXIT_FAIL}): " + "; ".join(failures))
        return EXIT_FAIL
    print(f"\n[gate] PASS: positive control, fit-point table (worst {worst_fit} digits over {len(fit)} points, "
          f"strict bar > 30), held-out points t = {', '.join(_t_label(g) for g in held)} (worst {worst_held} "
          f"digits, strict bar > 30), the paper's printed digits and its stated held-out digit count all clear"
          + (" (two-precision --check stable on both sets)" if args.check else ""))
    return EXIT_PASS


if __name__ == "__main__":
    sys.exit(main())
