Drop7 Research
approaches/lifetime-objective/leaf-evolution/compare.pyPython119 lines · 6.0 KB
#!/usr/bin/env python3
"""Paired whole-game comparison of two individuals in one population artifact.

Reports, for candidate minus reference on identical seeds: mean delta, paired
sd, one-sided 95% percentile-bootstrap lower bound over whole games (20,000
resamples, seed given), W-T-L, first-half / second-half means, Q25 deltas, and
the detection floor 1.645 * sd / sqrt(n) that docs/research/status.md asks to
be stated next to every null.  Nothing here extrapolates.
"""
from __future__ import annotations

import argparse
import json
import math

import numpy as np

BOOTSTRAP_SEED = 0xB007_1EAF


def t_quantile_95(df: int) -> float:
    """One-sided 95% Student-t quantile by the Cornish-Fisher expansion in the
    normal quantile z = 1.6448536 (accurate to about 1e-4 for df >= 10; the
    adversarial review noted the earlier code used z itself, 1.5% optimistic at
    df = 63).  t(63) = 1.6694, t(31) = 1.6955, t(255) = 1.6510."""
    z = 1.6448536269514722
    g1 = (z ** 3 + z) / 4
    g2 = (5 * z ** 5 + 16 * z ** 3 + 3 * z) / 96
    g3 = (3 * z ** 7 + 19 * z ** 5 + 17 * z ** 3 - 15 * z) / 384
    g4 = (79 * z ** 9 + 776 * z ** 7 + 1482 * z ** 5 - 1920 * z ** 3 - 945 * z) / 92160
    return z + g1 / df + g2 / df ** 2 + g3 / df ** 3 + g4 / df ** 4


def summary(ind: dict) -> dict:
    s = np.array([g["score"] for g in ind["games"]], dtype=float)
    m = np.array([g["moves"] for g in ind["games"]], dtype=float)
    occ = np.array([g["meanOccupiedCells"] for g in ind["games"]], dtype=float)
    return {
        "name": ind["name"], "games": len(s),
        "score": {"mean": s.mean(), "median": float(np.median(s)), "sd": s.std(ddof=1) if len(s) > 1 else 0.0,
                  "q25": float(np.quantile(s, 0.25)), "min": s.min(), "max": s.max()},
        "moves": {"mean": m.mean(), "q25": float(np.quantile(m, 0.25))},
        "numberedClearsPerMove": ind["numberedClearsPerMove"],
        "coverRevealsPerMove": ind["coverRevealsPerMove"],
        "maxChainDepth": max(g["maxChainDepth"] for g in ind["games"]),
        "meanOccupiedCells": occ.mean(),
        "censoredGames": ind["censoredGames"],
        "incompleteDecisions": ind["incompleteDecisions"],
        "illegalDecisions": ind["illegalDecisions"],
        "gamesAtOrAboveMillion": int((s >= 1_000_000).sum()),
    }


def paired(cand: dict, ref: dict, key: str) -> dict:
    a = np.array([g[key] for g in cand["games"]], dtype=float)
    b = np.array([g[key] for g in ref["games"]], dtype=float)
    assert [g["seedHex"] for g in cand["games"]] == [g["seedHex"] for g in ref["games"]], "seed alignment"
    d = a - b
    n = len(d)
    rng = np.random.default_rng(BOOTSTRAP_SEED)
    idx = rng.integers(0, n, size=(20000, n))
    boots = d[idx].mean(axis=1)
    sd = d.std(ddof=1) if n > 1 else 0.0
    half = n // 2
    return {
        "n": n, "meanDelta": d.mean(), "pairedSd": sd,
        "bootstrapLower95": float(np.quantile(boots, 0.05)),
        "bootstrapUpper95": float(np.quantile(boots, 0.95)),
        "studentTLower95": d.mean() - t_quantile_95(n - 1) * sd / math.sqrt(n) if n > 1 else d.mean(),
        "studentTQuantile": t_quantile_95(n - 1) if n > 1 else None,
        "detectionFloor": 1.645 * sd / math.sqrt(n) if n > 1 else 0.0,
        "detectionFloorNote": "1.645 * sd / sqrt(n), the z-based floor defined in docs/research/status.md",
        "wins": int((d > 0).sum()), "ties": int((d == 0).sum()), "losses": int((d < 0).sum()),
        "firstHalfMeanDelta": d[:half].mean(), "secondHalfMeanDelta": d[half:].mean(),
        "q25Delta": float(np.quantile(a, 0.25) - np.quantile(b, 0.25)),
        "largestSwings": sorted(d.tolist(), key=abs, reverse=True)[:5],
        "bootstrapSeedHex": f"0x{BOOTSTRAP_SEED:08x}", "resamples": 20000,
    }


def main() -> None:
    p = argparse.ArgumentParser(description=__doc__)
    p.add_argument("artifact")
    p.add_argument("--candidate", required=True)
    p.add_argument("--reference", required=True)
    p.add_argument("--out")
    args = p.parse_args()
    art = json.load(open(args.artifact))
    by = {i["name"]: i for i in art["individuals"]}
    cand, ref = by[args.candidate], by[args.reference]
    report = {
        "format": "drop7-leaf-evolution-paired-v1",
        "artifact": args.artifact, "config": art["config"], "seedStartHex": art["seedStartHex"],
        "candidate": summary(cand), "reference": summary(ref),
        "pairedScore": paired(cand, ref, "score"),
        "pairedMoves": paired(cand, ref, "moves"),
        "pairedNumberedCleared": paired(cand, ref, "numberedCleared"),
        "pairedCoversRevealed": paired(cand, ref, "coversRevealed"),
    }
    text = json.dumps(report, indent=2, default=float)
    if args.out:
        open(args.out, "w").write(text + "\n")
    ps = report["pairedScore"]
    print(f"{args.candidate} - {args.reference} on {ps['n']} paired games (seeds {art['seedStartHex']}+):")
    print(f"  score   {report['candidate']['score']['mean']:10.0f} vs {report['reference']['score']['mean']:10.0f}"
          f"  delta {ps['meanDelta']:+9.0f}  LB95 {ps['bootstrapLower95']:+9.0f}  UB95 {ps['bootstrapUpper95']:+9.0f}"
          f"  W-T-L {ps['wins']}-{ps['ties']}-{ps['losses']}  floor {ps['detectionFloor']:.0f}")
    pm = report["pairedMoves"]
    print(f"  moves   {report['candidate']['moves']['mean']:10.2f} vs {report['reference']['moves']['mean']:10.2f}"
          f"  delta {pm['meanDelta']:+9.2f}  LB95 {pm['bootstrapLower95']:+9.2f}")
    print(f"  halves  {ps['firstHalfMeanDelta']:+9.0f} / {ps['secondHalfMeanDelta']:+9.0f}   q25 delta {ps['q25Delta']:+9.0f}"
          f"   max {report['candidate']['score']['max']:.0f} vs {report['reference']['score']['max']:.0f}")
    print(f"  flow    clears/move {report['candidate']['numberedClearsPerMove']:.4f} vs {report['reference']['numberedClearsPerMove']:.4f}"
          f"  reveals/move {report['candidate']['coverRevealsPerMove']:.4f} vs {report['reference']['coverRevealsPerMove']:.4f}"
          f"  occupancy {report['candidate']['meanOccupiedCells']:.2f} vs {report['reference']['meanOccupiedCells']:.2f}")


if __name__ == "__main__":
    main()