j124 spinner claim: add ConstantSpinner adversary + spinner A/B arms, panel, analyzer

This commit is contained in:
2026-09-26 04:05:36 +02:00
parent 2a98aba91b
commit 06da7abb0e
11 changed files with 739 additions and 0 deletions
@@ -0,0 +1,11 @@
{
"name": "ConstantSpinner",
"version": "0.1.0",
"authors": ["Davide Cappellini"],
"description": "True constant-turn spinner adversary — constant speed + constant body turn rate, head-on firing",
"homepage": "",
"countryCodes": ["IT"],
"gameTypes": ["classic", "1v1"],
"platform": "Nim",
"programmingLang": "Nim"
}
@@ -0,0 +1,11 @@
# Package
version = "0.1.0"
author = "Davide Cappellini"
description = "True constant-turn spinner adversary — constant speed + constant body turn rate"
license = "MIT"
srcDir = "src"
bin = @["ConstantSpinner"]
# Dependencies
requires "nim >= 2.0.0"
requires "robocode_tankroyale_botapi >= 1.0.7"
@@ -0,0 +1,3 @@
#!/bin/sh
cd "$(dirname "$0")"
exec ./out/ConstantSpinner 2>> /tmp/constantspinner_stderr.log
@@ -0,0 +1,2 @@
--path:"../../.."
switch("outdir", "out")
@@ -0,0 +1,55 @@
#!/usr/bin/env bash
# make_variant.sh — generate a spin-rate preset of the ConstantSpinner adversary.
#
# make_variant.sh <NAME> <TURN_RATE_DEG_PER_TICK> <SPEED> [OUTDIR]
#
# e.g.
# make_variant.sh ConstantSpinner_s3 3 5 /tmp/tr_spinners
# make_variant.sh ConstantSpinner_s6 6 5 /tmp/tr_spinners
#
# The variant dir is a normal Tank Royale bot directory named <NAME>: the runner
# matches the connecting bot's identity against <OUTDIR>/<NAME>/<NAME>.json, so
# the launcher exports BOT_JSON to make the binary handshake under <NAME>.
#
# The single committed ConstantSpinner binary is reused (no per-variant build);
# only SPIN_TURN_RATE / SPIN_SPEED differ.
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BIN="$HERE/out/ConstantSpinner"
NAME="${1:?usage: make_variant.sh <NAME> <TURN_RATE> <SPEED> [OUTDIR]}"
TURN="${2:?usage: make_variant.sh <NAME> <TURN_RATE> <SPEED> [OUTDIR]}"
SPEED="${3:?usage: make_variant.sh <NAME> <TURN_RATE> <SPEED> [OUTDIR]}"
OUTDIR="${4:-/tmp/tr_spinners}"
[[ -x "$BIN" ]] || { echo "ERROR: build first: nim c -d:release src/ConstantSpinner.nim" >&2; exit 1; }
DIR="$OUTDIR/$NAME"
mkdir -p "$DIR"
cat > "$DIR/$NAME.json" <<JSON
{
"name": "$NAME",
"version": "0.1.0",
"authors": ["Davide Cappellini"],
"description": "ConstantSpinner preset: constant turn ${TURN} deg/tick at speed ${SPEED}",
"homepage": "",
"countryCodes": ["IT"],
"gameTypes": ["classic", "1v1"],
"platform": "Nim",
"programmingLang": "Nim"
}
JSON
cat > "$DIR/$NAME.sh" <<SH
#!/bin/sh
cd "\$(dirname "\$0")"
export BOT_JSON="$DIR/$NAME.json"
export SPIN_TURN_RATE="$TURN"
export SPIN_SPEED="$SPEED"
exec "$BIN" 2>> /tmp/constantspinner_stderr.log
SH
chmod +x "$DIR/$NAME.sh"
echo "generated $DIR (turn=$TURN deg/tick, speed=$SPEED)"
@@ -0,0 +1,11 @@
{
"name": "ConstantSpinner",
"version": "0.1.0",
"authors": ["Davide Cappellini"],
"description": "True constant-turn spinner adversary — constant speed + constant body turn rate, head-on firing",
"homepage": "",
"countryCodes": ["IT"],
"gameTypes": ["classic", "1v1"],
"platform": "Nim",
"programmingLang": "Nim"
}
@@ -0,0 +1,85 @@
# ConstantSpinner — a TRUE constant-turn spinner.
#
# Purpose: the legacy roster has no constant-turn spinner. `SpinBot` (the sample
# bot) and the in-repo movers are periodic/oscillating, but this fixture is the
# degenerate case the owner's gun claim is really about: a body that moves at a
# CONSTANT forward speed while turning at a CONSTANT rate, forever. Its path is a
# perfect circle of radius v / omega, i.e. a single-frequency, low-entropy,
# perfectly periodic movement — the easiest possible target for a fast-adapting
# or pattern-matching gun.
#
# It is a MOVEMENT FIXTURE, not a fighter: the gun is plain head-on, fired at low
# power when the turret happens to be on target. No adaptation, no randomness, no
# wall logic (the server's wall collision is the only non-periodic perturbation).
#
# Env (read at round start, so one binary serves every spin rate):
# SPIN_TURN_RATE body turn rate, deg/tick (default 6.0)
# SPIN_SPEED forward speed, units/tick (default 5.0)
# SPIN_FIRE_POWER bullet power (default 1.0)
#
# The requested turn rate is clamped to the maximum reachable at the target
# speed (`MAX_TURN_RATE - 0.75 * speed`), so the ACTUAL turn rate stays exactly
# constant instead of being server-clamped while the bot accelerates or slides
# along a wall.
import std/[math, os, strutils]
import robocode_tankroyale_botapi
# The identity JSON. A generated spin-rate variant directory (see
# `make_variant.sh`) sets BOT_JSON so the bot handshakes under the variant's own
# name, which is what the Tank Royale runner matches against `<dir>/<dir>.json`.
const defaultBotJsonPath = currentSourcePath().parentDir / "ConstantSpinner.json"
proc envFloat(name: string, default: float): float =
let v = getEnv(name)
if v.len == 0: return default
try:
parseFloat(v)
except ValueError:
default
type ConstantSpinner = ref object of Bot
turnRate: float
speed: float
firePower: float
method onRoundStarted*(bot: ConstantSpinner, e: RoundStartedEvent) =
# Gun and radar stay independent of the (continuously turning) body.
setAdjustGunForBodyTurn(true)
setAdjustRadarForBodyTurn(true)
setAdjustRadarForGunTurn(true)
bot.speed = envFloat("SPIN_SPEED", 5.0)
bot.turnRate = envFloat("SPIN_TURN_RATE", 6.0)
bot.firePower = envFloat("SPIN_FIRE_POWER", 1.0)
# A turn rate above the speed-dependent maximum would be clamped by the
# server, making the rate non-constant during acceleration / wall slides.
let maxAtSpeed = MAX_TURN_RATE - 0.75 * abs(bot.speed)
if bot.turnRate > maxAtSpeed:
bot.turnRate = maxAtSpeed
if bot.turnRate < 0.0:
bot.turnRate = 0.0
method onScannedBot*(bot: ConstantSpinner, e: ScannedBotEvent) =
# Head-on: aim at where the enemy is right now, fire low power whenever the
# gun is cool. It is a movement fixture, so the shot goes wherever the turret
# happens to point; the radar sweep keeps scans coming.
let bearing = directionTo(getX(), getY(), e.x, e.y)
let gunDelta = normalizeRelativeAngle(bearing - getGunDirection())
setGunTurnRate(gunDelta.clamp(-MAX_GUN_TURN_RATE, MAX_GUN_TURN_RATE))
if getGunHeat() <= 0.0:
discard setFire(bot.firePower)
method run*(bot: ConstantSpinner) =
while isRunning():
# The whole movement: constant turn, constant speed. Nothing else.
setRadarTurnRate(45.0)
setTurnRate(bot.turnRate)
setTargetSpeed(bot.speed)
go()
when isMainModule:
var bot = ConstantSpinner()
let jsonPath = getEnv("BOT_JSON", defaultBotJsonPath)
start(bot, jsonPath)
+21
View File
@@ -0,0 +1,21 @@
# ─────────────────────────────────────────────────────────────────────────────
# arms_spinner.txt — the SPINNER-CLAIM A/B (j124, owner claim 1).
#
# 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."
#
# Three arms, identical frozen binary, MOVEMENT PINNED (`TR_MOVEMENT=strafe`) in
# every arm (same standard as the gun campaign). Only the gun rack differs.
# Format: name | ENV=value ENV=value | label
# ─────────────────────────────────────────────────────────────────────────────
# 1. REFERENCE — shipped onlyPattern rack, movement pinned.
pattern | TR_MOVEMENT=strafe | shipped onlyPattern rack (REFERENCE)
# 2. BitBrain learned-gain, decay memory (the config the owner most likely ran;
# the arm used in the 32-opponent gauntlet and both j121 batches).
bitbrain | TR_MOVEMENT=strafe TR_RACK_PATTERN=off TR_RACK_BITBRAIN=both TR_BITBRAIN_GAINS=1.0,1.25,1.5,2.0 TR_BITBRAIN_MEM=decay | BitBrain-only, learned gain, decay
# 3. BitBrain learned-gain, RETAINED memory (the "adapt across the whole battle"
# mode the owner described; not previously panel-tested).
bitbrain_ret | TR_MOVEMENT=strafe TR_RACK_PATTERN=off TR_RACK_BITBRAIN=both TR_BITBRAIN_GAINS=1.0,1.25,1.5,2.0 TR_BITBRAIN_MEM=retained | BitBrain-only, learned gain, retained
+21
View File
@@ -0,0 +1,21 @@
# ─────────────────────────────────────────────────────────────────────────────
# panel_spinner.txt — the SPINNER-CLAIM panel (j124).
#
# The legacy roster has no constant-turn spinner (docs/gauntlet_bitbrain_vs_pattern.md,
# docs/gun_campaign.md). This panel adds two: the new ConstantSpinner adversary at
# two spin rates, plus the sample SpinBot as the j121 "spinner" control, plus the
# same kind of regular/dodger controls the j117 gauntlet used, so the
# regular-vs-dodger comparison is directly readable.
#
# Format: <bot dir> | <style group> | <note>. `style` is INFERRED (robots.json /
# the bot name / the fixture's own constants) and used ONLY to explain a result.
# ─────────────────────────────────────────────────────────────────────────────
/tmp/tr_spinners/ConstantSpinner_s3 | spinner | TRUE constant-turn spinner: 3.0 deg/tick @ speed 5 (r=95.5 px, period 120 ticks)
/tmp/tr_spinners/ConstantSpinner_s6 | spinner | TRUE constant-turn spinner: 6.0 deg/tick @ speed 5 (r=47.7 px, period 60 ticks)
/home/davide/Projects/tank-royale/sample-bots/java/build/archive/SpinBot | spinner | sample SpinBot (max-turn circle-mover; the j121 control)
/tmp/tr_bots/WallAvoider | regular | legacy regular mover control (j117/j121)
/tmp/tr_bots/DiamondStealer | regular | legacy rammer / close brawler control (j117/j121)
/tmp/tr_bots/HawkOnFire | regular | legacy corner-camper control (j117/j121)
/tmp/tr_bots/Diamond | dodger | legacy wave-surfing dodger control (j117/j121)
/tmp/tr_bots/CassiusClay | dodger | legacy pattern/surf dodger control (j117/j121)
/tmp/tr_bots/GresSuffurd | dodger | legacy wave-surfing dodger control (j117/j121)
+519
View File
@@ -0,0 +1,519 @@
#!/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())