Files
SirRoboGarage/common_libs/tests/measure_cornering_ram.py
T
SirStone 391318a7bd cornering/ramming premise REFUTED on three independent measurements
Hypothesis (user's): pushing an enemy toward a wall makes it predictable, which
both enables a ram and raises our gun hit rate. Measured offline over the
committed DrussGT fixtures using REAL server event attribution (an events
sidecar survived: tools/robocode_shim/evidence/tr_drussgt_vs_modularbot.events.json,
2534 fires / 229 hits; per-round event tick joins the fixture global tick at
global = round.startTick + tick - 2, verified exact over all 2534 fires).

M1 - cornering does NOT raise hit rate.
  REAL attribution, all 1134 ModularBot shots, bucketed by DrussGT's distance
  to the nearest wall at fire time:
    <=30px   69 shots   5 hits   7.25%
    30-60   336        18        5.36%
    60-120  555        26        4.68%
    120-250 172        11        6.40%
    >250      2         0        0.00%
    TOTAL  1134        60        5.29%
  Adjacent(<=60) 5.68% vs Open(>60) 5.08%, z=+0.435 -> NOT significant.
  Per-round ranges fully overlap (adjacent 0-18.2%, open 0-9.6%).
  Virtual per-gun within-gun check: most guns neutral-to-negative; only DecayGF
  favours it. Across all 10 fixtures every one of 12 guns scores LOWER adjacent
  (range-confounded, directional only).

M2 - a wall-adjacent enemy is LESS predictable, not more.
  30-degree tolerance, uniform chance 16.7%, adjacent vs open:
    keep-direction (1 tick)      93.35% vs 95.88%   z=-13.82
    turn-persistence             87.6%  vs 90.9%
    constant-velocity err H=10   45.9%  vs 25.7%    (1.8x MORE deviation)
    "move away from nearest wall" 1.1%  vs 8.4%
    "move toward centre"          0.4%  vs 3.0%
  Wall-adjacent DrussGT reverses more and deviates from constant-velocity ~1.8x
  more. It does NOT flee the wall - it surfs perpendicular. Base rate of
  wall-adjacency: 20.2% of moving ticks.

M3 - the ram is a near-zero-frequency opportunity against DrussGT.
  Strict contact (<=36px): ZERO ticks in all 10 fixtures. Closest global
  approach 39.1px. In the primary fixture (ModularBot vs DrussGT) the closest
  approach was 118.7px - 0 ticks <=80px, 0 near-contact episodes, and ZERO ram
  collisions in 15 rounds. Real ram collisions anywhere in the corpus: 2 total
  (drussgt_vs_ramfire 1/20 rounds, tr_drussgt_vs_crazy 1/10), each a ONE-SHOT
  0.6 energy to both bots, no sustained multi-tick stream.

CORRECTION TO AN EARLIER CLAIM: ram damage is 0.6 per CONTACT EVENT, not
0.6/turn sustained. The efficiency ratio (0.6 damage for 0.6 energy taken,
scored 2.0/pt) still beats firing, but the magnitude is 0.6 vs 16 for a p=3
bullet hit, and against DrussGT the frequency is zero.

CAVEAT (from the analysis): the fixtures capture DrussGT's NATURAL wall
behaviour, not an enemy being actively pushed into a corner by a rammer, so the
exact scenario is not directly represented. But M3 shows we never get close
enough to push in the first place - ModularBot's closest approach in 15 rounds
was 118.7px, so the <50px ram trigger has never fired against this adversary.

Adds two reusable offline instruments:
- measure_cornering_guns.nim (replays a fixture through the real VirtualTracker,
  attributing each resolved virtual bullet to its fire-tick wall bucket)
- measure_cornering_ram.py (real-event join, predictability, ram base rate)
Neither edits offline_range.nim; the 12/12 deterministic-gun contract is
untouched and was not re-run (it requires a live battle).
2026-09-21 22:49:56 +02:00

355 lines
14 KiB
Python

#!/usr/bin/env python3
"""OFFLINE MEASUREMENT ONLY - no live battles, no bot rebuild.
Measurements for the "cornered enemy is predictable -> ram + better guns"
hypothesis, over the committed jsonl fixtures in tools/fixtures/.
M1 real hit attribution (events sidecar) bucketed by enemy wall distance
M2 movement predictability, wall-adjacent vs open (1-tick and H-tick)
M3 ram / body-contact opportunity base rate, episodes, energy loss
Run:
python3 common_libs/tests/measure_cornering_ram.py
"""
import json, math, os, statistics
from collections import defaultdict, Counter
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
FIX = os.path.join(ROOT, "tools", "fixtures")
META = os.path.join(FIX, "drussgt_meta")
EVENTS = os.path.join(ROOT, "tools", "robocode_shim", "evidence",
"tr_drussgt_vs_modularbot.events.json")
BOT_RADIUS = 18.0
CONTACT = 2 * BOT_RADIUS # 36 px centre-to-centre
RAM_DAMAGE = 0.6 # Robocode/Tank Royale per-collision energy loss to each bot
WALL_BUCKETS = [("<=30", 0, 30), ("30-60", 30, 60), ("60-120", 60, 120),
("120-250", 120, 250), (">250", 250, 1e9)]
ADJ = ("<=30", "30-60")
OPEN = ("60-120", "120-250", ">250")
def load_fixture(path):
states, meta = [], {}
for line in open(path):
line = line.strip()
if not line:
continue
d = json.loads(line)
if "meta" in d:
meta = d["meta"]
continue
if "end" in d:
continue
states.append(d)
return meta, states
def load_rounds(path, n):
p = os.path.join(META, os.path.basename(path) + ".rounds.json")
if not os.path.exists(p):
return [{"round": 1, "startTick": 0, "count": n}]
return json.load(open(p))["rounds"]
def wall_dist(x, y, w=800.0, h=600.0):
return min(x, y, w - x, h - y)
def bucket_name(d):
for name, lo, hi in WALL_BUCKETS:
if lo < d <= hi:
return name
return "<=30"
def z2prop(h1, n1, h2, n2):
if n1 == 0 or n2 == 0:
return 0.0
p1, p2 = h1 / n1, h2 / n2
p = (h1 + h2) / (n1 + n2)
se = math.sqrt(p * (1 - p) * (1 / n1 + 1 / n2))
return (p1 - p2) / se if se > 0 else 0.0
def ang_diff(a, b):
d = (a - b) % 360.0
return min(d, 360.0 - d)
def norm180(a):
a = (a + 180.0) % 360.0 - 180.0
return a
# ────────────────────────────────────────────────────────────── M1 real ──
def m1_real():
fxpath = os.path.join(FIX, "tr_drussgt_vs_modularbot.jsonl")
meta, states = load_fixture(fxpath)
rounds = load_rounds(fxpath, len(states))
rstarts = {r["round"]: r["startTick"] for r in rounds}
events = [json.loads(l) for l in open(EVENTS)]
fires = [e for e in events if e["type"] == "fire"]
hits = {(e["round"], e["bullet"]) for e in events if e["type"] == "hit"}
shots, hitc = defaultdict(int), defaultdict(int)
perround = defaultdict(lambda: defaultdict(lambda: [0, 0]))
st = ht = 0
for e in fires:
if e["owner"] != 2: # 2 = ModularBot (s*), 1 = DrussGT (e*)
continue
g = rstarts[e["round"]] + e["tick"] - 2
s = states[g]
b = bucket_name(wall_dist(s["ex"], s["ey"]))
ish = (e["round"], e["bullet"]) in hits
shots[b] += 1
hitc[b] += ish
st += 1
ht += ish
perround[e["round"]][b][0] += ish
perround[e["round"]][b][1] += 1
print("== M1a REAL pooled hit attribution (ModularBot shots, all guns) ==")
print(f" {'bucket':>8} {'shots':>6} {'hits':>5} {'rate':>7}")
for name, _, _ in WALL_BUCKETS:
s, h = shots[name], hitc[name]
print(f" {name:>8} {s:>6} {h:>5} {h/s*100 if s else 0:>6.2f}%")
print(f" {'TOTAL':>8} {st:>6} {ht:>5} {ht/st*100:>6.2f}%")
a = sum(shots[n] for n in ADJ); ah = sum(hitc[n] for n in ADJ)
o = sum(shots[n] for n in OPEN); oh = sum(hitc[n] for n in OPEN)
print(f" ADJACENT(<=60) {ah}/{a}={ah/a*100:.2f}% OPEN(>60) {oh}/{o}={oh/o*100:.2f}%"
f" z={z2prop(ah,a,oh,o):.3f}")
ar = [sum(perround[r][b][0] for b in ADJ) / max(1, sum(perround[r][b][1] for b in ADJ)) * 100
for r in range(1, 16) if sum(perround[r][b][1] for b in ADJ)]
orr = [sum(perround[r][b][0] for b in OPEN) / max(1, sum(perround[r][b][1] for b in OPEN)) * 100
for r in range(1, 16) if sum(perround[r][b][1] for b in OPEN)]
print(f" per-round ADJ rate range {min(ar):.1f}-{max(ar):.1f}% (mean {statistics.mean(ar):.2f})")
print(f" per-round OPEN rate range {min(orr):.1f}-{max(orr):.1f}% (mean {statistics.mean(orr):.2f})")
print(" OVERLAP: per-round ranges fully overlap -> not separated by repo convention")
print()
# ────────────────────────────────────────────────────────────── M2 ──
def motion_dir(s):
return s["eh"] if s["es"] >= 0 else s["eh"] + 180.0
def nearest_wall_dir(s, w=800.0, h=600.0):
x, y = s["ex"], s["ey"]
dl, dr, db, dt = x, w - x, y, h - y
m = min(dl, dr, db, dt)
if m == dl:
return 0.0
if m == dr:
return 180.0
if m == db:
return 90.0
return 270.0
def centre_dir(s, w=800.0, h=600.0):
return math.degrees(math.atan2(h / 2 - s["ey"], w / 2 - s["ex"])) % 360.0
def m2_predictability():
files = [f for f in sorted(os.listdir(FIX)) if f.endswith(".jsonl") and "drussgt" in f]
tol = 30.0
HOR = [1, 5, 10, 15]
def newacc():
return dict(n=0, keep=0, turn=0, turn_n=0, rev=0, rev_n=0,
absdh=0.0, absdh_n=0,
away=0, centre=0, vh_err=defaultdict(list), vh_n=defaultdict(int))
acc = defaultdict(newacc)
per_file_adj = defaultdict(lambda: defaultdict(lambda: [0, 0])) # fixture -> bucket -> keep
for fn in files:
path = os.path.join(FIX, fn)
_, states = load_fixture(path)
rounds = load_rounds(path, len(states))
starts = set(r["startTick"] for r in rounds)
for i in range(1, len(states)):
if i in starts:
continue
s0, s1 = states[i - 1], states[i]
b = bucket_name(wall_dist(s0["ex"], s0["ey"]))
a = acc[b]
dh = norm180(s1["eh"] - s0["eh"])
a["absdh"] += abs(dh); a["absdh_n"] += 1
if abs(dh) >= 1.0 and i + 1 < len(states) and (i + 1) not in starts:
dh2 = norm180(states[i + 1]["eh"] - s1["eh"])
if abs(dh2) >= 1.0:
a["turn_n"] += 1
a["turn"] += (dh > 0) == (dh2 > 0)
# 1-tick direction prediction
if i + 1 < len(states) and (i + 1) not in starts:
dx = states[i + 1]["ex"] - s1["ex"]
dy = states[i + 1]["ey"] - s1["ey"]
if math.hypot(dx, dy) >= 0.5:
actual = math.degrees(math.atan2(dy, dx)) % 360.0
a["n"] += 1
a["keep"] += ang_diff(actual, motion_dir(s0)) <= tol
a["away"] += ang_diff(actual, nearest_wall_dir(s0)) <= tol
a["centre"] += ang_diff(actual, centre_dir(s0)) <= tol
per_file_adj[fn][b][1] += 1
per_file_adj[fn][b][0] += ang_diff(actual, motion_dir(s0)) <= tol
# velocity-sign reversals (both ticks have a definite sign)
if s0["es"] != 0.0 and s1["es"] != 0.0:
a["rev_n"] += 1
a["rev"] += (s0["es"] > 0) != (s1["es"] > 0)
# H-tick constant-velocity endpoint error
for H in HOR[1:]:
j = i + H
if j >= len(states) or j in starts:
continue
vx = s0["es"] * math.cos(math.radians(s0["eh"]))
vy = s0["es"] * math.sin(math.radians(s0["eh"]))
px, py = s0["ex"] + H * vx, s0["ey"] + H * vy
err = math.hypot(px - states[j]["ex"], py - states[j]["ey"])
travel = max(1e-6, H * abs(s0["es"]))
a["vh_err"][H].append(err / travel)
a["vh_n"][H] += 1
print("== M2 predictability of DrussGT motion, by wall distance at prediction time ==")
print(f" tuned predictors: keep-direction (1 tick, {tol:.0f} deg tol, chance {2*tol/360*100:.1f}%),")
print(" turn-persistence (sign of heading change), reversal rate, H-tick constant-velocity error")
print(f" {'bucket':>8} {'ticks':>7} {'keep':>7} {'turn':>7} {'rev':>7} {'|dh|':>6} "
+ " ".join(f"{'cv'+str(H):>7}" for H in HOR[1:]))
for name, _, _ in WALL_BUCKETS:
a = acc[name]
if a["n"] == 0:
continue
cells = []
for H in HOR[1:]:
v = a["vh_err"][H]
cells.append(f"{statistics.median(v)*100:>6.1f}%" if v else " -")
print(f" {name:>8} {a['n']:>7} {a['keep']/a['n']*100:>6.1f}% "
f"{(a['turn']/a['turn_n']*100 if a['turn_n'] else 0):>6.1f}% "
f"{(a['rev']/a['rev_n']*100 if a['rev_n'] else 0):>6.1f}% "
f"{a['absdh']/a['absdh_n']:>5.2f}d " + " ".join(cells))
# binary
def binc(keys):
t = newacc()
for k in keys:
for kk, vv in acc[k].items():
if kk == "vh_err":
for H, lst in vv.items():
t["vh_err"][H] += lst
elif kk == "vh_n":
for H, nv in vv.items():
t["vh_n"][H] += nv
else:
t[kk] += vv
return t
adj, opn = binc(ADJ), binc(OPEN)
base = adj["n"] + opn["n"]
print()
print(f" ADJACENT(<=60) = {adj['n']}/{base} ticks ({adj['n']/base*100:.1f}% of moving ticks)")
print(f" {'metric':>26} {'adjacent':>10} {'open':>10} {'delta':>9}")
for key, lab in (("keep", "keep-direction 1t"), ("away", "away-from-wall 1t"),
("centre", "toward-centre 1t")):
ra, ro = adj[key] / adj["n"] * 100, opn[key] / opn["n"] * 100
print(f" {lab:>26} {ra:>9.1f}% {ro:>9.1f}% {ra-ro:>+8.1f}pp")
ra = adj["turn"] / adj["turn_n"] * 100 if adj["turn_n"] else 0
ro = opn["turn"] / opn["turn_n"] * 100 if opn["turn_n"] else 0
print(f" {'turn-persistence':>26} {ra:>9.1f}% {ro:>9.1f}% {ra-ro:>+8.1f}pp")
ra = adj["rev"] / adj["rev_n"] * 100 if adj["rev_n"] else 0
ro = opn["rev"] / opn["rev_n"] * 100 if opn["rev_n"] else 0
print(f" {'velocity-reversal-rate':>26} {ra:>9.1f}% {ro:>9.1f}% {ra-ro:>+8.1f}pp")
for H in HOR[1:]:
ma = statistics.median(adj["vh_err"][H]); mo = statistics.median(opn["vh_err"][H])
print(f" {'cv-error H=%d (median %%tr)'%H:>26} {ma*100:>9.1f}% {mo*100:>9.1f}% {(ma-mo)*100:>+8.1f}pp")
# per-fixture consistency (paired sign test on keep-direction)
print()
print(" per-fixture keep-direction (1t) accuracy, adjacent vs open:")
wins = 0; nf = 0
for fn in files:
f = per_file_adj[fn]
na = sum(f[k][1] for k in ADJ); ha = sum(f[k][0] for k in ADJ)
no = sum(f[k][1] for k in OPEN); ho = sum(f[k][0] for k in OPEN)
if na == 0 or no == 0:
continue
nf += 1
ra, ro = ha / na * 100, ho / no * 100
wins += ra > ro
print(f" {fn:<38} adj {ra:5.1f}% open {ro:5.1f}% {'adj higher' if ra>ro else 'open higher'}")
print(f" fixtures where adjacent > open: {wins}/{nf}")
# ────────────────────────────────────────────────────────────── M3 ──
def near_episodes(flags, start, end):
eps = []
j = start
while j < end:
if flags[j]:
k = j
while k < end and flags[k]:
k += 1
eps.append((j, k))
j = k
else:
j += 1
return eps
def m3_ram():
files = [f for f in sorted(os.listdir(FIX)) if f.endswith(".jsonl") and "drussgt" in f]
print("== M3 ram / body-contact opportunity base rate ==")
print(f" strict contact = centre distance <= {CONTACT:.0f}px (2 x {BOT_RADIUS:.0f}px radius);")
print(f" ram collision signature = both bots lose ~{RAM_DAMAGE} energy on the same tick")
print(f" {'fixture':<36} {'ticks':>6} {'min':>6} {'<=36':>5} {'<=50':>5} {'<=60':>5} "
f"{'<=80':>5} {'eps60':>5} {'maxep':>5} {'ram':>4} {'dE/ram':>8}")
primary = None
for fn in files:
path = os.path.join(FIX, fn)
_, states = load_fixture(path)
rounds = load_rounds(path, len(states))
ivals = [(r["startTick"], r["startTick"] + r["count"]) for r in rounds]
dist = [math.hypot(s["ex"] - s["sx"], s["ey"] - s["sy"]) for s in states]
for name, thr in (("<=36", CONTACT), ("<=50", 50), ("<=60", 60), ("<=80", 80)):
pass
cnt = {t: sum(1 for d in dist if d <= t) for t in (36, 50, 60, 80)}
# near-contact episodes at <=60 inside rounds
eps = []
for a, b in ivals:
flags = [dist[i] <= 60 for i in range(len(states))]
eps += near_episodes(flags, a, b)
# ram collisions: distance <=60 and both energies drop by ~equal amount
rams = []
for a, b in ivals:
for i in range(max(a, 1), b):
de = states[i]["ee"] - states[i - 1]["ee"]
ds = states[i]["se"] - states[i - 1]["se"]
if (dist[i] <= 60 and de <= -0.4 and ds <= -0.4
and abs(de - ds) <= 0.15):
rams.append((i, de, ds))
dE = sum((de + ds) / 2 for _, de, ds in rams)
dEcol = f"{dE:>+8.2f}"
print(f" {fn:<36} {len(states):>6} {min(dist):>6.1f} {cnt[36]:>5} {cnt[50]:>5} "
f"{cnt[60]:>5} {cnt[80]:>5} {len(eps):>5} "
f"{(max(k-j for j,k in eps) if eps else 0):>5} {len(rams):>4} "
f"{dEcol}")
if "tr_drussgt_vs_modularbot.jsonl" == fn:
primary = (min(dist), cnt, len(eps), len(rams))
print()
print(" PRIMARY fixture tr_drussgt_vs_modularbot (ModularBot vs the real DrussGT):")
if primary:
mind, cnt, neps, nram = primary
print(f" closest approach = {mind:.1f}px (never within {CONTACT:.0f}px);"
f" ticks <=50/60/80 = {cnt[50]}/{cnt[60]}/{cnt[80]};"
f" near-contact episodes = {neps}; ram collisions = {nram}")
print()
def main():
m1_real()
m2_predictability()
m3_ram()
if __name__ == "__main__":
main()