From 94ffc63160a58cda892b68a923440519886fc79e Mon Sep 17 00:00:00 2001 From: Davide Cappellini Date: Sun, 27 Sep 2026 10:16:53 +0200 Subject: [PATCH] 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) --- .../tests/measure_tfil_hold_window.nim | 244 ++++++++++++++++++ docs/tfil_hold_when_trapped_ab.md | 90 +++++++ 2 files changed, 334 insertions(+) create mode 100644 common_libs/tests/measure_tfil_hold_window.nim create mode 100644 docs/tfil_hold_when_trapped_ab.md diff --git a/common_libs/tests/measure_tfil_hold_window.nim b/common_libs/tests/measure_tfil_hold_window.nim new file mode 100644 index 0000000..553daac --- /dev/null +++ b/common_libs/tests/measure_tfil_hold_window.nim @@ -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..= 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.. 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.. 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.. 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.. 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) diff --git a/docs/tfil_hold_when_trapped_ab.md b/docs/tfil_hold_when_trapped_ab.md new file mode 100644 index 0000000..2b21558 --- /dev/null +++ b/docs/tfil_hold_when_trapped_ab.md @@ -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. |