j153: the open-loop ruler for "hold when trapped" + the A/B proposal (NOT run)
Measures, on the recorded fixtures and with NO counterfactual replay: how long until a safe tile appears at a forced pick, whether the enemy had just fired, whether our own tile is already hot, and all three split by distance. Registers nothing and changes no default: the knob TR_TFIL_HOLD_WHEN_TRAPPED and its implementation live in the concurrently edited working tree. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
## OFFLINE — j153. THE OWNER'S "HOLD WHEN TRAPPED" — OPEN-LOOP DESCRIPTORS ONLY.
|
||||
##
|
||||
## The claim: "if no tile is found to go, to not choose the less dangerous, but
|
||||
## to stay still! the next tick probably the situation already changed and we
|
||||
## did not commit to any dangerous place."
|
||||
##
|
||||
## This ruler reports FOUR DESCRIPTORS of the RECORDED field around every forced
|
||||
## pick (the pick the mover makes when the safe set is too small to draw from).
|
||||
## They are all measured on the recorded trajectory, forward-looking in TIME but
|
||||
## NOT counterfactual: nothing here replays "what damage would holding have
|
||||
## cost". Per `docs/offline_harness_trust.md` that question is closed-loop and
|
||||
## the replay ruler scored 0/6 on such questions, so it is NOT asked here.
|
||||
##
|
||||
## 1. WAIT WINDOW — at a forced pick, holding OUR POSITION fixed, how many
|
||||
## ticks pass before ANY tile inside the reachable hull is safe
|
||||
## (pathMaxHeat <= PathDangerThreshold, after the CoolestLevels=2 filter)?
|
||||
## CAVEAT: the safe set depends on where we are, so this holds the pick
|
||||
## position and replays the field — the recorded field still belongs to a
|
||||
## bot that moved, so the window is an APPROXIMATION of the wait a holder
|
||||
## would really see. It is an UPPER bound on the wait (a moving recorded
|
||||
## bot stirs the field) and it is reported with the censoring share.
|
||||
## 2. FRESH FIRE — ticks since the enemy's last confirmed shot (energy-drop
|
||||
## detection, the same signal the mover itself uses).
|
||||
## 3. OUR OWN TILE — is our current tile already over the threshold at the
|
||||
## forced pick? (if yes, "stay" is not on the table at all)
|
||||
## 4. DISTANCE — all of the above split by distance to the nearest enemy.
|
||||
##
|
||||
## No battle, no Java, no server, no behaviour change (the knob is off).
|
||||
##
|
||||
## Run:
|
||||
## nim c -r --path:common_libs --nimcache:/tmp/nc_j153 \
|
||||
## common_libs/tests/measure_tfil_hold_window.nim [fixture.jsonl ...]
|
||||
|
||||
import std/[os, strformat, math, algorithm, json, random, sequtils, sets]
|
||||
import std/strutils except fromHex
|
||||
import gun_harness/offline_range
|
||||
include movements/the_floor_is_lava
|
||||
|
||||
const PathSampleStep = 18.0 ## the picker's own path sampling step
|
||||
const MaxWait = 40 ## censor horizon, ticks
|
||||
const ArenaW = 800.0
|
||||
const ArenaH = 600.0
|
||||
|
||||
type Probe = object
|
||||
idx: int ## fixture index of the forced pick
|
||||
x, y, heading, speed: float ## OUR position at the pick (held fixed)
|
||||
dist: float ## to the nearest enemy at the pick
|
||||
ownHot: bool ## our own tile already over the threshold
|
||||
emptySet: bool ## the safe set was EMPTY (0), not merely < 2
|
||||
sinceFire: int ## ticks since the enemy's last confirmed shot
|
||||
wait: int ## filled below: -1 = censored
|
||||
resolved: bool
|
||||
|
||||
proc loadRoundStarts(path: string): HashSet[int] =
|
||||
result = initHashSet[int]()
|
||||
for side in [path & ".rounds.json",
|
||||
currentSourcePath().parentDir.parentDir.parentDir /
|
||||
"tools" / "fixtures" / "drussgt_meta" /
|
||||
(extractFilename(path) & ".rounds.json")]:
|
||||
if not fileExists(side): continue
|
||||
let root = parseFile(side)
|
||||
if not root.hasKey("rounds"): continue
|
||||
for r in root["rounds"]:
|
||||
if r.hasKey("startTick"): result.incl r["startTick"].getInt()
|
||||
|
||||
proc pathMax(m: TFILModule, fx, fy, tx, ty: float): float =
|
||||
let ddx = tx - fx; let ddy = ty - fy
|
||||
let d = sqrt(ddx*ddx + ddy*ddy)
|
||||
if d <= 0.1: return 0.0
|
||||
let steps = max(1, int(d / PathSampleStep))
|
||||
var h = 0.0
|
||||
for si in 0..steps:
|
||||
let f = si.float / steps.float
|
||||
let (sc, sr) = m.tileAt(fx + ddx * f, fy + ddy * f)
|
||||
h = max(h, m.lavaAt(sc, sr))
|
||||
h
|
||||
|
||||
proc coolestOf(m: TFILModule, tiles: seq[tuple[col, row: int]]):
|
||||
seq[tuple[col, row: int]] =
|
||||
## The picker's CoolestLevels=2 filter: only the coolest 2 DISTINCT lava
|
||||
## values among the tiles count as candidates at all.
|
||||
var vals: seq[float]
|
||||
for t in tiles:
|
||||
let v = m.lavaAt(t.col, t.row)
|
||||
var found = false
|
||||
for dv in vals:
|
||||
if dv == v: found = true; break
|
||||
if not found: vals.add v
|
||||
for i in 1..<vals.len:
|
||||
let key = vals[i]; var j = i - 1
|
||||
while j >= 0 and vals[j] > key:
|
||||
vals[j + 1] = vals[j]; dec j
|
||||
vals[j + 1] = key
|
||||
let lv = min(CoolestLevels, vals.len)
|
||||
for t in tiles:
|
||||
let v = m.lavaAt(t.col, t.row)
|
||||
for li in 0..<lv:
|
||||
if v == vals[li]:
|
||||
result.add t
|
||||
break
|
||||
|
||||
proc safeCount(m: TFILModule, tiles: seq[tuple[col, row: int]],
|
||||
fx, fy, thr: float): int =
|
||||
for t in coolestOf(m, tiles):
|
||||
let tx = m.marginX + (t.col.float + 0.5) * GridSize
|
||||
let ty = m.marginY + (t.row.float + 0.5) * GridSize
|
||||
if pathMax(m, fx, fy, tx, ty) <= thr: inc result
|
||||
|
||||
proc safeTileExists(m: TFILModule, p: Probe, thr: float): bool =
|
||||
## The picker's own definition, from OUR HELD POSITION: inside the reachable
|
||||
## hull -> CoolestLevels=2 coolest distinct lava values -> path heat filter.
|
||||
let hull = computeReachableHull(p.x, p.y, p.heading, p.speed,
|
||||
ArenaW, ArenaH, HullTicks)
|
||||
if hull.len < 3: return false
|
||||
var inside: seq[tuple[col, row: int]]
|
||||
for row in 0..<m.rows:
|
||||
for col in 0..<m.cols:
|
||||
let cx = m.marginX + (col.float + 0.5) * GridSize
|
||||
let cy = m.marginY + (row.float + 0.5) * GridSize
|
||||
if pointInHull(cx, cy, hull): inside.add (col, row)
|
||||
safeCount(m, inside, p.x, p.y, thr) > 0
|
||||
|
||||
proc nearestEnemyDist(ws: WorldState): float =
|
||||
result = Inf
|
||||
for e in ws.enemies:
|
||||
result = min(result, sqrt((e.x - ws.selfX)^2 + (e.y - ws.selfY)^2))
|
||||
|
||||
proc collect(fx: seq[WorldState], starts: HashSet[int], seed: int): seq[Probe] =
|
||||
randomize(seed)
|
||||
var m = initTFIL()
|
||||
var lastPicks = 0
|
||||
var lastBullets = 0
|
||||
var sinceFire = 99
|
||||
for i in 0..<fx.len:
|
||||
if i == 0 or i in starts: m.resetRound()
|
||||
discard m.computeMove(fx[i])
|
||||
if m.bullets.len > lastBullets: sinceFire = 0 else: inc sinceFire
|
||||
lastBullets = m.bullets.len
|
||||
if m.picks == lastPicks: continue
|
||||
lastPicks = m.picks
|
||||
# a pick happened: was the safe set big enough to draw from?
|
||||
let safePre = safeCount(m, m.cachedInsideTiles, fx[i].selfX, fx[i].selfY,
|
||||
TfilDangerThreshold)
|
||||
if safePre >= 2: continue # not a forced pick
|
||||
let (cc, cr) = m.tileAt(fx[i].selfX, fx[i].selfY)
|
||||
result.add Probe(idx: i, x: fx[i].selfX, y: fx[i].selfY,
|
||||
heading: fx[i].selfHeading, speed: fx[i].selfSpeed,
|
||||
dist: nearestEnemyDist(fx[i]),
|
||||
emptySet: safePre == 0,
|
||||
ownHot: m.lavaAt(cc, cr) > TfilDangerThreshold,
|
||||
sinceFire: sinceFire, wait: -1)
|
||||
|
||||
proc fillWaits(fx: seq[WorldState], starts: HashSet[int], seed: int,
|
||||
probes: var seq[Probe]) =
|
||||
## Second linear pass, SAME trajectory: at tick j every still-open probe is
|
||||
## advanced to k = j - idx and asked whether a safe tile exists at k.
|
||||
randomize(seed)
|
||||
var m = initTFIL()
|
||||
for j in 0..<fx.len:
|
||||
if j == 0 or j in starts: m.resetRound()
|
||||
discard m.computeMove(fx[j])
|
||||
for p in probes.mitems:
|
||||
if p.resolved: continue
|
||||
let k = j - p.idx
|
||||
if k < 1: continue
|
||||
if k > MaxWait:
|
||||
p.resolved = true; p.wait = -1; continue
|
||||
if safeTileExists(m, p, TfilDangerThreshold):
|
||||
p.resolved = true; p.wait = k
|
||||
|
||||
proc mean(xs: seq[int]): float =
|
||||
if xs.len == 0: return 0.0
|
||||
var t = 0
|
||||
for x in xs: t += x
|
||||
t.float / xs.len.float
|
||||
|
||||
proc median(xs: seq[int]): int =
|
||||
if xs.len == 0: return -1
|
||||
let s = xs.sorted()
|
||||
s[s.len div 2]
|
||||
|
||||
proc shareN(n, d: int): string =
|
||||
if d == 0: return "n/a"
|
||||
&"{100.0 * n.float / d.float:.1f}%"
|
||||
|
||||
proc share(n, d: int): string = shareN(n, d)
|
||||
|
||||
proc report(label: string, ps: seq[Probe]) =
|
||||
echo &"\n\u2550\u2550\u2550 {label}"
|
||||
if ps.len == 0: echo " no forced picks"; return
|
||||
let waits = ps.filterIt(it.wait >= 0).mapIt(it.wait)
|
||||
let cens = ps.filterIt(it.wait < 0)
|
||||
echo &" forced picks: {ps.len} of which EMPTY safe set (0 tiles): " &
|
||||
&"{shareN(ps.filterIt(it.emptySet).len, ps.len)}"
|
||||
echo &" WAIT WINDOW (ticks until ANY safe tile exists, position held):"
|
||||
echo &" median {median(waits)} mean {mean(waits):.1f}" &
|
||||
&" resolved {waits.len}/{ps.len} censored(>{MaxWait}) {cens.len}"
|
||||
for k in [1, 3, 5, 10]:
|
||||
echo &" within {k:>2} tick(s): {share(waits.filterIt(it <= k).len, ps.len)}"
|
||||
echo &" FRESH FIRE (ticks since the enemy's last confirmed shot):"
|
||||
echo &" same tick {shareN(ps.filterIt(it.sinceFire == 0).len, ps.len)}" &
|
||||
&" prev 1 tick {shareN(ps.filterIt(it.sinceFire <= 1).len, ps.len)}" &
|
||||
&" prev 3 ticks {shareN(ps.filterIt(it.sinceFire <= 3).len, ps.len)}" &
|
||||
&" >3 ticks {shareN(ps.filterIt(it.sinceFire > 3).len, ps.len)}"
|
||||
echo &" OUR OWN TILE already over the threshold: " &
|
||||
&"{shareN(ps.filterIt(it.ownHot).len, ps.len)}"
|
||||
for (name, lo, hi) in [("close <150px", 0.0, 150.0), ("mid 150-300px", 150.0, 300.0),
|
||||
("far >300px", 300.0, 1.0e9)]:
|
||||
let g = ps.filterIt(it.dist >= lo and it.dist < hi)
|
||||
if g.len == 0:
|
||||
echo &" {name}: n/a"; continue
|
||||
let w = g.filterIt(it.wait >= 0).mapIt(it.wait)
|
||||
echo &" {name}: n={g.len} median wait {median(w)} " &
|
||||
&"<=3t {share(w.filterIt(it <= 3).len, g.len)} <=10t {share(w.filterIt(it <= 10).len, g.len)}" &
|
||||
&" fired<=1t ago {shareN(g.filterIt(it.sinceFire <= 1).len, g.len)}" &
|
||||
&" own tile hot {shareN(g.filterIt(it.ownHot).len, g.len)}"
|
||||
|
||||
# ── driver ───────────────────────────────────────────────────────────────────
|
||||
let args = commandLineParams()
|
||||
let fixtures = if args.len > 0: args
|
||||
else: @["/tmp/firelag_live2/tfil_on/run1.jsonl",
|
||||
"/tmp/firelag_live2/tfil_off/run1.jsonl",
|
||||
"/tmp/firelag_live2/strafe_on/run1.jsonl",
|
||||
"/tmp/firelag_live2/strafe_off/run1.jsonl",
|
||||
currentSourcePath().parentDir.parentDir.parentDir /
|
||||
"tools" / "fixtures" / "tr_drussgt_vs_modularbot.jsonl",
|
||||
currentSourcePath().parentDir.parentDir.parentDir /
|
||||
"tools" / "fixtures" / "tr_drussgt_vs_corners.jsonl",
|
||||
currentSourcePath().parentDir.parentDir.parentDir /
|
||||
"tools" / "fixtures" / "tr_drussgt_vs_crazy.jsonl",
|
||||
currentSourcePath().parentDir.parentDir.parentDir /
|
||||
"tools" / "fixtures" / "tr_drussgt_vs_spinbot.jsonl"]
|
||||
var total: seq[Probe]
|
||||
for f in fixtures:
|
||||
if not fileExists(f):
|
||||
echo "skip (missing): ", f; continue
|
||||
let fx = loadFixture(f).states
|
||||
let starts = loadRoundStarts(f)
|
||||
for seed in [7, 8, 9]:
|
||||
var ps = collect(fx, starts, seed)
|
||||
fillWaits(fx, starts, seed, ps)
|
||||
total.add ps
|
||||
if seed == 7: report(extractFilename(f), ps)
|
||||
report("ALL FIXTURES x 3 SEEDS", total)
|
||||
Reference in New Issue
Block a user