From 391318a7bd629f9536c4fb0d7c4227adc4e1b062 Mon Sep 17 00:00:00 2001 From: Davide Cappellini Date: Mon, 21 Sep 2026 22:49:56 +0200 Subject: [PATCH] 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). --- common_libs/tests/measure_cornering_guns.nim | 176 +++++++++ common_libs/tests/measure_cornering_ram.py | 354 +++++++++++++++++++ 2 files changed, 530 insertions(+) create mode 100644 common_libs/tests/measure_cornering_guns.nim create mode 100644 common_libs/tests/measure_cornering_ram.py diff --git a/common_libs/tests/measure_cornering_guns.nim b/common_libs/tests/measure_cornering_guns.nim new file mode 100644 index 0000000..992dc82 --- /dev/null +++ b/common_libs/tests/measure_cornering_guns.nim @@ -0,0 +1,176 @@ +## OFFLINE MEASUREMENT (read-only analysis). No live battles, no bot rebuild. +## +## Measurement 1, virtual-metric half: does an enemy being near a WALL raise our +## virtual-bullet hit rate, and does that hold per-gun (within-gun comparison)? +## +## This deliberately does NOT modify `gun_harness/offline_range.nim`. It re-runs +## the same replay contract (spawn -> tickBullets) in a local loop so it can +## capture one outcome per spawned bullet and bucket it by the enemy's distance +## to the nearest arena wall AT THE TICK WE FIRED. +## +## Because `spawnBullets` is not cooldown-gated, every gun spawns one bullet per +## power bin per tick; the sample size is therefore far larger than a live battle +## and the absolute rates are NOT live rates. The comparison of interest is +## WITHIN a gun, across wall-distance buckets, which shares all that sampling. +## +## Usage: +## nim c -r common_libs/tests/measure_cornering_guns.nim [fixture.jsonl ...] +## If no fixture is given, the TR + classic DrussGT fixtures are used. + +import std/[os, strformat, tables, math, algorithm] +import gun_harness/offline_range +import range_guns + +const + NumBuckets = 5 + BucketEdges = [30.0, 60.0, 120.0, 250.0] + BucketNames = ["<=30", "30-60", "60-120", "120-250", ">250"] + +type + BStat = object + shots*, hits*: int + +proc bucketIdx(d: float): int = + if d <= BucketEdges[0]: 0 + elif d <= BucketEdges[1]: 1 + elif d <= BucketEdges[2]: 2 + elif d <= BucketEdges[3]: 3 + else: 4 + +proc enemyWallDist(s: WorldState): float = + ## Distance from the ENEMY (e*) to the nearest arena wall. + min(min(s.enemyX, s.enemyY), + min(s.arenaWidth - s.enemyX, s.arenaHeight - s.enemyY)) + +proc replayBucketed(fx: Fixture, drivers: seq[GunDriver], + liveActual = false): seq[array[NumBuckets, BStat]] = + ## Copy of offline_range.replayFixture's ordering, extended to attribute each + ## resolved virtual bullet to its fire tick's wall-distance bucket. + let tid = fx.enemyId + let skipFinal = liveActual and fx.enemyDied + var tracker = initTracker(drivers.len, ActiveMetric) + var wallAtFire = newSeq[float](fx.states.len) + for i, s in fx.states: + wallAtFire[i] = enemyWallDist(s) + + # key = (gunId, powerBin, fireTick) -> bucket index + var pending = initTable[(int, int, int), int]() + result = newSeq[array[NumBuckets, BStat]](drivers.len) + + for si in 0..= 0: lst = fx.lastSeen[actIdx] + if act.enemies.len > 0: + for e in act.enemies: + enemyPositions[e.id] = (x: e.x, y: e.y, lastSeenTick: lst, alive: true) + else: + enemyPositions[tid] = (x: act.enemyX, y: act.enemyY, lastSeenTick: lst, alive: true) + + let res = addr result + let pend = addr pending + let dref = drivers + if not (skipFinal and si == fx.states.len - 1): + tracker.tickBullets(state, enemyPositions, + proc(gunId: GunId, binIdx: int, e: FeedbackEvent) = + # Forward to the owning gun exactly as replayFixture does, so learning + # guns (GF/DecayGF/KNN) keep their history. + dref[gunId].resultCb(e) + let key = (gunId, binIdx, e.fireTick) + if pend[].hasKey(key): + let bb = pend[][key] + inc res[][gunId][bb].shots + if e.hit: inc res[][gunId][bb].hits + pend[].del(key)) + + if tracker.droppedBullets != 0: + stderr.writeLine(&"[measure_cornering_guns] WARNING droppedBullets={tracker.droppedBullets}") + +proc z2prop(h1, n1, h2, n2: int): float = + if n1 == 0 or n2 == 0: return 0.0 + let p1 = h1.float / n1.float + let p2 = h2.float / n2.float + let p = (h1 + h2).float / (n1 + n2).float + let se = sqrt(p * (1.0 - p) * (1.0 / n1.float + 1.0 / n2.float)) + if se <= 0.0: 0.0 else: (p1 - p2) / se + +proc printGun(name: string, b: array[NumBuckets, BStat]) = + var ts, th = 0 + var line = &"{name:<12}" + for i in 0.. 0: s.hits.float / s.shots.float * 100.0 else: 0.0 + line.add &" {BucketNames[i]}={s.hits}/{s.shots} {rate:5.2f}%" + let tr = if ts > 0: th.float / ts.float * 100.0 else: 0.0 + echo line + echo &" TOTAL={th}/{ts} {tr:5.2f}%" + # adjacent (<=60) vs open (>60) + let adjS = b[0].shots + b[1].shots + let adjH = b[0].hits + b[1].hits + let opS = b[2].shots + b[3].shots + b[4].shots + let opH = b[2].hits + b[3].hits + b[4].hits + let adjR = if adjS > 0: adjH.float / adjS.float * 100.0 else: 0.0 + let opR = if opS > 0: opH.float / opS.float * 100.0 else: 0.0 + echo &" ADJ(<=60)={adjH}/{adjS} {adjR:5.2f}% OPEN(>60)={opH}/{opS} {opR:5.2f}% " & + &"z={z2prop(adjH, adjS, opH, opS):+.2f}" + +proc main() = + var files: seq[string] + for i in 1..paramCount(): + files.add paramStr(i) + if files.len == 0: + let dir = currentSourcePath().parentDir.parentDir.parentDir / "tools" / "fixtures" + for f in walkFiles(dir / "*drussgt*.jsonl"): + files.add f + files.sort() + + let drivers = buildAllGunDrivers(enableTmSelector = false, seed = 1) + var pooled: seq[array[NumBuckets, BStat]] = newSeq[array[NumBuckets, BStat]](drivers.len) + + for path in files: + let fx = loadFixture(path) + let res = replayBucketed(fx, drivers, liveActual = (fx.meta.source == "live")) + for gi in 0.. 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()