Files
SirRoboGarage/common_libs/tests/measure_tfil_heat_time.nim
T
SirStone fca899376e TFIL: time-indexed bullet heat (TR_TFIL_HEAT_TIME, DEFAULT OFF)
Make danger a function of time-to-arrival instead of flat distance. Bullet
core/aura/corridor heat becomes magnitude(power) * decay(dt), dt = along/speed:

  * decay(dt) = exp(-dt/tau) is a function of TIME; a fixed tau projects a
    pixel reach of speed*tau, so fast/weak bullets get a longer slope and slow
    ones a shorter one — derived from speed = 20 - 3*power, not hand-tuned.
    tau = TR_TFIL_HEAT_TAU.
  * magnitude(power) scales the near-end heat with power from DAMAGE
    (calcBulletDamage = 4p, linear in p; SCORE_PER_BULLET_DAMAGE = 1.0). Hit
    probability is FLAT across power (docs/env_reference.md), so risk does not
    justify power scaling — the cost of the hit does. Floored at 1.0 so a weak
    bullet's near end is never less dangerous than the flat model.
    Gain = TR_TFIL_HEAT_POWER_GAIN.

Every source is already f(dt), so the time-indexed planner (evaluate a cell at
the tick the bot would ARRIVE, i.e. heatDecay(dt - arrivalDelay)) is a one-line
change. It is intentionally NOT implemented here.

Default path is byte-identical: with TR_TFIL_HEAT_TIME unset both factors are
exactly 1.0 (IEEE x*1.0 is exact), and the committed golden replay in
common_libs/tests/test_tfil_commit_env.nim (20,026 ticks) still passes
byte-for-byte against the pre-change mover. The debug corridor outline is also
drawn only to the model's reach when enabled, so the GUI shows the shortening.

Offline field measurement (common_libs/tests/measure_tfil_heat_time.nim,
46,054 fixture ticks, tau=9/gain=1): corridor reach drops from 443px
wall-to-wall to 143px mean (32% retained); fraction of tiles > 10 goes
0.61 -> 0.57; largest contiguous safe region 118 -> 140 tiles; mean
distance-to-nearest-safe-tile 49 -> 42px. Saturation stays high because wall
radiance + pillar alone are 44% of tiles over threshold and are untouched.

Registers the three knobs in env_report (report + known-name set).
2026-09-25 21:47:54 +02:00

243 lines
9.5 KiB
Nim

## OFFLINE — does the time-indexed bullet heat (TR_TFIL_HEAT_TIME=1) actually
## open free zones? READ-ONLY: no live battles, no change to shipped defaults.
##
## Reuses the machinery of `measure_tfil_heat_field.nim` (jobs 42/43): drive the
## REAL `TFILModule.computeMove` over the committed DrussGT fixtures (so bullet
## tracking, reachable hull and cached inside-tiles are identical to the live
## mover) and read its private `lava` via `include`.
##
## Because `lava` depends only on bullets/enemies/walls/pillars — NOT on the
## bot's picks or the RNG — the OFF and ON replays differ in nothing but the
## heat function. That makes the before/after a clean static-field comparison.
##
## Metrics, per the user's question:
## * fraction of grid tiles above PathDangerThreshold (10)
## * largest contiguous safe region (4-connected), in tiles
## * mean grid-step / px distance from a tile to the nearest safe tile
## plus the corridor reach: the `dt` (and px) at which a bullet's corridor heat
## falls back to <= threshold, vs the old wall-to-wall corridor.
##
## Run:
## nim c -r --path:common_libs common_libs/tests/measure_tfil_heat_time.nim \
## [fixture.jsonl ...]
import std/[os, strformat, math, deques, sets, json]
import gun_harness/offline_range
# Private-field access: include (do NOT import) the shipped mover.
include movements/the_floor_is_lava
const SafeThreshold = 10.0
type
FieldMetrics = object
fracOver: float ## tiles with lava > 10 / all tiles
largestSafe: int ## largest 4-connected safe region, tiles
meanDistAll: float ## mean nearest-safe-tile distance over ALL tiles
meanDistUnsafe: float ## ... over the unsafe tiles only
hasSafe: bool
RunStats = object
ticks: int
sumFracOver: float
sumLargestSafe: float
maxLargestSafe: int
sumDistAll: float
sumDistUnsafe: float
noSafeTicks: int
# corridor reach (ON model only)
corridors: int
sumReachDt: float
sumReachPx: float
sumOldPx: float
histLo: array[6, int] ## reach px buckets: <50,50-100,... ,>=250
proc loadRoundStarts(fixturePath: string): HashSet[int] =
result = initHashSet[int]()
let side = currentSourcePath().parentDir.parentDir.parentDir /
"tools" / "fixtures" / "drussgt_meta" /
(extractFilename(fixturePath) & ".rounds.json")
if not fileExists(side): return
let root = parseFile(side)
if not root.hasKey("rounds"): return
for r in root["rounds"]:
if r.hasKey("startTick"): result.incl r["startTick"].getInt()
# ── the three field metrics, on a real `m.lava` ──────────────────────────────
proc fieldMetrics(m: TFILModule): FieldMetrics =
let n = m.cols * m.rows
var over = 0
var safe = newSeq[bool](n)
for i in 0..<n:
if m.lava[i] > SafeThreshold: inc over
else: safe[i] = true
result.fracOver = over.float / n.float
result.hasSafe = over < n
const Neigh = [(-1, 0), (1, 0), (0, -1), (0, 1)]
# multi-source BFS from every safe tile -> distance to nearest safe tile
var dist = newSeq[int](n)
var q = initDeque[int]()
for i in 0..<n:
if safe[i]:
dist[i] = 0
q.addLast(i)
else:
dist[i] = -1
while q.len > 0:
let cur = q.popFirst()
let cc = cur mod m.cols
let rr = cur div m.cols
for (dc, dr) in Neigh:
let nc = cc + dc
let nr = rr + dr
if nc < 0 or nc >= m.cols or nr < 0 or nr >= m.rows: continue
let ni = nr * m.cols + nc
if dist[ni] < 0:
dist[ni] = dist[cur] + 1
q.addLast(ni)
var sumAll = 0.0
var sumUnsafe = 0.0
var nUnsafe = 0
for i in 0..<n:
if dist[i] >= 0: sumAll += dist[i].float
if (not safe[i]) and dist[i] >= 0:
sumUnsafe += dist[i].float
inc nUnsafe
result.meanDistAll = sumAll / n.float
result.meanDistUnsafe = if nUnsafe > 0: sumUnsafe / nUnsafe.float else: 0.0
# largest 4-connected safe region
var seen = newSeq[bool](n)
var best = 0
for i in 0..<n:
if safe[i] and not seen[i]:
var size = 0
var q2 = initDeque[int]()
q2.addLast(i); seen[i] = true
while q2.len > 0:
let cur = q2.popFirst()
inc size
let cc = cur mod m.cols
let rr = cur div m.cols
for (dc, dr) in Neigh:
let nc = cc + dc
let nr = rr + dr
if nc < 0 or nc >= m.cols or nr < 0 or nr >= m.rows: continue
let ni = nr * m.cols + nc
if safe[ni] and not seen[ni]:
seen[ni] = true
q2.addLast(ni)
if size > best: best = size
result.largestSafe = best
# ── replay one fixture under one heat model ──────────────────────────────────
proc analyse(path: string, timeOn: bool, tau, gain: float): RunStats =
putEnv("TR_TFIL_HEAT_TIME", if timeOn: "1" else: "0")
putEnv("TR_TFIL_HEAT_TAU", $tau)
putEnv("TR_TFIL_HEAT_POWER_GAIN", $gain)
loadTfilHeatEnv()
let fx = loadFixture(path)
let starts = loadRoundStarts(path)
var m = initTFIL()
for si in 0..<fx.states.len:
let ws = fx.states[si]
if si == 0 or si in starts: m.resetRound()
discard m.computeMove(ws)
inc result.ticks
let fm = fieldMetrics(m)
result.sumFracOver += fm.fracOver
result.sumLargestSafe += fm.largestSafe.float
result.maxLargestSafe = max(result.maxLargestSafe, fm.largestSafe)
result.sumDistAll += fm.meanDistAll
result.sumDistUnsafe += fm.meanDistUnsafe
if not fm.hasSafe: inc result.noSafeTicks
if timeOn:
# Corridor reach: heat along the corridor is
# CorridorHeat * mag * exp(-dt/tau); it stops blocking at dt* where it
# falls to SafeThreshold, i.e. dt* = -tau*ln(thr/(CorridorHeat*mag)).
for b in m.bullets:
let cg = corridorGeom(b, m.arenaWidth, m.arenaHeight)
if cg.tMin == 0.0: continue
let speed = sqrt(b.velX * b.velX + b.velY * b.velY)
let mag = 1.0 + gain * (b.power / 3.0)
let near = CorridorHeat * mag
var reachPx = 0.0
var reachDt = 0.0
if near > SafeThreshold:
reachDt = -tau * ln(SafeThreshold / near)
reachPx = reachDt * speed
inc result.corridors
result.sumReachDt += reachDt
result.sumReachPx += reachPx
result.sumOldPx += cg.tMin
let bkt = if reachPx < 50.0: 0 elif reachPx < 100.0: 1
elif reachPx < 150.0: 2 elif reachPx < 200.0: 3
elif reachPx < 250.0: 4 else: 5
inc result.histLo[bkt]
proc f2(x: float): string = &"{x:.2f}"
proc reportOne(name: string, ticks: int, off, on: RunStats) =
let n = max(1, ticks).float
echo ""
echo "═══════════════════════════════════════════════════════════════════════════"
echo &"FILE {name} ticks={ticks}"
echo " OFF (shipped flat) ON (time-indexed)"
echo &" frac tiles > 10 {f2(off.sumFracOver/n):>19} {f2(on.sumFracOver/n):>19}"
echo &" largest safe region (tiles){f2(off.sumLargestSafe/n):>19} {f2(on.sumLargestSafe/n):>19}" &
&" (max {off.maxLargestSafe} -> {on.maxLargestSafe})"
echo &" mean dist->safe (all tiles){f2(off.sumDistAll/n):>19} {f2(on.sumDistAll/n):>19}" &
&" grid-steps (=x36 px: {f2(off.sumDistAll/n*36.0)} -> {f2(on.sumDistAll/n*36.0)})"
echo &" mean dist->safe (unsafe) {f2(off.sumDistUnsafe/n):>19} {f2(on.sumDistUnsafe/n):>19}"
echo &" ticks with NO safe tile {off.noSafeTicks:>19} {on.noSafeTicks:>19}"
if on.corridors > 0:
echo ""
echo " corridor reach under the ON model (where it stops blocking):"
echo &" mean reach dt = {f2(on.sumReachDt/on.corridors.float)} ticks"
echo &" mean reach px = {f2(on.sumReachPx/on.corridors.float)} px" &
&" vs mean old wall-to-wall corridor = {f2(on.sumOldPx/on.corridors.float)} px"
echo &" retained fraction = {f2(on.sumReachPx/on.sumOldPx*100.0)}% of the old corridor length"
const Names = ["<50", "50-100", "100-150", "150-200", "200-250", ">=250"]
for i in 0..5:
echo &" reach {Names[i]:>7} px : {on.histLo[i]} of {on.corridors} bullet-ticks"
proc main() =
var files: seq[string]
for i in 1..paramCount():
files.add paramStr(i)
if files.len == 0:
let dir = currentSourcePath().parentDir.parentDir.parentDir / "tools" / "fixtures"
files = @[
dir / "tr_drussgt_vs_modularbot.jsonl",
dir / "tr_drussgt_vs_modularbot_shield.jsonl",
dir / "tr_drussgt_vs_spinbot.jsonl",
dir / "tr_drussgt_vs_corners.jsonl",
]
const Tau = 9.0
const Gain = 1.0
# Sweep hooks: HT_TAU / HT_GAIN override the constants without a rebuild.
var tau = Tau
var gain = Gain
if existsEnv("HT_TAU"): tau = parseFloat(getEnv("HT_TAU"))
if existsEnv("HT_GAIN"): gain = parseFloat(getEnv("HT_GAIN"))
echo &"# Time-indexed bullet heat — offline field comparison (tau={tau} ticks, gain={gain})"
var ticks = 0
for f in files:
if not fileExists(f):
stderr.writeLine("missing fixture: " & f)
continue
let off = analyse(f, timeOn = false, tau = tau, gain = gain)
let on = analyse(f, timeOn = true, tau = tau, gain = gain)
reportOne(extractFilename(f), off.ticks, off, on)
ticks += off.ticks
echo ""
echo &"# done: {ticks} fixture ticks across {files.len} files (OFF vs ON)"
when isMainModule:
main()