#!/usr/bin/env python3
"""
Fresh-gap calibration (prelock_checks_plan.md, part 1), October 13, 2026. On old starts 1 to 18, untouched only: does a
fresh gap made at T + 200 measure the same growth over (T + 200, T + 500] as the carried gap, and give the same
natural link? Each base run is run again step for step, with two fresh copies made at T + 200 for every T = 2,000,
2,050, ..., 5,450, each measured and put back every 5 units against the first copy, as the carried gap is. No copy is
changed, no new start is run, and nothing here is locked. As preregistered, each re-run must reproduce its saved run
exactly (V1); after that failed on this machine, V1-revised compares it with the cause test's unchanged base-run code
run on the same machine (prelock_amendment.md). Both are reported.

A sandbox script kept with the project. Nothing here is served by the site.

Usage (each command runs in the foreground and ends by itself; nothing is left running)
    python3 -B prelock_calibration.py dryrun   # the machinery on old start 1 for 600 units; writes only to /tmp
    python3 -B prelock_calibration.py run      # one piece: the unfinished starts, 7 at a time; a start still running
                                               #   at 190 s keeps its exact state and goes on in the next piece
    python3 -B prelock_calibration.py report   # once all 18 are done: the criteria and the outputs

Each is run with a hard limit: timeout -k 10 285 python3 -B prelock_calibration.py <command>
"""
import json
import shutil
import sys
import time
from pathlib import Path

sys.dont_write_bytecode = True
import numpy as np  # noqa: E402

from prelock_common import (HERE, WORK, SEED, N, K, EPS, DT, GAP, EVERY, RUN, ct, nt, rule, egc,  # noqa: E402
                            stamp, shown, sha256, write_json, save_npz, run_piece, guard, lock_changes,
                            folder_fingerprints, reference_checks, same_environment)

STARTS = tuple(range(18))                                  # old starts 1 to 18 (seeds 0 to 17)
MOMENTS = np.arange(2000, 5451, 50)                        # T: 70 per start
AHEAD, WINDOW = 200, 300                                   # a fresh copy is made at T + 200 and measured over 300 units
INTERVALS = WINDOW // int(EVERY)                           # 60 put-backs per fresh gap
DIRECTIONS = 2                                             # fresh copies per moment
ALIGNED = 0.9                                              # the overlap that counts as lined up
FIRST_PART = 100                                           # also shown: the first 100 units of the window and the rest
CRITERIA = dict(aligned_share=0.9, median_alignment=100.0, noise_ratio=0.5, agreement=0.8, kappa=(0.75, 1.25),
                kappa_bar=0.25)
SHOWN_AT = (5, 10, 25, 50, 100, 150, 200, 300)             # times since the fresh copy was made, for the overlap
BUDGET, START_BY = 190.0, 160.0                            # seconds into a piece: keep the state and stop; begin no task
PARTS = WORK / "calibration"
RUNLOG = HERE / "calibration_runlog.json"
OUT_TXT, OUT_JSON, OUT_NPZ = (HERE / f"calibration_results.{x}" for x in ("txt", "json", "npz"))
DRY = Path("/tmp/prelock-calibration-dryrun")
BLOCKS = (("old 1-6", slice(0, 6)), ("old 7-12", slice(6, 12)), ("old 13-18", slice(12, 18)))
MEANING = dict(  # what each reading leads to (prelock_checks_plan.md, with amendment 2 of prelock_amendment.md)
    PASS="the fresh gap made at T + 200 stays the primary outcome as drafted, with kappa fixed from this calibration.",
    FAIL="the fresh gap is not the primary outcome. The carried gap is not put in its place automatically (amendment 2): "
         "the alternatives, with their measurement-history biases, go to Colson for review, and any new outcome gets "
         "its own check on old starts before any lock.",
    **{"NO READING": "the calibration is reported as it stands."})
SEEN_BEFORE = ("Seen before this check was complete (prelock_amendment.md; dryruns/calibration_dryrun.txt): the dry "
               "run on old start 1, fresh copies made at t = 200 and 250, outside these moments, gave G = 0.0049, "
               "0.0055 and 0.0071 against W = 0.0028 and 0.0024, and overlaps of 0.098 and 0.255 after 300 units.")


# ---------------------------------------------------------------------------------------------------------------------
# the run

def directions(s, T, count):
    """The fresh directions of moment T in start s (seed): (direction, group, pulled), direction j drawn from
    (SEED, 1, s, T, j), the mean over both groups taken out, rms GAP (the base run's recipe for its gap)."""
    offset = np.stack([np.random.default_rng((SEED, 1, int(s), int(T), j)).standard_normal((2, count))
                       for j in range(DIRECTIONS)])
    offset -= offset.mean(axis=(-2, -1), keepdims=True)
    offset *= GAP / np.sqrt((offset * offset).mean(axis=(-2, -1), keepdims=True))
    return offset


def calibrate(s, deadline, path, moments=MOMENTS, run=RUN, twin=False, keep=()):
    """Start s (seed): ct.base_run step for step, with the fresh copies beside it in their own arrays (two rows each,
    the base run's shapes and memory layout), from the state kept at `path` if there is one. Stops at the first
    put-back after `deadline` (time.time()) with its exact state kept at `path` and returns None; otherwise returns
    the finished record. keep: times at which the untouched first copy's state is kept (just after the put-back, as
    ct.base_run keeps it). twin (the dry run's check only): the first fresh copy of the first moment is the carried
    copy itself."""
    clock = time.time()
    slow, stepper = nt.model(N, K, EPS)
    run_steps, reset_every, sample_every = (int(round(x / DT)) for x in (run, EVERY, 1.0))
    made = {int(round((T + AHEAD) / DT)): m for m, T in enumerate(moments)}
    keep_steps = {int(round(t / DT)) for t in keep}
    assert DIRECTIONS == 2                                               # a fresh group has the base run's shapes
    first_u, first_f = nt.starting_phases(N, slow, s, 1)
    form = dict(u=np.concatenate([first_u, first_u * np.exp(1j * np.zeros((1,) + first_u.shape[1:]))]),
                f=np.concatenate([first_f, first_f]))                    # made as ct.base_run makes its two copies

    def shaped(name, values):
        """values in an array laid out in memory as the base run's (u: C order; f: the free turners interleaved by
        group). The sum over the free turners follows the layout to the last digit, so a state read back from a
        file, or a fresh copy, is laid out as the base run's own state is."""
        out = np.empty_like(form[name])
        out[...] = values
        return out

    def coherence(us, uf):
        return (us.sum(-1) + uf.sum(-1)) / N

    if path.exists():
        with np.load(path) as kept:
            i, spent, pieces = int(kept["i"]), float(kept["spent"]), int(kept["pieces"]) + 1
            us, uf, trace = shaped("u", kept["us"]), shaped("f", kept["uf"]), kept["trace"].copy()
            samples, logs = list(kept["samples"].copy()), list(kept["logs"].copy())
            fresh_logs, overlap = kept["fresh_logs"].copy(), kept["overlap"].copy()
            alive = [[int(m), int(c), shaped("u", U), shaped("f", F)]
                     for m, c, U, F in zip(kept["alive_m"], kept["alive_count"], kept["alive_U"], kept["alive_F"])]
            kept_u, kept_f = list(kept["kept_u"].copy()), list(kept["kept_f"].copy())
    else:
        us, uf = nt.starting_phases(N, slow, s, 1)                       # settling, exactly as ct.base_run
        settle_steps, trace_every = int(round(nt.SETTLE / DT)), int(round(5.0 / DT))
        trace = [np.abs(coherence(us, uf))]
        step, none = stepper(DT), np.zeros(1)
        for j in range(1, settle_steps + 1):
            step(us, uf, none)
            if j % trace_every == 0:
                trace.append(np.abs(coherence(us, uf)))
        trace = np.stack(trace, -1).astype(np.float32)
        offset = np.stack([np.random.default_rng((nt.LYAP_SEED, s)).standard_normal(us.shape[1:])])
        offset -= offset.mean(axis=(-2, -1), keepdims=True)
        offset *= GAP / np.sqrt((offset * offset).mean(axis=(-2, -1), keepdims=True))
        us, uf = np.concatenate([us, us * np.exp(1j * offset)]), np.concatenate([uf, uf])
        samples, logs = [coherence(us[:1], uf[:1])], []
        i, spent, pieces = 0, 0.0, 1
        fresh_logs = np.full((len(moments), DIRECTIONS, INTERVALS), np.nan)
        overlap = np.full_like(fresh_logs, np.nan)
        alive = []                                                       # [moment, put-backs so far, U, F]
        kept_u, kept_f = [], []
    if us.strides != form["u"].strides or uf.strides != form["f"].strides:
        raise RuntimeError("the state is not laid out as the base run's")
    step, none = stepper(DT), np.zeros(2)
    while i < run_steps:
        i += 1
        step(us, uf, none)
        for group in alive:
            step(group[2], group[3], none)
        if i % sample_every == 0:
            samples.append(coherence(us[:1], uf[:1]))
        if i % reset_every == 0:
            shift = np.angle(us[1:] * np.conj(us[:1]))                   # the carried gap, as ct.base_run
            shift -= shift.mean(axis=(-2, -1), keepdims=True)
            size = np.sqrt((shift * shift).mean(axis=(-2, -1)))
            logs.append(np.log(size / GAP))
            unit = shift[0] / np.sqrt((shift[0] * shift[0]).sum())       # its direction, before it is put back
            us[1:] = us[:1] * np.exp(1j * shift * (GAP / size)[:, None, None])
            for group in alive:                                          # each fresh gap, the same way
                m, done, U, F = group
                fresh = np.angle(U * np.conj(us[:1]))
                fresh -= fresh.mean(axis=(-2, -1), keepdims=True)
                fsize = np.sqrt((fresh * fresh).mean(axis=(-2, -1)))
                fresh_logs[m, :, done] = np.log(fsize / GAP)
                overlap[m, :, done] = (np.abs((fresh * unit).sum(axis=(-2, -1)))
                                       / np.sqrt((fresh * fresh).sum(axis=(-2, -1))))
                U[:] = us[:1] * np.exp(1j * fresh * (GAP / fsize)[:, None, None])
                group[1] = done + 1
            alive = [group for group in alive if group[1] < INTERVALS]
            if i in keep_steps:
                kept_u.append(us[0].copy())
                kept_f.append(uf[0].copy())
            if i in made:
                m = made[i]
                U = shaped("u", us[:1] * np.exp(1j * directions(s, moments[m], us.shape[-1])))
                if twin and m == 0:
                    U[0] = us[1]
                alive.append([m, 0, U, shaped("f", uf[:1])])
            if time.time() > deadline and i < run_steps:
                count, free = us.shape[-1], uf.shape[-1]
                save_npz(path, i=i, spent=spent + time.time() - clock, pieces=pieces, us=us, uf=uf, trace=trace,
                         samples=np.stack(samples), logs=np.stack(logs), fresh_logs=fresh_logs, overlap=overlap,
                         alive_m=np.array([g[0] for g in alive], int), alive_count=np.array([g[1] for g in alive], int),
                         alive_U=np.stack([g[2] for g in alive]) if alive else np.zeros((0, 2, 2, count), complex),
                         alive_F=np.stack([g[3] for g in alive]) if alive else np.zeros((0, 2, 2, free), complex),
                         kept_u=np.stack(kept_u) if kept_u else np.zeros((0, 2, count), complex),
                         kept_f=np.stack(kept_f) if kept_f else np.zeros((0, 2, free), complex))
                return None
    count, free = us.shape[-1], uf.shape[-1]
    return dict(z=np.stack(samples, -1)[0], logs=np.stack(logs, -1)[0], trace=trace[0], fresh_logs=fresh_logs,
                overlap=overlap, spent=spent + time.time() - clock, pieces=pieces,
                kept_u=np.stack(kept_u) if kept_u else np.zeros((0, 2, count), complex),
                kept_f=np.stack(kept_f) if kept_f else np.zeros((0, 2, free), complex))


def reproduced(out, s, run=RUN):
    """The re-run against the saved run: coherence (to complex64), every log growth and the settling, exactly."""
    saved = ct.saved_run(s)
    samples, intervals = int(round(run)) + 1, int(round(run / EVERY))
    return dict(coherence=bool(np.array_equal(out["z"].astype(np.complex64), saved["z"][:, :samples])),
                gap=bool(np.array_equal(out["logs"], saved["logs"][:intervals])),
                settling=bool(np.array_equal(out["trace"], saved["trace"])))


def work(task, deadline):
    s = task["seed"]
    PARTS.mkdir(parents=True, exist_ok=True)
    state, part = PARTS / f"{task['name']}.state.npz", PARTS / f"{task['name']}.npz"
    out = calibrate(s, deadline, state)
    if out is None:
        return dict(status="saved")
    same = reproduced(out, s)
    full = bool(np.isfinite(out["fresh_logs"]).all() and np.isfinite(out["overlap"]).all())
    save_npz(part, start=s + 1, moments=MOMENTS, z=out["z"].astype(np.complex64), logs=out["logs"],
             trace=out["trace"], fresh_logs=out["fresh_logs"], overlap=out["overlap"],
             same=np.array([same["coherence"], same["gap"], same["settling"]]), full=full, spent=out["spent"],
             pieces=out["pieces"])
    state.unlink(missing_ok=True)
    problem = "" if all(same.values()) and full else f"reproduced {same}, every number present {full}"
    return dict(status="done", sha256=sha256(part), error=problem)


def job(args):
    return guard(work, args)


def run():
    todo = [dict(name=f"start{s + 1:02d}", seed=s) for s in STARTS if not (PARTS / f"start{s + 1:02d}.npz").exists()]
    if not todo:
        print("All 18 starts are done: run report.")
        return
    run_piece(RUNLOG, todo, job, BUDGET, START_BY)


# ---------------------------------------------------------------------------------------------------------------------
# the dry run

def dryrun():
    """Old start 1 for 600 units, with fresh copies made at t = 200 and 250 (T = 0 and 50): in one go, and again
    stopped every 2 seconds and gone on from the kept state (must agree to the last digit); against the saved run
    (exactly); and the twin, a fresh copy that is the carried copy itself (must give the carried gap's numbers,
    overlap 1)."""
    if DRY.exists():
        shutil.rmtree(DRY)
    DRY.mkdir(parents=True)
    s, run, moments = 0, 600.0, np.array([0, 50])
    clock = time.time()
    whole = calibrate(s, np.inf, DRY / "whole.state.npz", moments, run, twin=True)
    seconds = time.time() - clock
    stops, out = 0, None
    while out is None:
        out = calibrate(s, time.time() + 2.0, DRY / "pieces.state.npz", moments, run, twin=True)
        stops += out is None
    same = reproduced(whole, s, run)
    keys = ("z", "logs", "trace", "fresh_logs", "overlap")
    resumed = {key: bool(np.array_equal(whole[key], out[key], equal_nan=True)) for key in keys}
    first = [int((T + AHEAD) // EVERY) for T in moments]               # the carried gap's put-backs in each window
    carried = [whole["logs"][j:j + INTERVALS] for j in first]
    twin_gap = float(np.abs(whole["fresh_logs"][0, 0] - carried[0]).max())
    twin_overlap = float(np.abs(whole["overlap"][0, 0] - 1).max())
    others = whole["fresh_logs"][[0, 1, 1], [1, 0, 1]]
    lines = [f"Dry run, {shown(stamp())}: old start 1 (seed 0), 600 units, fresh copies made at t = 200 and 250",
             f"  in one go: {seconds:.1f} s; in pieces: stopped {stops} times and gone on from the kept state",
             f"  reproduces the saved run exactly: {same}",
             f"  pieces agree with one go to the last digit: {resumed}",
             f"  twin (a fresh copy that is the carried copy): largest log difference {twin_gap:.3g}, "
             f"largest |overlap - 1| {twin_overlap:.3g}",
             f"  the other three fresh gaps: every number present {bool(np.isfinite(others).all())}; "
             f"G = {np.round(others.sum(-1) / WINDOW, 4).tolist()} against W = {carried[0].sum() / WINDOW:.4f} "
             f"(t = 200 copy) and {carried[1].sum() / WINDOW:.4f} (t = 250 copy)",
             "  overlap of the t = 250 copies at 5, 25, 100, 300 units: "
             + "; ".join(str(np.round(whole["overlap"][1, j, [0, 4, 19, 59]], 3).tolist()) for j in range(2)),
             f"  the cause test's locked files unchanged: {not lock_changes()}"]
    ok = all(same.values()) and all(resumed.values()) and twin_gap <= 1e-9 and twin_overlap <= 1e-9
    lines.append(f"  dry run {'passed' if ok else 'FAILED'}")
    (DRY / "dryrun.txt").write_text("\n".join(lines) + "\n")
    print("\n".join(lines))


# ---------------------------------------------------------------------------------------------------------------------
# the report

def load_parts():
    keys = ("z", "logs", "fresh_logs", "overlap", "same", "full", "spent", "pieces")
    data = {key: [] for key in keys}
    sums = {}
    for s in STARTS:
        path = PARTS / f"start{s + 1:02d}.npz"
        sums[path.name] = sha256(path)
        with np.load(path) as part:
            assert int(part["start"]) == s + 1 and np.array_equal(part["moments"], MOMENTS)
            for key in keys:
                data[key].append(part[key])
    return {key: np.stack(value) for key, value in data.items()}, sums


def within_weights(y, X, rows=None):
    """y: (start, row); X: (start, row, k). Each start's own means taken out, least squares pooled over the rows of
    the starts in `rows` (a start drawn twice counts twice)."""
    rows = np.arange(len(y)) if rows is None else rows
    yd, Xd = y - y.mean(1, keepdims=True), X - X.mean(1, keepdims=True)
    return np.linalg.lstsq(Xd[rows].reshape(-1, X.shape[-1]), yd[rows].reshape(-1), rcond=None)[0]


def pooled_corr(x, y, rows=None):
    rows = np.arange(len(x)) if rows is None else rows
    xd, yd = (x - x.mean(1, keepdims=True))[rows], (y - y.mean(1, keepdims=True))[rows]
    return float((xd * yd).sum() / np.sqrt((xd * xd).sum() * (yd * yd).sum()))


def pooled_sd(x):
    """The pooled within-start SD of x (start, row)."""
    d = x - x.mean(1, keepdims=True)
    return float(np.sqrt((d * d).sum() / (x.shape[0] * (x.shape[1] - 1))))


def mean_bar(values, picks):
    values = np.asarray(values, float)
    return dict(mean=float(values.mean()), error_bar=float(rule.error_bar(values, picks)))


def revised_v1(log, kind="calibration", starts=STARTS):
    """V1-revised (prelock_amendment.md, amendment 1; adopted after V1 as preregistered failed, and not the
    preregistered check): every re-run agrees to the last digit with the cause test's unchanged base-run code run in
    one go on the same machine (machine_check.json), and every piece and every reference run was made in one
    numerical environment (prelock_common.SAME_FIELDS)."""
    refs = reference_checks(kind)
    agree = {s + 1: bool(s + 1 in refs and all(refs[s + 1]["same"].values())) for s in starts}
    envs = [p["machine"] for p in log["pieces"] if "machine" in p] + [refs[s + 1]["machine"] for s in starts
                                                                       if s + 1 in refs]
    one = bool(envs) and all(same_environment(envs[0], e) for e in envs)
    full = [e for e in envs if "boot_id" in e]
    first_piece = log["pieces"][0]["started"]["utc"] if log["pieces"] else ""
    return dict(met=bool(all(agree.values()) and one), agree={str(k): v for k, v in agree.items()},
                environment_same=one, environment=full[-1] if full else (envs[-1] if envs else {}),
                boot_ids=sorted({e["boot_id"] for e in full}),
                container_since=min((e["container_started"]["halifax"] for e in full), default=""),
                container_before_first_piece=bool(full) and all(e["container_started"]["utc"] <= first_piece
                                                                for e in full),
                unrecorded_pieces=[p["piece"] for p in log["pieces"] if "machine" not in p],
                first_saved_difference={str(s + 1): refs[s + 1]["saved"] for s in starts if s + 1 in refs},
                checked=sorted({refs[s + 1]["made"]["halifax"] for s in starts if s + 1 in refs}))


def analyse(d, v1_revised):
    picks = rule.draws(len(STARTS))
    T = MOMENTS
    window = ((T + AHEAD) // int(EVERY))[:, None] + np.arange(INTERVALS)   # interval j covers (5j, 5j + 5]
    carried = d["logs"][:, window]                                     # (start, moment, put-back)
    fresh = d["fresh_logs"]                                            # (start, moment, direction, put-back)
    W = carried.sum(-1) / WINDOW
    G = fresh.sum(-1) / WINDOW
    Gbar = G.mean(-1)
    on = d["overlap"] >= ALIGNED
    last_off = np.where(~on, np.arange(INTERVALS), -1).max(-1)
    lined_up = on[..., -1]
    tau = np.where(lined_up, EVERY * (last_off + 2), np.inf)          # put-back p (from 0) is 5 (p + 1) units in
    # the untouched state before T, as in round 2 (500-unit history, fast part, Test 17's earlier growth)
    c = np.abs(d["z"].astype(np.complex128)).mean(1)                   # (start, time)
    cum = np.concatenate([np.zeros((len(STARTS), 1)), np.cumsum(c, 1)], 1)
    M = (cum[:, T] - cum[:, T - 500]) / 500
    F = c[:, T] - M
    g = np.stack([egc.before(d["logs"][i], T // int(EVERY) - 1, egc.MAIN) for i in range(len(STARTS))])
    X = np.concatenate([M[..., None], F[..., None], g], -1)            # (start, moment, 7)
    # C1
    share = float(lined_up.mean())
    median = float(np.median(tau))
    # C2
    noise = float(((G[..., 0] - G[..., 1]) / np.sqrt(2)).std(ddof=1))
    spread = pooled_sd(W)
    # C3
    r = pooled_corr(Gbar, W)
    r_bar = float(np.std([pooled_corr(Gbar, W, p) for p in picks]))
    # C4
    bG, bW = within_weights(Gbar, X)[0], within_weights(W, X)[0]
    redrawn = np.array([[within_weights(Gbar, X, p)[0], within_weights(W, X, p)[0]] for p in picks])
    kappa = float(bG / bW)
    kappa_bar = float((redrawn[:, 0] / redrawn[:, 1]).std())
    lo, hi = CRITERIA["kappa"]
    checks = dict(
        V1=bool(d["same"].all()), V1_revised=bool(v1_revised["met"]), V2=bool(d["full"].all()),
        C1=bool(share >= CRITERIA["aligned_share"] and median <= CRITERIA["median_alignment"]),
        C2=bool(noise <= CRITERIA["noise_ratio"] * spread),
        C3=bool(r >= CRITERIA["agreement"]),
        C4=bool(lo <= kappa <= hi and kappa_bar <= CRITERIA["kappa_bar"]))
    outcome = "PASS" if all(checks[k] for k in ("C1", "C2", "C3", "C4")) else "FAIL"
    verdict = outcome if checks["V1_revised"] and checks["V2"] else "NO READING"          # amendment 1
    as_preregistered = outcome if checks["V1"] and checks["V2"] else "NO READING"         # V1 as written, kept
    # reported without a criterion
    first = int(FIRST_PART // EVERY)
    parts = dict(
        first=dict(G=fresh[..., :first].sum(-1).mean(-1) / FIRST_PART, W=carried[..., :first].sum(-1) / FIRST_PART),
        rest=dict(G=fresh[..., first:].sum(-1).mean(-1) / (WINDOW - FIRST_PART),
                  W=carried[..., first:].sum(-1) / (WINDOW - FIRST_PART)))
    per_part = {name: dict(offset=mean_bar((v["G"] - v["W"]).mean(1), picks), r=pooled_corr(v["G"], v["W"]))
                for name, v in parts.items()}
    by_block = {}
    for name, rows in BLOCKS:
        idx = np.arange(len(STARTS))[rows]
        by_block[name] = dict(kappa=float(within_weights(Gbar, X, idx)[0] / within_weights(W, X, idx)[0]),
                              r=pooled_corr(Gbar, W, idx), aligned=float(lined_up[idx].mean()),
                              median_alignment=float(np.median(tau[idx])))
    difference = (fresh - carried[:, :, None, :]).mean((0, 1, 2))     # per put-back, fresh minus carried log growth
    result = dict(
        title="Fresh-gap calibration (prelock_checks_plan.md, part 1): old starts 1 to 18, untouched only",
        made=stamp(), verdict=verdict, verdict_as_preregistered=as_preregistered, meaning=MEANING[verdict],
        checks=checks, criteria=CRITERIA, v1_revised=v1_revised, seen_before=SEEN_BEFORE,
        numbers=dict(original_v1=dict(starts=int(d["same"].all(1).sum()), coherence=int(d["same"][:, 0].sum()),
                                      gap=int(d["same"][:, 1].sum()), settling=int(d["same"][:, 2].sum())),
                     aligned_share=share, median_alignment=median,
                     aligned_by={str(t): float((tau <= t).mean()) for t in (25, 50, 100, 150, 200, 300)},
                     direction_noise=noise, natural_spread_W=spread, noise_ratio=noise / spread,
                     agreement=dict(r=r, error_bar=r_bar), kappa=dict(value=kappa, error_bar=kappa_bar),
                     b_M=dict(fresh=dict(value=float(bG), error_bar=float(redrawn[:, 0].std())),
                              carried=dict(value=float(bW), error_bar=float(redrawn[:, 1].std()))),
                     offset=mean_bar((Gbar - W).mean(1), picks), mean_W=float(W.mean()), mean_G=float(Gbar.mean()),
                     median_overlap={str(t): float(np.median(d["overlap"][..., t // int(EVERY) - 1]))
                                     for t in SHOWN_AT},
                     log_difference={str(t): float(difference[t // int(EVERY) - 1]) for t in SHOWN_AT},
                     log_difference_sums=dict(first=float(difference[:first].sum()),
                                              rest=float(difference[first:].sum())),
                     parts=per_part, blocks=by_block,
                     seconds=dict(total=float(d["spent"].sum()), per_start=float(d["spent"].mean())),
                     pieces_per_start=d["pieces"].tolist()),
        sizes=dict(starts=len(STARTS), moments=len(MOMENTS), directions=DIRECTIONS, intervals=INTERVALS))
    values = dict(W=W, G=G, tau=tau, M=M, F=F, g=g, moments=MOMENTS, starts=np.array(STARTS) + 1)
    return result, values


def saved_difference_text(first):
    """Where each re-run first differs from its saved run: the end of the gap's first differing interval, and the
    first differing time of coherence (at its saved complex64 precision)."""
    out = []
    for start, f in first.items():
        gap = "none" if f["first_gap_interval"] is None else f"t = {5 * (f['first_gap_interval'] + 1)}"
        coherence = "none" if f["first_coherence_time"] is None else f"t = {f['first_coherence_time']}"
        out.append(f"{start}: {gap}, {coherence}")
    return "; ".join(out)


def environment_text(v):
    e = v["environment"]
    text = (f"{e.get('cpu', '?')}, Python {e.get('python', '?')}, {e.get('libc', '?')}, numpy {e.get('numpy', '?')} "
            f"({', '.join(e.get('numpy_dispatch', []))}); one boot id in every full record: {len(v['boot_ids']) == 1}")
    if v["unrecorded_pieces"]:
        text += (f"; pieces {', '.join(map(str, v['unrecorded_pieces']))} ran before the environment was recorded, "
                 f"and every later record shows the container running without a restart since "
                 f"{v['container_since'][11:19]} Halifax, before piece 1: {v['container_before_first_piece']}")
    return text


def report_lines(res, log, sums):
    n, k, v, o = res["numbers"], res["checks"], res["v1_revised"], res["numbers"]["original_v1"]
    yes = {True: "met", False: "NOT met"}
    lines = [res["title"], f"made {shown(res['made'])}; not a locked plan; no new start; no change to any copy", "",
             f"Reading, with the revised exactness check (V1-revised, adopted after V1 as preregistered failed; "
             f"prelock_amendment.md): {res['verdict']}",
             f"Reading with V1 as preregistered: {res['verdict_as_preregistered']}",
             f"What the reading leads to: {res['meaning']}", "",
             f"V1 as preregistered, every re-run reproduces its saved run exactly: {yes[k['V1']]} ({o['starts']} of 18 "
             f"starts; coherence in {o['coherence']}, the gap in {o['gap']}, the settling in {o['settling']})",
             f"   first difference from the saved run, by start (gap; coherence): "
             f"{saved_difference_text(v['first_saved_difference'])}",
             f"V1-revised (not the preregistered check), every re-run agrees to the last digit with the cause test's "
             f"unchanged base-run code run in one go on the same machine, in one numerical environment throughout: "
             f"{yes[k['V1_revised']]} ({sum(v['agree'].values())} of 18 agree; one environment: "
             f"{v['environment_same']})",
             f"   the environment: {environment_text(v)}",
             "   bitwise agreement on one machine shows reproducibility, not numerical accuracy; no step-size check was "
             "made (prelock_amendment.md)",
             f"V2 every fresh gap measured at all 60 put-backs, every number finite: {yes[k['V2']]}",
             f"C1 convergence: {100 * n['aligned_share']:.1f}% lined up within the window (needs 90%); median "
             f"alignment {n['median_alignment']:.0f} units (needs at most 100): {yes[k['C1']]}",
             f"C2 stability: direction noise {n['direction_noise']:.5f} against the natural spread of W "
             f"{n['natural_spread_W']:.5f}, ratio {n['noise_ratio']:.2f} (needs at most 0.5): {yes[k['C2']]}",
             f"C3 agreement: pooled within-start r(G, W) = {n['agreement']['r']:.3f} ± "
             f"{n['agreement']['error_bar']:.3f} (needs at least 0.8): {yes[k['C3']]}",
             f"C4 interpretability: kappa = {n['kappa']['value']:.3f} ± {n['kappa']['error_bar']:.3f} (needs 0.75 to "
             f"1.25, error bar at most 0.25): {yes[k['C4']]}", "",
             "Also reported (no criterion):",
             f"  b_M with the fresh gap {n['b_M']['fresh']['value']:.3f} ± {n['b_M']['fresh']['error_bar']:.3f}; "
             f"with the carried gap {n['b_M']['carried']['value']:.3f} ± {n['b_M']['carried']['error_bar']:.3f}",
             f"  mean growth: fresh {n['mean_G']:.4f}, carried {n['mean_W']:.4f}; fresh minus carried "
             f"{n['offset']['mean']:.5f} ± {n['offset']['error_bar']:.5f}",
             "  share lined up by " + ", ".join(f"{t} units {100 * v:.0f}%" for t, v in n["aligned_by"].items()),
             "  median overlap at " + ", ".join(f"{t}: {v:.3f}" for t, v in n["median_overlap"].items()),
             "  mean log growth per put-back, fresh minus carried, at " +
             ", ".join(f"{t}: {v:+.4f}" for t, v in n["log_difference"].items()),
             f"  summed over the first 100 units {n['log_difference_sums']['first']:+.3f}, over the last 200 "
             f"{n['log_difference_sums']['rest']:+.3f}"]
    for name, p in n["parts"].items():
        span = "first 100 units" if name == "first" else "last 200 units"
        lines.append(f"  {span}: fresh minus carried {p['offset']['mean']:+.5f} ± {p['offset']['error_bar']:.5f}, "
                     f"r = {p['r']:.3f}")
    for name, b in n["blocks"].items():
        lines.append(f"  {name}: kappa {b['kappa']:.3f}, r {b['r']:.3f}, lined up {100 * b['aligned']:.0f}%, median "
                     f"alignment {b['median_alignment']:.0f}")
    lines += ["", res["seen_before"],
              "", f"Compute: {n['seconds']['total'] / 60:.1f} CPU minutes ({n['seconds']['per_start']:.0f} s per "
              f"start); {len(log['pieces'])} pieces",
              f"Parts (in {WORK.relative_to(HERE.parent.parent)}/calibration): "
              f"{sum(log['parts'].get(Path(name).stem, {}).get('sha256') == value for name, value in sums.items())} "
              f"of {len(sums)} match the run log"]
    return lines


def report():
    missing = [s + 1 for s in STARTS if not (PARTS / f"start{s + 1:02d}.npz").exists()]
    if missing:
        sys.exit(f"Starts {missing} are not done yet: run the pieces first.")
    log = json.loads(RUNLOG.read_text())
    changed = lock_changes()
    now = folder_fingerprints()
    moved = sorted(name for name in set(now) | set(log["folders_before"])
                   if now.get(name) != log["folders_before"].get(name))
    d, sums = load_parts()
    res, values = analyse(d, revised_v1(log))
    res["unchanged"] = dict(lock=not changed, folders=not moved, changed=changed + moved)
    lines = report_lines(res, log, sums)
    lines.append(f"The cause test's locked files unchanged: {not changed}; its folder and the exploratory folder "
                 f"unchanged: {not moved}")
    write_json(OUT_JSON, res)
    save_npz(OUT_NPZ, **values)
    OUT_TXT.write_text("\n".join(lines) + "\n")
    for path in PARTS.glob("*.state.npz"):
        path.unlink()
    print("\n".join(lines))


if __name__ == "__main__":
    commands = dict(dryrun=dryrun, run=run, report=report)
    command = sys.argv[1] if len(sys.argv) > 1 else ""
    if command not in commands:
        sys.exit(__doc__)
    commands[command]()
