#!/usr/bin/env python3
"""
Cause test: the numbers its plan is built on (October 12, 2026). Read-only, from the 18 saved starts at N = 4,000
(starts 1 to 18: Test 16's long run, the coherence-growth test and the replication). The model is not run, and no file
it reads is changed.

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

What it works out (each is used in cause_test_prereg.md)
    - The benchmark: start by start, least squares over the moments T = 2,000, 2,005, ..., 5,500,
          W(T) = a + beta x C(T) + c1 x g1(T) + ... + c5 x g5(T)
      W: the growth rate of the gap over (T + 200, T + 500]; C: the coherence |z| at T (average of groups A and B);
      g1 to g5: the growth rate in the five 100-unit windows up to T, as in Test 17. beta, averaged over the 18 starts,
      is how much later growth goes with coherence; the plan's threshold is set from it. Also shown: without g1 to g5,
      and with C averaged over the 5 time units up to T.
    - How much later growth still varies once C and g1 to g5 are known: the noise the sample size is set against.
    - Natural persistence: how much of a coherence difference at T is still there, on average, over (T, T + 200].
    - Each group's coherence over t = 2,000 to 5,500, and so the sizes of the planned phase changes.
    - The moment table cause_moments.csv (starts 19 to 60, ten moments each), from the plan's seed.
    - How much the ten moments of one start go together: the correlation of the later growth (and of what is left of
      it once C and g1 to g5 are known) between moments 350, 700, ... time units apart.
    - The chance of each decision under several assumed true effects and noise levels, made by the plan's own decision
      code (cause_rule.py, the code the report is to use) on simulated numbers with the measured spread standing in for
      the outcomes (this is not the model). With it, the exact bound on a false positive from the t distribution, and
      for comparison the chances with the first draft's 21 starts.
    - The computing time, from the replication's own run.

Usage
    python3 design_numbers.py    # writes design_numbers.txt, design_numbers.json and cause_moments.csv here

Needs Python 3, numpy and scipy (the t distribution's bound only), cause_rule.py, ../coherence-growth-2026-10-11 and
../nudge-test-2026-10-10 with their saved runs, unchanged.
"""
import csv
import json
import sys
from pathlib import Path

import numpy as np

HERE = Path(__file__).resolve().parent
CG = HERE.parent / "coherence-growth-2026-10-11"
sys.dont_write_bytecode = True                             # leave every folder as it is
sys.path.insert(0, str(HERE))
sys.path.insert(1, str(CG))
import cause_rule as rule  # noqa: E402  (the plan's decision rule: the code the report is to use)
import coherence_growth as cg  # noqa: E402  (the saved runs and the model's settings; unchanged)
import earlier_growth_check as egc  # noqa: E402  (the earlier growth, computed as in Test 17; unchanged)

nt = cg.nt
EVERY = nt.LYAP_EVERY                                      # 5 time units per interval of the gap
FIRST, LAST = 2000.0, 5500.0                               # moments T run from here to here, so T + 500 stays in the run
LATER = (200.0, 500.0)                                     # the growth measured over (T + 200, T + 500]
PERSIST = 200.0                                            # natural persistence: the coherence over (T, T + 200]
FRACTION = 0.5                                             # the planned change: each group's coherence x 1.5 or x 0.5
SPARSE = 0.1                                               # the second method: a random tenth of the pulled oscillators
NEW = tuple(range(18, 60))                                 # starts 19 to 60 (seeds 18 to 59): never run at this size
MOMENTS, SPACING = 10, 350.0                               # ten moments per start, 350 time units apart
SEED = 20261012                                            # the plan's random draws (here: the moment offsets)
HALF = 0.5                                                 # the threshold: half the benchmark
TRIALS, NULL_TRIALS, CURVE_TRIALS = 4000, 20000, 2000      # simulated repeats of the whole test, per row
CAUTIOUS_RHO = 0.1                                         # most cautious: a tenth of the noise shared by a start's moments
CURVE = (0.5, 1.0, 1.5, 2.0, 2.5, 3.0)                     # noise levels tried, as multiples of the expected noise
DRAFT_STARTS = 21                                          # the first draft's sample, for comparison only
REPLICATION = CG / "sim_replication4000.npz"
assert rule.RESAMPLE_SEED == cg.SEED                       # 20261015, as in every earlier part
OUT_TXT, OUT_JSON, MOMENT_TABLE = HERE / "design_numbers.txt", HERE / "design_numbers.json", HERE / "cause_moments.csv"


def saved_sets():
    """The 18 saved starts: (label, z, logs) for starts 1 to 6, 7 to 12 and 13 to 18."""
    old = np.load(cg.EARLIER)
    i = [round(float(x), 2) for x in old["ks"]].index(cg.K)
    fresh, rep = np.load(cg.SAVED), np.load(REPLICATION)
    return (("1 to 6", old["z"][i], old["logs"][i]), ("7 to 12", fresh["z"], fresh["logs"]),
            ("13 to 18", rep["z"], rep["logs"]))


def fit(y, columns):
    """Least squares of y on a constant and the columns: the weights, the spread left over (n - parameters) and what
    is left over at each moment."""
    design = np.column_stack([np.ones(len(y)), *columns])
    weights = np.linalg.lstsq(design, y, rcond=None)[0]
    left = y - design @ weights
    return weights, float(np.sqrt(left @ left / (len(y) - design.shape[1]))), left


def slope(y, x):
    x = x - x.mean()
    return float((x @ (y - y.mean())) / (x @ x))


def corr(x, y):
    x, y = x - x.mean(), y - y.mean()
    return float((x @ y) / np.sqrt((x @ x) * (y @ y)))


def one_start(z, logs):
    """Every design number for one saved start. z: (group, time), every time unit from t = 0; logs: (interval,)."""
    r = np.abs(z.astype(np.complex128))                    # each group's coherence
    c = r.mean(0)                                          # C: A and B averaged
    moments = np.arange(FIRST, LAST + EVERY / 2, EVERY).astype(int)
    js = moments // int(EVERY) - 1                         # the gap's interval js ends at T
    total = np.concatenate([[0.0], np.cumsum(logs)])
    lo, hi = (int(round(x / EVERY)) for x in LATER)
    w = (total[js + 1 + hi] - total[js + 1 + lo]) / (LATER[1] - LATER[0])
    now = c[moments]                                       # C at T
    r5 = c[1:].reshape(-1, int(EVERY)).mean(-1)[js]        # C averaged over (T - 5, T], as Test 17 measured it
    g = egc.before(logs, js, egc.MAIN)                     # the growth rate in the five 100-unit windows up to T
    held, left, residual = fit(w, [now, *g.T])
    plain, _, _ = fit(w, [now])
    held5, _, _ = fit(w, [r5, *g.T])

    def ahead(a, b):                                       # the mean coherence over (T + a, T + b]
        return np.array([c[t + int(a) + 1:t + int(b) + 1].mean() for t in moments])

    def apart(x):                                          # correlation between moments 350, 700, ..., 3,150 apart
        step = int(round(SPACING / EVERY))
        return [corr(x[:-step * k], x[step * k:]) for k in range(1, MOMENTS)]

    span = c[int(FIRST):int(LAST) + 1]
    return dict(
        beta_held=float(held[1]), beta_plain=float(plain[1]), beta_held_r5=float(held5[1]), left=left,
        w_mean=float(w.mean()), w_sd=float(w.std(ddof=1)), c_mean=float(now.mean()), c_sd=float(now.std(ddof=1)),
        persist={"0-100": slope(ahead(0, 100), now), "0-200": slope(ahead(0, PERSIST), now),
                 "200-500": slope(ahead(*LATER), now)},
        autocorrelation={str(lag): corr(span[:-lag], span[lag:]) for lag in (1, 5, 25, 100, 200, 500)},
        apart={"W left over": apart(residual), "W": apart(w), "C": apart(now)}, shape=residual / left,
        groups=r[:, int(FIRST):int(LAST) + 1].ravel())


def together(lags):
    """The average correlation over all pairs of ten moments 350 time units apart, from the correlations at 350, 700,
    ..., 3,150 apart (9 pairs at 350, 8 at 700, ...)."""
    return float(sum((MOMENTS - k) * r for k, r in enumerate(lags, 1)) / (MOMENTS * (MOMENTS - 1) / 2))


def moment_table():
    """The moments of every new start: ten, 350 time units apart, after one random offset per start (a multiple of 5
    from 0 to 345), drawn from (SEED, seed of the start)."""
    rows = []
    for s in NEW:
        offset = EVERY * int(np.random.default_rng((SEED, s)).integers(0, int(SPACING / EVERY)))
        rows += [(s + 1, m + 1, int(FIRST + offset + SPACING * m)) for m in range(MOMENTS)]
    return rows


def simulated(up, down, spread, sigma, same, rho, trials, delta, rng, picks, shape=None, chunk=500):
    """One scenario: `trials` simulated repeats of the whole test (42 starts, 10 moments, 2 methods, 3 arms), each
    decided by the plan's own rule (cause_rule.py) with the manipulation checks taken as passed. up, down: the true
    change of the later growth in up and in down, each against its own sham, for each method (dense, sparse); spread:
    how much each start's own effect differs (opposite in up and down, the same in both methods); sigma: the noise of
    each branch's later growth, a share rho of its variance shared by the ten moments of a start and the rest fresh at
    each moment, independent across branches (the two methods' noise identical if `same`); shape: unit-spread values to
    draw the noise from, instead of the normal curve."""
    starts = picks.shape[1]
    keys = ("pooled", *rule.METHODS)
    deltas, passed = dict.fromkeys(keys, delta), dict.fromkeys(keys, True)
    counts = {name: dict.fromkeys(rule.READINGS, 0) for name in ("overall", *rule.METHODS)}
    bars = []

    def draw(size):
        return rng.standard_normal(size) if shape is None else rng.choice(shape, size)

    for begin in range(0, trials, chunk):
        n = min(chunk, trials - begin)
        size = (n, starts, MOMENTS, len(rule.METHODS), len(rule.ARMS))
        g = sigma * (np.sqrt(rho) * draw((n, starts, 1, *size[3:])) + np.sqrt(1 - rho) * draw(size))
        if same:
            g[:, :, :, 1] = g[:, :, :, 0]
        own = spread * rng.standard_normal((n, starts, 1, 1))
        g[..., 0] += np.asarray(up) - own
        g[..., 1] += np.asarray(down) + own
        pooled, each = rule.decide(*rule.per_start(g), deltas, picks, passed, passed)
        for name, (out, _) in (("overall", pooled), *each.items()):
            for v in rule.READINGS:
                counts[name][v] += int((out == v).sum())
        bars.append(pooled[1]["s_bar"])
    return dict(shares={name: {v: c / trials for v, c in tally.items()} for name, tally in counts.items()},
                s_bar=float(np.concatenate(bars).mean()), trials=trials)


def main():
    starts, labels = [], []
    for label, z, logs in saved_sets():
        for i in range(len(z)):
            starts.append(one_start(z[i], logs[i]))
            labels.append(label)
    labels = np.array(labels)

    def column(key):
        return np.array([s[key] for s in starts])

    def stats(values):
        values = np.asarray(values, float)
        return dict(mean=float(values.mean()), sd=float(values.std(ddof=1)),
                    se=float(values.std(ddof=1) / np.sqrt(len(values))),
                    sets={name: float(values[labels == name].mean()) for name in dict.fromkeys(labels)},
                    starts=values.tolist())

    beta = {key: stats(column(key)) for key in ("beta_held", "beta_plain", "beta_held_r5")}
    left = column("left")
    sigma = float(np.sqrt((left ** 2).mean()))             # the spread left over, root mean square over the starts
    w_sd = float(np.sqrt((column("w_sd") ** 2).mean()))
    c_mean, w_mean = float(column("c_mean").mean()), float(column("w_mean").mean())
    persist = {key: stats([s["persist"][key] for s in starts]) for key in starts[0]["persist"]}
    autocorrelation = {lag: float(np.mean([s["autocorrelation"][lag] for s in starts]))
                       for lag in starts[0]["autocorrelation"]}
    groups = np.concatenate([s["groups"] for s in starts])
    centiles = {str(q): float(np.percentile(groups, q)) for q in (1, 5, 25, 50, 75, 95, 99)}
    apart = {key: [float(np.mean([s["apart"][key][k] for s in starts])) for k in range(MOMENTS - 1)]
             for key in starts[0]["apart"]}
    pairs = {key: together(lags) for key, lags in apart.items()}
    pairs_starts = np.array([together(s["apart"]["W left over"]) for s in starts])
    shape = np.concatenate([s["shape"] for s in starts])
    shape /= np.sqrt((shape ** 2).mean())                  # what is left over, at unit spread: its measured shape

    omega = nt.natural_rates(cg.N)
    pulled = int((np.abs(omega) <= nt.FAST).sum())         # per group; the rest turn freely (still counted in C)
    subset = int(round(SPARSE * pulled))
    dense_per_r = FRACTION / (pulled / (2 * cg.N))         # a / R: (1/N) sum of sin^2 over the pulled is about pulled / 2N
    sparse_per_r = FRACTION / (subset / (2 * cg.N))
    sizes = {q: dict(R=v, dense=dense_per_r * v, sparse=sparse_per_r * v) for q, v in centiles.items()}

    bench_beta = abs(beta["beta_held"]["mean"])
    change = FRACTION * c_mean                             # the expected |change in C|: half of C at the moment
    bench = bench_beta * change                            # D_up = -bench and D_down = +bench if the link were all cause
    delta = HALF * bench
    m2 = HALF * persist["0-200"]["mean"]

    rng, picks, b = np.random.default_rng(SEED), rule.draws(len(NEW)), bench

    def both(x):
        return (x, x)

    scenarios = (("no effect", both(0.0), both(0.0), 0.0),
                 ("a quarter of the benchmark", both(-b / 4), both(b / 4), 0.0),
                 ("half the benchmark", both(-b / 2), both(b / 2), 0.0),
                 ("the benchmark", both(-b), both(b), 0.0),
                 ("the benchmark, starts differ by half of it", both(-b), both(b), b / 2),
                 ("the benchmark, plus a kick speeding growth as much", both(0.0), both(2 * b), 0.0),
                 ("only lowering matters, the benchmark's size", both(0.0), both(b), 0.0),
                 ("the benchmark in the dense method only", (-b, 0.0), (b, 0.0), 0.0),
                 ("the benchmark in the sparse method only", (0.0, -b), (0.0, b), 0.0),
                 ("reversed, the benchmark's size", both(b), both(-b), 0.0))
    key_scenarios = (scenarios[0], scenarios[2], scenarios[3])
    noises = (("expected", f"the spread left over once C and the earlier growth are known ({sigma:.5f}), every branch"
                           " and every moment independent", sigma, False, 0.0, None),
              ("most cautious", f"all of later growth's spread ({w_sd:.5f}), the two methods' noise identical, and"
                                f" {CAUTIOUS_RHO:g} of its variance shared by the ten moments of a start", w_sd, True,
               CAUTIOUS_RHO, None))
    extras = (("the measured shape", "expected, but drawn from what is left over in the saved runs (its actual shape)",
               sigma, False, 0.0, shape),
              ("moments sharing a tenth", f"expected, but {CAUTIOUS_RHO:g} of the variance shared by a start's moments",
               sigma, False, CAUTIOUS_RHO, None))

    def row_of(noise, level, name, up, down, spread, noise_sigma, same, rho, noise_shape, trials):
        return dict(noise=noise, level=level, scenario=name, up=list(up), down=list(down), spread=spread,
                    sigma=noise_sigma, same=same, rho=rho,
                    **simulated(up, down, spread, noise_sigma, same, rho, trials, delta, rng, picks, noise_shape))

    power = [row_of(noise, None, name, up, down, spread, noise_sigma, same, rho, noise_shape,
                    NULL_TRIALS if name == "no effect" else TRIALS)
             for noise, _, noise_sigma, same, rho, noise_shape in noises for name, up, down, spread in scenarios]
    extra = [row_of(noise, None, name, up, down, spread, noise_sigma, same, rho, noise_shape,
                    NULL_TRIALS if name == "no effect" else TRIALS)
             for noise, _, noise_sigma, same, rho, noise_shape in extras for name, up, down, spread in key_scenarios]
    curve = [row_of("expected", level, name, up, down, spread, level * sigma, False, 0.0, None, CURVE_TRIALS)
             for level in CURVE for name, up, down, spread in key_scenarios]
    draft_picks = rule.draws(DRAFT_STARTS)                  # the first draft's 21 starts, the same rule (after the rest)
    draft = [dict(noise=noise, scenario=name, **simulated(up, down, spread, noise_sigma, same, rho,
                                                          NULL_TRIALS if name == "no effect" else TRIALS, delta, rng,
                                                          draft_picks, noise_shape))
             for noise, _, noise_sigma, same, rho, noise_shape in noises
             for name, up, down, spread in (scenarios[0], scenarios[3])]
    from scipy.stats import t as student                   # the bound on a false positive only
    bound = float(student.sf(rule.RULE, len(NEW) - 1))

    rep = np.load(REPLICATION)
    per_start = float(rep["seconds"].mean())               # one start, six running at once
    base_units = nt.SETTLE + 2 * cg.RUN                    # settling one copy, then the two copies
    rate = base_units / per_start                          # copy-time-units per second, per worker
    base = len(NEW) * base_units
    branch = len(NEW) * MOMENTS * 6 * 2 * LATER[1]         # six branches, two copies, 500 time units each
    minutes = (base + branch) / (rate * nt.WORKERS) / 60

    rows = moment_table()
    with MOMENT_TABLE.open("w", newline="") as f:
        writer = csv.writer(f, lineterminator="\n")
        writer.writerow(("start", "moment", "T"))
        writer.writerows(rows)

    lines = []

    def say(text=""):
        print(text)
        lines.append(text)

    def row(name, s, digits=4):
        sets = ", ".join(f"{k} {v:+.{digits}f}" for k, v in s["sets"].items())
        return f"  {name:<52}{s['mean']:+.{digits}f} (SD {s['sd']:.{digits}f}, SD/sqrt(18) {s['se']:.{digits}f}); {sets}"

    say(f"Cause test, design numbers: October 12, 2026. N = {cg.N:,} per group, K = {cg.K:.2f}, eps = {nt.EPS:g}")
    say("Read-only, from the 18 saved starts (1 to 6: Test 16's long run; 7 to 12: the coherence-growth test; 13 to 18:"
        " the replication). The model is not run; the chances of each decision are simulated sampling.")
    say(f"Moments T = {FIRST:,.0f}, {FIRST + EVERY:,.0f}, ..., {LAST:,.0f} in every start ("
        f"{len(np.arange(FIRST, LAST + EVERY / 2, EVERY))} per start). W: the growth rate of the gap over"
        f" (T + {LATER[0]:.0f}, T + {LATER[1]:.0f}]. C: the coherence |z| at T, average of A and B.")
    say()
    say("The benchmark: least squares start by start, then averaged over the 18 starts")
    say(row("W = a + beta C(T) + c1 g1 + ... + c5 g5 (beta)", beta["beta_held"]))
    say(row("W = a + beta C(T) (no earlier growth held fixed)", beta["beta_plain"]))
    say(row("with C averaged over (T - 5, T], as Test 17", beta["beta_held_r5"]))
    say(f"  below 0 in {(column('beta_held') < 0).sum()} of 18 starts (held fixed), {(column('beta_plain') < 0).sum()}"
        " of 18 (plain)")
    say()
    say("Spread")
    say(f"  later growth W: average {w_mean:.4f}, SD within a start {w_sd:.5f} (root mean square over the starts)")
    say(f"  W left over once C(T) and g1 to g5 are known: {sigma:.5f} (root mean square over the starts; per start"
        f" {left.min():.5f} to {left.max():.5f})")
    say(f"  C at T: average {c_mean:.4f}, SD within a start {float(np.sqrt((column('c_sd') ** 2).mean())):.4f}")
    say()
    say("Natural persistence: the slope of the mean coherence over a later stretch on C(T)")
    for key, name in (("0-100", "(T, T + 100]"), ("0-200", "(T, T + 200]"), ("200-500", "(T + 200, T + 500]")):
        say(row(name, persist[key], 3))
    say("  autocorrelation of C, t = 2,000 to 5,500 (average over the starts): "
        + ", ".join(f"lag {lag}: {v:.2f}" for lag, v in autocorrelation.items()))
    say()
    say(f"Each group's coherence, t = {FIRST:,.0f} to {LAST:,.0f}, all starts: centiles "
        + ", ".join(f"{q}%: {v:.3f}" for q, v in centiles.items()))
    say(f"Pulled oscillators per group: {pulled:,} of {cg.N:,} (natural rates within +/-{nt.FAST:g}); the sparse"
        f" method moves {subset} of them")
    say(f"Planned largest phase change, radians, to move a group's coherence by {FRACTION:.0%} (first order:"
        f" a = {FRACTION:g} R / ((1/N) sum of sin^2), about {dense_per_r:.2f} R dense and {sparse_per_r:.1f} R sparse):")
    for q, v in sizes.items():
        say(f"  at the {q:>2}% centile (R = {v['R']:.3f}): dense {v['dense']:.3f}, sparse {v['sparse']:.3f}")
    say()
    say("Numbers the plan is set from")
    say(f"  benchmark slope |beta| = {bench_beta:.4f}; expected |change in C| = {FRACTION:g} x {c_mean:.4f} ="
        f" {change:.4f}")
    say(f"  if the whole link were cause: D_up = -{bench:.5f}, D_down = +{bench:.5f}, S = {bench:.5f}"
        f" ({bench / w_mean:.0%} of the average later growth; {bench / w_sd:.2f} SD of W)")
    say(f"  threshold delta = {HALF:g} x |beta| x |change in C| = {delta:.5f}")
    say(f"  persistence floor for a null result = {HALF:g} x {persist['0-200']['mean']:.3f} = {m2:.3f}")
    say()
    say("How much the ten moments of one start go together: the correlation between moments 350, 700, ..., 3,150 time"
        " units apart (average over the 18 starts; removing each start's own mean pulls these slightly below 0)")
    for key, lags in apart.items():
        say(f"  {key:<13}" + ", ".join(f"{SPACING * k:,.0f}: {r:+.2f}" for k, r in enumerate(lags, 1))
            + f"; average over all 45 pairs of ten moments {pairs[key]:+.3f}")
    say(f"  W left over, each start's own average over the 45 pairs: {pairs_starts.min():+.3f} to {pairs_starts.max():+.3f}")
    say(f"  so: expected noise, moments independent; most cautious, {CAUTIOUS_RHO:g} of the noise shared by a start's"
        " moments (above every start's own value)")
    say()

    def positive(shares):
        return sum(shares[v] for v in rule.POSITIVE)

    def table(rows, label):
        say(f"    {label:<56}" + "".join(f"{v:>13}" for v in rule.READINGS) + "   error bar of S")
        for p in rows:
            say(f"    {p['scenario']:<56}" + "".join(f"{p['shares']['overall'][v]:>13.4f}" for v in rule.READINGS)
                + f"   {p['s_bar']:.5f}")

    def methods(rows):
        say(f"    each method's own reading, the same rule: {' | '.join(rule.METHODS)} (PASS, then PASS, SMALL or PARTIAL)")
        for p in rows:
            say(f"    {p['scenario']:<56}" + " | ".join(
                f"{p['shares'][m]['PASS']:.3f}, {positive(p['shares'][m]):.3f}" for m in rule.METHODS))

    say(f"Chance of each decision, made by the plan's own rule (cause_rule.py): {TRIALS:,} simulated repeats of the"
        f" whole test per row ({NULL_TRIALS:,} for no effect); {len(NEW)} starts, {MOMENTS} moments, 2 methods, 3 arms;"
        " manipulation checks taken as passed; sampling only, not the model")
    say(f"  the benchmark's size: D_up = -{bench:.5f}, D_down = +{bench:.5f} in both methods; threshold {delta:.5f}")
    for noise, words, *_ in noises:
        rows_here = [p for p in power if p["noise"] == noise]
        say(f"  noise, {noise}: {words}")
        table(rows_here, "overall decision")
        methods(rows_here)
    for noise, words, *_ in extras:
        say(f"  noise, {noise}: {words}")
        table([p for p in extra if p["noise"] == noise], "overall decision")
    say(f"  noise from {CURVE[0]:g} to {CURVE[-1]:g} times the expected ({CURVE_TRIALS:,} repeats each): overall"
        " PASS | PASS, SMALL or PARTIAL | FAIL | INCONCLUSIVE")
    for name, *_ in key_scenarios:
        say(f"    {name:<56}" + "   ".join(
            f"x{p['level']:g}: {p['shares']['overall']['PASS']:.3f} | {positive(p['shares']['overall']):.3f} |"
            f" {p['shares']['overall']['FAIL']:.3f} | {p['shares']['overall']['INCONCLUSIVE']:.3f}"
            for p in curve if p["scenario"] == name))
    say(f"  for comparison, the first draft's {DRAFT_STARTS} starts (the same rule and noise models):")
    for noise, *_ in noises:
        table([p for p in draft if p["noise"] == noise], f"{noise}, {DRAFT_STARTS} starts")
    say()
    nulls = [p for p in power + extra + curve if p["scenario"] == "no effect"]
    say("False positives: with no effect, the chance of a reading in the predicted direction (PASS, SMALL or PARTIAL)")
    say(f"  bound: each needs S above 0 by more than {rule.RULE:g} error bars, and the error bar is never below"
        f" SD / sqrt({len(NEW)}); with normal per-start values the chance is at most P(t, {len(NEW) - 1} degrees of"
        f" freedom, > {rule.RULE:g}) = {bound:.4f}, at any noise level. The same bound holds for REVERSED.")
    say("  simulated, each with its own sampling error (+/- one SD of a share from that many repeats):")

    def share(x, trials):
        return f"{x:.4f} +/- {np.sqrt(x * (1 - x) / trials):.4f}"

    for p in nulls:
        name = p["noise"] + ("" if p["level"] is None else f", x{p['level']:g}")
        say(f"    {name:<26} overall {share(positive(p['shares']['overall']), p['trials'])} (REVERSED"
            f" {share(p['shares']['overall']['REVERSED'], p['trials'])}); "
            + ", ".join(f"{m} alone {positive(p['shares'][m]):.4f}" for m in rule.METHODS) + f"; {p['trials']:,} repeats")
    say()
    say(f"Computing: the replication ran one start (settling, then 2 copies x {cg.RUN:,.0f} time units) in"
        f" {per_start:.0f} s per worker, six at once: {rate:.0f} copy-time-units per second per worker.")
    say(f"  this plan: {base:,.0f} copy-time-units of base runs + {branch:,.0f} of branches = {base + branch:,.0f};"
        f" at that pace with {nt.WORKERS} workers about {minutes:.0f} minutes")
    say()
    offsets = {r[0]: r[2] - int(FIRST) for r in rows if r[1] == 1}
    say(f"Moment table ({MOMENT_TABLE.name}): starts {NEW[0] + 1} to {NEW[-1] + 1}, moments T = 2,000 + offset + 350 m"
        f" (m = 0 to 9), offset from seed ({SEED}, seed of the start):")
    say("  " + ", ".join(f"{s}: {o}" for s, o in offsets.items()))

    result = dict(
        read_only=True, starts_read=list(range(1, 19)), moments=dict(first=FIRST, last=LAST, every=EVERY),
        later=list(LATER), beta=beta, sigma_left=sigma, sigma_left_starts=left.tolist(), w_sd=w_sd, w_mean=w_mean,
        c_mean=c_mean, persistence=persist, autocorrelation=autocorrelation, group_centiles=centiles,
        pulled=pulled, sparse_subset=subset, change_sizes=sizes, fraction=FRACTION,
        benchmark=dict(beta=bench_beta, change=change, effect=bench, delta=delta, persistence_floor=m2),
        moments_together=dict(apart=apart, pairs=pairs, pairs_starts=pairs_starts.tolist(), cautious=CAUTIOUS_RHO),
        rule=dict(code="cause_rule.py", rule=rule.RULE, side=rule.SIDE, readings=list(rule.READINGS)),
        power=power, extra=extra, curve=curve, false_positive_bound=bound,
        draft=dict(starts=DRAFT_STARTS, rows=draft),
        trials=dict(scenario=TRIALS, no_effect=NULL_TRIALS, curve=CURVE_TRIALS),
        seeds=dict(simulation=SEED, resample=rule.RESAMPLE_SEED, moments=SEED),
        computing=dict(seconds_per_start=per_start, rate=rate, base=base, branches=branch, workers=nt.WORKERS,
                       minutes=minutes),
        moment_table=MOMENT_TABLE.name, offsets=offsets)
    OUT_TXT.write_text("\n".join(lines) + "\n")
    OUT_JSON.write_text(json.dumps(result, indent=1) + "\n")
    print(f"\nWritten: {OUT_TXT.name}, {OUT_JSON.name}, {MOMENT_TABLE.name}")


if __name__ == "__main__":
    main()
