48f38b80e7
Runs the pre-registered A/B for the two movement changes in HEAD: the time-indexed bullet heat (TR_TFIL_HEAT_TIME,fca8993) and the removal of the invented virtual centre pillar (d0750ab). One frozen binary from HEAD vs real DrussGT: 6 arms x 10 runs x 7 rounds = 60 battles, 420 rounds, 0 failed. Judged on damage/run and ROUND WINS only (hit rate and hits-taken are context): hit rate would have inverted the verdict again - tau3 has the best pooled hit rate of all arms (11.56%) and the fewest round wins (20/70). RESULT (vs the reconstructed pre-change mover "old"): heat-time HURTS. tau3/tau5/tau9 lose 1.3-1.7 wins/run (p=0.0010-0.0125) and deal 22-38 less damage/run (p=0.004-0.047); tau15 is a wash on wins (p=0.64) and 22 damage/run lower (p=0.046). Nothing improves either metric. pillar removal does nothing measurable. old vs pillaoff: +5.7 damage/run (p=0.71), +0.5 wins/run (35 vs 30, p=0.43), 30.8 MORE damage taken/run without the pillar (p=0.040). The mechanism check proves the knob works (centre-box occupancy 0.09% -> 2.37%, p<0.0001; range 469 -> 443 px, p=0.0002), so this is a real behaviour change that buys nothing. At n=10 the pillar contrast is inside the MDE (33 damage/run, 1.2 wins/run), so this is not a proven regression. Flags that the shipped default (pillar removed) should be reverted to the TR_TFIL_PILLAR_ON behaviour; heat-time stays off. Adds tools/ab/arms_heat_pillar.txt and tools/ab/ab_mechanism.py (per-tick mechanism check: central-box occupancy, range distribution, live enemy-bullet proximity) plus the captured summary/report fixtures.
405 lines
15 KiB
Python
405 lines
15 KiB
Python
#!/usr/bin/env python3
|
|
"""ab_mechanism.py — MECHANISM CHECK for a movement A/B session (ab_run.sh).
|
|
|
|
python3 tools/ab/ab_mechanism.py <session_dir> [--reference ARM]
|
|
|
|
ab_analyze.py answers "did the arm win more?", this answers "did the arm
|
|
actually MOVE differently?" — because a win/loss difference is uninterpretable
|
|
if the knob never changed the trajectory.
|
|
|
|
Three measurements, all from the captured per-tick worldstate (both bots'
|
|
positions every tick) plus the fire/hit event sidecar:
|
|
|
|
1. CENTRAL-BOX OCCUPANCY. Fraction of ticks our tank spends inside the
|
|
144x144 px box centred on the arena centre (x 328-472, y 228-372) — the box
|
|
the removed virtual pillar covered. Spawns are bottom-left (us) / top (enemy),
|
|
never in the box, so any occupancy is genuine transit.
|
|
|
|
2. DISTANCE TO THE ENEMY. Per-tick shooter->target distance: mean, p10/median/
|
|
p90 and the fraction of ticks in 0-100 / 100-200 / 200-300 / 300-400 / 400+ px.
|
|
The flat heat model was measured to keep us at ~400 px; a change that works
|
|
should move this distribution.
|
|
|
|
3. BULLET PROXIMITY. Reconstruct every bullet from its fire event (x, y, dir,
|
|
power -> speed = 20-3p) and the resolving hit/hitwall/hitbullet event, and
|
|
take the per-tick distance from our tank to the NEAREST live bullet. Reported
|
|
as mean min-distance and the fraction of ticks with a bullet within 50/100/150
|
|
px. This is how much time we spend near live bullet paths.
|
|
|
|
All three are aggregated PER ROUND and then averaged over rounds (rounds differ
|
|
in length and in how early somebody dies, so a pooled mean would weight a long
|
|
lost round more than a short won one), and a two-sided permutation test on the
|
|
analysed runs' per-run means says whether an arm's behaviour differs from the
|
|
reference beyond run-to-run noise.
|
|
|
|
MEASURED = every number printed. INFERRED = the causal reading in the doc.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import itertools
|
|
import json
|
|
import math
|
|
import os
|
|
import random
|
|
import sys
|
|
|
|
ARENA_W, ARENA_H = 800.0, 600.0
|
|
BOX_X0, BOX_X1 = 328.0, 472.0 # 144x144 px centred on (400, 300)
|
|
BOX_Y0, BOX_Y1 = 228.0, 372.0
|
|
DIST_BANDS = [(0, 100), (100, 200), (200, 300), (300, 400), (400, 1e9)]
|
|
DIST_LABELS = ["0-100", "100-200", "200-300", "300-400", "400+"]
|
|
NEAR_PX = [50.0, 100.0, 150.0]
|
|
SPEED_A, SPEED_B = 20.0, 3.0
|
|
US_SIDE = "s" # adversary (ModularBot) = s* (see the capture)
|
|
MC_SEED = 0x5EED5EED
|
|
MC_DRAWS = 200_000
|
|
Z_ALPHA_POWER = 1.959963984540054 + 0.8416212335729143
|
|
|
|
|
|
def rows_of(path):
|
|
out = []
|
|
with open(path) as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
o = json.loads(line)
|
|
if "tick" in o:
|
|
out.append(o)
|
|
return out
|
|
|
|
|
|
def events_of(path):
|
|
return [json.loads(l) for l in open(path) if l.strip()]
|
|
|
|
|
|
def resolve_owner_side(rows_by_tick, rounds, events):
|
|
"""owner id -> 's' (us) / 'e' (DrussGT), by matching each fire event's
|
|
(x, y) to a capture row within +-8 ticks of `startTick + ev.tick`."""
|
|
start = {r["round"]: r["startTick"] for r in rounds}
|
|
votes = {}
|
|
for ev in events:
|
|
if ev.get("type") != "fire":
|
|
continue
|
|
g = start.get(ev["round"], 0) + ev["tick"]
|
|
for t in range(g - 8, g + 9):
|
|
r = rows_by_tick.get(t)
|
|
if r is None:
|
|
continue
|
|
for side in ("s", "e"):
|
|
if (abs(r[side + "x"] - ev["x"]) <= 0.02
|
|
and abs(r[side + "y"] - ev["y"]) <= 0.02):
|
|
d = votes.setdefault(ev["owner"], {"s": 0, "e": 0})
|
|
d[side] += 1
|
|
return {o: ("s" if d["s"] >= d["e"] else "e") for o, d in votes.items()}
|
|
|
|
|
|
def analyse_run(cap_path, ev_path, rj_path):
|
|
rows = rows_of(cap_path)
|
|
if not rows:
|
|
return None
|
|
by_tick = {r["tick"]: r for r in rows}
|
|
rounds = json.load(open(rj_path))["rounds"]
|
|
events = events_of(ev_path)
|
|
oside = resolve_owner_side(by_tick, rounds, events)
|
|
|
|
# bullet tracks: (round, owner, bullet) -> start tick / direction / speed.
|
|
# A live window is [t0, resolving event] (hit / hitwall / hitbullet); a
|
|
# bullet with no resolver (round ended mid-flight) is bounded at t0 + 400.
|
|
start = {r["round"]: r["startTick"] for r in rounds}
|
|
bullets = {}
|
|
for ev in events:
|
|
key = (ev["round"], ev.get("owner"), ev.get("bullet"))
|
|
if ev.get("type") == "fire":
|
|
p = ev["power"]
|
|
v = SPEED_A - SPEED_B * p
|
|
th = math.radians(ev["dir"])
|
|
bullets[key] = {
|
|
"t0": start[ev["round"]] + ev["tick"],
|
|
"x": ev["x"], "y": ev["y"],
|
|
"ux": math.cos(th), "uy": math.sin(th), "v": v,
|
|
"side": oside.get(ev["owner"]), "end": None,
|
|
}
|
|
elif ev.get("type") in ("hit", "hitwall", "hitbullet"):
|
|
if key in bullets:
|
|
bullets[key]["end"] = start[ev["round"]] + ev["tick"]
|
|
|
|
# per-round accumulators
|
|
per_round = []
|
|
for rd in rounds:
|
|
a, n = rd["startTick"], rd["count"]
|
|
ticks = [t for t in range(a, a + n) if t in by_tick]
|
|
if not ticks:
|
|
continue
|
|
box = near = near_e = 0
|
|
dsum = 0.0
|
|
mindist_sum = 0.0
|
|
mindist_e_sum = 0.0
|
|
band_cnt = [0] * len(DIST_BANDS)
|
|
near_cnt = [0] * len(NEAR_PX)
|
|
near_e_cnt = [0] * len(NEAR_PX)
|
|
live = [(k, b) for k, b in bullets.items() if k[0] == rd["round"]]
|
|
for t in ticks:
|
|
r = by_tick[t]
|
|
sx, sy = r[US_SIDE + "x"], r[US_SIDE + "y"]
|
|
ox, oy = r["ex"], r["ey"]
|
|
if BOX_X0 <= sx <= BOX_X1 and BOX_Y0 <= sy <= BOX_Y1:
|
|
box += 1
|
|
d = math.hypot(ox - sx, oy - sy)
|
|
dsum += d
|
|
for i, (lo, hi) in enumerate(DIST_BANDS):
|
|
if lo <= d < hi:
|
|
band_cnt[i] += 1
|
|
break
|
|
md = None
|
|
mde_ = None
|
|
for _k, b in live:
|
|
if b["t0"] is None or t < b["t0"]:
|
|
continue
|
|
te = b["end"] if b["end"] is not None else b["t0"] + 400
|
|
if t > te:
|
|
continue
|
|
dt = t - b["t0"]
|
|
bx = b["x"] + b["v"] * dt * b["ux"]
|
|
by = b["y"] + b["v"] * dt * b["uy"]
|
|
if not (0.0 <= bx <= ARENA_W and 0.0 <= by <= ARENA_H):
|
|
continue
|
|
dd = math.hypot(bx - sx, by - sy)
|
|
if md is None or dd < md:
|
|
md = dd
|
|
if b["side"] == "e" and (mde_ is None or dd < mde_):
|
|
mde_ = dd
|
|
if md is not None:
|
|
near += 1
|
|
mindist_sum += md
|
|
for i, x in enumerate(NEAR_PX):
|
|
if md <= x:
|
|
near_cnt[i] += 1
|
|
if mde_ is not None:
|
|
near_e += 1
|
|
mindist_e_sum += mde_
|
|
for i, x in enumerate(NEAR_PX):
|
|
if mde_ <= x:
|
|
near_e_cnt[i] += 1
|
|
nt = len(ticks)
|
|
per_round.append({
|
|
"round": rd["round"], "ticks": nt,
|
|
"box_frac": box / nt,
|
|
"mean_dist": dsum / nt,
|
|
"dist_bands": [c / nt for c in band_cnt],
|
|
"mean_minbullet": (mindist_sum / near if near else float("nan")),
|
|
"near_frac": [c / nt for c in near_cnt],
|
|
"mean_minbullet_enemy": (mindist_e_sum / near_e if near_e
|
|
else float("nan")),
|
|
"near_enemy_frac": [c / nt for c in near_e_cnt],
|
|
"ticks_with_bullet_frac": near / nt,
|
|
"ticks_with_enemy_bullet_frac": near_e / nt,
|
|
})
|
|
return {"cap": cap_path, "per_round": per_round, "owner_side": oside,
|
|
"n_rounds": len(per_round)}
|
|
|
|
|
|
def _mean(xs):
|
|
return sum(xs) / len(xs) if xs else float("nan")
|
|
|
|
|
|
def _sd(xs):
|
|
if len(xs) < 2:
|
|
return 0.0
|
|
m = _mean(xs)
|
|
return math.sqrt(sum((v - m) ** 2 for v in xs) / (len(xs) - 1))
|
|
|
|
|
|
def perm_test(xa, xb):
|
|
na, nb = len(xa), len(xb)
|
|
if na == 0 or nb == 0:
|
|
return None
|
|
obs = abs(_mean(xa) - _mean(xb))
|
|
pooled = list(xa) + list(xb)
|
|
n = na + nb
|
|
total = sum(pooled)
|
|
ncomb = math.comb(n, na)
|
|
if ncomb <= 20_000_000:
|
|
cnt = 0
|
|
for combo in itertools.combinations(range(n), na):
|
|
sa = sum(pooled[i] for i in combo)
|
|
if abs(sa / na - (total - sa) / nb) >= obs - 1e-9:
|
|
cnt += 1
|
|
return obs, cnt / ncomb, "exact"
|
|
rng = random.Random(MC_SEED)
|
|
cnt = 0
|
|
for _ in range(MC_DRAWS):
|
|
sa = sum(pooled[i] for i in rng.sample(range(n), na))
|
|
if abs(sa / na - (total - sa) / nb) >= obs - 1e-9:
|
|
cnt += 1
|
|
return obs, (cnt + 1) / (MC_DRAWS + 1), f"MC/B={MC_DRAWS:,}"
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("session_dir")
|
|
ap.add_argument("--reference", default=None)
|
|
args = ap.parse_args()
|
|
root = args.session_dir
|
|
arms = [d for d in sorted(os.listdir(root))
|
|
if os.path.isdir(os.path.join(root, d)) and d != "frozen"
|
|
and not d.startswith(".") and any(
|
|
f.endswith(".jsonl") and not f.endswith(".events.jsonl")
|
|
for f in os.listdir(os.path.join(root, d)))]
|
|
ref = args.reference or arms[0]
|
|
data = {}
|
|
for arm in arms:
|
|
d = os.path.join(root, arm)
|
|
runs = []
|
|
for fn in sorted(os.listdir(d)):
|
|
if not fn.endswith(".jsonl") or fn.endswith(".events.jsonl"):
|
|
continue
|
|
cap = os.path.join(d, fn)
|
|
ev, rj = cap[:-6] + ".events.jsonl", cap + ".rounds.json"
|
|
if os.path.exists(ev) and os.path.exists(rj):
|
|
a = analyse_run(cap, ev, rj)
|
|
if a:
|
|
runs.append(a)
|
|
data[arm] = runs
|
|
|
|
print("=" * 100)
|
|
print("MECHANISM CHECK — did the arm actually move differently?")
|
|
print(f"session {root} arms {', '.join(arms)} reference {ref}")
|
|
print("box = 144x144 px centred on the arena centre (x 328-472, y 228-372)")
|
|
print("=" * 100)
|
|
|
|
metrics = [
|
|
("box_frac", "central-box occupancy", 100.0, "%"),
|
|
("mean_dist", "mean dist to enemy", 1.0, "px"),
|
|
("near_enemy_frac[1]", "ticks enemy bullet<=100px", 100.0, "%"),
|
|
("mean_minbullet_enemy", "mean dist nearest enemy bullet", 1.0, "px"),
|
|
("near_frac[1]", "ticks ANY bullet<=100px", 100.0, "%"),
|
|
]
|
|
|
|
def per_run(arm, key, band=None):
|
|
out = []
|
|
for a in data[arm]:
|
|
vals = []
|
|
for pr in a["per_round"]:
|
|
if key.startswith("near_enemy_frac["):
|
|
vals.append(pr["near_enemy_frac"][int(key[16])])
|
|
elif key.startswith("near_frac["):
|
|
vals.append(pr["near_frac"][int(key[10])])
|
|
else:
|
|
vals.append(pr[key])
|
|
out.append(_mean(vals))
|
|
return out
|
|
|
|
print("\nPER-ARM MECHANISM (mean of the per-round values, then over runs)")
|
|
hdr = (f"{'arm':<10} {'runs':>4} {'box%':>7} {'dist px':>8} "
|
|
f"{'p10':>6} {'med':>6} {'p90':>6} {'enB<=100px%':>13} "
|
|
f"{'enBmin px':>11}")
|
|
print(hdr)
|
|
print("-" * len(hdr))
|
|
for arm in arms:
|
|
runs = data[arm]
|
|
if not runs:
|
|
print(f"{arm:<10} {0:>4} (no runs)")
|
|
continue
|
|
boxf = [_mean([pr["box_frac"] for pr in a["per_round"]]) for a in runs]
|
|
md = [_mean([pr["mean_dist"] for pr in a["per_round"]]) for a in runs]
|
|
nb = [_mean([pr["near_enemy_frac"][1] for pr in a["per_round"]])
|
|
for a in runs]
|
|
mb = [_mean([pr["mean_minbullet_enemy"] for pr in a["per_round"]])
|
|
for a in runs]
|
|
allmd = sorted(v for a in runs for v in
|
|
[pr["mean_dist"] for pr in a["per_round"]])
|
|
p10 = allmd[int(0.10 * (len(allmd) - 1))]
|
|
med = allmd[len(allmd) // 2]
|
|
p90 = allmd[int(0.90 * (len(allmd) - 1))]
|
|
print(f"{arm:<10} {len(runs):>4} {100*_mean(boxf):>7.2f} "
|
|
f"{_mean(md):>8.1f} {p10:>6.0f} {med:>6.0f} {p90:>6.0f} "
|
|
f"{100*_mean(nb):>13.2f} {_mean(mb):>11.1f}")
|
|
|
|
print("\nDISTANCE-TO-ENEMY BANDS (fraction of ticks, mean over rounds/runs, %)")
|
|
hdr = f"{'arm':<10}" + "".join(f"{lb:>10}" for lb in DIST_LABELS)
|
|
print(hdr)
|
|
print("-" * len(hdr))
|
|
for arm in arms:
|
|
runs = data[arm]
|
|
if not runs:
|
|
continue
|
|
cells = []
|
|
for i in range(len(DIST_BANDS)):
|
|
v = _mean([_mean([pr["dist_bands"][i] for pr in a["per_round"]])
|
|
for a in runs])
|
|
cells.append(f"{100*v:>10.2f}")
|
|
print(f"{arm:<10}" + "".join(cells))
|
|
|
|
print("\nENEMY-BULLET PROXIMITY (nearest LIVE ENEMY bullet; our own bullets are "
|
|
"excluded because a bullet is born at its own tank, so 'any bullet' "
|
|
"is dominated by our just-fired shot)")
|
|
hdr = (f"{'arm':<10}" + "".join(f"{('<=%gpx' % x):>9}" for x in NEAR_PX)
|
|
+ f"{'min px':>9}{'enemy%':>9}")
|
|
print(hdr)
|
|
print("-" * len(hdr))
|
|
for arm in arms:
|
|
runs = data[arm]
|
|
if not runs:
|
|
continue
|
|
cells = []
|
|
for i in range(len(NEAR_PX)):
|
|
v = _mean([_mean([pr["near_enemy_frac"][i] for pr in a["per_round"]])
|
|
for a in runs])
|
|
cells.append(f"{100*v:>9.2f}")
|
|
mb = _mean([_mean([pr["mean_minbullet_enemy"] for pr in a["per_round"]])
|
|
for a in runs])
|
|
anyv = _mean([_mean([pr["ticks_with_enemy_bullet_frac"]
|
|
for pr in a["per_round"]]) for a in runs])
|
|
print(f"{arm:<10}" + "".join(cells) + f"{mb:>9.1f}{100*anyv:>9.2f}")
|
|
|
|
print("\nANY-BULLET PROXIMITY (per the task text: nearest live bullet, ours "
|
|
"included; context only)")
|
|
hdr = (f"{'arm':<10}" + "".join(f"{('<=%gpx' % x):>9}" for x in NEAR_PX)
|
|
+ f"{'min px':>9}{'any%':>9}")
|
|
print(hdr)
|
|
print("-" * len(hdr))
|
|
for arm in arms:
|
|
runs = data[arm]
|
|
if not runs:
|
|
continue
|
|
cells = []
|
|
for i in range(len(NEAR_PX)):
|
|
v = _mean([_mean([pr["near_frac"][i] for pr in a["per_round"]])
|
|
for a in runs])
|
|
cells.append(f"{100*v:>9.2f}")
|
|
anyv = _mean([_mean([pr["ticks_with_bullet_frac"] for pr in a["per_round"]])
|
|
for a in runs])
|
|
mb = _mean([_mean([pr["mean_minbullet"] for pr in a["per_round"]])
|
|
for a in runs])
|
|
print(f"{arm:<10}" + "".join(cells) + f"{mb:>9.1f}{100*anyv:>9.2f}")
|
|
|
|
print(f"\nPER-RUN MECHANISM vs `{ref}` (two-sided permutation on per-run means; "
|
|
f"exact when C(n,na)<=2e7)")
|
|
print(f"{'metric':<24} {'arm':<10} {'delta':>9} {'p':>9} {'method':<12} "
|
|
f"{'MDE':>9}")
|
|
print("-" * 78)
|
|
for key, label, scale, unit in metrics:
|
|
xa = per_run(ref, key)
|
|
if len(xa) < 2:
|
|
continue
|
|
sd = _sd(xa)
|
|
mde = Z_ALPHA_POWER * sd * math.sqrt(2.0 / len(xa)) * scale
|
|
for arm in arms:
|
|
if arm == ref:
|
|
continue
|
|
xb = per_run(arm, key)
|
|
res = perm_test(xa, xb)
|
|
if res is None:
|
|
continue
|
|
obs, p, method = res
|
|
signed = (_mean(xb) - _mean(xa)) * scale
|
|
print(f"{label:<24} {arm:<10} {signed:>+9.2f} {p:>9.4f} "
|
|
f"{method:<12} {mde:>8.2f}{unit}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|