Files
SirRoboGarage/common_libs/tests/measure_cornering_guns.nim
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

177 lines
6.9 KiB
Nim

## 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..<fx.states.len:
let state = fx.states[si]
let bi = bucketIdx(wallAtFire[si])
for gi in 0..<drivers.len:
var preds: array[len(PowerBins), GunPrediction]
for i in 0..<len(PowerBins):
preds[i] = drivers[gi].predictCb(state, bulletSpeed(PowerBins[i]))
let ready = if drivers[gi].readyCb == nil: true else: drivers[gi].readyCb()
if ready:
let h0 = tracker.head
tracker.spawnBullets(gi, preds, state, tid)
# spawnBullets appended exactly len(PowerBins) bullets starting at h0.
for j in 0..<len(PowerBins):
let slot = (h0 + j) mod MaxBullets
if tracker.bullets[slot].active and
tracker.bullets[slot].fireTick == state.tick and
tracker.bullets[slot].gunId == gi:
pending[(gi, j, state.tick)] = bi
let actIdx = if liveActual and si + 1 < fx.states.len: si + 1 else: si
let act = fx.states[actIdx]
var enemyPositions: Table[int, tuple[x, y: float, lastSeenTick: int, alive: bool]]
var lst = act.tick
if actIdx < fx.lastSeen.len and fx.lastSeen[actIdx] >= 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..<NumBuckets:
let s = b[i]
ts += s.shots; th += s.hits
let rate = if s.shots > 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..<drivers.len:
for bi in 0..<NumBuckets:
pooled[gi][bi].shots += res[gi][bi].shots
pooled[gi][bi].hits += res[gi][bi].hits
echo &"# {extractFilename(path)} ticks={fx.states.len}"
echo ""
echo "== M1 VIRTUAL per-gun (full-run, all resolved bullets) by DrussGT wall dist at fire =="
echo " (NOT the live fire cadence; within-gun comparison across buckets is the point)"
for gi, d in drivers:
printGun(d.name, pooled[gi])
echo ""
echo "== pooled over all 13 guns =="
var all: array[NumBuckets, BStat]
for gi in 0..<drivers.len:
for bi in 0..<NumBuckets:
all[bi].shots += pooled[gi][bi].shots
all[bi].hits += pooled[gi][bi].hits
printGun("ALL", all)
when isMainModule:
main()