Files
SirRoboGarage/tools/ab/ab_mechanism.py
T
SirStone 48f38b80e7 TFIL heat-time + virtual pillar: live A/B (6 arms x 70 rounds) - neither change beats the pre-change mover
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.
2026-09-25 22:23:11 +02:00

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())