"""Check 3 of the pilot review, read as a fluidity question: when the system gets more coherent on its own, does the extra
order sit with the oscillators that can stay in step while moving, and does that order last?

After the fact and exploratory. This describes the pilot's saved results; it is not a reading fixed in plan.md and not a
new experiment. It runs no model. It reads only the pilot's saved files in generated-artifacts/medium-test/pilot: the 12
untouched base runs (base_103 to base_114, each checked against the hash recorded in pilot_runlog.json) and the 120
moment files (each checked against the hash recorded in pilot_results.json). It writes check3_fluidity.txt and
check3_fluidity.json beside this file. Nothing else is changed.

Background (pilot_review.md): at R the medium put nearly all of its extra coherence in the slower-turning half of each
group (the oscillators whose natural rates are closest to the group's own), while the matched push spread it across both
halves. Untouched runs already hold most of their coherence in the slower half on average. Check 3 asks whether the
system's own rises in coherence sit there too.

At Colson's request (Oct 14) the check is read as a fluidity question (order that holds while everything keeps moving)
and asks three things:
1. Where natural order sits: when a group is more coherent on its own, which oscillators supply the extra?
2. The same relationship across routes: does the extra coherence divide between the two halves the same way when the
   system gets there on its own as when the medium takes it there, and differently when the push does?
3. Which order lasts: in the untouched runs, is order held in the slower half still there later, and order held in the
   faster half not?

Natural states: the untouched base run's saved states at T and at R of every moment (first copy), 20 per start, each
group read separately (480 group states). They are the states the medium arms started from (T) and the pushes started
from (R).

Measures, fixed before looking at the numbers:
- A group's coherence is R_g = |z_g|, with z_g the average of e^(i theta) over its 4,000 oscillators. It splits exactly
  into three contributions along the group's own rhythm psi_g: the slower half of the pulled oscillators (1,936), the
  faster half (1,936) and the free turners (128). Each is c = Re(sum over the part of e^(i (theta - psi_g))) / 4,000, and
  c_slower + c_faster + c_free = R_g.
- Question 1: the pooled within-start slope of each contribution on R_g (each group's own average removed first, then
  the two groups pooled), averaged over the starts. The three slopes add up to 1: they are the shares of the extra
  natural coherence that each part supplies. The even-spread share is 1,936 / 4,000 = 0.484, what the slower half would
  supply if every oscillator took an equal part.
  Reading: "more in the slower half than an even spread" if the slower half's share minus 0.484 is clear and positive;
  "less in the slower half than an even spread" if clear and negative; "not clear" otherwise.
- Question 2: the routes' states at R were saved only as each half's own coherence (|average of e^(i theta)| over the
  half: the report's "slower half" and "faster half"), so this comparison uses those, in the same form for every route:
  - natural: the pooled within-start slopes of the slower half's and the faster half's own coherence on R_g (as in
    question 1), b_slower and b_faster;
  - the medium and the push: half of (raised minus lowered) at R in each half's own coherence, averaged over the two
    groups, moment by moment, over the moments with both pushes built (like for like), d_slower and d_faster.
  The slower half's share of the extra is b_slower / (b_slower + b_faster) for natural rises and
  d_slower / (d_slower + d_faster) for each route, each a ratio of averages over the starts.
  Reading: "like the medium" if natural differs clearly from the push and not clearly from the medium; "like the push" if
  the reverse; "between the two" if it differs clearly from both and lies between them; "outside the two" if it differs
  clearly from both and lies outside them; "not clear" if it differs clearly from neither.
- Question 3: for each natural state at time t, each group's later coherence |z_g| from the untouched run, averaged over
  100-unit blocks from (t, t + 100] to (t + 400, t + 500]. The pooled within-start least-squares coefficients of each
  block on c_slower, c_faster and c_free together (each group's own average removed), averaged over the starts: how much
  of each part's contribution at t is still there in the group's coherence later.
  Reading, on the first block (t, t + 100]: "the slower half's order lasts longer" if the slower half's coefficient minus
  the faster half's is clear and positive; "the faster half's order lasts longer" if clear and negative; "not clear"
  otherwise.
- Error bars as in the report: per-start values are averaged over the 12 starts, with the larger of the resampled error
  bar (starts drawn with replacement 400 times, seed 20261016) and SD / sqrt(12). Ratios of averages and the
  differences between them take the resampled error bar, with the same draws for every route. Clear means more than 3
  bars.

Descriptions, not readings:
- the parts' average shares of R_g (how natural coherence is made up on average), and the natural spread of R_g;
- what random phases alone would give, about sqrt(pi / (4 n)) for n oscillators: 0.0201 for a half, 0.0140 for a group;
- questions 1 and 2 from the states at R alone and at T alone;
- each route's change per unit of group coherence beside the natural slopes, the raised and lowered arms separately
  against untouched, and the medium over all 120 moments;
- question 3's coefficients in every block, and the share of a coherence excess still there, block by block and over
  (t + 200, t + 500]: for natural rises the slope of later coherence on R_g; for the medium and the push the share of
  their raised-lowered difference at R (the report's persistence, which the script first reproduces).

Limits stated before looking:
- The natural states at T and R are 130 to 200 units apart within a start, so they are not independent; the error bars
  treat the start as the unit.
- Natural slopes compare moments with each other; the routes compare arms at the same moment. They agree in form (extra
  in each half per unit of extra group coherence), not in design.
- A half's own coherence responds only weakly to small amounts of order near the random-phase floor, the same way for
  every route. Question 2 shares that limit; questions 1 and 3 use the exact contributions instead.
- This describes where order sits and how long it lasts. It does not test what carries the growth difference.
"""

import hashlib
import json
import sys
import time
import warnings
from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo

import numpy as np

HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[2]
PARTS = ROOT / "generated-artifacts" / "medium-test" / "pilot"
OUT_TXT, OUT_JSON = HERE / "check3_fluidity.txt", HERE / "check3_fluidity.json"
STARTS, MOMENTS = tuple(range(103, 115)), tuple(range(1, 11))
N, DELTA, FAST, PULLED, HALF = 4000, 1.0, 20.0, 3872, 1936   # the model's sizes (nudge_test.py, medium_test.py)
EVEN = HALF / N                                            # the slower half's share if every oscillator took an equal part
W, AFTER, BLOCK, LATER = 200, 500, 100, (200, 500)         # window; followed after R; block; growth window after R
BLOCKS = tuple((b, b + BLOCK) for b in range(0, AFTER, BLOCK))
RESAMPLES, SEED, CLEAR = 400, 20261016, 3.0                # as in the report
STATES = ("untouched", "medium raised", "medium lowered", "push raised", "push lowered", "sham raised", "sham lowered")
PART_NAMES = ("slower half", "faster half", "free turners")
TOL = 1e-12


def sha256(path):
    return hashlib.sha256(path.read_bytes()).hexdigest()


def natural_rates(n):
    """The model's natural rates, as nudge_test.natural_rates (copied so that no model file is imported)."""
    return DELTA * np.tan(np.pi * (np.arange(1, n + 1) - 0.5) / n - np.pi / 2)


def inner_half():
    """The slower-turning half of the pulled oscillators, as medium_test.inner_half: 1,936 of 3,872."""
    omega = natural_rates(N)
    w = np.abs(omega[np.abs(omega) <= FAST])
    return w <= np.median(w)


def numbers(u, f, inner):
    """One group's state (u: pulled, f: free turners): R_g; the three parts' contributions along the group's own rhythm,
    which add up to R_g; and each half's own coherence, as the report describes it."""
    z = (u.sum() + f.sum()) / N
    turn = np.exp(-1j * np.angle(z))
    return dict(R=float(np.abs(z)), c_in=float((u[inner].sum() * turn).real / N),
                c_out=float((u[~inner].sum() * turn).real / N), c_free=float((f.sum() * turn).real / N),
                m_in=float(np.abs(u[inner].mean())), m_out=float(np.abs(u[~inner].mean())))


def by_start(values):
    with warnings.catch_warnings():
        warnings.simplefilter("ignore", category=RuntimeWarning)
        return np.nanmean(np.asarray(values, float), axis=1)


def summary(per_start):
    """The report's average over the starts and its error bar."""
    v = np.asarray(per_start, float)
    v = v[np.isfinite(v)]
    n = len(v)
    if n < 2:
        return dict(mean=float(v.mean()) if n else float("nan"), bar=float("nan"), n=n, above=int((v > 0).sum()),
                    clear=False)
    boot = v[np.random.default_rng(SEED).integers(0, n, (RESAMPLES, n))].mean(-1)
    mean = float(v.mean())
    bar = max(float(boot.std(ddof=1)), float(v.std(ddof=1) / np.sqrt(n)))
    return dict(mean=mean, bar=bar, n=n, above=int((v > 0).sum()), clear=bool(abs(mean) > CLEAR * bar))


def ratio_summary(numerator, denominator):
    """The report's ratio of averages over the starts, with the resampled error bar."""
    a, b = np.asarray(numerator, float), np.asarray(denominator, float)
    keep = np.isfinite(a) & np.isfinite(b)
    a, b = a[keep], b[keep]
    idx = np.random.default_rng(SEED).integers(0, len(a), (RESAMPLES, len(a)))
    mean, bar = float(a.mean() / b.mean()), float((a[idx].mean(-1) / b[idx].mean(-1)).std(ddof=1))
    return dict(mean=mean, bar=bar, n=len(a), clear=bool(abs(mean) > CLEAR * bar))


def share(a, b):
    """a / (a + b) for averages over the 12 starts, and its value under each of the report's 400 resampled draws."""
    a, b = np.asarray(a, float), np.asarray(b, float)
    if not (np.isfinite(a).all() and np.isfinite(b).all() and len(a) == len(STARTS)):
        sys.exit("A share needs a value for every start. Stopping.")
    idx = np.random.default_rng(SEED).integers(0, len(a), (RESAMPLES, len(a)))
    return float(a.mean() / (a.mean() + b.mean())), a[idx].mean(-1) / (a[idx].mean(-1) + b[idx].mean(-1))


def as_dict(s):
    """A share and its resampled error bar, for the record."""
    return dict(mean=float(s[0]), bar=float(np.std(s[1], ddof=1)))


def compared(x, y):
    """The difference between two shares, with the resampled error bar from the same draws."""
    mean, bar = x[0] - y[0], float((x[1] - y[1]).std(ddof=1))
    return dict(mean=float(mean), bar=bar, clear=bool(abs(mean) > CLEAR * bar))


def within_slope(y, x):
    """Pooled within-group slope of y on x for one start: (group, state) arrays, each group's own average removed."""
    xd, yd = x - x.mean(-1, keepdims=True), y - y.mean(-1, keepdims=True)
    return float((xd * yd).sum() / (xd * xd).sum())


def within_joint(y, xs):
    """Pooled within-group least-squares coefficients of y on several x together, for one start."""
    yd = (y - y.mean(-1, keepdims=True)).ravel()
    X = np.column_stack([(x - x.mean(-1, keepdims=True)).ravel() for x in xs])
    return np.linalg.lstsq(X, yd, rcond=None)[0]


def shown(s, digits=4):
    return (f"{s['mean']:+.{digits}f} +- {s['bar']:.{digits}f} ({s['mean'] / s['bar']:+.1f} bars; "
            f"{s['above']} of {s['n']} starts above zero)")


def shown_share(value, boot):
    return f"{value:.3f} +- {float(np.std(boot, ddof=1)):.3f}"


def shown_diff(d):
    return f"{d['mean']:+.3f} +- {d['bar']:.3f} ({d['mean'] / d['bar']:+.1f} bars)"


def shown_ratio(s, digits=3):
    return f"{s['mean']:+.{digits}f} +- {s['bar']:.{digits}f}"


def main():
    began = time.time()
    runlog = json.loads((HERE / "pilot_runlog.json").read_text())["parts"]
    report = json.loads((HERE / "pilot_results.json").read_text())
    hashes = report["moment_sha256"]
    inner = inner_half()
    if inner.size != PULLED or int(inner.sum()) != HALF:
        sys.exit("The slower half isn't 1,936 of 3,872 pulled oscillators. Stopping.")
    S_, M_ = len(STARTS), len(MOMENTS)
    nat = {key: np.full((S_, 2, 2 * M_), np.nan) for key in ("R", "c_in", "c_out", "c_free", "m_in", "m_out")}
    later = np.full((S_, 2, 2 * M_, len(BLOCKS)), np.nan)    # each group's later coherence, block by block after t
    late = np.full((S_, 2, 2 * M_), np.nan)                   # over (t + 200, t + 500]
    at_R = np.zeros((S_, 2 * M_), bool)
    rows = []
    worst = dict(desc=0.0, base_z=0.0, z_none=0.0, parts=0.0)
    for i, s in enumerate(STARTS):
        path = PARTS / f"base_{s:03d}.npz"
        if runlog.get(path.stem, {}).get("sha256") != sha256(path):
            sys.exit(f"{path.name} differs from the hash recorded in pilot_runlog.json. Stopping.")
        with np.load(path) as b:
            z, kept_t, kept_u, kept_f = b["z"], b["kept_t"], b["kept_u"], b["kept_f"]
        moment_rows = []
        for m in MOMENTS:
            path = PARTS / f"moment_{s:03d}_{m:02d}.npz"
            if hashes.get(path.name) != sha256(path):
                sys.exit(f"{path.name} differs from the hash recorded in pilot_results.json. Stopping.")
            with np.load(path) as p:
                moment_rows.append({key: p[key] for key in ("T", "R", "z_none", "z_medium", "z_push", "built", "desc_R",
                                                            "desc_inner", "desc_outer")})
        times = [int(t) for t in kept_t]
        if times != sorted({int(r["T"]) for r in moment_rows} | {int(r["R"]) for r in moment_rows}):
            sys.exit(f"base_{s:03d}'s saved states aren't the moments' T and R. Stopping.")
        for j, t in enumerate(times):
            at_R[i, j] = t in {int(r["R"]) for r in moment_rows}
            for g in range(2):
                values = numbers(kept_u[j, 0, g], kept_f[j, 0, g], inner)
                for key in nat:
                    nat[key][i, g, j] = values[key]
                worst["base_z"] = max(worst["base_z"], abs(values["R"] - abs(z[g, t])))
                worst["parts"] = max(worst["parts"], abs(values["c_in"] + values["c_out"] + values["c_free"]
                                                         - values["R"]))
                coherence = np.abs(z[g])
                for k, (lo, hi) in enumerate(BLOCKS):
                    later[i, g, j, k] = coherence[t + lo + 1:t + hi + 1].mean()
                late[i, g, j] = coherence[t + LATER[0] + 1:t + LATER[1] + 1].mean()
        for r in moment_rows:
            j, T = times.index(int(r["R"])), int(r["T"])
            for g in range(2):
                worst["desc"] = max(worst["desc"], abs(nat["R"][i, g, j] - r["desc_R"][0, g]),
                                    abs(nat["m_in"][i, g, j] - r["desc_inner"][0, g]),
                                    abs(nat["m_out"][i, g, j] - r["desc_outer"][0, g]))
            worst["z_none"] = max(worst["z_none"], float(np.max(np.abs(r["z_none"] - z[:, T:T + W + AFTER + 1]))))
        rows += moment_rows

    def grid(fn):
        return np.array([[fn(rows[i * M_ + m]) for m in range(M_)] for i in range(S_)], float)

    def C(zz):
        return np.abs(zz).mean(-2)

    built = np.array([[rows[i * M_ + m]["built"] for m in range(M_)] for i in range(S_)], bool)
    mask = np.where(built.all(-1), 1.0, np.nan)

    # the numbers reproduce the report before anything else is read
    checks = {
        "untouched states at R from the base runs against the report's descriptions (coherence, slower half, faster "
        "half)": worst["desc"],
        "coherence from the saved states against the base runs' record at T and R": worst["base_z"],
        "the moment files' untouched coherence against the base runs": worst["z_none"],
        "the three contributions adding up to the group's coherence": worst["parts"]}
    worst_state = 0.0
    for i_state, state in enumerate(STATES):
        m_ = mask if i_state >= 3 else 1.0
        for key in ("R", "inner", "outer"):
            mine = float(np.mean(by_start(grid(lambda r, i_state=i_state, key=key:
                                               np.mean(np.abs(r[f"desc_{key}"][i_state]))) * m_)))
            worst_state = max(worst_state, abs(mine - report["descriptions"]["states"][state][key]))
    checks["the report's state descriptions at R (coherence, slower half, faster half; seven states)"] = worst_state
    diffR = grid(lambda r: C(r["z_medium"][0])[W] - C(r["z_medium"][1])[W])

    def medium_block(lo, hi):
        return grid(lambda r: (C(r["z_medium"][0]) - C(r["z_medium"][1]))[W + lo + 1:W + hi + 1].mean())

    def push_block(lo, hi):
        return grid(lambda r: (C(r["z_push"][0]) - C(r["z_push"][1]))[lo + 1:hi + 1].mean())

    persistence = dict(medium=ratio_summary(by_start(medium_block(*LATER) * mask), by_start(diffR * mask)),
                       push=ratio_summary(by_start(push_block(*LATER) * mask), by_start(diffR * mask)))
    checks["the report's persistence over (R + 200, R + 500], medium and push"] = max(
        abs(persistence[k][q] - report["persistence"][k][q]) for k in ("medium", "push") for q in ("mean", "bar"))
    bad = {key: value for key, value in checks.items() if not value <= TOL}
    if bad:
        sys.exit(f"These differ from the record: {bad}. Stopping.")

    # question 1: where natural order sits (exact contributions)
    keys = ("c_in", "c_out", "c_free")

    def shares_of(select):
        return np.array([[within_slope(nat[k][i][:, select(i)], nat["R"][i][:, select(i)]) for k in keys]
                         for i in range(S_)])

    every, only_R, only_T = (lambda i: slice(None)), (lambda i: at_R[i]), (lambda i: ~at_R[i])
    a = shares_of(every)
    q1 = summary(a[:, 0] - EVEN)
    verdict1 = ("more in the slower half than an even spread" if q1["clear"] and q1["mean"] > 0
                else "less in the slower half than an even spread" if q1["clear"] else "not clear")
    one = dict(
        shares={name: summary(a[:, k]) for k, name in enumerate(PART_NAMES)}, slower_minus_even=q1,
        sum_of_shares_largest_departure_from_1=float(np.max(np.abs(a.sum(-1) - 1))),
        at_R_alone={name: summary(v) for name, v in zip(PART_NAMES, shares_of(only_R).T)},
        at_T_alone={name: summary(v) for name, v in zip(PART_NAMES, shares_of(only_T).T)},
        made_up_of={name: ratio_summary(nat[k].reshape(S_, -1).mean(-1), nat["R"].reshape(S_, -1).mean(-1))
                    for name, k in zip(PART_NAMES, keys)},
        average_R=summary(nat["R"].reshape(S_, -1).mean(-1)),
        spread_of_R=summary([float(np.sqrt(np.mean((nat["R"][i] - nat["R"][i].mean(-1, keepdims=True)) ** 2)))
                             for i in range(S_)]),
        random_floor=dict(half=float(np.sqrt(np.pi / (4 * HALF))), group=float(np.sqrt(np.pi / (4 * N)))))

    # question 2: the same relationship across routes (each half's own coherence)
    def slopes_of(select):
        return np.array([[within_slope(nat[k][i][:, select(i)], nat["R"][i][:, select(i)]) for k in ("m_in", "m_out")]
                         for i in range(S_)])

    b = slopes_of(every)

    def half_split(key, x, y):
        return grid(lambda r: float(np.mean(r[f"desc_{key}"][x] - r[f"desc_{key}"][y])) / 2) * mask

    d = {route: {key: by_start(half_split(key, x, y)) for key in ("inner", "outer", "R")}
         for route, (x, y) in (("medium", (1, 2)), ("push", (3, 4)))}
    sh = dict(natural=share(b[:, 0], b[:, 1]), medium=share(d["medium"]["inner"], d["medium"]["outer"]),
              push=share(d["push"]["inner"], d["push"]["outer"]))
    nm, np_, mp = compared(sh["natural"], sh["medium"]), compared(sh["natural"], sh["push"]), \
        compared(sh["medium"], sh["push"])
    if np_["clear"] and not nm["clear"]:
        verdict2 = "like the medium"
    elif nm["clear"] and not np_["clear"]:
        verdict2 = "like the push"
    elif nm["clear"] and np_["clear"]:
        low, high = sorted((sh["medium"][0], sh["push"][0]))
        verdict2 = ("between the two" if low < sh["natural"][0] < high else
                    "outside the two (above both)" if sh["natural"][0] > high else "outside the two (below both)")
    else:
        verdict2 = "not clear"

    def against_untouched(key, arm, lowered):
        if lowered:
            return grid(lambda r: float(np.mean(r[f"desc_{key}"][0] - r[f"desc_{key}"][arm]))) * mask
        return grid(lambda r: float(np.mean(r[f"desc_{key}"][arm] - r[f"desc_{key}"][0]))) * mask

    separately, separate_shares = {}, {}
    for name, arm, lowered in (("medium raised", 1, False), ("medium lowered", 2, True), ("push raised", 3, False),
                               ("push lowered", 4, True)):
        x, y = by_start(against_untouched("inner", arm, lowered)), by_start(against_untouched("outer", arm, lowered))
        separate_shares[name] = share(x, y)
        separately[name] = dict(share=as_dict(separate_shares[name]), slower=summary(x), faster=summary(y))
    all_moments = share(by_start(grid(lambda r: float(np.mean(r["desc_inner"][1] - r["desc_inner"][2])) / 2)),
                        by_start(grid(lambda r: float(np.mean(r["desc_outer"][1] - r["desc_outer"][2])) / 2)))
    natural_R, natural_T = share(*slopes_of(only_R).T), share(*slopes_of(only_T).T)
    two = dict(
        share={k: as_dict(v) for k, v in sh.items()},
        natural_minus_medium=nm, natural_minus_push=np_, medium_minus_push=mp,
        per_unit=dict(natural=dict(slower=summary(b[:, 0]), faster=summary(b[:, 1])),
                      **{route: dict(slower=ratio_summary(d[route]["inner"], d[route]["R"]),
                                     faster=ratio_summary(d[route]["outer"], d[route]["R"]),
                                     group_change=summary(d[route]["R"])) for route in ("medium", "push")}),
        natural_at_R_alone=as_dict(natural_R), natural_at_T_alone=as_dict(natural_T),
        separately=separately, medium_all_moments=as_dict(all_moments))

    # question 3: which order lasts (exact contributions at t, the group's coherence later)
    beta = np.array([[within_joint(later[i, :, :, k], [nat[key][i] for key in keys]) for k in range(len(BLOCKS))]
                     for i in range(S_)])                    # (start, block, part)
    q3 = summary(beta[:, 0, 0] - beta[:, 0, 1])
    verdict3 = ("the slower half's order lasts longer" if q3["clear"] and q3["mean"] > 0
                else "the faster half's order lasts longer" if q3["clear"] else "not clear")
    by_block = [dict(block=f"(t+{lo}, t+{hi}]", **{name: summary(beta[:, k, p]) for p, name in enumerate(PART_NAMES)},
                     slower_minus_faster=summary(beta[:, k, 0] - beta[:, k, 1])) for k, (lo, hi) in enumerate(BLOCKS)]
    still_clear = [row["block"] for row in by_block if row["slower half"]["clear"] and row["slower half"]["mean"] > 0]
    survival = [dict(block=f"({lo}, {hi}]",
                     natural=summary([within_slope(later[i, :, :, k], nat["R"][i]) for i in range(S_)]),
                     medium=ratio_summary(by_start(medium_block(lo, hi) * mask), by_start(diffR * mask)),
                     push=ratio_summary(by_start(push_block(lo, hi) * mask), by_start(diffR * mask)))
                for k, (lo, hi) in enumerate(BLOCKS)]
    survival_late = dict(natural=summary([within_slope(late[i], nat["R"][i]) for i in range(S_)]), **persistence)
    three = dict(main=q3, by_block=by_block, slower_half_still_clear=still_clear, survival=survival,
                 survival_over_200_500=survival_late)

    made = datetime.now(ZoneInfo("America/Halifax")).strftime("%Y-%m-%d %H:%M")
    floor = one["random_floor"]
    lines = [
        f"Check 3 of the pilot review, read as a fluidity question: where natural order sits, and whether it lasts "
        f"(run {made} Halifax).",
        "After the fact and exploratory: a description of the pilot's saved results, not a reading fixed in plan.md and "
        "not a new experiment. No model was run.",
        "A toy model: structure inside the model, not evidence about cells. Histories, not memory: differences are "
        "between intervention histories and the states they leave.",
        "Read: the 12 untouched base runs (each matching the hash in pilot_runlog.json) and the pilot's 120 moment files "
        "(each matching the hash in pilot_results.json). Reproduced before reading on: "
        + "; ".join(f"{key} (largest difference {value:.1e})" for key, value in checks.items()) + ".",
        f"Natural states: the untouched run at T and at R of every moment, 20 per start, both groups "
        f"({int(np.isfinite(nat['R']).sum())} group states). Moments with both pushes built for the routes: "
        f"{int(np.isfinite(mask).sum())}. Error bars and 'clear' (more than 3 bars) as in the report.",
        "",
        "Answers",
        f"  1. Where natural order sits: {verdict1}.",
        f"  2. The same relationship across routes: {verdict2}.",
        f"  3. Which order lasts: {verdict3}.",
        "",
        "1. Where natural order sits: the share of the extra natural coherence each part supplies (exact; they add up "
        "to 1)",
        *(f"  {name}: {shown(one['shares'][name], 3)}" for name in PART_NAMES),
        f"  slower half minus the even-spread share ({EVEN:.3f}): {shown(q1, 3)}",
        f"  states at R alone: " + "; ".join(f"{name} {shown_ratio(one['at_R_alone'][name])}" for name in PART_NAMES),
        f"  states at T alone: " + "; ".join(f"{name} {shown_ratio(one['at_T_alone'][name])}" for name in PART_NAMES),
        "  how a group's coherence is made up on average: "
        + "; ".join(f"{name} {shown_ratio(one['made_up_of'][name])}" for name in PART_NAMES),
        f"  average R_g {one['average_R']['mean']:.4f}; its spread within starts (SD) {one['spread_of_R']['mean']:.4f}; "
        f"random phases alone would give about {floor['group']:.4f} for a group and {floor['half']:.4f} for a half",
        "",
        "2. The same relationship across routes: the slower half's share of the extra, from each half's own coherence",
        f"  natural: {shown_share(*sh['natural'])}; medium: {shown_share(*sh['medium'])}; push: "
        f"{shown_share(*sh['push'])}",
        f"  natural minus medium: {shown_diff(nm)}; natural minus push: {shown_diff(np_)}; medium minus push: "
        f"{shown_diff(mp)}",
        f"  natural from states at R alone: {shown_share(*natural_R)}; at T alone: {shown_share(*natural_T)}",
        "  per unit of extra group coherence (slower half, faster half):",
        f"    natural slopes: {shown_ratio(two['per_unit']['natural']['slower'])}, "
        f"{shown_ratio(two['per_unit']['natural']['faster'])}",
        *(f"    {route}: {shown_ratio(two['per_unit'][route]['slower'])}, {shown_ratio(two['per_unit'][route]['faster'])}"
          f" (group change {two['per_unit'][route]['group_change']['mean']:+.4f})" for route in ("medium", "push")),
        "  against untouched, separately (slower half's share; change in the slower half, in the faster half):",
        *(f"    {name}: {shown_share(*separate_shares[name])}; {shown(v['slower'])}; {shown(v['faster'])}"
          for name, v in separately.items()),
        f"  medium over all 120 moments: {shown_share(*all_moments)}",
        "",
        "3. Which order lasts: how much of each part's contribution at t is still there in the group's coherence later "
        "(coefficients, the three parts together)",
        *(f"  {row['block']}: slower half {shown(row['slower half'], 3)}; faster half {shown(row['faster half'], 3)}; "
          f"free turners {shown(row['free turners'], 3)}; slower minus faster {shown(row['slower_minus_faster'], 3)}"
          for row in by_block),
        f"  blocks where the slower half's contribution is still clearly there: {', '.join(still_clear) or 'none'}",
        "  share of a coherence excess still there (natural: slope of later coherence on R_g; medium and push: share of "
        "their raised-lowered difference at R):",
        *(f"    {row['block']} after t or R: natural {shown_ratio(row['natural'])}; medium {shown_ratio(row['medium'])}; "
          f"push {shown_ratio(row['push'])}" for row in survival),
        f"    (200, 500] after t or R: natural {shown_ratio(survival_late['natural'])}; medium "
        f"{shown_ratio(survival_late['medium'])}; push {shown_ratio(survival_late['push'])} (the report's persistence)",
        "",
        "Limits",
        "  The natural states at T and R are 130 to 200 units apart within a start, so they are not independent; the "
        "error bars treat the start as the unit.",
        "  Natural slopes compare moments with each other; the routes compare arms at the same moment. They agree in "
        "form (extra in each half per unit of extra group coherence), not in design.",
        "  A half's own coherence responds only weakly to small amounts of order near the random-phase floor, the same "
        "way for every route. Question 2 shares that limit; questions 1 and 3 use the exact contributions instead.",
        "  This describes where order sits and how long it lasts. It does not test what carries the growth difference.",
        "",
        f"Compute: {time.time() - began:.1f} seconds, in the foreground.",
    ]
    OUT_TXT.write_text("\n".join(lines) + "\n")
    OUT_JSON.write_text(json.dumps(dict(made_halifax=made, verdicts=dict(where=verdict1, routes=verdict2,
                                                                         lasts=verdict3),
                                        reproduced=checks, question_1=one, question_2=two, question_3=three),
                                   indent=1, default=lambda o: o.tolist() if hasattr(o, "tolist") else str(o)))
    print("\n".join(lines))


if __name__ == "__main__":
    main()
