Files
SirRoboGarage/tools/ab/spinner_analyze.py
T

520 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""spinner_analyze.py — the SPINNER-CLAIM analyzer (j124, owner claim 1).
python3 tools/ab/spinner_analyze.py <session_dir> [--reference pattern] [--report FILE]
Reads a session produced by `tools/ab/tournament_run.sh` (layout:
<outdir>/<opponent>/<arm>/run<N>.{battle.log,events.jsonl,jsonl,jsonl.rounds.json,bot.stdout.log})
and answers the owner's claim:
"I never seen a gun that learns wall movement or circular movement like
spinning bot so fast - like a gun made on purpose for those movements."
Unlike the gun campaign's gauntlet_analyze.py (exactly 2 arms), this supports N
arms and adds the CONVERGENCE layer the claim is really about: our gun's per-round
hit rate on the first rounds vs the later rounds, per arm, and within-round
(first-half vs second-half ticks) for the spinner opponents.
Primary metrics stay damage/run and ROUND WINS. Hit rate (ours, per-round) is the
SECONDARY mechanism metric, reported but never the verdict. MDE is printed for
every paired test so a null is distinguishable from an under-powered null.
Standard library only. Deterministic (fixed permutation seed).
"""
import itertools
import json
import math
import os
import random
import re
import statistics
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import gauntlet_analyze as ga # noqa: E402 (reuse the proven parsers/stats)
BOT_NAME = "ModularBot"
EXACT_PAIR_CAP = 20
MC_DRAWS = 200_000
MC_SEED = 0x5EED5EED
# ── parsing one run ──────────────────────────────────────────────────────────
def read_round_starts(path):
try:
data = json.load(open(path))
except (OSError, json.JSONDecodeError):
return {}
return {r["round"]: r["startTick"] for r in data.get("rounds", [])}
def parse_run(armdir, run, env_req=None):
evs = ga.parse_events(os.path.join(armdir, f"run{run}.events.jsonl"))
log_text = "".join(ga.read_lines(os.path.join(armdir, f"run{run}.battle.log")))
counters = ga.parse_counters(log_text)
wins = ga.parse_first_places(log_text)
nrounds = ga.round_count(os.path.join(armdir, f"run{run}.jsonl.rounds.json"))
starts = read_round_starts(os.path.join(armdir, f"run{run}.jsonl.rounds.json"))
r = {"run": run, "valid": False, "rounds": nrounds, "wins": wins,
"damage": 0.0, "damage_taken": 0.0, "mb_fired": 0, "mb_hits": 0,
"opp_fired": 0, "opp_hits": 0, "env_ok": True, "per_round": {}}
if env_req:
text = "".join(ga.read_lines(os.path.join(armdir, f"run{run}.bot.stdout.log")))
r["env_ok"] = all(
re.search(re.escape(k) + r"\s*=\s*" + re.escape(v) + r"(\s|$)", text)
is not None for k, v in env_req.items())
if wins is None or counters is None or nrounds == 0:
return r
subj, other = ga.attribute_subject(evs, counters)
if subj is None:
return r
def slot(rnd):
return r["per_round"].setdefault(rnd, {
"mb_fired": 0, "mb_hits": 0, "mb_damage": 0.0,
"opp_fired": 0, "opp_hits": 0, "opp_damage": 0.0})
for o in evs:
rnd = o.get("round", 0)
t = o.get("type")
if t == "fire":
if o.get("owner") == subj:
r["mb_fired"] += 1; slot(rnd)["mb_fired"] += 1
elif o.get("owner") == other:
r["opp_fired"] += 1; slot(rnd)["opp_fired"] += 1
elif t == "hit":
d = float(o.get("damage", 0.0))
if o.get("owner") == subj:
r["damage"] += d; r["mb_hits"] += 1
slot(rnd)["mb_damage"] += d; slot(rnd)["mb_hits"] += 1
if o.get("owner") == other:
r["opp_hits"] += 1; slot(rnd)["opp_damage"] += d
slot(rnd)["opp_hits"] += 1
if o.get("victim") == subj:
r["damage_taken"] += d
r["valid"] = True
r["round_starts"] = starts
return r
def discover_runs(armdir):
if not os.path.isdir(armdir):
return []
return sorted(int(m.group(1)) for m in
(re.fullmatch(r"run(\d+)\.jsonl", f) for f in os.listdir(armdir))
if m)
def load_arm(opp_dir, arm, runs, env_req):
valid, invalid = [], []
for run in range(1, runs + 1):
r = parse_run(os.path.join(opp_dir, arm), run, env_req)
(valid if (r["valid"] and r["env_ok"]) else invalid).append(r)
return valid, invalid
# ── aggregation ──────────────────────────────────────────────────────────────
def sumruns(runs, key):
return sum(r[key] for r in runs)
def pooled(runs):
n = len(runs)
if n == 0:
return None
rounds = sumruns(runs, "rounds")
mb_fired = sumruns(runs, "mb_fired")
opp_fired = sumruns(runs, "opp_fired")
return {
"runs": n,
"damage": sumruns(runs, "damage") / n,
"damage_taken": sumruns(runs, "damage_taken") / n,
"wins": sumruns(runs, "wins") / n,
"win_rate": sumruns(runs, "wins") / rounds if rounds else float("nan"),
"rounds": rounds,
"hit_rate": 100.0 * sumruns(runs, "mb_hits") / mb_fired if mb_fired else float("nan"),
"incoming_hit_rate": 100.0 * sumruns(runs, "opp_hits") / opp_fired if opp_fired else float("nan"),
"mb_fired": mb_fired, "opp_fired": opp_fired,
}
def round_hit_rates(runs):
"""{round_index: (hits, fires)} pooled over runs."""
out = {}
for r in runs:
for rnd, d in r["per_round"].items():
h, f = out.get(rnd, (0, 0))
out[rnd] = (h + d["mb_hits"], f + d["mb_fired"])
return out
# ── main ─────────────────────────────────────────────────────────────────────
def main():
args = sys.argv[1:]
if not args:
print(__doc__)
return 2
session_dir = args[0].rstrip("/")
ref = "pattern"
if "--reference" in args:
ref = args[args.index("--reference") + 1]
report_path = None
if "--report" in args:
report_path = args[args.index("--report") + 1]
session = json.load(open(os.path.join(session_dir, "session.json")))
arms = [a["name"] for a in session["arms"]]
arm_env = {a["name"]: ga.parse_envspec(a.get("env", "")) for a in session["arms"]}
if ref not in arms:
ref = arms[0]
opps = [o["name"] for o in session["opponents"]]
style_of = {o["name"]: (o.get("style") or "other").strip() or "other"
for o in session["opponents"]}
lines = []
def out(s=""):
lines.append(s)
print(s)
out("### MEASURED: session")
out()
out(f"* commit `{session['commit']}`, frozen binary sha256 "
f"`{session['binary_sha256'][:12]}…`")
out(f"* {len(opps)} opponents × {len(arms)} arms × {session['runs']} runs × "
f"{session['rounds']} rounds = {len(opps) * len(arms) * session['runs']} battles")
out(f"* arms file `{os.path.basename(session.get('arms_file', '?'))}`, "
f"panel file `{os.path.basename(session.get('panel_file', '?'))}`")
out(f"* reference arm: **`{ref}`** (deltas are arm − reference, opponent by opponent)")
out()
data = {}
invalid_total = 0
for o in opps:
data[o] = {}
for a in arms:
valid, invalid = load_arm(os.path.join(session_dir, o), a,
session["runs"], arm_env.get(a))
data[o][a] = valid
invalid_total += len(invalid)
out(f"* liveness: {invalid_total} run(s) excluded "
f"({len(opps) * len(arms) * session['runs']} total)")
out()
# ── spinner movement validation (MEASURED from the opponent trace) ──────
out("### MEASURED: the spinner fixture is a true constant-turn spinner")
out()
out("Per-tick body turn and speed of each spinner opponent, read from the")
out("capture's adversary trace (`s*` fields), pooled over its runs.")
out()
out("| opponent | ticks | turn mode (deg/tick) | mode share | turn SD | speed mode | speed mode share | wall-hug frac |")
out("|---|---:|---:|---:|---:|---:|---:|---:|")
spinner_opps = [o for o in opps if style_of[o] == "spinner"]
for o in spinner_opps:
adir = os.path.join(session_dir, o, ref)
turns, speeds, wall, n, prev = [], [], 0, 0, None
for run in discover_runs(adir):
try:
fh = open(os.path.join(adir, f"run{run}.jsonl"), errors="replace")
except OSError:
continue
with fh:
for line in fh:
if '"sx"' not in line:
continue
try:
e = json.loads(line)
except json.JSONDecodeError:
continue
if "sx" not in e:
continue
x, y, h, s = e["sx"], e["sy"], e["sh"], e.get("ss", 0.0)
n += 1
if min(x, 800 - x, y, 600 - y) < 50:
wall += 1
if prev is not None:
px, py, ph = prev
if math.hypot(x - px, y - py) < 30:
turns.append((h - ph + 180.0) % 360.0 - 180.0)
speeds.append(s)
prev = (x, y, h)
if not turns:
out(f"| {o} | 0 | n/a | n/a | n/a | n/a | n/a | n/a |")
continue
tc = {}
for t in turns:
tc[round(t, 1)] = tc.get(round(t, 1), 0) + 1
tmode, tcnt = max(tc.items(), key=lambda kv: kv[1])
sc = {}
for s in speeds:
sc[round(s, 1)] = sc.get(round(s, 1), 0) + 1
smode, scnt = max(sc.items(), key=lambda kv: kv[1])
sd = statistics.pstdev(turns)
out(f"| {o} | {n} | {tmode} | {100 * tcnt / len(turns):.1f}% | {sd:.3f} "
f"| {smode} | {100 * scnt / len(speeds):.1f}% | {100 * wall / n:.1f}% |")
out()
# ── per-opponent paired table ───────────────────────────────────────────
per_opp = {a: {} for a in arms}
for o in opps:
ref_runs = data[o][ref]
if not ref_runs:
for a in arms:
per_opp[a][o] = None
continue
rm = pooled(ref_runs)
for a in arms:
m = pooled(data[o][a])
if m is None:
per_opp[a][o] = None
continue
per_opp[a][o] = {
"m": m, "ref": rm,
"d_damage": m["damage"] - rm["damage"],
"d_wins": m["wins"] - rm["wins"],
"d_hit": m["hit_rate"] - rm["hit_rate"],
"d_taken": m["damage_taken"] - rm["damage_taken"],
}
out("### MEASURED: per-opponent paired table")
out()
for a in arms:
if a == ref:
continue
out(f"#### `{a}` vs `{ref}`")
out()
out("| opponent | style | dmg/run ref→arm | Δdmg | wins/run ref→arm | Δwins | our hit% ref→arm | Δhit (pp) | dmg taken Δ |")
out("|---|---|---:|---:|---:|---:|---:|---:|---:|")
for o in opps:
e = per_opp[a][o]
if e is None:
out(f"| {o} | {style_of[o]} | n/a | n/a | n/a | n/a | n/a | n/a | n/a |")
continue
m, rm = e["m"], e["ref"]
out(f"| {o} | {style_of[o]} | {rm['damage']:.1f}→{m['damage']:.1f} "
f"| {e['d_damage']:+.1f} | {rm['wins']:.2f}→{m['wins']:.2f} "
f"| {e['d_wins']:+.2f} | {rm['hit_rate']:.2f}→{m['hit_rate']:.2f} "
f"| {e['d_hit']:+.2f} | {e['d_taken']:+.1f} |")
out()
# ── pooled dashboard ────────────────────────────────────────────────────
out("### MEASURED: pooled dashboard (all valid runs; NOT the verdict)")
out()
out("| arm | runs | dmg/run | dmg taken/run | wins/run | round wins | win rate | our hit rate | incoming hit rate |")
out("|---|---:|---:|---:|---:|---:|---:|---:|---:|")
pooled_arm = {}
for a in arms:
runs = [r for o in opps for r in data[o][a]]
m = pooled(runs)
pooled_arm[a] = m
out(f"| `{a}` | {m['runs']} | {m['damage']:.1f} | {m['damage_taken']:.1f} "
f"| {m['wins']:.2f} | {int(sumruns(runs, 'wins'))}/{m['rounds']} "
f"| {100 * m['win_rate']:.1f}% | {m['hit_rate']:.2f}% "
f"| {m['incoming_hit_rate']:.2f}% |")
out()
# ── cross-opponent aggregation ──────────────────────────────────────────
out("### MEASURED: cross-opponent aggregation (the verdict layer)")
out()
out("| arm | metric | mean Δ | spread (SD) | 95% CI | sign test | p(sign) | p(sign-flip) | p(Wilcoxon) | MDE |")
out("|---|---|---:|---:|---|---:|---:|---:|---:|---:|")
stats = {}
for a in arms:
if a == ref:
continue
st = {}
for key, mkey in (("d_damage", "damage"), ("d_wins", "wins"),
("d_hit", "our hit rate (pp)")):
ds = [per_opp[a][o][key] for o in opps
if per_opp[a][o] and not math.isnan(per_opp[a][o][key])]
if len(ds) < 2:
continue
desc = ga.describe(ds)
pos, neg, ties, p_sign = ga.sign_test(ds)
sf = ga.signflip_perm(ds)
pw = wilcoxon_signed(ds)
st[key] = {"desc": desc, "pos": pos, "neg": neg, "ties": ties,
"p_sign": p_sign, "p_flip": sf["p"], "p_wilcox": pw}
out(f"| `{a}` | {mkey} | {desc['mean']:+.2f} | {desc['sd']:.2f} "
f"| [{desc['mean'] - 1.96 * desc['se']:+.2f}, "
f"{desc['mean'] + 1.96 * desc['se']:+.2f}] "
f"| {pos}/{pos + neg} | {p_sign:.4g} | {sf['p']:.4g} "
f"| {pw:.4g} | {desc['mde']:.2f} |")
stats[a] = st
out()
# ── style split ─────────────────────────────────────────────────────────
styles = sorted({style_of[o] for o in opps})
out("### MEASURED: by style (explanation only)")
out()
out("| arm | style | n | mean Δdmg | mean Δwins | mean Δour-hit (pp) |")
out("|---|---|---:|---:|---:|---:|")
for a in arms:
if a == ref:
continue
for s in styles:
es = [per_opp[a][o] for o in opps
if per_opp[a][o] and style_of[o] == s]
if not es:
continue
out(f"| `{a}` | {s} | {len(es)} "
f"| {statistics.mean([e['d_damage'] for e in es]):+.2f} "
f"| {statistics.mean([e['d_wins'] for e in es]):+.2f} "
f"| {statistics.mean([e['d_hit'] for e in es]):+.2f} |")
out()
# ── CONVERGENCE ─────────────────────────────────────────────────────────
out("### MEASURED: CONVERGENCE — our per-round hit rate")
out()
out("Pooled over every valid run (sum of hits / sum of shots, per round index).")
out()
maxr = session["rounds"]
out("| arm | " + " | ".join(f"R{i}" for i in range(1, maxr + 1)) + " | R1→Rlast (pp) |")
out("|---|" + "---:|" * (maxr + 1))
conv_all = {}
for a in arms:
runs = [r for o in opps for r in data[o][a]]
rhs = round_hit_rates(runs)
vals = []
for i in range(1, maxr + 1):
h, f = rhs.get(i, (0, 0))
vals.append(100.0 * h / f if f else float("nan"))
conv_all[a] = vals
delta = (vals[-1] - vals[0]) if not (math.isnan(vals[0]) or math.isnan(vals[-1])) else float("nan")
out(f"| `{a}` | " + " | ".join("n/a" if math.isnan(v) else f"{v:.1f}%"
for v in vals) + f" | {delta:+.1f} |")
out()
out("Same, restricted to the TRUE-SPINNER opponents "
f"({', '.join(spinner_opps)}):")
out()
out("| arm | " + " | ".join(f"R{i}" for i in range(1, maxr + 1)) + " | R1→Rlast (pp) |")
out("|---|" + "---:|" * (maxr + 1))
conv_spin = {}
for a in arms:
runs = [r for o in spinner_opps for r in data[o][a]]
rhs = round_hit_rates(runs)
vals = []
for i in range(1, maxr + 1):
h, f = rhs.get(i, (0, 0))
vals.append(100.0 * h / f if f else float("nan"))
conv_spin[a] = vals
delta = (vals[-1] - vals[0]) if not (math.isnan(vals[0]) or math.isnan(vals[-1])) else float("nan")
out(f"| `{a}` | " + " | ".join("n/a" if math.isnan(v) else f"{v:.1f}%"
for v in vals) + f" | {delta:+.1f} |")
out()
# within-round early vs late (fast adaptation), spinner opponents
out("Within-round adaptation on the true spinners: our hit rate over the")
out("first vs second half of each round (round-relative tick from the")
out("rounds sidecar), pooled over runs.")
out()
out("| arm | first-half hit% | second-half hit% | late−early (pp) |")
out("|---|---:|---:|---:|")
for a in arms:
early = [0, 0]
late = [0, 0]
for o in spinner_opps:
adir = os.path.join(session_dir, o, a)
for run in discover_runs(adir):
evs = ga.parse_events(os.path.join(adir, f"run{run}.events.jsonl"))
rpath = os.path.join(adir, f"run{run}.jsonl.rounds.json")
starts = read_round_starts(rpath)
log_text = "".join(ga.read_lines(os.path.join(adir, f"run{run}.battle.log")))
counters = ga.parse_counters(log_text)
if counters is None or not starts:
continue
subj, other = ga.attribute_subject(evs, counters)
if subj is None:
continue
counts = {rr["round"]: rr["count"]
for rr in json.load(open(rpath))["rounds"]}
for e in evs:
rnd = e.get("round", 0)
st = starts.get(rnd)
if st is None:
continue
rel = e.get("tick", 0) - st
bucket = early if rel < counts.get(rnd, 0) / 2 else late
if e.get("type") == "fire" and e.get("owner") == subj:
bucket[1] += 1
elif e.get("type") == "hit" and e.get("owner") == subj:
bucket[0] += 1
if early[1] and late[1]:
eh = 100.0 * early[0] / early[1]
lh = 100.0 * late[0] / late[1]
out(f"| `{a}` | {eh:.2f}% | {lh:.2f}% | {lh - eh:+.2f} |")
else:
out(f"| `{a}` | n/a | n/a | n/a |")
out()
# ── verdict ─────────────────────────────────────────────────────────────
out("### The pre-registered reading")
out()
out("PRIMARY = dmg/run and round wins. Our per-round hit rate is the")
out("SECONDARY mechanism metric. An arm is BETTER only if a primary improves")
out("with sign-test p<0.05 while the other primary does not go down.")
out()
out("| arm | Δwins/run | Δdmg/run | Δour-hit (pp) | sign test dmg | verdict |")
out("|---|---:|---:|---:|---|---|")
for a in arms:
if a == ref:
continue
st = stats.get(a, {})
d = st.get("d_damage")
w = st.get("d_wins")
h = st.get("d_hit")
if not d or not w:
out(f"| `{a}` | n/a | n/a | n/a | n/a | n/a |")
continue
up_d = d["desc"]["mean"] > 0 and d["p_sign"] < 0.05
up_w = w["desc"]["mean"] > 0 and w["p_sign"] < 0.05
if (up_d and w["desc"]["mean"] >= 0) or (up_w and d["desc"]["mean"] >= 0):
verdict = "**BETTER**"
elif d["desc"]["mean"] < -d["desc"]["mde"] or w["desc"]["mean"] < -w["desc"]["mde"]:
verdict = "WORSE"
else:
verdict = "not distinguishable"
out(f"| `{a}` | {w['desc']['mean']:+.2f} | {d['desc']['mean']:+.2f} "
f"| {h['desc']['mean']:+.2f} | {d['pos']}/{d['pos'] + d['neg']} "
f"p={d['p_sign']:.4g} | {verdict} |")
out()
if report_path:
with open(report_path, "w") as fh:
fh.write("\n".join(lines) + "\n")
return 0
def wilcoxon_signed(deltas):
"""Two-sided Wilcoxon signed-rank normal approximation with tie correction."""
nz = [d for d in deltas if d != 0.0]
n = len(nz)
if n < 3:
return 1.0
order = sorted(range(n), key=lambda i: abs(nz[i]))
ranks = [0.0] * n
i = 0
while i < n:
j = i
while j + 1 < n and abs(nz[order[j + 1]]) == abs(nz[order[i]]):
j += 1
avg = (i + j) / 2.0 + 1.0
for k in range(i, j + 1):
ranks[order[k]] = avg
i = j + 1
w_plus = sum(ranks[i] for i in range(n) if nz[i] > 0)
mu = n * (n + 1) / 4.0
cnt = {}
for d in nz:
cnt[abs(d)] = cnt.get(abs(d), 0) + 1
tie = sum(c ** 3 - c for c in cnt.values())
sigma2 = n * (n + 1) * (2 * n + 1) / 24.0 - tie / 48.0
if sigma2 <= 0:
return 1.0
z = (w_plus - mu - 0.5 * (1 if w_plus > mu else -1)) / math.sqrt(sigma2)
return min(1.0, 2.0 * 0.5 * math.erfc(abs(z) / math.sqrt(2.0)))
if __name__ == "__main__":
sys.exit(main())