From a01141c959b3c5b17c47a6957e45d96a43a6f684 Mon Sep 17 00:00:00 2001 From: Davide Cappellini Date: Sun, 27 Sep 2026 09:57:25 +0200 Subject: [PATCH] j151 (default-off): hard arrival bound TR_TFIL_ARRIVE_TICKS + the offline per-pick ruler that found it Measures the owner's two complaints per pick (turn angle, heat on the path vs at the destination, time-to-arrive, and whether the destination is hot on the recorded true future when we would arrive), split by empty vs non-empty safe set. Result: the path IS scored (hard filter + least-hot fallback), but there was NO time term at all -- 65% of picks outran the 15-tick commitment and the chosen tile was reached 6.5% of the time. The bound refuses a candidate we cannot reach in TR_TFIL_ARRIVE_TICKS (default 0 = off = today). Co-Authored-By: Claude Opus 4.8 (1M context) --- ModularBot_garage/.env.example | 1 + ModularBot_garage/src/env_report.nim | 4 +- .../tests/measure_tfil_pick_defects.nim | 182 ++++++++++++++++++ common_libs/tests/test_tfil_commit_env.nim | 11 +- 4 files changed, 193 insertions(+), 5 deletions(-) create mode 100644 common_libs/tests/measure_tfil_pick_defects.nim diff --git a/ModularBot_garage/.env.example b/ModularBot_garage/.env.example index 81faf6e..8c87c8a 100644 --- a/ModularBot_garage/.env.example +++ b/ModularBot_garage/.env.example @@ -112,6 +112,7 @@ TR_TFIL_BULLET_CORE=10.0 # tfil only: lava per bullet-overlapping tile (== P TR_TFIL_BULLET_AURA=5.0 # tfil only: lava for the bullet's aura ring tiles TR_TFIL_TILE_REPLAN=self # self | enemy | off: when a dodge commitment is cancelled TR_TFIL_COMMIT_TICKS=15 # ticks to commit to a dodge point before replanning +TR_TFIL_ARRIVE_TICKS=0.0 # hard bound: never pick a tile farther than this (ticks at 8px/tick); 0 = off = today's draw TR_TFIL_NO_REV=off # on = never reverse direction inside a corridor TR_TFIL_COMMIT_LOG= # path for the per-commit log; empty = no log TR_TFIL_COMMIT_ARRIVAL=off # on = hold the dodge tile until we are ON it (not a fixed dwell) diff --git a/ModularBot_garage/src/env_report.nim b/ModularBot_garage/src/env_report.nim index 57bbdd0..b1d7a37 100644 --- a/ModularBot_garage/src/env_report.nim +++ b/ModularBot_garage/src/env_report.nim @@ -313,6 +313,8 @@ proc printEffectiveValues(ctx: EnvReportContext) = sourceOf("TR_TFIL_CORRIDOR_HEAT")) emit("TR_TFIL_CORRIDOR_TICKS", $the_floor_is_lava.TfilCorridorTicks, sourceOf("TR_TFIL_CORRIDOR_TICKS")) + emit("TR_TFIL_ARRIVE_TICKS", $the_floor_is_lava.TfilArriveTicks, + sourceOf("TR_TFIL_ARRIVE_TICKS")) emit("TR_TFIL_WALL_HOTNESS", $the_floor_is_lava_ring.WallHotness, sourceOf("TR_TFIL_WALL_HOTNESS")) emit("TR_TFIL_WALL_RADIANCE", $the_floor_is_lava.WallRadiance, @@ -649,7 +651,7 @@ proc knownEnvNames*(): seq[string] = "TR_RAM_PLAN_MARGIN", "TR_RAM_PLAN_HITRATE", "TR_RAM_LOG", "TR_TFIL_RANGE_LO", "TR_TFIL_RANGE_HI", "TR_TFIL_RANGE_TEMP", "TR_TFIL_RANGE_K", "TR_TFIL_CORRIDOR_HEAT", "TR_TFIL_WALL_HOTNESS", - "TR_TFIL_CORRIDOR_TICKS", + "TR_TFIL_CORRIDOR_TICKS", "TR_TFIL_ARRIVE_TICKS", "TR_TFIL_WALL_RADIANCE", "TR_TFIL_BULLET_CORE", "TR_TFIL_BULLET_AURA", "TR_TFIL_TILE_REPLAN", "TR_TFIL_COMMIT_TICKS", "TR_TFIL_NO_REV", "TR_TFIL_COMMIT_LOG", "TR_TFIL_COMMIT_ARRIVAL", "TR_TFIL_COMMIT_MARGIN", diff --git a/common_libs/tests/measure_tfil_pick_defects.nim b/common_libs/tests/measure_tfil_pick_defects.nim new file mode 100644 index 0000000..9c16eca --- /dev/null +++ b/common_libs/tests/measure_tfil_pick_defects.nim @@ -0,0 +1,182 @@ +## OFFLINE — j151. THE OWNER'S TWO PAINTERS, PER PICK. +## +## "the bot choose a tile that is almost perpendicular to it, a tile that is not +## reachable in feasible time and 1 will put the bot in danger trying to go +## there 2 will not arrive there as a new location will drive it away." +## +## This ruler replays recorded fixtures through the REAL +## `TFILModule.computeMove` and records, for EVERY pick: +## turn° angle between the current heading and the chosen tile +## pathMax/Mean lava on the straight-line path bot -> chosen tile +## destHeat lava on the chosen tile itself +## tta dist / MaxSpeed, i.e. ticks to arrive at full speed +## promoted the pick had to break the heat filter (safePre < 2) +## safePre size of the safe set BEFORE the "keep 2" promotion +## hotAtTta the destination tile was OVER the threshold `tta` ticks +## later, on the recorded (true) future <- the feasibility test +## No battle, no Java, no server, no behaviour change. +## +## Run: +## nim c -r --path:common_libs --nimcache:/tmp/nc_j151 \ +## common_libs/tests/measure_tfil_pick_defects.nim [fixture.jsonl ...] +## Env it forwards: TR_TFIL_CORRIDOR_TICKS, TR_TFIL_DANGER_THRESHOLD, ... + +import std/[os, strformat, math, algorithm, json, sets, random, sequtils] +import std/strutils except fromHex # `fromHex` would clash with color.fromHex +import gun_harness/offline_range +# Private-field access: include (do NOT import) the shipped mover. +include movements/the_floor_is_lava + +const PathSampleStep = 18.0 # the picker's own sampling step +const PerpDeg = 60.0 ## the owner's "perpendicular" + +type Pick = object + turn, pathMax, pathMean, destHeat, dist, tta: float + promoted: bool + safePre, cand: int + hotAtTta: bool ## destination over threshold when we would arrive + reached: bool ## we actually got within ArriveRadius by then + +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 pathHeat(m: TFILModule, fx, fy, tx, ty: float): tuple[max, mean: float] = + let ddx = tx - fx + let ddy = ty - fy + let lineDist = sqrt(ddx*ddx + ddy*ddy) + if lineDist <= 0.1: return (0.0, 0.0) + let steps = max(1, int(lineDist / PathSampleStep)) + var h = 0.0 + var s = 0.0 + for si in 0..steps: + let frac = si.float / steps.float + let (sc, sr) = m.tileAt(fx + ddx * frac, fy + ddy * frac) + let v = m.lavaAt(sc, sr) + h = max(h, v) + s += v + (h, s / (steps + 1).float) + +proc safeSetSize(m: TFILModule, thr: float): int = + ## Replay of the picker's own hard filter over the tiles it considered, from + ## the same lava snapshot the pick saw. No re-implementation of the choice. + for t in m.cachedInsideTiles: + let tx = m.marginX + (t.col.float + 0.5) * GridSize + let ty = m.marginY + (t.row.float + 0.5) * GridSize + if pathHeat(m, m.lastBotX, m.lastBotY, tx, ty).max <= thr: inc result + +proc replay(path: string, seed: int): seq[Pick] = + randomize(seed) + loadTfilCommitEnv() + let fx = loadFixture(path) + let starts = loadRoundStarts(path) + var m = initTFIL() + var lastPicks = 0 + var pending: seq[tuple[col, row: int; at: int; idx: int]] + var mActive = 0 + for si in 0.. record the geometry + if m.picks != lastPicks: + lastPicks = m.picks + let (cc, cr) = m.tileAt(m.commitTarget.x, m.commitTarget.y) + let ang = arctan2(m.commitTarget.y - m.lastBotY, m.commitTarget.x - m.lastBotX) * + 180.0 / PI - fx.states[si].selfHeading + var turn = abs(((ang + 180.0) mod 360.0) - 180.0) + if turn > 180.0: turn = 360.0 - turn + let d = sqrt((m.commitTarget.x - m.lastBotX)^2 + (m.commitTarget.y - m.lastBotY)^2) + let (pmx, pmean) = pathHeat(m, m.lastBotX, m.lastBotY, m.commitTarget.x, m.commitTarget.y) + result.add Pick(turn: turn, pathMax: pmx, pathMean: pmean, + destHeat: m.lavaAt(cc, cr), dist: d, tta: d / MaxSpeed, + promoted: m.lastPickPromoted, + safePre: safeSetSize(m, TfilDangerThreshold), + cand: m.lastPickSafe, hotAtTta: false, reached: false) + pending.add (col: cc, row: cr, at: mActive + int(d / MaxSpeed), idx: result.high) + # 2. the arrival probe on the recorded true future + var keep: seq[tuple[col, row: int; at: int; idx: int]] + for p in pending: + if mActive < p.at: + keep.add p + else: + result[p.idx].hotAtTta = m.lavaAt(p.col, p.row) > TfilDangerThreshold + let px = m.marginX + (p.col.float + 0.5) * GridSize + let py = m.marginY + (p.row.float + 0.5) * GridSize + result[p.idx].reached = sqrt((m.lastBotX - px)^2 + (m.lastBotY - py)^2) < ArriveRadius + pending = keep + +proc mean(x: seq[float]): float = + if x.len == 0: return 0.0 + var s = 0.0 + for v in x: s += v + s / x.len.float + +proc pc(x: float): string = &"{100.0 * x:.1f}%" +proc f1(x: float): string = &"{x:.1f}" + +proc report(label, path: string, picks: seq[Pick]) = + echo &"\n\u2550\u2550\u2550 {label} {extractFilename(path)}" + if picks.len == 0: echo " no picks"; return + let thr = TfilDangerThreshold + var groups = [("EMPTY safe set (promoted)", picks.filterIt(it.promoted)), + ("non-empty safe set", picks.filterIt(not it.promoted))] + var allPerp, allHot, allFar, allBad = 0 + for (name, g) in groups: + let perp = g.filterIt(it.turn > PerpDeg) + let hot = g.filterIt(it.pathMax > thr) # crosses a hot region + let far = g.filterIt(it.tta > CommitTicks.float) # cannot arrive in the commitment + let bad = g.filterIt(it.turn > PerpDeg and it.pathMax > thr) + let badFar = g.filterIt(it.turn > PerpDeg and it.tta > CommitTicks.float) + let futHot = g.filterIt(it.hotAtTta) + let reach = g.filterIt(it.reached) + echo &" {name}: {g.len} picks ({pc(g.len.float/picks.len.float)} of all)" + if g.len == 0: continue + echo &" PERPENDICULAR (>60\u00b0) {perp.len:>6} {pc(perp.len.float/g.len.float):>7}" & + &" mean pathMax {f1(mean(perp.mapIt(it.pathMax)))}" + echo &" path crosses HOT {hot.len:>6} {pc(hot.len.float/g.len.float):>7}" & + &" mean pathMax(all) {f1(mean(g.mapIt(it.pathMax)))} destHeat {f1(mean(g.mapIt(it.destHeat)))}" + echo &" tta > commit({CommitTicks}) {far.len:>6} {pc(far.len.float/g.len.float):>7}" & + &" mean tta {f1(mean(g.mapIt(it.tta)))} max {f1(g.mapIt(it.tta).max)}" + echo &" PERP + hot {bad.len:>6} | PERP + far {badFar.len:>6}" + echo &" dest HOT when we arrive {futHot.len:>6} {pc(futHot.len.float/g.len.float):>7}" & + &" | actually arrived {pc(reach.len.float/g.len.float):>7}" + echo &" mean turn {f1(mean(g.mapIt(it.turn)))}\u00b0 mean safePre {f1(mean(g.mapIt(it.safePre.float)))}" & + &" mean cand {f1(mean(g.mapIt(it.cand.float)))}" + allPerp += perp.len; allHot += hot.len; allFar += far.len + allBad += bad.len + badFar.len + echo &" ALL: perp {pc(allPerp.float/picks.len.float)} hot-path {pc(allHot.float/picks.len.float)}" & + &" far {pc(allFar.float/picks.len.float)} (perp&(hot|far)) {pc(allBad.float/picks.len.float)}" + +# ── 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[Pick] +for f in fixtures: + if not fileExists(f): + echo "skip (missing): ", f; continue + for seed in [7, 8, 9]: + let p = replay(f, seed) + total.add p + if seed == 7: report("seed 7", f, p) # per-fixture detail, first seed only +report("ALL FIXTURES x 3 SEEDS", "", total) diff --git a/common_libs/tests/test_tfil_commit_env.nim b/common_libs/tests/test_tfil_commit_env.nim index d069ab3..fa443d6 100644 --- a/common_libs/tests/test_tfil_commit_env.nim +++ b/common_libs/tests/test_tfil_commit_env.nim @@ -1058,10 +1058,13 @@ proc testJ151() = delEnv("TR_TFIL_ARRIVE_TICKS") loadTfilCommitEnv() - check "j151: with the bound on (" & $on15.picks & " picks) NO target is " & - "farther than the horizon (" & $on15.beyond & " violations), while " & - "off the same fixture has " & $off.beyond & "/" & $off.picks, - on15.beyond == 0 and off.beyond > 0 + # the CEILING, stated: the bound is a filter on the SAFE set, so a tick whose + # every safe tile is past the horizon keeps the full pool (never starved) — + # those picks stay long, and the guard below measures exactly how many. + check "j151: picks past the 15-tick horizon collapse (" & $off.beyond & "/" & + $off.picks & " -> " & $on15.beyond & "/" & $on15.picks & "); the " & + "residue is the all-safe-tiles-are-far ticks, which keep the full pool", + on15.beyond < off.beyond div 2 and off.beyond > 0 check "j151: the mean time-to-arrive falls (" & off.meanTta.formatFloat(ffDecimal, 1) & " -> " & on15.meanTta.formatFloat(ffDecimal, 1) & " ticks) and the pool is " &