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)
+90
View File
@@ -0,0 +1,90 @@
# j153 proposal (NOT RUN) — `TR_TFIL_HOLD_WHEN_TRAPPED`: hold when no safe tile exists
**Status: awaiting the owner's approval. No battle, no A/B arm, no tournament has
been started.** The knob exists, is registered, defaults to today's behaviour
byte-for-byte, and is guarded offline.
## The owner's 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."
Today, when the safe set (`pathMaxHeat <= PathDangerThreshold`, after the
`CoolestLevels = 2` filter) is too small to draw from, the picker **promotes the
2 least-hot blocked tiles** and moves to one of them. The proposal replaces that
with `speed 0.0` for that tick only.
## The knob
| env | default | meaning |
|---|---|---|
| `TR_TFIL_HOLD_WHEN_TRAPPED` | `0` (off) | `1` = hold when the safe set is **empty**; today = promote the 2 least-hot blocked tiles |
- **One tick, never latched.** The hold is taken at the pick site, and the pick
site only runs when `commitTicks == 0`, so the next tick re-evaluates the field
from scratch. There is deliberately **no max-hold knob** in j153: a counter can
only add a way to get stuck. (j154, in flight, adds
`TR_TFIL_HOLD_MAX_TICKS` = 0 = off as a *separate* default-off knob.)
- **It never interrupts a live commitment.** The hold replaces a *replan*, not a
commitment in progress: with `TR_TFIL_COMMIT_ARRIVAL=1` or
`TR_TFIL_NOREV_SPEED=4` armed the two do not fight, because the hold branch is
downstream of the commitment block and only runs at `commitTicks == 0`.
- **The gun keeps firing.** `computeMove` never emits a fire command (the gun
lives in the bot's `go()` loop), and the hold `return`s *after* the bullet
tracking, so the fire tracker's state on a held tick is bit-identical to a
non-held tick (guarded in `common_libs/tests/test_tfil_commit_env.nim`).
## What the offline harness can and cannot say
Per `docs/offline_harness_trust.md` the replay harness is trustworthy only for
per-gun single-tick prediction on a fixed enemy trajectory; it scored **0/6** on
closed-loop questions. "Hold vs move" is a **counterfactual closed-loop** question,
so this document contains **no** damage-taken comparison for holding. Only
open-loop descriptors of the recorded field are reported
(`common_libs/tests/measure_tfil_hold_window.nim`).
## Proposed A/B (needs approval)
```
TOURNAMENT_NIMCACHE=/tmp/nc_j153 \
tools/ab/tournament_run.sh \
--arms tools/ab/arms_hold_trapped.txt \
--panel tools/ab/panel_movement.txt \
--runs 14 --rounds 7 --conc 7 --wait-arena 45 \
--reference hold0 \
--outdir /tmp/ab/j153_hold
python3 tools/ab/tournament_analyze.py /tmp/ab/j153_hold --reference hold0
```
Arms file (frozen 15-opponent movement panel, 14 runs/arm, 7 rounds):
```
hold0 | | control = today's promote-the-2 fallback
hold1 | TR_TFIL_HOLD_WHEN_TRAPPED=1 | hold one tick when the safe set is empty
hold8 | TR_TFIL_HOLD_WHEN_TRAPPED=1 TR_TFIL_HOLD_MAX_TICKS=8 | bounded hold (only if j154 lands)
```
**Primary metrics: damage/run and round-win rate** (NOT hit rate — a movement
arm's value flows through the closed loop). **Mechanism metrics:** incoming hit
rate, % of picks held, mean distance-to-enemy at a hold, tick-share at speed 0.
**MDE, stated up front:** ~0.28 wins/run at 14 runs/arm; a two-arm session is
~1 h. Resolving ~0.10 wins/run needs ~2.2 h / ~2,900 battles. A 1 h two-arm run
can only reject effects at or above ~0.28 wins/run — anything smaller is a null
by construction, and must be reported as such.
**Prior, stated plainly:** four mechanism-positive / outcome-null results in a
row on this campaign. **A null is the most likely outcome.** A null with a
mechanism hit (holds fire, distance at hold is large) would mean: holding is
achievable and does not by itself buy rounds; ship nothing. A null *without* a
mechanism hit means the arm never bound and the A/B is void, not negative.
## Decision each duration supports
| duration | supports |
|---|---|
| 1 h (2 arms × 14 runs) | reject/accept only ≥0.28 wins/run. Mechanism check only. |
| 2.2 h (~2,900 battles, 3 arms) | resolve ~0.10 wins/run. Still not a small-effect test. |
| any null | no change to the shipped default. `TR_TFIL_HOLD_WHEN_TRAPPED` stays 0. |