"""
Running Axelrod's Tournament Again, With Noise This Time
=======================================================
Science Journaling Club, Volume 2 Issue 1, Fall 2025, "Evolution in Silico".

QUESTION
--------
Axelrod's 1980 round robin found that tit for tat, the simplest cooperative rule
anybody submitted, scored higher than every other program sent to it. Does that
result survive when moves are occasionally misread, and what wins instead?

Concretely we ask three things.
  1. In a noiseless round robin over a fixed library of fifteen classical
     strategies, which strategy scores highest, and does tit for tat win or tie?
  2. As the probability that a played move is flipped rises from 0 to 0.20, how
     does the ranking change, and where does tit for tat lose the crown?
  3. If strategy frequencies are updated by replicator dynamics using the
     measured payoff matrix, what population does the system settle into at each
     noise level, and what average payoff does it earn?

WHAT THIS IS
------------
This is a computation, not an observation. The club has no laboratory. Nothing
here was measured from a living organism, a person, or an economy. Every number
below is produced by the program you are reading, running a mathematical game on
a laptop. The computation IS the experiment, and the honest description of the
result is "this is what the model does", never "this is what cooperation does".

MODEL
-----
Stage game: the prisoner's dilemma with Axelrod's own payoffs,
    T = 5 (defect against a cooperator), R = 3 (mutual cooperation),
    P = 1 (mutual defection),           S = 0 (cooperate against a defector).
The inequalities T > R > P > S and 2R > T + S are checked at run time.

Iterated game: a match is ROUNDS = 200 repetitions of the stage game between two
strategies, the length Axelrod used in his first tournament. Both players see
only the moves that were actually played, never the move that was intended.

Noise: execution error, sometimes called a trembling hand. Each player's
intended move on each round is flipped with independent probability `noise`.
This is misimplementation noise, not misperception noise: when a move is
flipped, BOTH players see the flipped move. The distinction matters and is
discussed under LIMITATIONS.

Round robin: every unordered pair of strategies plays, including each strategy
against a copy of itself, which is how Axelrod scored his tournaments (every
entrant played its own twin). A strategy's tournament score is its mean payoff
per round averaged over all fifteen opponents. One "repetition" of the whole
tournament is the unit of Monte Carlo replication, so standard errors are the
standard deviation across repetitions divided by sqrt(REPS).

Evolutionary run: discrete-time replicator dynamics on the measured mean payoff
matrix M, starting from a uniform population,
    x_i(t+1) = x_i(t) f_i(t) / phi(t),  f_i = sum_j M_ij x_j,  phi = sum_i x_i f_i.
Total frequency is never renormalised, so the conservation check below is a real
check on the arithmetic rather than a tautology.

STRATEGY LIBRARY (15)
---------------------
always cooperate, always defect, random, tit for tat, generous tit for tat
(forgiveness 1/3, the level Molander 1985 derived for this payoff matrix), tit
for two tats, suspicious tit for tat, grim trigger, Pavlov (win-stay lose-shift),
contrite tit for tat, Joss, Tester, alternator, hard tit for tat, Prober.

ASSUMPTIONS
-----------
 * Fixed match length of 200 rounds, known to us but not used by any strategy.
   No strategy plays an end-game defection, so the backward-induction problem of
   a known finite horizon is assumed away.
 * Every strategy is memory-bounded and stationary. None of them learns across
   matches, and none of them recognises its opponent.
 * Noise is independent and identically distributed across rounds and players.
   Real errors cluster.
 * The round robin is unweighted: a strategy meets each opponent exactly as
   often as any other, so the tournament score depends entirely on which
   strategies were entered. This is the single largest modelling choice in the
   study and it is varied explicitly in the field-composition sensitivity.
 * Replicator dynamics assume an infinite, well-mixed population with no
   mutation, no spatial structure and no drift. Frequencies are real numbers.

LIMITATIONS, STATED PLAINLY
---------------------------
 * A strategy tournament is not a measurement of animal or human cooperation. It
   is a measurement of a game. Nothing here licenses a claim about what any
   organism does.
 * Misimplementation noise (both players see the error) and misperception noise
   (only the victim sees it) give different answers; contrite tit for tat in
   particular depends on being able to detect its own error, which it can only
   do under misimplementation noise.
 * Contrite tit for tat's standing rules have more than one implementation in
   the literature. Ours is written out in the class and the choice is not
   neutral.
 * Replicator dynamics with a fixed strategy list cannot invent a strategy. The
   winner of the evolutionary run is the best of the fifteen we entered, never
   the best possible.
 * Payoffs are the same for both players and never change. No population
   structure, no reputation, no partner choice, no communication.

VALIDATION PERFORMED
--------------------
 1. Payoff structure: T > R > P > S and 2R > T + S, printed with values.
 2. Analytic pairwise payoffs at zero noise. Deterministic pairings have exact
    closed-form per-round payoffs over 200 rounds; the club's simulated value is
    printed beside the analytic one with the difference.
 3. Zero-noise determinism: every pair of deterministic strategies must have
    exactly zero spread across repetitions, and every pair involving a coin must
    have nonzero spread.
 4. The noiseless round robin is run and the full 15x15 payoff matrix printed so
    that any cell can be checked by hand.
 5. Replicator conservation: max |sum(x) - 1| over every generation of every run.
 6. Two tit-for-tats under noise: an exact 200-round Markov-chain value for the
    pair, computed independently of the simulation, printed beside the simulated
    cell with the difference in standard errors.
 7. Molander's (1985) optimal generosity q* = min{1-(T-R)/(R-S), (R-P)/(T-P)} is
    computed and compared against an empirical scan of generosity.

REPRODUCING
-----------
    python cooperation-tournament.py > cooperation-tournament-output.txt
Python 3.12, numpy. Master seed is MASTER_SEED below. Set the environment
variable SJC_JSON to a file path to also dump the figure data as JSON; the
article's figures were drawn from that dump. The JSON is an optional side
output and nothing in the printed report depends on it.
"""

import os
import sys
import copy
import json
import time

import numpy as np

MASTER_SEED = 20250913

# ---------------------------------------------------------------------------
# The stage game. D = 0, C = 1, so PAY[mine][theirs] indexes directly.
# ---------------------------------------------------------------------------
D, C = 0, 1
T, R, P, S = 5.0, 3.0, 1.0, 0.0
PAY = ((P, T),      # I defect:    they defect -> P, they cooperate -> T
       (S, R))      # I cooperate: they defect -> S, they cooperate -> R

ROUNDS = 200
REPS = 400
SENS_REPS = 100
NOISE_LEVELS = [0.0, 0.001, 0.002, 0.005, 0.01, 0.02, 0.05, 0.10, 0.15, 0.20]
GENERATIONS = 4000
EXTINCT = 1e-4
ZMARK = 0.05


# ---------------------------------------------------------------------------
# Strategies. Each one gets reset() before a match, move(t, u) returns the
# INTENDED move, and update(intended, actual_mine, actual_theirs) is called
# after the round with what was actually played.
# ---------------------------------------------------------------------------
class Strategy:
    key = "?"
    name = "?"
    stochastic = False

    def reset(self):
        pass

    def move(self, t, u):
        return C

    def update(self, intended, mine, theirs):
        pass


class AllC(Strategy):
    key, name = "ALLC", "always cooperate"


class AllD(Strategy):
    key, name = "ALLD", "always defect"

    def move(self, t, u):
        return D


class Rand(Strategy):
    key, name, stochastic = "RAND", "random", True

    def move(self, t, u):
        return C if u < 0.5 else D


class TFT(Strategy):
    key, name = "TFT", "tit for tat"

    def reset(self):
        self.last = C

    def move(self, t, u):
        return C if t == 0 else self.last

    def update(self, intended, mine, theirs):
        self.last = theirs


class GTFT(Strategy):
    """Tit for tat that forgives a defection with probability g."""
    key, name, stochastic = "GTFT", "generous tit for tat", True

    def __init__(self, g=1.0 / 3.0):
        self.g = g

    def reset(self):
        self.last = C

    def move(self, t, u):
        if t == 0 or self.last == C:
            return C
        return C if u < self.g else D

    def update(self, intended, mine, theirs):
        self.last = theirs


class TF2T(Strategy):
    """Defects only after two defections in a row."""
    key, name = "TF2T", "tit for two tats"

    def reset(self):
        self.a = C
        self.b = C

    def move(self, t, u):
        return D if (self.a == D and self.b == D) else C

    def update(self, intended, mine, theirs):
        self.a = self.b
        self.b = theirs


class STFT(Strategy):
    """Tit for tat that opens with a defection."""
    key, name = "STFT", "suspicious tit for tat"

    def reset(self):
        self.last = D

    def move(self, t, u):
        return D if t == 0 else self.last

    def update(self, intended, mine, theirs):
        self.last = theirs


class Grim(Strategy):
    key, name = "GRIM", "grim trigger"

    def reset(self):
        self.burned = False

    def move(self, t, u):
        return D if self.burned else C

    def update(self, intended, mine, theirs):
        if theirs == D:
            self.burned = True


class Pavlov(Strategy):
    """Win-stay lose-shift: repeat the last move after T or R, switch after P or
    S. Equivalent to cooperating exactly when the two players last agreed."""
    key, name = "PAVLOV", "Pavlov (win-stay lose-shift)"

    def reset(self):
        self.nxt = C

    def move(self, t, u):
        return self.nxt

    def update(self, intended, mine, theirs):
        self.nxt = C if mine == theirs else D


class ContriteTFT(Strategy):
    """Tit for tat with standing (Boyd 1989; Wu and Axelrod 1995).

    Both players start in good standing. Using the standings held at the START
    of a round, a player who defects while the opponent is in good standing
    falls into bad standing, and a player who cooperates is restored to good
    standing. Contrite tit for tat defects only when the opponent is in bad
    standing and it is itself in good standing, so after its own accidental
    defection it accepts one retaliation instead of echoing it.
    """
    key, name = "CTFT", "contrite tit for tat"

    def reset(self):
        self.my_bad = False
        self.opp_bad = False

    def move(self, t, u):
        return D if (self.opp_bad and not self.my_bad) else C

    def update(self, intended, mine, theirs):
        my_bad, opp_bad = self.my_bad, self.opp_bad
        if mine == C:
            new_my = False
        elif not opp_bad:
            new_my = True
        else:
            new_my = my_bad
        if theirs == C:
            new_opp = False
        elif not my_bad:
            new_opp = True
        else:
            new_opp = opp_bad
        self.my_bad, self.opp_bad = new_my, new_opp


class Joss(Strategy):
    """Tit for tat that sneaks in a defection one time in ten."""
    key, name, stochastic = "JOSS", "Joss (sneaky tit for tat)", True

    def __init__(self, p=0.1):
        self.p = p

    def reset(self):
        self.last = C

    def move(self, t, u):
        base = C if t == 0 else self.last
        if base == C and u < self.p:
            return D
        return base

    def update(self, intended, mine, theirs):
        self.last = theirs


class Tester(Strategy):
    """Opens with a defection to see what happens. If the opponent retaliates,
    it apologises once and plays tit for tat forever after. If the opponent lets
    it pass, it alternates cooperation and defection to keep milking."""
    key, name = "TESTER", "Tester"

    def reset(self):
        self.retaliated = False
        self.apologised = False
        self.last = C

    def move(self, t, u):
        if t == 0:
            return D
        if self.retaliated:
            if not self.apologised:
                return C
            return self.last
        return C if (t % 2 == 1) else D

    def update(self, intended, mine, theirs):
        if self.retaliated and not self.apologised:
            self.apologised = True
        if theirs == D:
            self.retaliated = True
        self.last = theirs


class Alternator(Strategy):
    key, name = "ALT", "alternator"

    def move(self, t, u):
        return C if t % 2 == 0 else D


class HardTFT(Strategy):
    """Defects if the opponent defected on any of the last three rounds."""
    key, name = "HTFT", "hard tit for tat"

    def reset(self):
        self.hist = [C, C, C]

    def move(self, t, u):
        return D if D in self.hist else C

    def update(self, intended, mine, theirs):
        self.hist.pop(0)
        self.hist.append(theirs)


class Prober(Strategy):
    """Opens D, C, C. If the opponent cooperated on rounds 2 and 3 it concludes
    the opponent is a pushover and defects forever; otherwise tit for tat."""
    key, name = "PROBER", "Prober"

    def reset(self):
        self.obs = []
        self.exploit = False
        self.decided = False
        self.last = C

    def move(self, t, u):
        if t == 0:
            return D
        if t == 1 or t == 2:
            return C
        if self.exploit:
            return D
        return self.last

    def update(self, intended, mine, theirs):
        self.obs.append(theirs)
        self.last = theirs
        if len(self.obs) == 3 and not self.decided:
            self.decided = True
            self.exploit = (self.obs[1] == C and self.obs[2] == C)


def build_library(gtft_g=1.0 / 3.0):
    return [AllC(), AllD(), Rand(), TFT(), GTFT(gtft_g), TF2T(), STFT(), Grim(),
            Pavlov(), ContriteTFT(), Joss(), Tester(), Alternator(), HardTFT(),
            Prober()]


LIB = build_library()
NAMES = [s.name for s in LIB]
KEYS = [s.key for s in LIB]
NSTRAT = len(LIB)
DETERMINISTIC = [not s.stochastic for s in LIB]
IDX = {k: i for i, k in enumerate(KEYS)}


# ---------------------------------------------------------------------------
# One match.
# ---------------------------------------------------------------------------
def play_match(a, b, rounds, noise, rng):
    """Return (mean per-round payoff to a, to b). Both players observe only the
    move that was actually played after the noise flip."""
    a.reset()
    b.reset()
    u = rng.random((rounds, 2))
    ua = u[:, 0]
    ub = u[:, 1]
    if noise > 0.0:
        flip = (rng.random((rounds, 2)) < noise)
        fa = flip[:, 0]
        fb = flip[:, 1]
    else:
        fa = fb = None
    sa = 0.0
    sb = 0.0
    for t in range(rounds):
        ia = a.move(t, ua[t])
        ib = b.move(t, ub[t])
        if fa is None:
            aa, ab = ia, ib
        else:
            aa = (1 - ia) if fa[t] else ia
            ab = (1 - ib) if fb[t] else ib
        sa += PAY[aa][ab]
        sb += PAY[ab][aa]
        a.update(ia, aa, ab)
        b.update(ib, ab, aa)
    return sa / rounds, sb / rounds


def round_robin(lib, reps, rounds, noise, rng):
    """Return an array (reps, n, n) where [r, i, j] is the mean per-round payoff
    to strategy i against strategy j in repetition r. The diagonal is a match
    against an independent copy of the same strategy, scored as the mean of the
    two sides, which is how Axelrod scored an entrant against its own twin."""
    n = len(lib)
    twins = [copy.deepcopy(s) for s in lib]
    out = np.zeros((reps, n, n))
    for r in range(reps):
        for i in range(n):
            pa, pb = play_match(lib[i], twins[i], rounds, noise, rng)
            out[r, i, i] = 0.5 * (pa + pb)
            for j in range(i + 1, n):
                pa, pb = play_match(lib[i], lib[j], rounds, noise, rng)
                out[r, i, j] = pa
                out[r, j, i] = pb
    return out


def scores_from(mats, self_play=True):
    """Tournament score per repetition: mean payoff over all opponents."""
    if self_play:
        return mats.mean(axis=2)
    n = mats.shape[1]
    mask = ~np.eye(n, dtype=bool)
    return np.array([[m[i][mask[i]].mean() for i in range(n)] for m in mats])


# ---------------------------------------------------------------------------
# Replicator dynamics.
# ---------------------------------------------------------------------------
def replicate(M, generations, x0=None):
    """Discrete replicator dynamics. Returns (trajectory, max |sum x - 1|)."""
    n = M.shape[0]
    x = np.full(n, 1.0 / n) if x0 is None else x0.copy()
    traj = np.zeros((generations + 1, n))
    traj[0] = x
    worst = abs(x.sum() - 1.0)
    for g in range(generations):
        f = M @ x
        phi = float(x @ f)
        x = x * f / phi
        traj[g + 1] = x
        dev = abs(x.sum() - 1.0)
        if dev > worst:
            worst = dev
    return traj, worst


# ---------------------------------------------------------------------------
# An exact value for two tit-for-tats under noise, computed without simulating.
# ---------------------------------------------------------------------------
def tft_pair_exact(z, rounds):
    """Exact expected mean per-round payoff to one of two tit-for-tats when each
    player's played move is flipped independently with probability z.

    State is the pair of moves ACTUALLY played last round, (mine, theirs). Next
    round each player intends to copy what the other actually played, so the
    intended pair is (theirs, mine); then each is flipped independently. Round 1
    starts from intended (C, C). Iterating the distribution for `rounds` rounds
    and averaging gives the finite-horizon expectation the simulation estimates,
    transient included, so the two are directly comparable.
    """
    Pm = np.zeros((4, 4))
    for s in range(4):
        mine, theirs = s >> 1, s & 1
        im, it = theirs, mine           # intended next moves
        for e1 in (0, 1):
            for e2 in (0, 1):
                p = (z if e1 else 1 - z) * (z if e2 else 1 - z)
                ns = ((im ^ e1) << 1) | (it ^ e2)
                Pm[s, ns] += p
    pay = np.array([PAY[s >> 1][s & 1] for s in range(4)], dtype=float)
    d = np.zeros(4)
    for e1 in (0, 1):
        for e2 in (0, 1):
            p = (z if e1 else 1 - z) * (z if e2 else 1 - z)
            d[((C ^ e1) << 1) | (C ^ e2)] += p
    total = 0.0
    for _ in range(rounds):
        total += float(d @ pay)
        d = d @ Pm
    return total / rounds


# ---------------------------------------------------------------------------
# Printing helpers.
# ---------------------------------------------------------------------------
def rule(ch="-", n=78):
    print(ch * n)


def head(title):
    print()
    rule("=")
    print(title)
    rule("=")


def cmp_line(label, club, accepted, tol=1e-12):
    diff = club - accepted
    ok = abs(diff) <= tol
    print("  %s %-26s club %11.6f   accepted %11.6f   diff %+.3e"
          % ("OK" if ok else "!!", label, club, accepted, diff))
    return ok


# ---------------------------------------------------------------------------
# Analytic pairwise payoffs at zero noise, worked out by hand over 200 rounds.
# ---------------------------------------------------------------------------
def analytic_pairs(n):
    out = []

    def add(a, b, pa, pb, why):
        out.append((a, b, pa, pb, why))

    for k in ("TFT", "GRIM", "PAVLOV", "CTFT", "HTFT", "TF2T", "ALLC"):
        add(k, "ALLC", R, R, "both cooperate on every round")
    add("TFT", "TFT", R, R, "both cooperate on every round")
    add("GRIM", "GRIM", R, R, "neither one ever triggers")
    add("PAVLOV", "PAVLOV", R, R, "they agree every round, so win-stay")
    add("CTFT", "CTFT", R, R, "both stay in good standing throughout")
    add("HTFT", "HTFT", R, R, "no defection inside any three-round window")

    add("ALLD", "ALLD", P, P, "mutual defection on every round")
    add("STFT", "ALLD", P, P, "a suspicious opening meets a defector")

    one_s = (S + (n - 1) * P) / n
    one_t = (T + (n - 1) * P) / n
    for k in ("TFT", "GRIM", "HTFT", "CTFT"):
        add(k, "ALLD", one_s, one_t, "cooperate once, then mutual defection")

    add("ALLC", "ALLD", S, T, "cooperates into a defector on every round")
    add("TF2T", "ALLD", (2 * S + (n - 2) * P) / n, (2 * T + (n - 2) * P) / n,
        "cooperates twice, then mutual defection")

    h = n // 2
    add("ALT", "TFT", (R + h * T + (h - 1) * S) / n, (R + h * S + (h - 1) * T) / n,
        "R on round 1, then ALT takes T on even rounds and S on odd ones")
    add("ALT", "GRIM", (R + T + (h - 1) * S + (h - 1) * P) / n,
        (R + S + (h - 1) * T + (h - 1) * P) / n,
        "GRIM is burned on round 2 and takes T on every later ALT cooperation")
    add("ALT", "ALLD", (h * S + h * P) / n, (h * T + h * P) / n,
        "ALT cooperates on 100 rounds and defects on 100")

    add("STFT", "ALLC", (T + (n - 1) * R) / n, (S + (n - 1) * R) / n,
        "one free defection, then STFT echoes cooperation forever")
    add("PROBER", "ALLC", (T + 2 * R + (n - 3) * T) / n,
        (S + 2 * R + (n - 3) * S) / n,
        "probe, find a pushover, then exploit it for the rest of the match")
    return out


# ===========================================================================
def main():
    t_start = time.time()
    streams = np.random.SeedSequence(MASTER_SEED).spawn(96)
    state = {"i": 0}

    def nxt():
        g = np.random.default_rng(streams[state["i"]])
        state["i"] += 1
        return g

    print("=" * 78)
    print("RUNNING AXELROD'S TOURNAMENT AGAIN, WITH NOISE THIS TIME")
    print("Science Journaling Club, Volume 2 Issue 1, Fall 2025")
    print("=" * 78)
    print("python       : %s" % sys.version.split()[0])
    print("numpy        : %s" % np.__version__)
    print("master seed  : %d" % MASTER_SEED)
    print("rounds/match : %d       tournament repetitions: %d" % (ROUNDS, REPS))
    print("strategies   : %d       unordered pairs incl. twin: %d"
          % (NSTRAT, NSTRAT * (NSTRAT + 1) // 2))
    print("noise levels : %s" % ", ".join("%.3f" % z for z in NOISE_LEVELS))
    print("generations  : %d   (discrete replicator, uniform start)" % GENERATIONS)
    print("matches in the main sweep: %d"
          % (len(NOISE_LEVELS) * REPS * NSTRAT * (NSTRAT + 1) // 2))

    dump = {"seed": MASTER_SEED, "rounds": ROUNDS, "reps": REPS,
            "keys": KEYS, "names": NAMES, "noise_levels": NOISE_LEVELS,
            "zmark": ZMARK}
    tft = IDX["TFT"]
    all_pass = True

    # ------------------------------------------------------------------
    head("VALIDATION 1.  THE PAYOFF STRUCTURE IS A PRISONER'S DILEMMA")
    print("  T = %.1f   R = %.1f   P = %.1f   S = %.1f    (Axelrod 1980 values)"
          % (T, R, P, S))
    ok1 = T > R > P > S
    ok2 = 2 * R > T + S
    print("  ordering       T > R > P > S : %.1f > %.1f > %.1f > %.1f   -> %s"
          % (T, R, P, S, "PASS" if ok1 else "FAIL"))
    print("  no-alternation      2R > T+S : %.1f > %.1f               -> %s"
          % (2 * R, T + S, "PASS" if ok2 else "FAIL"))
    print("  Without 2R > T+S, taking turns being exploited would beat mutual")
    print("  cooperation and the game would not be a prisoner's dilemma at all.")
    all_pass = all_pass and ok1 and ok2

    # ------------------------------------------------------------------
    head("VALIDATION 2.  ANALYTIC PAIRWISE PAYOFFS AT ZERO NOISE")
    print("Each pairing below has an exact per-round payoff over %d rounds that can" % ROUNDS)
    print("be worked out with a pencil. The club's simulated value is printed beside")
    print("it. Any nonzero difference is a bug in our code.")
    print()
    rng = nxt()
    checks = analytic_pairs(ROUNDS)
    npass = 0
    for ka, kb, pa_true, pb_true, why in checks:
        a = build_library()[IDX[ka]]
        b = build_library()[IDX[kb]]
        pa, pb = play_match(a, b, ROUNDS, 0.0, rng)
        g1 = cmp_line("%s vs %s" % (ka, kb), pa, pa_true)
        g2 = cmp_line("%s vs %s" % (kb, ka), pb, pb_true)
        print("       %s" % why)
        npass += int(g1) + int(g2)
    print()
    print("  %d of %d analytic pairwise payoffs reproduced exactly."
          % (npass, 2 * len(checks)))
    all_pass = all_pass and (npass == 2 * len(checks))
    dump["analytic_checks"] = {"passed": npass, "total": 2 * len(checks)}

    # ------------------------------------------------------------------
    head("THE NOISELESS ROUND ROBIN")
    rng = nxt()
    t0 = time.time()
    mats0 = round_robin(LIB, REPS, ROUNDS, 0.0, rng)
    print("  %d repetitions of the full round robin at noise 0 in %.1f s"
          % (REPS, time.time() - t0))
    M0 = mats0.mean(axis=0)
    sc0 = scores_from(mats0)
    mean0 = sc0.mean(axis=0)
    se0 = sc0.std(axis=0, ddof=1) / np.sqrt(REPS)
    order0 = np.argsort(-mean0)

    print()
    print("FULL PAYOFF MATRIX AT NOISE 0. Mean per-round payoff to the ROW strategy")
    print("against the COLUMN strategy, averaged over %d repetitions. The diagonal is" % REPS)
    print("the twin match. SCORE is the row mean, which is the tournament score.")
    print()
    print("  %-8s" % "" + "".join("%8s" % k[:7] for k in KEYS) + "%10s" % "SCORE")
    for i in range(NSTRAT):
        print("  %-8s" % KEYS[i] + "".join("%8.3f" % M0[i, j] for j in range(NSTRAT))
              + "%10.4f" % mean0[i])
    print()
    print("NOISELESS LEADERBOARD")
    print("  %-5s %-8s %-28s %11s %11s" % ("rank", "key", "strategy", "score", "SE"))
    for rank, i in enumerate(order0, 1):
        print("  %-5d %-8s %-28s %11.5f %11.5f"
              % (rank, KEYS[i], NAMES[i], mean0[i], se0[i]))

    win0 = int(order0[0])
    gap = mean0[win0] - mean0[tft]
    sgap = float(np.sqrt(se0[win0] ** 2 + se0[tft] ** 2))
    tft_rank0 = int(np.where(order0 == tft)[0][0]) + 1
    print()
    print("  winner at noise 0 : %s (%s), score %.5f"
          % (KEYS[win0], NAMES[win0], mean0[win0]))
    print("  tit for tat       : rank %d of %d, score %.5f"
          % (tft_rank0, NSTRAT, mean0[tft]))
    print()
    print("  The repetitions are paired: in repetition r every strategy met the same")
    print("  field. So the sharp test of 'does tit for tat win or tie' is a paired")
    print("  difference, not two separate error bars. Top three against TFT:")
    print("  %-10s %12s %12s %9s  %s" % ("strategy", "gap/round", "paired SE", "t", "verdict"))
    ties0 = []
    for i in order0[:4]:
        i = int(i)
        if i == tft:
            continue
        dv = sc0[:, i] - sc0[:, tft]
        dse = dv.std(ddof=1) / np.sqrt(REPS)
        tstat = dv.mean() / dse if dse > 0 else float("inf")
        verdict = "ahead of TFT" if tstat > 2 else ("tied with TFT" if abs(tstat) <= 2
                                                    else "behind TFT")
        print("  %-10s %12.5f %12.5f %9.2f  %s"
              % (KEYS[i], dv.mean(), dse, tstat, verdict))
        ties0.append([KEYS[i], float(dv.mean()), float(dse), float(tstat)])
    print()
    if win0 == tft:
        print("  Tit for tat wins the noiseless round robin outright, which is the")
        print("  published 1980 outcome.")
    else:
        zz = gap / sgap if sgap > 0 else float("inf")
        dv = sc0[:, win0] - sc0[:, tft]
        dse = dv.std(ddof=1) / np.sqrt(REPS)
        print("  DISAGREEMENT WITH THE PUBLISHED OUTCOME, REPORTED AS FOUND.")
        print("  Tit for tat does not take first place in this field. It trails %s by"
              % KEYS[win0])
        print("  %.5f per round, which is %.1f standard errors unpaired (SE %.5f) and"
              % (gap, zz, sgap))
        print("  %.1f standard errors paired (SE %.5f). The disagreement is real and it"
              % (dv.mean() / dse, dse))
        print("  survives more replication; it is not Monte Carlo scatter.")
        print("  A round robin score is a property of the field as much as of the")
        print("  strategy, and our field is not Axelrod's. The leave-one-out table")
        print("  below identifies exactly which entrant is responsible.")
    dump["noise0"] = {"M": M0.tolist(), "mean": mean0.tolist(), "se": se0.tolist(),
                      "order": order0.tolist(), "winner": KEYS[win0],
                      "tft_rank": tft_rank0, "ties": ties0}

    # ------------------------------------------------------------------
    head("LEAVE ONE OUT: WHICH ENTRANT COSTS TIT FOR TAT THE CROWN?")
    print("Drop one strategy from the field, rerun the noiseless round robin over the")
    print("remaining 14, and see who wins. %d repetitions each." % SENS_REPS)
    print()
    print("  %-10s %-10s %11s %11s %9s" % ("dropped", "winner", "TFT score",
                                           "win score", "TFT rank"))
    loo = []
    for drop in range(NSTRAT):
        if drop == tft:
            continue
        sub = [i for i in range(NSTRAT) if i != drop]
        rng = nxt()
        sublib = [build_library()[i] for i in sub]
        mats = round_robin(sublib, SENS_REPS, ROUNDS, 0.0, rng)
        mu = scores_from(mats).mean(axis=0)
        oo = np.argsort(-mu)
        subkeys = [KEYS[i] for i in sub]
        ti = subkeys.index("TFT")
        rk = int(np.where(oo == ti)[0][0]) + 1
        print("  %-10s %-10s %11.5f %11.5f %9d"
              % (KEYS[drop], subkeys[int(oo[0])], mu[ti], mu[oo[0]], rk))
        loo.append([KEYS[drop], subkeys[int(oo[0])], float(mu[ti]),
                    float(mu[oo[0]]), rk])
    wins = [r[0] for r in loo if r[1] == "TFT"]
    print()
    if wins:
        print("  Tit for tat takes first place as soon as any one of these is removed:")
        print("    %s" % ", ".join(wins))
    else:
        print("  No single removal puts tit for tat in first place in this field.")
    dump["loo"] = loo

    # ------------------------------------------------------------------
    head("VALIDATION 3.  ZERO NOISE MUST BE DETERMINISTIC WHERE IT CAN BE")
    print("With noise off, a match between two deterministic strategies contains no")
    print("randomness at all, so its payoff must be identical in every one of the %d" % REPS)
    print("repetitions. Cells involving a coin (RAND, GTFT, JOSS) must not be.")
    print()
    spread = mats0.max(axis=0) - mats0.min(axis=0)
    bad = 0
    worst_det = 0.0
    ndet = 0
    stoch = []
    for i in range(NSTRAT):
        for j in range(NSTRAT):
            if DETERMINISTIC[i] and DETERMINISTIC[j]:
                ndet += 1
                worst_det = max(worst_det, spread[i, j])
                if spread[i, j] > 0:
                    bad += 1
            else:
                stoch.append(spread[i, j])
    stoch_max = max(stoch)
    stoch_min = min(stoch)
    print("  deterministic cells : %3d, max spread %.3e   -> %s"
          % (ndet, worst_det, "PASS" if bad == 0 else "FAIL (%d cells varied)" % bad))
    print("  cells with a coin   : %3d, spread from %.4f to %.4f   -> %s"
          % (len(stoch), stoch_min, stoch_max, "PASS" if stoch_max > 0 else "FAIL"))
    all_pass = all_pass and bad == 0 and stoch_max > 0

    # ------------------------------------------------------------------
    head("VALIDATION 4.  REPLICATOR DYNAMICS CONSERVE TOTAL FREQUENCY")
    traj0, worst0 = replicate(M0, GENERATIONS)
    print("  noise 0 run: %d generations, max |sum(x) - 1| = %.3e"
          % (GENERATIONS, worst0))
    print("  Frequencies are never renormalised, so this is the arithmetic of")
    print("  x_i <- x_i f_i / phi checking itself. Machine epsilon is %.3e."
          % np.finfo(float).eps)
    all_pass = all_pass and worst0 < 1e-12

    # ------------------------------------------------------------------
    head("THE NOISE SWEEP: ROUND ROBIN AT EVERY NOISE LEVEL")
    print("Every level below is a fresh set of %d full round robins with its own" % REPS)
    print("independent random stream spawned from the master seed.")
    print()
    sweep = {}
    per_rep = {}
    for z in NOISE_LEVELS:
        t0 = time.time()
        if z == 0.0:
            mats, el = mats0, 0.0
        else:
            rng = nxt()
            mats = round_robin(LIB, REPS, ROUNDS, z, rng)
            el = time.time() - t0
        M = mats.mean(axis=0)
        Mse = mats.std(axis=0, ddof=1) / np.sqrt(REPS)
        sc = scores_from(mats)
        mu = sc.mean(axis=0)
        se = sc.std(axis=0, ddof=1) / np.sqrt(REPS)
        sweep[z] = {"M": M, "Mse": Mse, "mean": mu, "se": se}
        per_rep[z] = sc
        o = np.argsort(-mu)
        print("  noise %.3f  (%5.1f s)  winner %-8s %.5f +/- %.5f"
              % (z, el, KEYS[o[0]], mu[o[0]], se[o[0]]))
        print("      top five : " + ",  ".join("%s %.4f" % (KEYS[k], mu[k]) for k in o[:5]))
        print("      ranks    : TFT %d, CTFT %d, GTFT %d, PAVLOV %d, GRIM %d, TF2T %d"
              % (int(np.where(o == tft)[0][0]) + 1,
                 int(np.where(o == IDX["CTFT"])[0][0]) + 1,
                 int(np.where(o == IDX["GTFT"])[0][0]) + 1,
                 int(np.where(o == IDX["PAVLOV"])[0][0]) + 1,
                 int(np.where(o == IDX["GRIM"])[0][0]) + 1,
                 int(np.where(o == IDX["TF2T"])[0][0]) + 1))

    # ------------------------------------------------------------------
    head("VALIDATION 5.  TWO TIT-FOR-TATS AGAINST AN EXACT MARKOV CHAIN")
    print("The twin match between two tit-for-tats under noise can be solved exactly")
    print("as a four-state Markov chain on the pair of moves actually played, with")
    print("the same 200-round horizon and the same starting condition. That value is")
    print("computed in tft_pair_exact() without simulating anything, and compared")
    print("with the simulated diagonal cell.")
    print()
    print("  %-8s %13s %13s %12s %10s" % ("noise", "club", "exact", "diff", "sigmas"))
    worst_sig = 0.0
    exact_tbl = []
    for z in NOISE_LEVELS:
        cl = sweep[z]["M"][tft, tft]
        ex = tft_pair_exact(z, ROUNDS)
        sd = sweep[z]["Mse"][tft, tft]
        sig = (cl - ex) / sd if sd > 0 else 0.0
        worst_sig = max(worst_sig, abs(sig))
        exact_tbl.append((z, cl, ex, cl - ex, sig))
        print("  %-8.3f %13.6f %13.6f %+12.3e %10.2f" % (z, cl, ex, cl - ex, sig))
    print()
    print("  largest disagreement: %.2f standard errors. With %d comparisons, the"
          % (worst_sig, len(NOISE_LEVELS)))
    print("  largest of that many standard normals is expected near 2.0, so this is")
    print("  the result we wanted.  -> %s" % ("PASS" if worst_sig < 4.0 else "INVESTIGATE"))
    all_pass = all_pass and worst_sig < 4.0
    dump["exact_tft"] = [[float(v) for v in row] for row in exact_tbl]

    # ------------------------------------------------------------------
    head("WHERE TIT FOR TAT LOSES THE CROWN")
    print("  %-8s %-9s %-28s %11s %11s %9s"
          % ("noise", "winner", "winner name", "win score", "TFT score", "TFT rank"))
    cross = None
    for z in NOISE_LEVELS:
        mu = sweep[z]["mean"]
        o = np.argsort(-mu)
        rk = int(np.where(o == tft)[0][0]) + 1
        print("  %-8.3f %-9s %-28s %11.5f %11.5f %9d"
              % (z, KEYS[o[0]], NAMES[o[0]], mu[o[0]], mu[tft], rk))
        if cross is None and rk > 1:
            cross = z
    print()
    if cross is None:
        print("  Tit for tat held first place at every noise level tested.")
    elif cross == 0.0:
        print("  Tit for tat is not in first place at any noise level tested, zero")
        print("  included. Its best placing is rank %d."
              % min(int(np.where(np.argsort(-sweep[z2]["mean"]) == tft)[0][0]) + 1
                    for z2 in NOISE_LEVELS))
    else:
        print("  Tit for tat is out of first place from noise = %.3f upward." % cross)

    # ------------------------------------------------------------------
    head("SIGNIFICANCE OF THE HEADLINE UPSET")
    mu = sweep[ZMARK]["mean"]
    se = sweep[ZMARK]["se"]
    o = np.argsort(-mu)
    w = int(o[0])
    d = mu[w] - mu[tft]
    sd = float(np.sqrt(se[w] ** 2 + se[tft] ** 2))
    print("  At noise %.3f the leader is %s at %.5f +/- %.5f."
          % (ZMARK, KEYS[w], mu[w], se[w]))
    print("  Tit for tat scores %.5f +/- %.5f." % (mu[tft], se[tft]))
    print("  Difference %.5f, unpaired SE %.5f, that is %.1f standard errors."
          % (d, sd, d / sd if sd > 0 else float("inf")))
    dd = per_rep[ZMARK][:, w] - per_rep[ZMARK][:, tft]
    dse = dd.std(ddof=1) / np.sqrt(REPS)
    print("  The repetitions are paired: leader and tit for tat met the same field in")
    print("  the same repetition. Paired difference %.5f +/- %.5f, t = %.1f on %d df."
          % (dd.mean(), dse, dd.mean() / dse, REPS - 1))
    dump["upset"] = {"noise": ZMARK, "winner": KEYS[w], "gap": float(d),
                     "se": float(sd), "paired_t": float(dd.mean() / dse)}

    # ------------------------------------------------------------------
    head("CONVERGENCE OF THE MONTE CARLO ESTIMATE")
    print("Running mean of the tournament score as repetitions accumulate, at noise")
    print("%.3f, for the four strategies that finish highest. The standard error at" % ZMARK)
    print("each repetition count is printed beside it.")
    print()
    watch = [int(k) for k in o[:4]]
    sc = per_rep[ZMARK]
    csum = np.cumsum(sc, axis=0)
    ladder = [1, 2, 5, 10, 20, 50, 100, 150, REPS]
    print("  %-6s" % "reps" + "".join("%21s" % (KEYS[k] + " mean+/-SE") for k in watch))
    for m in ladder:
        row = "  %-6d" % m
        for k in watch:
            rm = csum[m - 1, k] / m
            s = sc[:m, k].std(ddof=1) / np.sqrt(m) if m > 1 else float("nan")
            row += "%21s" % ("%.4f+/-%.4f" % (rm, s))
        print(row)
    print()
    print("  Half-width of the leader's 95%% interval at %d repetitions: %.5f per round."
          % (REPS, 1.96 * se[w]))
    print("  The gap it has to resolve is %.5f, larger by a factor of %.1f."
          % (d, d / (1.96 * se[w]) if se[w] > 0 else float("inf")))
    dump["convergence"] = {
        "noise": ZMARK,
        "keys": [KEYS[k] for k in watch],
        "running": {KEYS[k]: (csum[:, k] / np.arange(1, REPS + 1)).tolist() for k in watch},
        "se_running": {KEYS[k]: [float(sc[:m, k].std(ddof=1) / np.sqrt(m)) if m > 1 else 0.0
                                 for m in range(1, REPS + 1)] for k in watch}}

    # ------------------------------------------------------------------
    head("EVOLUTIONARY TOURNAMENT: REPLICATOR DYNAMICS AT EVERY NOISE LEVEL")
    print("Uniform start, %d generations, no mutation. A strategy is called extinct" % GENERATIONS)
    print("below frequency %.0e. Average payoff is the population mean phi at the last" % EXTINCT)
    print("generation.")
    print()
    evo = {}
    worst_cons = 0.0
    for z in NOISE_LEVELS:
        M = sweep[z]["M"]
        traj, worst = replicate(M, GENERATIONS)
        worst_cons = max(worst_cons, worst)
        xf = traj[-1]
        phi = float(xf @ (M @ xf))
        top = np.argsort(-xf)
        surv = [(KEYS[i], xf[i]) for i in top if xf[i] >= EXTINCT]
        evo[z] = {"traj": traj, "final": xf, "phi": phi, "surv": len(surv)}
        print("  noise %.3f  dominant %-8s %.4f   survivors %2d   avg payoff %.4f"
              % (z, KEYS[top[0]], xf[top[0]], len(surv), phi))
        print("      " + ",  ".join("%s %.4f" % (k, v) for k, v in surv[:6]))
    print()
    print("  VALIDATION 6: max |sum(x) - 1| over every generation of every noise")
    print("  level = %.3e   -> %s" % (worst_cons, "PASS" if worst_cons < 1e-12 else "FAIL"))
    all_pass = all_pass and worst_cons < 1e-12

    head("EVOLUTIONARY FINAL COMPOSITION, FULL TABLE")
    print("  %-8s" % "noise" + "".join("%8s" % k[:7] for k in KEYS) + "%9s" % "phi")
    for z in NOISE_LEVELS:
        xf = evo[z]["final"]
        print("  %-8.3f" % z + "".join("%8.4f" % xf[i] for i in range(NSTRAT))
              + "%9.4f" % evo[z]["phi"])

    head("HOW LONG THE EVOLUTIONARY RUN TAKES TO SETTLE")
    print("Generation at which the eventual dominant strategy first passes 50 per")
    print("cent and 90 per cent of the population.")
    print()
    print("  %-8s %-9s %10s %10s" % ("noise", "dominant", "gen>50%", "gen>90%"))
    for z in NOISE_LEVELS:
        tr = evo[z]["traj"]
        dom = int(np.argmax(evo[z]["final"]))
        col = tr[:, dom]
        g50 = int(np.argmax(col > 0.5)) if (col > 0.5).any() else -1
        g90 = int(np.argmax(col > 0.9)) if (col > 0.9).any() else -1
        print("  %-8.3f %-9s %10s %10s"
              % (z, KEYS[dom], g50 if g50 >= 0 else "never", g90 if g90 >= 0 else "never"))

    # ------------------------------------------------------------------
    head("SENSITIVITY 1.  FIELD COMPOSITION")
    print("A round robin score depends entirely on who was entered. Four fields, each")
    print("a defensible modelling choice, run at noise 0 and %.2f with %d repetitions."
          % (ZMARK, SENS_REPS))
    print()
    fields = [
        ("full field (15 strategies)", list(range(NSTRAT))),
        ("drop the exploiters: JOSS, TESTER, PROBER, STFT",
         [i for i in range(NSTRAT) if KEYS[i] not in ("JOSS", "TESTER", "PROBER", "STFT")]),
        ("drop the free lunch: ALLC, ALT, RAND",
         [i for i in range(NSTRAT) if KEYS[i] not in ("ALLC", "ALT", "RAND")]),
        ("nice strategies only",
         [i for i in range(NSTRAT) if KEYS[i] in
          ("ALLC", "TFT", "GTFT", "TF2T", "GRIM", "PAVLOV", "CTFT", "HTFT")]),
    ]
    sens1 = []
    for label, sub in fields:
        for z in (0.0, ZMARK):
            rng = nxt()
            sublib = [build_library()[i] for i in sub]
            mats = round_robin(sublib, SENS_REPS, ROUNDS, z, rng)
            mu = scores_from(mats).mean(axis=0)
            oo = np.argsort(-mu)
            subkeys = [KEYS[i] for i in sub]
            ti = subkeys.index("TFT")
            rk = int(np.where(oo == ti)[0][0]) + 1
            tied = int(np.sum(np.abs(mu - mu[oo[0]]) < 1e-9))
            print("  %-48s noise %.2f  winner %-8s %.5f  TFT rank %d/%d (%.5f)%s"
                  % (label, z, subkeys[oo[0]], mu[oo[0]], rk, len(sub), mu[ti],
                     "  EXACT %d-WAY TIE AT THE TOP" % tied if tied > 1 else ""))
            sens1.append([label, z, subkeys[int(oo[0])], rk, len(sub),
                          float(mu[oo[0]]), float(mu[ti]), tied])
    dump["sens_field"] = sens1

    # ------------------------------------------------------------------
    head("SENSITIVITY 2.  SCORING THE TWIN MATCH OR NOT")
    print("Axelrod scored every entrant against its own twin. Dropping the twin")
    print("changes who wins, because the strategies that do best against themselves")
    print("are exactly the nice ones.")
    print()
    sens2 = []
    for z in (0.0, ZMARK):
        if z == 0.0:
            mats = mats0
        else:
            rng = nxt()
            mats = round_robin(LIB, SENS_REPS, ROUNDS, z, rng)
        a = scores_from(mats, True).mean(axis=0)
        b = scores_from(mats, False).mean(axis=0)
        oa, ob = np.argsort(-a), np.argsort(-b)
        print("  noise %.2f   with twin : winner %s, TFT rank %d"
              % (z, KEYS[oa[0]], int(np.where(oa == tft)[0][0]) + 1))
        print("               no twin  : winner %s, TFT rank %d"
              % (KEYS[ob[0]], int(np.where(ob == tft)[0][0]) + 1))
        sens2.append([z, KEYS[int(oa[0])], KEYS[int(ob[0])]])
    dump["sens_twin"] = sens2

    # ------------------------------------------------------------------
    head("SENSITIVITY 3.  HOW GENEROUS SHOULD GENEROUS TIT FOR TAT BE?")
    q_star = min(1 - (T - R) / (R - S), (R - P) / (T - P))
    print("Molander (1985) derived an optimal forgiveness level for generous tit for")
    print("tat under noise,")
    print("    q* = min{ 1 - (T-R)/(R-S),  (R-P)/(T-P) }")
    print("       = min{ 1 - %.1f/%.1f, %.1f/%.1f } = min{%.4f, %.4f} = %.4f"
          % (T - R, R - S, R - P, T - P, 1 - (T - R) / (R - S),
             (R - P) / (T - P), q_star))
    print("for this payoff matrix. That derivation concerns evolutionary stability in")
    print("a two-strategy setting, not winning a fifteen-way round robin, so agreement")
    print("here is suggestive rather than a strict test. Scan at noise %.2f, %d reps."
          % (ZMARK, SENS_REPS))
    print()
    gscan = []
    gi = IDX["GTFT"]
    for g in [0.0, 0.05, 0.1, 0.15, 0.2, 0.25, 1.0 / 3.0, 0.4, 0.5, 0.6, 0.8, 1.0]:
        rng = nxt()
        lib = build_library(g)
        mats = round_robin(lib, SENS_REPS, ROUNDS, ZMARK, rng)
        scg = scores_from(mats)
        mu = scg.mean(axis=0)
        seg = scg.std(axis=0, ddof=1) / np.sqrt(SENS_REPS)
        oo = np.argsort(-mu)
        rk = int(np.where(oo == gi)[0][0]) + 1
        gscan.append((g, float(mu[gi]), float(seg[gi]), rk))
        print("  g = %.4f   GTFT score %.5f +/- %.5f   rank %d/%d"
              % (g, mu[gi], seg[gi], rk, NSTRAT))
    best = max(gscan, key=lambda r: r[1])
    print()
    at_q = [r for r in gscan if abs(r[0] - q_star) < 1e-9][0]
    sep = abs(best[1] - at_q[1]) / np.sqrt(best[2] ** 2 + at_q[2] ** 2)
    print("  club's scan peaks at g = %.4f, score %.5f +/- %.5f"
          % (best[0], best[1], best[2]))
    print("  Molander's q*          = %.4f, score %.5f +/- %.5f"
          % (q_star, at_q[1], at_q[2]))
    print("  difference in g        = %+.4f" % (best[0] - q_star))
    print("  the two scores differ by %.5f, which is %.1f standard errors, so the"
          % (abs(best[1] - at_q[1]), sep))
    print("  scan cannot separate them. The curve is flat from about 0.15 to 0.5 and")
    print("  Molander's value sits inside that plateau. Full generosity is a disaster.")
    dump["gscan"] = [[float(x) for x in row] for row in gscan]
    dump["q_star"] = q_star

    # ------------------------------------------------------------------
    head("SENSITIVITY 4.  MATCH LENGTH")
    print("Every strategy that exploits an opening does so once, so its advantage is")
    print("spread over the match length. Shorter matches favour the exploiters.")
    print()
    sens4 = []
    for L in (20, 50, 200, 500):
        rng = nxt()
        mats = round_robin(LIB, SENS_REPS, L, 0.0, rng)
        scl = scores_from(mats)
        mu = scl.mean(axis=0)
        oo = np.argsort(-mu)
        dv = scl[:, oo[0]] - scl[:, tft]
        dse = dv.std(ddof=1) / np.sqrt(SENS_REPS)
        tt = dv.mean() / dse if dse > 0 else 0.0
        print("  rounds %4d   winner %-8s %.5f   TFT rank %2d (%.5f)   "
              "lead over TFT %+.5f, t = %.2f"
              % (L, KEYS[oo[0]], mu[oo[0]], int(np.where(oo == tft)[0][0]) + 1,
                 mu[tft], dv.mean(), tt))
        sens4.append([L, KEYS[int(oo[0])], float(mu[oo[0]]),
                      int(np.where(oo == tft)[0][0]) + 1, float(mu[tft]), float(tt)])
    dump["sens_len"] = sens4

    # ------------------------------------------------------------------
    head("COMPARISON WITH THE PUBLISHED LITERATURE")
    print("  Axelrod (1980) tournament 1: 14 entries plus RANDOM, 200 rounds, the same")
    print("  payoff numbers. TIT FOR TAT won with a reported average of 504.5 points")
    print("  per match, which on a 200-round match is 2.5225 per round.")
    print("    club's TFT at noise 0 : %.4f per round against our field" % mean0[tft])
    print("    difference            : %+.4f per round" % (mean0[tft] - 2.5225))
    print("  The two numbers are NOT measuring the same thing. Axelrod's average is")
    print("  over his entrants and ours is over ours. We print the comparison because")
    print("  it is the number readers will look for, and we say plainly that it is a")
    print("  different quantity and not a reproduction.")
    print()
    print("  Structural results that CAN be checked against published work:")
    print("    mutual cooperation among nice strategies at noise 0 pays exactly R = 3")
    print("      club: TFT/TFT %.6f, GRIM/GRIM %.6f, CTFT/CTFT %.6f, PAVLOV/PAVLOV %.6f"
          % (M0[tft, tft], M0[IDX["GRIM"], IDX["GRIM"]],
             M0[IDX["CTFT"], IDX["CTFT"]], M0[IDX["PAVLOV"], IDX["PAVLOV"]]))
    print("    Nowak and Sigmund (1992, 1993) and Wu and Axelrod (1995) report that")
    print("    under noise, forgiveness and contrition beat strict reciprocity:")
    for z in (0.0, 0.01, 0.05, 0.20):
        m = sweep[z]["mean"]
        print("      noise %.2f  TFT %.4f  GTFT %.4f  CTFT %.4f  PAVLOV %.4f  TF2T %.4f"
              % (z, m[tft], m[IDX["GTFT"]], m[IDX["CTFT"]],
                 m[IDX["PAVLOV"]], m[IDX["TF2T"]]))
    print("    the cost of noise to a pair of tit-for-tats, from the exact chain:")
    for z, cl, ex, df, sg in exact_tbl:
        print("      noise %.3f  exact twin payoff %.4f, a loss of %.4f per round from R=3"
              % (z, ex, R - ex))

    # ------------------------------------------------------------------
    head("SUMMARY")
    print("  every validation passed            : %s" % ("YES" if all_pass else "NO"))
    print("  noiseless winner                   : %s (%s), %.5f"
          % (KEYS[win0], NAMES[win0], mean0[win0]))
    print("  tit for tat at noise 0             : rank %d, %.5f" % (tft_rank0, mean0[tft]))
    o05 = np.argsort(-sweep[ZMARK]["mean"])
    o20 = np.argsort(-sweep[0.20]["mean"])
    print("  winner at noise %.2f               : %s (%s), %.5f"
          % (ZMARK, KEYS[o05[0]], NAMES[o05[0]], sweep[ZMARK]["mean"][o05[0]]))
    print("  winner at noise 0.20               : %s (%s), %.5f"
          % (KEYS[o20[0]], NAMES[o20[0]], sweep[0.20]["mean"][o20[0]]))
    for z in (0.0, ZMARK, 0.20):
        i = int(np.argmax(evo[z]["final"]))
        print("  evolutionary dominant at noise %.2f : %s %.4f, avg payoff %.4f"
              % (z, KEYS[i], evo[z]["final"][i], evo[z]["phi"]))
    print("  average payoff, noise 0 -> 0.20    : %.4f -> %.4f (%.1f%% of it lost)"
          % (evo[0.0]["phi"], evo[0.20]["phi"],
             100 * (evo[0.0]["phi"] - evo[0.20]["phi"]) / evo[0.0]["phi"]))
    print("  total runtime                      : %.1f s" % (time.time() - t_start))

    # ------------------------------------------------------------------
    dump["sweep"] = {("%.3f" % z): {"M": sweep[z]["M"].tolist(),
                                    "mean": sweep[z]["mean"].tolist(),
                                    "se": sweep[z]["se"].tolist()}
                     for z in NOISE_LEVELS}
    dump["evo"] = {("%.3f" % z): {"final": evo[z]["final"].tolist(),
                                  "phi": evo[z]["phi"],
                                  "traj": evo[z]["traj"][:600].tolist()}
                   for z in NOISE_LEVELS}
    dump["runtime_s"] = time.time() - t_start
    path = os.environ.get("SJC_JSON")
    if path:
        with open(path, "w") as fh:
            json.dump(dump, fh)
        print("  figure data written to %s" % path)


if __name__ == "__main__":
    main()
