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).
This commit is contained in:
2026-09-25 21:47:54 +02:00
parent 4270136948
commit fca899376e
3 changed files with 344 additions and 5 deletions
+96 -5
View File
@@ -94,6 +94,12 @@ proc getEnvBool(name: string, default: bool): bool =
if s.len == 0: return default
s in ["1", "true", "on", "yes"]
proc getEnvFloat(name: string, default: float): float =
let s = getEnv(name, "")
if s.len == 0: return default
try: result = parseFloat(s.strip())
except ValueError: result = default
proc loadTfilCommitEnv*() =
## Read the commit knobs. Called once at module init; the guard test calls it
## again after `putEnv` so the non-default arms can be exercised in one process.
@@ -107,6 +113,66 @@ proc loadTfilCommitEnv*() =
loadTfilCommitEnv()
# ── Time-indexed bullet heat (TR_TFIL_HEAT_TIME=1, default OFF = shipped) ─────
#
# WHY: the flat model gives every bullet-overlapping tile the same heat and
# paints the bullet's corridor all the way to the arena wall, regardless of how
# far away or how weak the bullet still is. `CorridorHeat` (20) is twice
# `PathDangerThreshold` (10), so ONE weak far bullet saturates a 108px-wide
# swath from its nose to the wall, and a path the bullet will not reach until
# long after the bot has left it is already marked unsafe.
#
# WHAT: heat becomes a function of `dt`, the time (ticks) until the bullet
# REACHES that cell:
#
# dt = along / speed # along = distance from the bullet
# heat = magnitude(power) * decay(dt)
#
# * `decay(dt) = exp(-dt / tau)` is a function of TIME, not pixels. A fixed time
# constant `tau` therefore projects a PIXEL reach of `speed * tau`: a fast
# bullet's slope is longer, a slow one's shorter — DERIVED from the physics
# (`speed = 20 - 3*power`), not hand-tuned per power. `tau` = TR_TFIL_HEAT_TAU.
# * `magnitude(power)` scales the near-end heat with power from DAMAGE, not from
# hit chance: server damage is `calcBulletDamage = 4p`, linear in p, and
# `SCORE_PER_BULLET_DAMAGE = 1.0`, so a stronger bullet costs more when it
# hits. 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.
#
# TIME-INDEXED PLANNER (NOT implemented, by design): because every source is
# already expressed as `f(dt)`, evaluating a cell at the tick the bot would
# ARRIVE there is the one-line change `heatDecay(dt - arrivalDelay)` — heat a
# later bullet's path by that bullet's lead on the bot's own arrival time, so
# the bot can use the path and leave before the bullet arrives. This is the real
# fix for the user's second point; the shipped move is unchanged until then.
var
TfilHeatTime*: bool = false
TfilHeatTau*: float = 9.0 ## decay time constant, ticks
TfilHeatPowerGain*: float = 1.0 ## extra near-end heat at max power (damage proxy)
proc loadTfilHeatEnv*() =
## Read the heat-model knobs. Called once at module init; also callable after
## `putEnv` so one process can A/B both models (the offline ruler does this).
TfilHeatTime = getEnvBool("TR_TFIL_HEAT_TIME", false)
TfilHeatTau = max(0.05, getEnvFloat("TR_TFIL_HEAT_TAU", 9.0))
TfilHeatPowerGain = max(0.0, getEnvFloat("TR_TFIL_HEAT_POWER_GAIN", 1.0))
loadTfilHeatEnv()
proc heatDecay*(dt: float): float =
## Fraction of a bullet's heat still present `dt` ticks before it arrives.
## Exactly 1.0 when the time model is off, so the default field is
## bit-identical to the flat model (multiplying any heat by 1.0 is exact).
if not TfilHeatTime or dt <= 0.0: return 1.0
exp(-dt / TfilHeatTau)
proc bulletMagScale*(power: float): float =
## Near-end heat multiplier from the bullet's DAMAGE (4p, linear in power),
## floored at 1.0. Exactly 1.0 when the time model is off.
if not TfilHeatTime: return 1.0
1.0 + TfilHeatPowerGain * (power / 3.0)
proc bulletRadii(power: float): tuple[core, aura: float] =
let t = (power - 0.1) / 2.9
let core = BulletCoreRadiusMin + t * (BulletCoreRadiusMax - BulletCoreRadiusMin)
@@ -444,6 +510,12 @@ proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand =
let bx = b.x
let by = b.y
let (coreR, auraR) = bulletRadii(b.power)
# Time model: project the tile's nearest point onto the bullet heading. When
# the model is off these factors are exactly 1.0, so heat is unchanged.
let bSpeed = sqrt(b.velX * b.velX + b.velY * b.velY)
let bUx = if bSpeed > 0.0: b.velX / bSpeed else: 0.0
let bUy = if bSpeed > 0.0: b.velY / bSpeed else: 0.0
let bMag = bulletMagScale(b.power)
let colMin = max(0, int(floor((bx - auraR - m.marginX) / GridSize)))
let colMax = min(m.cols-1, int(floor((bx + auraR - m.marginX) / GridSize)))
let rowMin = max(0, int(floor((by - auraR - m.marginY) / GridSize)))
@@ -458,15 +530,23 @@ proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand =
let dy = nearY - by
let d2 = dx*dx + dy*dy
if d2 <= coreR * coreR:
m.lava[row * m.cols + col] += BulletCore
let along = dx * bUx + dy * bUy
m.lava[row * m.cols + col] += BulletCore * bMag *
heatDecay(along / bSpeed)
elif d2 <= auraR * auraR:
m.lava[row * m.cols + col] += BulletAura
let along = dx * bUx + dy * bUy
m.lava[row * m.cols + col] += BulletAura * bMag *
heatDecay(along / bSpeed)
# Corridor heat — rotated rectangle from bullet position to arena wall, auraR wide
for b in m.bullets:
let cg = corridorGeom(b, m.arenaWidth, m.arenaHeight)
if cg.tMin == 0.0: continue # zero-speed bullet, skip
let (_, auraR) = bulletRadii(b.power)
# Time model: the corridor gradient is `dt = along / speed` (see the heat
# block above). Off -> exactly 1.0, so the corridor is the flat shipped one.
let bSpeed = sqrt(b.velX * b.velX + b.velY * b.velY)
let bMag = bulletMagScale(b.power)
let wx = cg.bx + cg.dx * cg.tMin
let wy = cg.by + cg.dy * cg.tMin
# Bounding box of the 4 corners
@@ -492,7 +572,8 @@ proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand =
let along = relX * cg.dx + relY * cg.dy
let perp = relX * cg.px + relY * cg.py
if along >= 0.0 and along <= cg.tMin and perp >= -auraR and perp <= auraR:
m.lava[row * m.cols + col] += CorridorHeat
m.lava[row * m.cols + col] += CorridorHeat * bMag *
heatDecay(along / bSpeed)
# Enemy heat auras — core (18px) and aura ring (54px), same pattern as bullets
for ei in ws.enemies:
@@ -582,8 +663,18 @@ proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand =
let (_, auraR) = bulletRadii(b.power)
let cg = corridorGeom(b, m.arenaWidth, m.arenaHeight)
if cg.tMin == 0.0: continue
let wx = cg.bx + cg.dx * cg.tMin
let wy = cg.by + cg.dy * cg.tMin
# When the time model is on, draw only as far as the corridor still blocks
# (heat > PathDangerThreshold = 10); otherwise the outline would claim a
# wall-to-wall threat the field no longer has. Off -> reach = cg.tMin.
var reach = cg.tMin
if TfilHeatTime:
let speed = sqrt(b.velX * b.velX + b.velY * b.velY)
let near = CorridorHeat * bulletMagScale(b.power)
reach = if speed > 0.0 and near > 10.0:
min(reach, -TfilHeatTau * ln(10.0 / near) * speed)
else: 0.0
let wx = cg.bx + cg.dx * reach
let wy = cg.by + cg.dy * reach
let corners: seq[(float, float)] = @[
(cg.bx + cg.px * auraR, cg.by + cg.py * auraR),
(cg.bx - cg.px * auraR, cg.by - cg.py * auraR),