lead capture by range: we under-lead (0.40->0.135) — a RANGE effect, not a power one

Measure, for every shot ModularBot fires at the real DrussGT, the lead we
actually applied vs the lead the enemy's motion required, from the recorded
live battles (/tmp/tfil_ab2, 70 battles / 490 rounds / 54926 shots, plus a
35-battle powtest replication of a different binary).

- requiredLead from an AIM-INDEPENDENT interception solve (bullet speed vs
  enemy truth), appliedLead from the server-recorded bullet bearing.
- capture = applied/required, guarded at 2px lateral lead (1.6% excluded);
  headline metric is the robust proportional slope.
- validation: hits 11.6px mean miss / 80.8% inside 18px, misses 134px,
  11.6x separation; 496/496 death + 70/70 owner attributions correct.

Direct answer: capture falls with RANGE (capSlp 0.401 -> 0.135, and
|err|/tolerance 1.27 -> 7.54) but is FLAT across fired POWER within a band
(450+, enemy alive: 0.154 / 0.127 / 0.127). The sub-0.5 long-range shots
(1.18% hit) are finishKill endgame shots at a near-dead DrussGT, not a
lead-capture failure. A naive linear predictor captures 0.29-0.60; we reach
46-67% of that, so the under-lead is real but capture=1.0 is unattainable
against a dodger (oracle required lead).
This commit is contained in:
2026-09-24 22:47:18 +02:00
parent 795a0e59fe
commit f91e121965
4 changed files with 2795 additions and 0 deletions
@@ -0,0 +1,583 @@
#!/usr/bin/env python3
"""MEASURE LEAD CAPTURE BY RANGE: how much of the required lead do we apply?
Reads the recent LIVE recorded battles vs the real DrussGT
(``/tmp/tfil_ab2/out/<ARM>/run<N>.jsonl`` per-tick worldstate +
``.events.jsonl`` fire/hit sidecar + ``.jsonl.rounds.json`` tick index,
produced by ``tools/robocode_shim/run_bridge_battle.sh`` / ``tools/ab/ab_run.sh``)
and, for every shot WE fire, compares the lead we actually put into the bullet
against the lead the enemy's motion required -- split by RANGE band and then by
fired POWER inside each band.
WHICH SIDE IS US
----------------
``TrBattleCapture`` writes the movement subject (the bot whose name matches
``--subject``, here DrussGT) as ``e*`` and the adversary (ModularBot, our bot)
as ``s*``. So our worldstate track is ``sx,sy,se`` and DrussGT's is
``ex,ey,ee``. Owner ids in the event sidecar are NOT stable across runs, so
every run is attributed independently: a fire event's (x,y) is the firing
tank's centre and its energy drops by exactly the fired power one tick later.
Attribution is cross-checked against the server ``death`` events (496/496).
THE DECOMPOSITION (all angles in deg, all relative to the LOS at the FIRE tick)
------------------------------------------------------------------------------
O = the firing tank's centre at the fire tick (the bullet line origin)
v = 20 - 3p px/tick
t* = the AIM-INDEPENDENT interception tick: the first tick k with
|E(t0+k) - O| <= v*k. It depends only on the enemy's recorded
truth and the bullet speed, never on our aim.
required = bearing(O -> E(t*)) - LOS (the lead a perfect gun would need)
applied = bullet bearing (server-recorded, perfect truth) - LOS
leadErr = applied - required (wrapped)
capture = applied / required (guarded: only where |required| >= 2px at range)
miss_px = |E(t*) - (O + v*t* * u)| (bullet line vs enemy at t*)
CAPTURE < 1 IS NOT BY ITSELF A BUG
----------------------------------
``required`` uses the enemy's ACTUAL future dodge (perfect information), which
no real gun can know. So the script also computes a NAIVE LINEAR PREDICTOR
control -- the same bullet speed, aimed at the intercept of a straight-line
continuation of the enemy's last LIN_M ticks of velocity. That control is the
ceiling a trivial predictive gun reaches against this dodger; our capture is
read against it, not only against 1.0.
MEASURED = every number printed. INFERRED = the causal reading in the doc.
"""
from __future__ import annotations
import argparse
import collections
import json
import math
import os
import statistics
US_SIDE = "s" # adversary = ModularBot = us
BOT_RADIUS = 18.0
SPEED_A, SPEED_B = 20.0, 3.0 # bullet speed = 20 - 3*p px/tick
MAX_FLIGHT = 220
MIN_REQ_PX = 2.0 # capture defined only above this lateral lead
LIN_M = 4 # ticks of velocity for the naive-predictor control
BANDS = [(0, 100), (100, 200), (200, 300), (300, 450), (450, 1e9)]
BAND_LABELS = ["0-100", "100-200", "200-300", "300-450", "450+"]
PWR_EDGES = [0.0, 0.5, 0.75, 1.0, 1.5, 100.0]
PWR_LABELS = ["<0.50", "0.50-0.75", "0.75-1.00", "1.00-1.50", ">=1.50"]
def wrap180(x):
"""Signed angular difference in (-180, 180]."""
x = (x + 180.0) % 360.0 - 180.0
return x + 360.0 if x <= -180.0 else x
def band_of(r):
for (lo, hi), lab in zip(BANDS, BAND_LABELS):
if lo <= r < hi:
return lab
return BAND_LABELS[-1]
def pbin(p):
for i in range(len(PWR_EDGES) - 1):
if PWR_EDGES[i] <= p < PWR_EDGES[i + 1]:
return i
return None
def mean(xs):
return statistics.fmean(xs) if xs else float("nan")
def median(xs):
return statistics.median(xs) if xs else float("nan")
def stdev(xs):
return statistics.pstdev(xs) if len(xs) > 1 else float("nan")
def _rows(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
class Run:
"""One live battle: capture rows, event sidecar, per-round tick index."""
def __init__(self, cap_path, events_path, rounds_path):
self.cap_path = cap_path
self.rows = _rows(cap_path)
self.by_tick = {r["tick"]: r for r in self.rows}
self.events = [json.loads(l) for l in open(events_path) if l.strip()]
self.rounds = json.load(open(rounds_path))["rounds"]
self.start = {r["round"]: r["startTick"] for r in self.rounds}
self.count = {r["round"]: r["count"] for r in self.rounds}
self.owner_side = self._resolve_owner_side()
self.resolution = {}
for ev in self.events:
if ev.get("type") in ("hit", "hitwall", "hitbullet"):
self.resolution[(ev["round"], ev["owner"], ev["bullet"])] = ev["type"]
# -- attribution ------------------------------------------------------
def _match(self, t, side, ev):
r, r1 = self.by_tick.get(t), self.by_tick.get(t + 1)
if r is None or r1 is None:
return False
if abs(r[side + "x"] - ev["x"]) > 0.02 or abs(r[side + "y"] - ev["y"]) > 0.02:
return False
ef = "ee" if side == "e" else "se"
return abs((r[ef] - r1[ef]) - ev["power"]) < 0.02
def _resolve_owner_side(self):
votes = collections.defaultdict(collections.Counter)
for ev in self.events:
if ev.get("type") != "fire":
continue
guess = self.start.get(ev["round"], 0) + ev["tick"]
for t in range(guess - 8, guess + 9):
for side in ("e", "s"):
if self._match(t, side, ev):
votes[ev["owner"]][side] += 1
return {o: c.most_common(1)[0][0] for o, c in votes.items() if c}
def align(self, ev):
guess = self.start.get(ev["round"], 0) + ev["tick"]
side = self.owner_side.get(ev["owner"])
best = None
for t in range(guess - 8, guess + 9):
for sd in (("e", "s") if side is None else (side,)):
if self._match(t, sd, ev):
d = abs(t - guess)
if best is None or d < best[0]:
best = (d, t, sd)
return None if best is None else best[1]
def n_position_matches(self, ev):
n = 0
guess = self.start.get(ev["round"], 0) + ev["tick"]
for t in range(guess - 8, guess + 9):
for sd in ("e", "s"):
r = self.by_tick.get(t)
if r is None:
continue
if abs(r[sd + "x"] - ev["x"]) <= 0.02 and abs(r[sd + "y"] - ev["y"]) <= 0.02:
n += 1
return n
def death_side_ok(self):
"""Every death must land on a side whose energy has collapsed to 0."""
out = []
for ev in self.events:
if ev.get("type") != "death":
continue
side = self.owner_side.get(ev["victim"])
g = self.start.get(ev["round"], 0) + ev["tick"]
ekey = "ee" if side == "e" else "se"
ok = False
for t in range(g - 8, g + 10):
r = self.by_tick.get(t)
if r is not None and r[ekey] <= 1e-6:
ok = True
break
out.append(ok)
return out
# -- per-shot extraction ---------------------------------------------
def shots(self):
for ev in self.events:
if ev.get("type") != "fire":
continue
if self.owner_side.get(ev["owner"]) != US_SIDE:
continue
t0 = self.align(ev)
if t0 is None:
continue
s = self._shot(ev, t0)
if s is not None:
yield s
def _shot(self, ev, t0):
r0 = self.by_tick[t0]
p = ev["power"]
v = SPEED_A - SPEED_B * p
th = math.radians(ev["dir"])
ux, uy = math.cos(th), math.sin(th)
x0, y0 = ev["x"], ev["y"] # tank centre == bullet line origin
rnd = ev["round"]
end = self.start[rnd] + self.count[rnd]
rng = math.hypot(r0["ex"] - x0, r0["ey"] - y0)
# aim-independent interception tick (oracle)
tstar = None
for k in range(1, MAX_FLIGHT + 1):
if t0 + k >= end:
break
r = self.by_tick.get(t0 + k)
if r is None:
break
if math.hypot(r["ex"] - x0, r["ey"] - y0) <= v * k:
tstar = (k, r)
break
if tstar is None:
return None
kstar, ea = tstar
# miss distance: perpendicular of the enemy at the tick our (actual) bullet
# reaches its along-track plane, relative to our bullet's real line.
# Same definition as common_libs/tests/analyze_drussgt_dodge_vs_power.py.
perp_arr = None
for k in range(1, MAX_FLIGHT + 1):
if t0 + k >= end:
break
r = self.by_tick.get(t0 + k)
if r is None:
break
dx, dy = r["ex"] - x0, r["ey"] - y0
al = dx * ux + dy * uy
if al > 0 and v * k >= al:
perp_arr = dx * uy - dy * ux
break
miss_px = abs(perp_arr) if perp_arr is not None else float("nan")
los = math.degrees(math.atan2(r0["ey"] - y0, r0["ex"] - x0))
required = wrap180(math.degrees(math.atan2(ea["ey"] - y0, ea["ex"] - x0)) - los)
applied = wrap180(ev["dir"] - los)
leaderr = wrap180(applied - required)
# naive linear-predictor control: continue the last LIN_M ticks of velocity
lin = None
rp = self.by_tick.get(t0 - LIN_M)
if rp is not None:
vx = (r0["ex"] - rp["ex"]) / LIN_M
vy = (r0["ey"] - rp["ey"]) / LIN_M
kk = rng / v
for _ in range(40):
kk = math.hypot(r0["ex"] + vx * kk - x0, r0["ey"] + vy * kk - y0) / v
lin = wrap180(math.degrees(math.atan2(r0["ey"] + vy * kk - y0,
r0["ex"] + vx * kk - x0)) - los)
req_px = abs(math.radians(required)) * rng
capture = (applied / required) if (req_px >= MIN_REQ_PX and abs(required) > 1e-9) else None
capture_lin = (lin / required) if (lin is not None and req_px >= MIN_REQ_PX
and abs(required) > 1e-9) else None
kind = self.resolution.get((rnd, ev["owner"], ev["bullet"]))
return dict(
run=self.cap_path, rnd=rnd, tick=t0, power=p, speed=v,
range=rng, band=band_of(rng), pbin=pbin(p), flight=kstar,
required=required, applied=applied, leaderr=leaderr, lin=lin,
capture=capture, capture_lin=capture_lin, req_px=req_px,
miss_px=miss_px,
tolerance_deg=math.degrees(math.atan(BOT_RADIUS / rng)),
hit=1.0 if kind == "hit" else 0.0,
resolution=kind, npos=self.n_position_matches(ev),
our_energy=r0["se"], enemy_energy=r0["ee"], rel_tick=t0 - self.start[rnd],
)
def discover_tfil(root):
runs = []
for sub in sorted(os.listdir(root)):
d = os.path.join(root, sub)
if not os.path.isdir(d):
continue
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):
runs.append(Run(cap, ev, rj))
return runs
def discover_powtest(root):
"""powtest layout: cap_<arm>_r<N>.jsonl + events_<arm>_r<N>.json + .rounds.json."""
runs = []
if not os.path.isdir(root):
return runs
for fn in sorted(os.listdir(root)):
if not (fn.startswith("cap_") and fn.endswith(".jsonl")):
continue
cap = os.path.join(root, fn)
stem = fn[4:].replace(".jsonl", ".json")
ev = os.path.join(root, "events_" + stem)
rj = cap + ".rounds.json"
if os.path.exists(ev) and os.path.exists(rj):
runs.append(Run(cap, ev, rj))
return runs
# --------------------------------------------------------------- statistics
def slope(xs, ys):
d = sum(x * x for x in xs)
return sum(x * y for x, y in zip(xs, ys)) / d if d > 0 else float("nan")
def cell(shots):
caps = [s["capture"] for s in shots if s["capture"] is not None]
lins = [s["capture_lin"] for s in shots if s["capture_lin"] is not None]
req = [s["required"] for s in shots]
resolved = [s for s in shots if s["resolution"] in ("hit", "hitwall", "hitbullet")]
return dict(
n=len(shots), nc=len(caps), excl=len(shots) - len(caps),
unresolved=len(shots) - len(resolved),
range=mean([s["range"] for s in shots]),
req=mean(req),
app=mean([s["applied"] for s in shots]),
cap=mean(caps), cap_med=median(caps), cap_slope=slope(req, [s["applied"] for s in shots]),
cap_lin=mean(lins), cap_lin_slope=slope(req, [s["lin"] for s in shots if s["lin"] is not None]),
ae=mean([abs(s["leaderr"]) for s in shots]),
ae_sd=stdev([abs(s["leaderr"]) for s in shots]),
ae_px=mean([abs(math.radians(s["leaderr"])) * s["range"] for s in shots]),
miss=mean([s["miss_px"] for s in shots]),
med_miss=median([s["miss_px"] for s in shots]),
tol=mean([s["tolerance_deg"] for s in shots]),
hit=mean([s["hit"] for s in resolved]),
enE=mean([s["enemy_energy"] for s in shots]),
flight=mean([s["flight"] for s in shots]),
)
def table(cells, title):
print(f"\n{title}")
hdr = (f"{'cell':<24} {'n':>6} {'excl':>5} {'range':>5} {'flt':>4} {'reqL':>7} {'appL':>7} "
f"{'capt':>6} {'capSlp':>7} {'capLin':>7} {'|err|':>6} {'+/-':>5} {'|err|px':>7} "
f"{'tol':>5} {'hit%':>6}")
print(hdr)
print("-" * len(hdr))
for name, sh in cells:
if not sh:
continue
c = cell(sh)
print(f"{name:<24} {c['n']:>6} {c['excl']:>5} {c['range']:>5.0f} {c['flight']:>4.1f} "
f"{c['req']:>7.2f} {c['app']:>7.2f} {c['cap']:>6.2f} {c['cap_slope']:>7.3f} "
f"{c['cap_lin_slope']:>7.3f} {c['ae']:>6.2f} {c['ae_sd']:>5.2f} {c['ae_px']:>7.1f} "
f"{c['tol']:>5.2f} {100*c['hit']:>5.2f}")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--tfil", default="/tmp/tfil_ab2/out")
ap.add_argument("--powtest", default="/tmp/powtest")
ap.add_argument("--json", default=None)
args = ap.parse_args()
runs = discover_tfil(args.tfil)
print("=" * 132)
print("LEAD CAPTURE BY RANGE -- live ModularBot vs real DrussGT")
print("=" * 132)
print(f"corpus : {args.tfil}")
print(f"battles (runs) : {len(runs)}")
if not runs:
raise SystemExit("no runs found")
amb = 0
death_ok = death_n = 0
pow_all = collections.defaultdict(collections.Counter)
sideowners = collections.Counter()
allshots = []
for rn in runs:
for o, sd in rn.owner_side.items():
sideowners[(sd, o)] += 1
amb += sum(1 for ev in rn.events if ev.get("type") == "fire"
and rn.n_position_matches(ev) > 1)
for ev in rn.events:
if ev.get("type") == "fire":
pow_all[rn.owner_side.get(ev["owner"], "?")][round(ev["power"], 2)] += 1
ds = rn.death_side_ok()
death_n += len(ds)
death_ok += sum(ds)
allshots.extend(rn.shots())
sides = collections.Counter(tuple(sorted(rn.owner_side.values())) for rn in runs)
print(f"per-run owner maps: {dict(sides)} (expect all ('e','s'))")
print(f"runs with a broken owner map : {sum(1 for rn in runs if sorted(rn.owner_side.values()) != ['e','s'])}")
print(f"fire events w/ ambiguous position : {amb}")
print(f"death events side-validated : {death_ok}/{death_n}")
for sd in ("s", "e"):
tot = sum(pow_all[sd].values())
top = pow_all[sd].most_common(6)
print(f" side {sd} = {'US (ModularBot)' if sd == US_SIDE else 'DrussGT'} "
f"n={tot:>6} top powers: {top}")
hits = [s for s in allshots if s["hit"] == 1.0]
misses = [s for s in allshots if s["resolution"] in ("hitwall", "hitbullet")
and s["hit"] == 0.0]
unresolved = [s for s in allshots if s["resolution"] is None]
print("\n" + "=" * 132)
print("SAMPLE")
print("=" * 132)
print(f"our shots (aligned, with an interception) : {len(allshots)}")
print(f" server hit / hitwall / hitbullet / other : "
f"{sum(1 for s in allshots if s['resolution']=='hit')} / "
f"{sum(1 for s in allshots if s['resolution']=='hitwall')} / "
f"{sum(1 for s in allshots if s['resolution']=='hitbullet')} / "
f"{len(unresolved)}")
print(f"capture excluded (|required| < {MIN_REQ_PX} px lateral) : "
f"{sum(1 for s in allshots if s['capture'] is None)}")
print("\n" + "=" * 132)
print("GEOMETRY VALIDATION (hits must separate from misses)")
print("=" * 132)
hdr = f"{'':<16}{'n':>7} {'|leadErr|deg':>13} {'|leadErr|px':>12} {'misPx mean':>11} {'misPx med':>10} {'<18px':>7}"
print(hdr)
for lab, grp in (("HITS", hits), ("MISSES", misses)):
if not grp:
continue
ae = [abs(s["leaderr"]) for s in grp]
apx = [abs(math.radians(s["leaderr"])) * s["range"] for s in grp]
mp = [s["miss_px"] for s in grp]
print(f"{lab:<16}{len(grp):>7} {mean(ae):>13.3f} {mean(apx):>12.1f} "
f"{mean(mp):>11.1f} {median(mp):>10.1f} {100*sum(1 for x in mp if x<18)/len(mp):>6.1f}%")
if hits and misses:
sep = mean([s["miss_px"] for s in misses]) / max(1e-9, mean([s["miss_px"] for s in hits]))
print(f"\nmiss separation ratio (misses/hits) = {sep:.2f}x "
f"-> {'OK (bearing recovery validated)' if sep > 2 else 'WEAK - bearing recovery suspect'}")
print(f"overall measured hit rate = {100*mean([s['hit'] for s in allshots]):.2f}%")
cells = [(lab, [s for s in allshots if s["band"] == lab]) for lab in BAND_LABELS]
print("\n" + "=" * 132)
print("MAIN TABLE -- all our shots, by RANGE band")
print("=" * 132)
print("reqL/appL = mean signed lead vs LOS (deg). capt = mean ratio, capSlp = robust proportional")
print("capture, capLin = the same slope for a naive linear predictor (control).")
table(cells, "by range band")
print("\n" + "=" * 132)
print("POWER WITHIN RANGE BAND -- this is what separates 'range' from 'power'")
print("=" * 132)
for lab in BAND_LABELS:
sub = [s for s in allshots if s["band"] == lab]
if not sub:
continue
pcs = [(f"{lab} {PWR_LABELS[i]}", [s for s in sub if s["pbin"] == i])
for i in range(len(PWR_LABELS))]
table(pcs, f"range {lab}")
# ---- endgame confound and its removal --------------------------------
print("\n" + "=" * 132)
print("CONFOUND CHECK -- the sub-0.5-power shots at long range are the ENDGAME finish")
print("=" * 132)
print("hit% of our 450+ shots, by the ENEMY's remaining energy at the fire tick:")
sub = [s for s in allshots if s["band"] == "450+"]
for lo, hi in ((0, 2), (2, 5), (5, 10), (10, 20), (20, 40), (40, 150)):
g = [s for s in sub if lo <= s["enemy_energy"] < hi]
if g:
c = cell(g)
print(f" enemy energy [{lo:>3},{hi:>3}) n={c['n']:>6} meanP={mean([x['power'] for x in g]):>5.2f} "
f"capSlp={c['cap_slope']:>6.3f} hit%={100*c['hit']:>5.2f}")
print("\nsame table, EXCLUDING shots fired while the enemy still has >= 5 energy")
print("(this removes the near-dead-target endgame and leaves the clean power comparison):")
for lab in BAND_LABELS:
sub = [s for s in allshots if s["band"] == lab and s["enemy_energy"] >= 5.0]
if not sub:
continue
pcs = [(f"{lab} {PWR_LABELS[i]}", [s for s in sub if s["pbin"] == i])
for i in range(len(PWR_LABELS))]
table(pcs, f"range {lab} (enemy energy >= 5)")
# ---- response curve ---------------------------------------------------
print("\n" + "=" * 132)
print("RESPONSE CURVE -- do we move the aim in proportion to the required lead?")
print("=" * 132)
for lab in ("200-300", "300-450", "450+"):
g = sorted([s for s in allshots if s["band"] == lab], key=lambda s: s["required"])
if len(g) < 100:
continue
nb = 8
print(f"\nrange {lab} (deciles of required lead -> mean applied lead):")
for i in range(nb):
q = g[i * len(g) // nb:(i + 1) * len(g) // nb]
c = cell(q)
print(f" required [{q[0]['required']:>7.2f},{q[-1]['required']:>7.2f}] "
f"meanReq={c['req']:>7.2f} meanApp={c['app']:>7.2f} "
f"hit%={100*c['hit']:>5.2f} n={c['n']:>5}")
print("\n" + "=" * 132)
print("DIRECT ANSWER -- does capture degrade with range / power?")
print("=" * 132)
print(f"{'range band':<12} {'n':>6} {'capSlp':>7} {'capLin':>7} {'us/lin':>7} "
f"{'medcap':>7} {'|req|':>6} {'|app|':>6} {'|err|px':>8} {'tol':>5} {'|err|/tol':>9} {'hit%':>6}")
for lab, sh in cells:
if not sh:
continue
c = cell(sh)
ratio = c["ae_px"] / (c["tol"] * math.pi / 180.0 * c["range"])
areq = mean([abs(s["required"]) for s in sh])
aapp = mean([abs(s["applied"]) for s in sh])
print(f"{lab:<12} {c['n']:>6} {c['cap_slope']:>7.3f} {c['cap_lin_slope']:>7.3f} "
f"{c['cap_slope']/c['cap_lin_slope'] if c['cap_lin_slope'] else float('nan'):>7.2f} "
f"{c['cap_med']:>7.3f} {areq:>6.2f} {aapp:>6.2f} {c['ae_px']:>8.1f} {c['tol']:>5.2f} "
f"{ratio:>9.2f} {100*c['hit']:>5.2f}")
print("\n(capSlp = our proportional lead capture; capLin = naive linear predictor control;")
print(" us/lin = our capture / the trivial-predictor ceiling; |req|/|app| = mean ABSOLUTE")
print(" lead magnitude in deg; |err|/tol = mean lead error px / 18px)")
print("\nPOWER WITHIN 450+ BAND, enemy energy >= 5 (removes the endgame confound):")
sub = [s for s in allshots if s["band"] == "450+" and s["enemy_energy"] >= 5.0]
for i in range(len(PWR_LABELS)):
g = [s for s in sub if s["pbin"] == i]
if not g:
continue
c = cell(g)
print(f" power {PWR_LABELS[i]:<11} n={c['n']:>6} meanP={mean([s['power'] for s in g]):>5.2f} "
f"meanRange={c['range']:>4.0f} capSlp={c['cap_slope']:>6.3f} "
f"capLin={c['cap_lin_slope']:>6.3f} |err|px={c['ae_px']:>6.1f} hit%={100*c['hit']:>5.2f}")
if args.powtest:
pruns = discover_powtest(args.powtest)
if pruns:
pshots = [s for rn in pruns for s in rn.shots()]
print("\n" + "=" * 132)
print(f"ROBUSTNESS CROSS-CHECK -- powtest corpus ({len(pruns)} runs, "
f"{len(pshots)} shots, DIFFERENT binary)")
print("=" * 132)
pc = [(lab, [s for s in pshots if s["band"] == lab]) for lab in BAND_LABELS]
table(pc, "by range band (powtest)")
if args.json:
blob = dict(
corpus=args.tfil, runs=len(runs), shots=len(allshots),
hits=len(hits), misses=len(misses),
excl=sum(1 for s in allshots if s["capture"] is None),
death_ok=death_ok, death_n=death_n, amb=amb,
overall_hit=mean([s["hit"] for s in allshots]),
hit_ae=mean([abs(s["leaderr"]) for s in hits]),
miss_ae=mean([abs(s["leaderr"]) for s in misses]),
hit_miss_px=mean([s["miss_px"] for s in hits]),
miss_miss_px=mean([s["miss_px"] for s in misses]),
pwr_hist={sd: dict(pow_all[sd]) for sd in pow_all},
bands={lab: cell([s for s in allshots if s["band"] == lab]) for lab in BAND_LABELS},
bands_enemy_alive={lab: cell([s for s in allshots if s["band"] == lab
and s["enemy_energy"] >= 5.0])
for lab in BAND_LABELS},
band_power_alive={lab: {PWR_LABELS[i]: cell([s for s in allshots
if s["band"] == lab
and s["pbin"] == i
and s["enemy_energy"] >= 5.0])
for i in range(len(PWR_LABELS))}
for lab in BAND_LABELS},
hit_by_enemy_energy={f"{lo}-{hi}": cell([s for s in allshots
if s["band"] == "450+"
and lo <= s["enemy_energy"] < hi])
for lo, hi in ((0, 2), (2, 5), (5, 10), (10, 20), (20, 40), (40, 150))},
band_power={lab: {PWR_LABELS[i]: cell([s for s in allshots
if s["band"] == lab and s["pbin"] == i])
for i in range(len(PWR_LABELS))} for lab in BAND_LABELS},
)
with open(args.json, "w") as f:
json.dump(blob, f, indent=2)
print(f"\n[json] wrote {args.json}")
if __name__ == "__main__":
main()
@@ -0,0 +1,213 @@
====================================================================================================================================
LEAD CAPTURE BY RANGE -- live ModularBot vs real DrussGT
====================================================================================================================================
corpus : /tmp/tfil_ab2/out
battles (runs) : 70
per-run owner maps: {('e', 's'): 70} (expect all ('e','s'))
runs with a broken owner map : 0
fire events w/ ambiguous position : 12112
death events side-validated : 496/496
side s = US (ModularBot) n= 56242 top powers: [(1.0, 32044), (0.5, 14556), (0.1, 807), (0.51, 225), (0.55, 208), (0.53, 199)]
side e = DrussGT n= 67065 top powers: [(0.15, 27209), (0.95, 22789), (0.45, 5274), (0.25, 3071), (0.65, 2862), (0.35, 2340)]
====================================================================================================================================
SAMPLE
====================================================================================================================================
our shots (aligned, with an interception) : 54926
server hit / hitwall / hitbullet / other : 5480 / 47101 / 1203 / 1142
capture excluded (|required| < 2.0 px lateral) : 887
====================================================================================================================================
GEOMETRY VALIDATION (hits must separate from misses)
====================================================================================================================================
n |leadErr|deg |leadErr|px misPx mean misPx med <18px
HITS 5480 1.478 11.4 11.6 10.8 80.8%
MISSES 48304 16.724 141.3 134.1 123.7 0.8%
miss separation ratio (misses/hits) = 11.59x -> OK (bearing recovery validated)
overall measured hit rate = 9.98%
====================================================================================================================================
MAIN TABLE -- all our shots, by RANGE band
====================================================================================================================================
reqL/appL = mean signed lead vs LOS (deg). capt = mean ratio, capSlp = robust proportional
capture, capLin = the same slope for a naive linear predictor (control).
by range band
cell n excl range flt reqL appL capt capSlp capLin |err| +/- |err|px tol hit%
-------------------------------------------------------------------------------------------------------------------------
0-100 27 1 81 10.3 1.51 -0.23 0.72 0.401 0.597 16.76 11.13 23.2 13.01 44.00
100-200 274 14 162 14.7 -0.95 2.23 0.07 0.391 0.600 17.74 13.38 50.1 6.57 25.28
200-300 990 30 261 17.9 -1.67 0.37 0.04 0.246 0.428 16.65 11.97 75.8 3.99 16.04
300-450 17178 259 405 24.5 -0.56 0.40 0.01 0.198 0.404 15.98 11.27 112.9 2.56 11.79
450+ 36457 583 535 31.0 0.19 -0.44 0.15 0.135 0.291 14.71 10.36 137.0 1.95 9.14
====================================================================================================================================
POWER WITHIN RANGE BAND -- this is what separates 'range' from 'power'
====================================================================================================================================
range 0-100
cell n excl range flt reqL appL capt capSlp capLin |err| +/- |err|px tol hit%
-------------------------------------------------------------------------------------------------------------------------
0-100 0.50-0.75 6 1 83 6.5 -4.82 -1.09 0.41 0.448 0.669 12.35 3.75 17.7 12.61 66.67
0-100 1.00-1.50 2 0 77 7.5 -28.19 -7.90 0.27 0.292 0.730 20.29 10.29 27.8 13.17 50.00
0-100 >=1.50 19 0 80 11.8 6.63 0.85 0.84 0.405 0.574 17.78 12.32 24.4 13.12 35.29
range 100-200
cell n excl range flt reqL appL capt capSlp capLin |err| +/- |err|px tol hit%
-------------------------------------------------------------------------------------------------------------------------
100-200 <0.50 5 0 161 10.4 4.77 -1.52 -0.08 0.436 0.495 14.84 8.18 41.5 6.48 20.00
100-200 0.50-0.75 53 12 166 10.8 1.78 5.83 0.04 0.481 0.567 14.31 8.72 41.2 6.38 30.77
100-200 0.75-1.00 5 0 177 11.2 0.60 -4.71 -1.53 0.527 0.394 11.72 7.59 35.6 5.89 40.00
100-200 1.00-1.50 23 0 168 12.5 -0.21 6.11 -0.04 0.442 0.616 14.50 11.98 46.5 6.47 42.86
100-200 >=1.50 188 2 159 16.3 -2.00 1.02 0.13 0.375 0.606 19.34 14.50 53.7 6.65 21.43
range 200-300
cell n excl range flt reqL appL capt capSlp capLin |err| +/- |err|px tol hit%
-------------------------------------------------------------------------------------------------------------------------
200-300 <0.50 6 0 257 15.7 -0.40 -3.70 0.06 0.512 0.839 10.39 8.54 50.9 4.07 50.00
200-300 0.50-0.75 130 12 267 16.1 -1.54 1.25 -0.40 0.202 0.415 15.92 10.93 74.3 3.90 18.55
200-300 0.75-1.00 21 4 263 17.1 -3.60 3.14 0.34 0.135 -0.015 17.32 10.06 79.1 3.96 11.11
200-300 1.00-1.50 780 13 261 17.8 -1.29 0.29 0.08 0.287 0.462 16.04 11.33 73.0 3.99 15.92
200-300 >=1.50 53 1 248 25.3 -6.97 -1.28 0.29 0.063 0.291 27.83 17.66 122.1 4.20 8.70
range 300-450
cell n excl range flt reqL appL capt capSlp capLin |err| +/- |err|px tol hit%
-------------------------------------------------------------------------------------------------------------------------
300-450 <0.50 333 63 409 22.0 -0.93 0.92 -0.67 0.094 0.493 16.30 9.09 116.4 2.54 3.07
300-450 0.50-0.75 5268 82 411 23.4 0.12 0.75 0.25 0.216 0.431 15.16 10.68 108.9 2.52 11.65
300-450 0.75-1.00 880 12 408 24.7 -0.72 -0.36 -0.19 0.203 0.458 16.00 11.28 113.6 2.55 13.14
300-450 1.00-1.50 10696 102 402 25.0 -0.86 0.27 -0.07 0.191 0.386 16.38 11.59 114.8 2.59 12.02
300-450 >=1.50 1 0 312 22.0 -7.39 22.61 -3.06 -3.062 -3.217 30.00 nan 163.1 3.31 0.00
range 450+
cell n excl range flt reqL appL capt capSlp capLin |err| +/- |err|px tol hit%
-------------------------------------------------------------------------------------------------------------------------
450+ <0.50 950 120 537 28.2 0.80 0.67 0.02 0.106 0.302 14.61 8.72 136.8 1.94 1.18
450+ 0.50-0.75 12957 205 536 29.6 0.32 -0.12 0.13 0.151 0.314 14.04 9.96 131.1 1.94 10.20
450+ 0.75-1.00 2424 28 540 31.6 0.52 -0.41 0.01 0.132 0.284 14.92 10.42 140.6 1.93 9.50
450+ 1.00-1.50 20126 230 534 32.0 0.05 -0.71 0.18 0.126 0.279 15.11 10.66 140.4 1.95 8.80
====================================================================================================================================
CONFOUND CHECK -- the sub-0.5-power shots at long range are the ENDGAME finish
====================================================================================================================================
hit% of our 450+ shots, by the ENEMY's remaining energy at the fire tick:
enemy energy [ 0, 2) n= 1017 meanP= 0.22 capSlp= 0.111 hit%= 1.10
enemy energy [ 2, 5) n= 1076 meanP= 0.54 capSlp= 0.128 hit%= 8.55
enemy energy [ 5, 10) n= 2614 meanP= 0.59 capSlp= 0.135 hit%= 9.69
enemy energy [ 10, 20) n= 6758 meanP= 0.63 capSlp= 0.146 hit%= 9.85
enemy energy [ 20, 40) n= 10733 meanP= 0.83 capSlp= 0.147 hit%= 9.43
enemy energy [ 40,150) n= 14259 meanP= 0.97 capSlp= 0.122 hit%= 9.12
same table, EXCLUDING shots fired while the enemy still has >= 5 energy
(this removes the near-dead-target endgame and leaves the clean power comparison):
range 0-100 (enemy energy >= 5)
cell n excl range flt reqL appL capt capSlp capLin |err| +/- |err|px tol hit%
-------------------------------------------------------------------------------------------------------------------------
0-100 0.50-0.75 5 0 80 6.6 -5.78 -4.34 0.41 0.448 0.669 11.80 3.88 16.2 13.00 80.00
0-100 1.00-1.50 2 0 77 7.5 -28.19 -7.90 0.27 0.292 0.730 20.29 10.29 27.8 13.17 50.00
0-100 >=1.50 16 0 81 11.3 5.37 0.41 0.98 0.503 0.605 14.99 11.43 20.8 13.05 40.00
range 100-200 (enemy energy >= 5)
cell n excl range flt reqL appL capt capSlp capLin |err| +/- |err|px tol hit%
-------------------------------------------------------------------------------------------------------------------------
100-200 0.50-0.75 42 2 168 11.2 2.51 2.50 0.03 0.481 0.573 12.93 8.67 37.8 6.33 35.71
100-200 0.75-1.00 5 0 177 11.2 0.60 -4.71 -1.53 0.527 0.394 11.72 7.59 35.6 5.89 40.00
100-200 1.00-1.50 23 0 168 12.5 -0.21 6.11 -0.04 0.442 0.616 14.50 11.98 46.5 6.47 42.86
100-200 >=1.50 175 2 161 16.3 -2.60 1.10 0.05 0.384 0.590 19.19 14.48 54.1 6.57 22.35
range 200-300 (enemy energy >= 5)
cell n excl range flt reqL appL capt capSlp capLin |err| +/- |err|px tol hit%
-------------------------------------------------------------------------------------------------------------------------
200-300 <0.50 6 0 257 15.7 -0.40 -3.70 0.06 0.512 0.839 10.39 8.54 50.9 4.07 50.00
200-300 0.50-0.75 119 2 267 16.2 -1.88 -0.63 -0.41 0.192 0.412 15.49 11.23 72.5 3.89 20.35
200-300 0.75-1.00 17 0 265 17.5 -4.44 -0.78 0.34 0.135 -0.015 16.75 10.98 77.4 3.93 14.29
200-300 1.00-1.50 776 9 261 17.8 -1.30 0.17 0.08 0.287 0.462 16.00 11.34 72.8 3.98 16.01
200-300 >=1.50 40 0 249 25.5 -3.41 -0.27 0.03 0.061 0.290 26.76 18.77 118.8 4.19 11.11
range 300-450 (enemy energy >= 5)
cell n excl range flt reqL appL capt capSlp capLin |err| +/- |err|px tol hit%
-------------------------------------------------------------------------------------------------------------------------
300-450 <0.50 4 0 381 21.5 2.94 -0.28 -1.41 1.202 1.280 17.26 10.28 109.0 2.76 25.00
300-450 0.50-0.75 4825 54 412 23.5 0.14 0.58 0.24 0.220 0.430 15.08 10.64 108.4 2.52 11.82
300-450 0.75-1.00 860 9 408 24.8 -0.72 -0.53 -0.19 0.205 0.453 15.94 11.27 113.3 2.54 13.32
300-450 1.00-1.50 10681 99 402 25.0 -0.87 0.26 -0.07 0.191 0.385 16.38 11.60 114.8 2.59 12.03
300-450 >=1.50 1 0 312 22.0 -7.39 22.61 -3.06 -3.062 -3.217 30.00 nan 163.1 3.31 0.00
range 450+ (enemy energy >= 5)
cell n excl range flt reqL appL capt capSlp capLin |err| +/- |err|px tol hit%
-------------------------------------------------------------------------------------------------------------------------
450+ <0.50 4 0 519 26.8 -1.51 -11.72 -5.43 -3.021 -1.809 26.98 6.38 247.2 2.00 0.00
450+ 0.50-0.75 11882 137 535 29.5 0.31 -0.21 0.16 0.154 0.312 13.97 9.96 130.3 1.95 10.39
450+ 0.75-1.00 2377 28 540 31.6 0.47 -0.45 0.01 0.127 0.275 14.94 10.41 140.7 1.93 9.60
450+ 1.00-1.50 20101 230 534 32.0 0.04 -0.71 0.18 0.127 0.279 15.11 10.66 140.4 1.95 8.80
====================================================================================================================================
RESPONSE CURVE -- do we move the aim in proportion to the required lead?
====================================================================================================================================
range 200-300 (deciles of required lead -> mean applied lead):
required [ -46.11, -23.72] meanReq= -27.79 meanApp= -9.18 hit%=19.67 n= 123
required [ -23.71, -16.96] meanReq= -19.97 meanApp= -1.30 hit%=14.53 n= 124
required [ -16.83, -11.27] meanReq= -13.99 meanApp= -0.85 hit%=14.29 n= 124
required [ -11.23, -2.84] meanReq= -7.43 meanApp= 0.34 hit%=14.41 n= 124
required [ -2.76, 5.10] meanReq= 1.01 meanApp= 1.67 hit%= 9.24 n= 123
required [ 5.11, 13.22] meanReq= 9.12 meanApp= -3.36 hit%=14.88 n= 124
required [ 13.23, 23.92] meanReq= 18.32 meanApp= 3.04 hit%=11.76 n= 124
required [ 23.94, 45.18] meanReq= 27.20 meanApp= 12.55 hit%=29.41 n= 124
range 300-450 (deciles of required lead -> mean applied lead):
required [ -28.84, -19.87] meanReq= -24.21 meanApp= -6.04 hit%=13.39 n= 2147
required [ -19.87, -13.44] meanReq= -16.43 meanApp= -0.20 hit%= 9.09 n= 2147
required [ -13.44, -7.81] meanReq= -10.63 meanApp= -0.19 hit%=10.22 n= 2147
required [ -7.80, -1.44] meanReq= -4.66 meanApp= -0.12 hit%=11.82 n= 2148
required [ -1.44, 5.26] meanReq= 1.83 meanApp= 0.34 hit%= 9.96 n= 2147
required [ 5.26, 12.33] meanReq= 8.84 meanApp= 0.39 hit%= 9.00 n= 2147
required [ 12.33, 20.39] meanReq= 16.05 meanApp= 0.44 hit%= 9.60 n= 2147
required [ 20.39, 28.98] meanReq= 24.76 meanApp= 8.54 hit%=21.20 n= 2148
range 450+ (deciles of required lead -> mean applied lead):
required [ -28.60, -17.03] meanReq= -21.60 meanApp= -4.09 hit%= 9.27 n= 4557
required [ -17.03, -10.56] meanReq= -13.70 meanApp= -1.51 hit%= 7.92 n= 4557
required [ -10.56, -5.13] meanReq= -7.79 meanApp= -1.00 hit%=10.29 n= 4557
required [ -5.13, 0.00] meanReq= -2.51 meanApp= -0.55 hit%=10.08 n= 4557
required [ 0.00, 5.27] meanReq= 2.50 meanApp= 0.36 hit%= 9.95 n= 4557
required [ 5.27, 11.06] meanReq= 8.12 meanApp= -0.16 hit%= 8.75 n= 4557
required [ 11.06, 17.66] meanReq= 14.26 meanApp= 0.14 hit%= 7.22 n= 4557
required [ 17.66, 28.54] meanReq= 22.26 meanApp= 3.26 hit%= 9.69 n= 4558
====================================================================================================================================
DIRECT ANSWER -- does capture degrade with range / power?
====================================================================================================================================
range band n capSlp capLin us/lin medcap |req| |app| |err|px tol |err|/tol hit%
0-100 27 0.401 0.597 0.67 0.450 24.77 13.38 23.2 13.01 1.27 44.00
100-200 274 0.391 0.600 0.65 0.442 20.21 16.95 50.1 6.57 2.70 25.28
200-300 990 0.246 0.428 0.58 0.238 15.71 13.84 75.8 3.99 4.17 16.04
300-450 17178 0.198 0.404 0.49 0.174 13.46 13.09 112.9 2.56 6.23 11.79
450+ 36457 0.135 0.291 0.46 0.120 11.59 11.43 137.0 1.95 7.54 9.14
(capSlp = our proportional lead capture; capLin = naive linear predictor control;
us/lin = our capture / the trivial-predictor ceiling; |req|/|app| = mean ABSOLUTE
lead magnitude in deg; |err|/tol = mean lead error px / 18px)
POWER WITHIN 450+ BAND, enemy energy >= 5 (removes the endgame confound):
power <0.50 n= 4 meanP= 0.10 meanRange= 519 capSlp=-3.021 capLin=-1.809 |err|px= 247.2 hit%= 0.00
power 0.50-0.75 n= 11882 meanP= 0.53 meanRange= 535 capSlp= 0.154 capLin= 0.312 |err|px= 130.3 hit%=10.39
power 0.75-1.00 n= 2377 meanP= 0.87 meanRange= 540 capSlp= 0.127 capLin= 0.275 |err|px= 140.7 hit%= 9.60
power 1.00-1.50 n= 20101 meanP= 1.00 meanRange= 534 capSlp= 0.127 capLin= 0.279 |err|px= 140.4 hit%= 8.80
====================================================================================================================================
ROBUSTNESS CROSS-CHECK -- powtest corpus (35 runs, 24277 shots, DIFFERENT binary)
====================================================================================================================================
by range band (powtest)
cell n excl range flt reqL appL capt capSlp capLin |err| +/- |err|px tol hit%
-------------------------------------------------------------------------------------------------------------------------
0-100 6 0 91 11.2 -6.18 -0.00 0.60 0.529 0.496 14.10 10.10 20.8 11.38 60.00
100-200 83 0 170 15.8 -3.28 -2.27 0.89 0.354 0.470 19.49 14.92 58.1 6.16 23.46
200-300 442 3 260 17.6 -2.70 0.80 -0.02 0.283 0.380 16.47 11.92 74.2 4.01 16.55
300-450 9843 102 405 24.9 -1.13 0.09 0.05 0.219 0.417 16.12 11.52 114.1 2.56 11.90
450+ 13903 143 525 30.9 0.12 -0.88 0.18 0.136 0.301 15.14 10.80 138.6 1.98 9.24
[json] wrote common_libs/tests/fixtures/lead_capture_by_range_results.json
File diff suppressed because it is too large Load Diff
+307
View File
@@ -0,0 +1,307 @@
# Lead capture by range: how much of the required lead do we actually apply?
**Question (the user's hypothesis).** *"DrussGT is dodging more when low power only
because DrussGT is moving faster, more far away from the head-on and our displacement
capacity, so we try to hit him but we miss as we are not capable of reaching the
correct angle."*
**Answer: half right, and the half that is wrong matters.** We genuinely **do not
apply the required lead, and the shortfall grows with range** — capture falls from
**0.40 at 0–200 px to 0.135 at 450+ px** on 54 926 shots, so at long range we put
**~13 %** of the lead a perfect gun would need into the bullet, and miss by ~137 px
against an 18 px bot. But this is a **RANGE** effect, **not a POWER** effect: inside a
range band, capture is the same at 0.5 power as at 1.0 power. Low-power shots miss
more because *they are long-range shots*, plus a separate endgame confound, **not**
because low power costs us displacement capacity.
The other half: capture below 1.0 is **partly unavoidable**. `requiredLead` uses the
enemy's *actual* future dodge (perfect information), which no real gun can know. A
trivial straight-line predictor — the same bullet, aimed at the intercept of the
enemy's last 4 ticks of velocity — captures only **0.29–0.60**. We capture **46–67 %**
of that trivial ceiling. So the under-lead is real and fixable, but roughly half of
the distance from us to "perfect lead" is the dodger's intrinsic unpredictability.
Measured on **70 real live battles / 490 rounds / 54 926 shots** against the real,
unmodified DrussGT (`/tmp/tfil_ab2/out/`), replicated on **35 more battles / 24 277
shots** of a *different* ModularBot build (`/tmp/powtest/`). All live Tank Royale
battles recorded through `tools/robocode_shim/run_bridge_battle.sh`; no offline
fixture replay.
---
## 1. The decomposition
For every shot **we (ModularBot)** fire at DrussGT, with power `p`, all angles in
degrees relative to the **line of sight (LOS) at the fire tick**:
* `O` = the firing tank's centre at the fire tick (= the bullet-line origin; the
server's fire `(x,y)` is the tank centre to 0.02 px, verified);
* `v = 20 − 3p` px/tick, so **low power is a faster bullet**;
* `t*` = the **aim-independent** interception tick: the first tick `k` with
`|E(t₀+k) − O| ≤ v·k`, where `E` is DrussGT's recorded true position. It depends
only on the enemy's truth and the bullet speed, **never on our aim**;
* **`requiredLead`** = `bearing(O → E(t*)) − LOS`;
* **`appliedLead`** = our bullet's server-recorded bearing `− LOS`;
* **`leadError`** = `appliedLead − requiredLead` (wrapped to ±180°);
* **`capture`** = `appliedLead / requiredLead`.
`capture` is guarded: it is defined only when the required lateral lead is at least
**2 px** at the fire range (`|requiredLead|·range ≥ 2 px`). This excludes **887 of
54 926 shots (1.6 %)**. The mean of the *ratio* is a noisy statistic (small
denominators); the headline capture is therefore the **proportional slope**
`Σ(applied·required)/Σ(required²)` computed cell-by-cell, labelled `capSlp`. The mean
ratio (`capt`) and its median (`medcap`) are printed too and tell the same story.
Arrival-adjacent quantities use two ticks, both non-circular:
* `miss_px` = perpendicular distance between DrussGT's true position and our bullet's
real line at the tick the bullet reaches DrussGT's along-track plane — the **same
definition used in `docs/drussgt_dodge_vs_power.md`**, so the validation numbers
match that job exactly;
* `flight` = `t*` (ticks in the air).
### Attribution (stated explicitly, this has bitten the project before)
* In the capture rows **`e*` is the SUBJECT = DrussGT** and **`s*` is the adversary =
ModularBot = us**, by construction of
`tools/robocode_shim/src/robocode_shim/TrBattleCapture.java` (`en` = the bot whose
name contains "DrussGT", written `e*`; `sh` = the other, written `s*`).
* The event sidecar's `owner` id is a Tank Royale id and **is not stable across runs**.
It is recovered **per battle** from the fire geometry: the owner's position equals
the fire event's `(x,y)` and its energy drops by exactly `power` on the next capture
row. **70/70 battles** resolve to the two sides `('e','s')`.
* Cross-check: for **496/496 death events** the mapped victim is the bot whose energy
is ~0 at the end of that round.
* Fingerprint check on the way round: our recovered side fires
`{1.00 ×32044, 0.50 ×14556, 0.10 ×807, …}` — the documented ModularBot policy
(spike at `TR_POWER_ENERGY_MIN = 0.5` under 20 energy, spike at the 1.0 range cap
beyond `TR_POWER_FAR_DIST = 200`, linear slope between). DrussGT's recovered side
fires `{0.15 ×27209, 0.95 ×22789, 0.45 ×5274, …}` — a completely different,
distance-quantised curve. **No power-value heuristic is used anywhere** (the old
`{1.0, 1.5, 2.0, 3.0}` assumption is exactly what mis-attributed ModularBot in a
previous job).
Because the four-decimal positions repeat when a tank is stationary, 12 112 fire
events have more than one *position* match inside the ±8-tick search window; the
energy-drop term of the match disambiguates all of them, and the 496/496 death check
plus the hit geometry below validate the result end to end.
---
## 2. Geometry validation: hits separate from misses
| | n | mean &#124;leadError&#124; (deg) | mean &#124;leadError&#124; (px) | miss_px mean | miss_px median | fraction < 18 px |
|---|---|---|---|---|---|---|
| **HITS** (server truth) | 5 480 | **1.478** | 11.4 | **11.6** | 10.8 | **80.8 %** |
| **MISSES** (hitwall/hitbullet) | 48 304 | **16.724** | 141.3 | **134.1** | 123.7 | **0.8 %** |
Miss separation ratio (misses/hits) = **11.59×**. The hits show a mean miss of
**11.6 px** and **80.8 % inside 18 px**, matching the previously recorded live
measurement (11.6 px, 80.6 %) exactly — the bearing recovery is correct. Overall
measured hit rate **9.98 %**.
---
## 3. Main result: by range band
`capt` = mean of the ratio, `capSlp` = proportional capture (robust), `capLin` = the
same slope for the **naive linear predictor control**, `|req|`/`|app|` = mean *absolute*
lead magnitude in degrees, `tol` = angular tolerance `atan(18/range)`.
| range | n | range px | flight | &#124;req&#124;° | &#124;app&#124;° | capt | **capSlp** | capLin | &#124;err&#124;° | &#124;err&#124;px | tol° | &#124;err&#124;/tol | hit % |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0–100 | 27 | 81 | 10.3 | 24.8 | 13.4 | 0.72 | **0.401** | 0.597 | 16.8 | 23.2 | 13.01 | 1.27 | 44.00 |
| 100–200 | 274 | 162 | 14.7 | 20.2 | 17.0 | 0.07 | **0.391** | 0.600 | 17.7 | 50.1 | 6.57 | 2.70 | 25.28 |
| 200–300 | 990 | 261 | 17.9 | 15.7 | 13.8 | 0.04 | **0.246** | 0.428 | 16.7 | 75.8 | 3.99 | 4.17 | 16.04 |
| 300–450 | 17 178 | 405 | 24.5 | 13.5 | 13.1 | 0.01 | **0.198** | 0.404 | 16.0 | 112.9 | 2.56 | 6.23 | 11.79 |
| 450+ | 36 457 | 535 | 31.0 | 11.6 | 11.4 | 0.15 | **0.135** | 0.291 | 14.7 | 137.0 | 1.95 | 7.54 | 9.14 |
Read this table twice. Two things happen at once and they are different:
1. **The angular error `|leadError|` is roughly constant (~15–18°) at every range**
(it does not shrink with distance).
2. **The window shrinks**: the 18 px bot spans 13° at 100 px but only 1.95° at 500 px,
because `tol = atan(18/range)`.
So `|err|/tol` grows from **1.27 to 7.54**: the same angular miss that was survivable
up close becomes fatal at distance. This is the "tighter angular window" half of the
story — but it is *not* the whole story, because `capSlp` genuinely **falls with range**
(0.40 → 0.135). We are not applying a constant fraction of a shrinking lead; we are
applying a *shrinking fraction* of a roughly constant-magnitude lead.
---
## 4. Power does NOT change capture (the 'range vs power' split)
Within the 450+ band, restricting to shots fired while **DrussGT still has ≥ 5 energy**
(which removes the endgame confound of §5):
| 450+, enemy alive | n | mean p | range px | **capSlp** | capLin | &#124;err&#124;px | hit % |
|---|---|---|---|---|---|---|---|
| 0.50–0.75 | 11 882 | 0.53 | 535 | **0.154** | 0.312 | 130.3 | 10.39 |
| 0.75–1.00 | 2 377 | 0.87 | 540 | **0.127** | 0.275 | 140.7 | 9.60 |
| 1.00–1.50 | 20 101 | 1.00 | 534 | **0.127** | 0.279 | 140.4 | 8.80 |
**Flat.** Same result in the 300–450 band (`capSlp` 0.220 / 0.205 / 0.191 for the same
three power bins). At 450+ our policy fires only 0.50 or 1.00 in this regime, so the
comparison is exactly "our fastest long-range bullet" (p = 0.5, v = 18.5, flight 29.5
ticks) versus "our standard bullet" (p = 1.0, v = 17, flight 32 ticks). The faster
low-power bullet has a **shorter** flight and a **smaller** required lead by
construction, and we capture **the same fraction** of it. Power is not the driver.
---
## 5. The endgame confound (why raw per-power hit rates lie)
Our power policy fires **sub-0.5 power only to finish a nearly-dead enemy**. At 450+,
**944 of the 950 sub-0.5 shots have the enemy's energy below 2** (mean **1.1**), because
`prFinishKill` cuts the power when the enemy's remaining energy is already tiny. Those
shots hit at **1.18 %**, which looks like a catastrophic low-power failure — it is not:
| 450+, by enemy energy at the fire tick | n | mean p | capSlp | hit % |
|---|---|---|---|---|
| [0, 2) | 1 017 | 0.22 | 0.111 | **1.10** |
| [2, 5) | 1 076 | 0.54 | 0.128 | 8.55 |
| [5, 10) | 2 614 | 0.59 | 0.135 | 9.69 |
| [10, 20) | 6 758 | 0.63 | 0.146 | 9.85 |
| [20, 40) | 10 733 | 0.83 | 0.147 | 9.43 |
| [40, 150) | 14 259 | 0.97 | 0.122 | 9.12 |
The hit rate collapses **only when the target is already effectively dead**, and it
collapses for **high-power shots too** (73 shots at p ≥ 0.5 against a < 2-energy
DrussGT hit 0.00 %). Every other enemy-energy stratum sits at 8.5–9.9 %. So the
"low power hits 1 %" reading is a *dead-target* artifact, not a lead-capture failure.
Capture itself is flat across enemy energy (0.111 → 0.147 → 0.122).
---
## 6. Do we move the aim in proportion to the required lead? (response curve)
At 450+, split the shots into deciles of `requiredLead` and look at the **mean applied
lead** in each:
| requiredLead decile (mean) | −21.6 | −13.7 | −7.8 | −2.5 | +2.5 | +8.1 | +14.3 | +22.3 |
|---|---|---|---|---|---|---|---|---|
| **mean appliedLead** | −4.09 | −1.51 | −1.00 | −0.55 | +0.36 | −0.16 | +0.14 | +3.26 |
| hit % | 9.27 | 7.92 | 10.29 | 10.08 | 9.95 | 8.75 | 7.22 | 9.69 |
The required lead swings across **±22°**, and our mean applied lead responds by about
**±4°** — and the hit rate does **not** depend on the size of the required lead at all.
This is the cleanest single number in the report: the bullet is aimed, to first order,
**at the line of sight, not at the interception point**. Consistently,
`mean|appliedLead| ≈ mean|requiredLead|` (11.4° vs 11.6° at 450+) — we have as much
*dispersion* of aim as the target has motion, but almost **no correlation** with it
(`capSlp = 0.135`).
---
## 7. The ceiling: what a trivial predictor would do
`requiredLead` is an **oracle** (it uses DrussGT's actual future dodge). To separate
"our gun is bad" from "the dodge is unpredictable", the analyzer also computes a
**naive linear-predictor control**: same bullet speed, aimed at the intercept of a
straight-line continuation of DrussGT's last 4 ticks of velocity. Its capture
(`capLin`) and the ratio are in §3:
| range | our capSlp | naive-linear capLin | **us / linear** |
|---|---|---|---|
| 0–100 | 0.401 | 0.597 | 0.67 |
| 100–200 | 0.391 | 0.600 | 0.65 |
| 200–300 | 0.246 | 0.428 | 0.58 |
| 300–450 | 0.198 | 0.404 | 0.49 |
| 450+ | 0.135 | 0.291 | **0.46** |
So a *trivial* predictor still only reaches 0.29 at long range — most of the oracle
lead is genuinely unattainable against a strong dodger. But we reach only **46 %** of
that trivial benchmark. The under-lead is therefore real and worth fixing, while the
gap from the trivial benchmark to 1.0 is the dodger's unpredictability and is not.
Corroboration: the powtest corpus — a **different** ModularBot binary — reproduces the
shape almost exactly (`capSlp` 0.529 / 0.354 / 0.283 / 0.219 / 0.136 by the same range
bands). It is a property of the architecture, not of one build.
---
## 8. Caveats and sample sizes
* **The oracle caveat.** `requiredLead` uses perfect future information. `capture = 1.0`
is *unattainable* against a bot that dodges; it is a diagnostic, not a target. Compare
our capture to the `capLin` control, not to 1.0.
* **Weak correlation, large dispersion.** At long range our applied lead is essentially
an aim with mean 0 and ~11.5° dispersion that is only weakly proportional to the
required lead (proportional slope 0.135). "We apply 13 % of the lead"
is a proportional-fit statement; per shot, what we actually do is *aim near the LOS
with a wide error*.
* **Stale live world state.** The recorded `dir` is what the **live** ModularBot fired
using its own stale between-scan world state; the capture's positions are perfect
truth. The measured `leadError` therefore folds in the bot's scan staleness. That is
the correct thing to measure (it is the error the bullet actually carries), but it is
not the same as the gun's internal prediction error.
* **Stratification.** Power is not randomised: it is assigned by the range cap, the
energy slope and the finishing rule. Every power comparison above is made **within a
range band** and (in §4) with the endgame removed. The raw cross-range power
distribution is reported in the captured output.
* **Sample sizes.** The short bands are thin (0–100: n = 27; 100–200: n = 274) — treat
their capture as indicative. Everything from 200 px out is large (990 → 36 457).
The 450+ sub-0.5 power cell *with the enemy alive* is n = 4 and should be ignored
(it is printed for completeness; the real sub-0.5 shots are the endgame of §5).
* **Rotation direction.** `requiredLead`/`appliedLead` are signed about the LOS;
`capSlp` additionally assumes a proportional (through-origin) relation, which is the
right first-order model for a lead gun but not exact for large angles.
---
## 9. Verdict
**MEASURED**
* Capture falls monotonically with range: **capSlp 0.401 → 0.391 → 0.246 → 0.198 →
0.135**. At 450+ we apply **~13 %** of the required lead and miss by **~137 px**
against an 18 px bot; `|leadError|/tolerance` grows **1.27 → 7.54**.
* Capture is **flat across fired power within a range band**
(450+, enemy alive: 0.154 / 0.127 / 0.127 for p = 0.53 / 0.87 / 1.00).
* The applied lead barely responds to the required lead: across required-lead deciles
spanning ±22°, the mean applied lead moves by ~±4° and the hit rate is flat.
* A naive linear predictor captures 0.29–0.60; we capture **46–67 %** of it.
* Hits validate the geometry (11.6 px mean, 80.8 % inside 18 px) and separate from
misses by **11.6×**; **496/496** deaths and **70/70** owner maps resolve correctly.
* The sub-0.5-power long-range shots (1.18 % hit) are **endgame shots at a near-dead
DrussGT** (mean enemy energy 1.1), and high-power shots there miss just as much.
**INFERRED**
* The gun is, to first order, **a line-of-sight / weak-lead gun against DrussGT**, not
an intercept-point gun: the bullet direction tracks the enemy's current line rather
than where the enemy will be, which is why capture is low and why it degrades with
range while the angular error stays ~constant.
* The user's mechanism — "we are not capable of reaching the correct angle at range" —
is **confirmed for range**, but **not through power**: low power does not reduce our
displacement capacity here (it is a faster bullet and we capture the same fraction).
The low-power/long-range association is a policy artifact (the range cap), so
*fixing the lead at range fixes the low-power miss rate too*.
* Where to look next: the gap between our capture and the trivial-linear control
(0.46×) is the actionable, fixable part. The gap from the trivial control to 1.0 is
the dodger's unpredictability and should not be chased.
---
## 10. Reproducing
```bash
# corpora (live captures; not in the repo, ~500 MB total)
# /tmp/tfil_ab2/out/<A..E>/run*.jsonl{,.events.jsonl,.rounds.json}
# /tmp/powtest/{cap_<arm>_r<n>.jsonl,events_<arm>_r<n>.json,cap_*_r<n>.jsonl.rounds.json}
python3 common_libs/tests/analyze_lead_capture_by_range.py \
--tfil /tmp/tfil_ab2/out --powtest /tmp/powtest \
--json common_libs/tests/fixtures/lead_capture_by_range_results.json \
| tee common_libs/tests/fixtures/lead_capture_by_range_output.txt
```
`common_libs/tests/fixtures/lead_capture_by_range_output.txt` is the verbatim captured
output every table above is taken from; `..._results.json` is the same numbers as JSON.
Runtime ≈ 15 s for both corpora, single-threaded pure Python (no numpy).
To rebuild a corpus, `tools/robocode_shim/make_botdir.sh /tmp/tr_bots/DrussGT` and then
`TR_EVENTS_OUT=<run>.events.jsonl tools/robocode_shim/run_bridge_battle.sh
<adversaryBotDir> 7 <run>.jsonl`.