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:
2026-09-27 10:16:53 +02:00
parent a01141c959
commit 94ffc63160
2 changed files with 334 additions and 0 deletions
@@ -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)