j144 TFIL: arrival-based commitment + hysteresis + no mid-flight reversal

The owner's live-GUI report was correct on all four counts, and all four are
one bug: the commitment is cancelled by our own tile-boundary crossing
(96.1% of picks, 3793/3946, mean hold 5.06 ticks) while the bot is still
accelerating, and the picker is an unconstrained uniform draw over every
safe tile, so the new target can land in the mirror direction at |speed| < 4.

New knobs, all env-gated and default = today's behaviour (byte-for-byte
default parity guard re-run and green, 51 checks):
  TR_TFIL_COMMIT_ARRIVAL  hold the committed tile until we are ON it; the
                          tick knob becomes a MINIMUM dwell. 0 = shipped.
  TR_TFIL_COMMIT_MARGIN   leave only if the best alternative is at least
                          this much cooler on the same pathMaxHeat scale.
                          0 = shipped.
  TR_TFIL_NOREV_SPEED     while |speed| is below this, a mid-flight switch
                          may not take a tile >90 deg off the travel
                          direction. 0 = shipped. norevPool() never returns
                          an empty pool: with every candidate behind us it
                          takes the least-bad turn.

Offline gate (recorded DrussGT fixture, 20026 ticks): mean hold 4.1 -> 24.0
ticks, abandoned-before-arrival 92.8% -> 40.5%, committed tile actually
reached 3.3% -> 17.2%, opposite-direction slow mid-flight switches 394 -> 64
(-84%). 'TR_TFIL_TILE_REPLAN=off' alone - what cc11ede's arm B already tried -
only gets the hold to 13.6, which is why that A/B could not find this.

strafe is untouched: it imports only heatDecay/bulletMagScale/Pillar*, none
of which this touches. TR_MOVEMENT default stays strafe. Registered in
env_report.nim + knownEnvNames() + .env.example. Arms pre-registered in
docs/movement_campaign.md and tools/ab/arms_tfil_commit.txt.
This commit is contained in:
2026-09-26 21:07:09 +02:00
parent d4a5d100e7
commit d2005abee9
7 changed files with 728 additions and 34 deletions
+156 -27
View File
@@ -79,7 +79,8 @@ type
ttrSelf, ttrOff, ttrEnemy
TfilReplanReason* = enum
rrNone, rrInit, rrTileSelf, rrTileEnemy, rrDanger, rrExpiry
rrNone, rrInit, rrTileSelf, rrTileEnemy, rrDanger, rrExpiry,
rrArrival, rrHyst ## j144: the tile was REACHED / the alternative won by a margin
proc tileReplanName*(m: TfilTileReplan): string =
case m
@@ -95,16 +96,38 @@ proc reasonName*(r: TfilReplanReason): string =
of rrTileEnemy: "tile_enemy"
of rrDanger: "danger"
of rrExpiry: "expiry"
of rrArrival: "arrival"
of rrHyst: "hyst"
const
DefaultTfilCommitTicks = CommitTicks ## 15 — the shipped commitment length
NoRevForwardWeight = 3 ## forward:backward weight ratio (arm C)
ArriveRadius = 18.0 ## "we are on the committed tile" — same
## 18px radius the steering already uses
## at the bottom of this file
HullTicks = 50 ## the reachable-hull planning horizon. Past
## it the committed target is no longer
## guaranteed reachable, so the arrival
## commitment must release (stall escape).
var
TfilTileReplanMode*: TfilTileReplan = ttrSelf
TfilCommitTicks*: int = DefaultTfilCommitTicks
TfilNoRev*: bool = false
TfilCommitLogPath*: string = ""
## j144 — the commitment is held until the tile is REACHED, not for a fixed
## number of ticks. Every knob below defaults to OFF, so the shipped default
## path is byte-for-byte unchanged (see `test_tfil_commit_env.nim`).
## TR_TFIL_COMMIT_ARRIVAL 0/1 hold the committed tile until we are ON it
## (0 = shipped: fixed 15-tick dwell)
## TR_TFIL_COMMIT_MARGIN float leave it only if the best alternative tile
## is at least this much cooler (0 = no margin)
## TR_TFIL_NOREV_SPEED float while |speed| is below this, a mid-flight
## switch to the OPPOSITE side is refused
## (0 = off, today's behaviour)
TfilCommitArrival*: bool = false
TfilCommitMargin*: float = 0.0
TfilNoRevSpeed*: float = 0.0
## j134: the shared fire-detection correction (`TR_FIRE_FIX`, default on).
## Off = the shipped `prev - energy` detector byte-for-byte.
TfilFireFix*: bool = true
@@ -136,6 +159,9 @@ proc loadTfilCommitEnv*() =
TfilCommitTicks = max(1, getEnvInt("TR_TFIL_COMMIT_TICKS", DefaultTfilCommitTicks))
TfilNoRev = getEnvBool("TR_TFIL_NO_REV", false)
TfilCommitLogPath = getEnv("TR_TFIL_COMMIT_LOG", "")
TfilCommitArrival = getEnvBool("TR_TFIL_COMMIT_ARRIVAL", false)
TfilCommitMargin = max(0.0, getEnvFloat("TR_TFIL_COMMIT_MARGIN", 0.0))
TfilNoRevSpeed = max(0.0, getEnvFloat("TR_TFIL_NOREV_SPEED", 0.0))
TfilFireFix = getEnvBool("TR_FIRE_FIX", true)
loadTfilCommitEnv()
@@ -251,6 +277,7 @@ type
fire: FireTracker ## shared energy-drop detector (j134)
commitTarget: tuple[x, y: float] ## world coords of committed dodge point
commitTicks: int ## ticks remaining on commitment
commitAge: int ## total ticks held on the current target
commitLava: float ## lava at commit time (for spike detection)
blockedTile: tuple[col, row: int; active: bool] ## excluded from next pick after danger replan
cachedHull: seq[tuple[x, y: float]]
@@ -291,6 +318,7 @@ proc resetRound*(m: var TFILModule) =
m.bullets = @[]
m.fire.reset()
m.commitTicks = 0
m.commitAge = 0
m.cachedHull = @[]
m.cachedInsideTiles = @[]
m.blockedTile = (col: 0, row: 0, active: false)
@@ -517,6 +545,30 @@ proc noRevWeights*(dirs: openArray[float], travelDeg: float): seq[int] =
while a < -180.0: a += 360.0
result.add (if abs(a) <= 90.0: NoRevForwardWeight else: 1)
proc norevPool*(offs: openArray[float], threshold: float): seq[int] =
## j144: which candidate tiles may a slow, mid-flight switch take?
## `offs` are the signed angles (deg) from the travel direction to each
## candidate, `threshold` is the speed gate (px/tick). Returns the indices
## that are NOT more than 90 deg off — i.e. the bot does not have to turn
## around to reach them.
## IF EVERY candidate is behind us the reversal is unavoidable (boxed in, or
## the field only offers rearward space), so the single LEAST-bad one is
## returned: a shallow turn, not a 180 deg flip. The result is NEVER empty,
## so the pick can never be starved. `threshold <= 0` means the knob is off
## and every candidate stays.
if offs.len == 0: return
if threshold <= 0.0: # the knob is off: no filtering at all
for i in 0..<offs.len: result.add i
return
var keep: seq[int]
for i, a in offs:
if abs(a) <= 90.0: keep.add i
if keep.len > 0: return keep
var best = 0
for i, a in offs:
if abs(a) < abs(offs[best]): best = i
@[best]
proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand =
if m.cols == 0:
m.initGrid(ws.arenaWidth, ws.arenaHeight)
@@ -537,7 +589,12 @@ proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand =
m.fire.prevEnergySet(ei.id, ei.energy)
# Tile-change replan — see the knob rationale at the top of the file.
if (not jumped) and (m.callCount > 0) and (m.commitTicks > 0):
# j144: with TR_TFIL_COMMIT_ARRIVAL the SELF-tile crossing is exactly the event
# that must NOT cancel a commitment: crossing a boundary is the very motion
# the commitment commands, and it fires every ~5 ticks (GridSize 36, speed 8).
# Under the shipped default (arrival off) this is the original block verbatim.
if (not jumped) and (m.callCount > 0) and (m.commitTicks > 0) and
not (TfilCommitArrival and TfilTileReplanMode == ttrSelf):
case TfilTileReplanMode
of ttrSelf:
let curTileCol = clamp(int((ws.selfX - m.marginX) / GridSize), 0, m.cols - 1)
@@ -761,13 +818,14 @@ proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand =
# log-only bookkeeping for this tick
var pickedThisTick = false
var pickedRev = false
var pickedMidFlight = false
var pickedInterval = 0
# Hull + inside-tiles: only recompute on replan tick (commitTicks == 0)
type TileRef = tuple[col, row: int]
if m.commitTicks == 0:
let hull = computeReachableHull(ws.selfX, ws.selfY, ws.selfHeading, ws.selfSpeed,
m.arenaWidth, m.arenaHeight, 50)
m.arenaWidth, m.arenaHeight, HullTicks)
# store as named-field seq to match cachedHull type
m.cachedHull = @[]
for p in hull: m.cachedHull.add (x: p[0], y: p[1])
@@ -814,23 +872,39 @@ proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand =
const PathSampleStep = 18.0 # ~half a tile
const PathDangerThreshold = 10.0 # max lava on path; above this = unsafe
type ScoredTile = tuple[col, row: int; pathMaxHeat: float]
proc pathMaxHeat(m: TFILModule, fx, fy, tx, ty: float): float =
## MAX lava on the straight-line segment (fx,fy) -> (tx,ty), sampled every
## ~half a tile. One function so the picker and the j144 hysteresis test
## score the committed target and the alternatives on the SAME scale.
let ddx = tx - fx
let ddy = ty - fy
let lineDist = sqrt(ddx*ddx + ddy*ddy)
if lineDist <= 0.1: return 0.0
let steps = max(1, int(lineDist / PathSampleStep))
var h = 0.0
for si in 0..steps:
let frac = si.float / steps.float
let (sc, sr) = m.tileAt(fx + ddx * frac, fy + ddy * frac)
h = max(h, m.lavaAt(sc, sr))
h
proc tileOffTravel(m: TFILModule, col, row: int,
sx, sy, travelDeg: float): float =
## Signed angle in degrees from the travel direction to the tile centre,
## folded into (-180, 180]. Positive = the tile lies counterclockwise.
let tx = m.marginX + (col.float + 0.5) * GridSize
let ty = m.marginY + (row.float + 0.5) * GridSize
result = arctan2(ty - sy, tx - sx) * 180.0 / PI - travelDeg
while result > 180.0: result -= 360.0
while result < -180.0: result += 360.0
var scoredTiles: seq[ScoredTile]
for t in coolTiles:
let tx = m.marginX + (t.col.float + 0.5) * GridSize
let ty = m.marginY + (t.row.float + 0.5) * GridSize
let ddx = tx - ws.selfX
let ddy = ty - ws.selfY
let lineDist = sqrt(ddx*ddx + ddy*ddy)
var pathMaxHeat = 0.0
if lineDist > 0.1:
let steps = max(1, int(lineDist / PathSampleStep))
for si in 0..steps:
let frac = si.float / steps.float
let sx = ws.selfX + ddx * frac
let sy = ws.selfY + ddy * frac
let (sc, sr) = m.tileAt(sx, sy)
pathMaxHeat = max(pathMaxHeat, m.lavaAt(sc, sr))
scoredTiles.add (col: t.col, row: t.row, pathMaxHeat: pathMaxHeat)
scoredTiles.add (col: t.col, row: t.row,
pathMaxHeat: pathMaxHeat(m, ws.selfX, ws.selfY, tx, ty))
# Sort by pathMaxHeat ascending (insertion sort — small N)
for i in 1..<scoredTiles.len:
@@ -857,24 +931,62 @@ proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand =
safeTiles.add blockedTiles[i]
blockedTiles = blockedTiles[promote ..< blockedTiles.len]
# Commitment logic
# Commitment logic. With every j144 knob at its default (all off) this is the
# original three-way test, unchanged. j144 adds two ways OUT of a commitment
# that are NOT a tile crossing, and turns the tick counter into a MINIMUM
# dwell: the target is held until we are actually standing on it.
let atTarget = (ws.selfX - m.commitTarget.x)^2 + (ws.selfY - m.commitTarget.y)^2 <
ArriveRadius * ArriveRadius
var commitEnd = rrNone
if m.commitTicks > 0:
# Only allow danger replan after MinCommitTicks have elapsed
inc m.commitAge
# Only allow a replan after MinCommitTicks have elapsed
let ticksElapsed = TfilCommitTicks - m.commitTicks
if ticksElapsed >= MinCommitTicks:
let (cc, cr) = m.tileAt(m.commitTarget.x, m.commitTarget.y)
let curLava = m.lavaAt(cc, cr)
if curLava > m.commitLava + DangerReplanThreshold:
# Mark committed tile blocked so we don't re-pick it
# GENUINE DANGER: the committed tile got hot. Block it so we don't
# immediately re-pick it, and replan. This is the safety valve and is
# deliberately independent of any boundary crossing.
m.blockedTile = (col: cc, row: cr, active: true)
m.commitTicks = 0 # replan
m.replanReason = rrDanger
else:
dec m.commitTicks
if m.commitTicks == 0: m.replanReason = rrExpiry
else:
commitEnd = rrDanger
elif TfilCommitArrival:
if atTarget:
# Reached. Only now is a new target allowed.
commitEnd = rrArrival
elif m.commitAge >= HullTicks:
# Stall escape: past the planner's own reachability horizon the
# committed tile is no longer guaranteed reachable (rammed, boxed in).
commitEnd = rrExpiry
elif TfilCommitMargin > 0.0:
# Hysteresis: "the path is still good" must not be able to switch us.
# Leave only when the best OTHER safe tile is cooler by > margin,
# measured on the same pathMaxHeat scale the picker uses.
var altBest = Inf
for t in safeTiles:
if t.col == cc and t.row == cr: continue
altBest = min(altBest, t.pathMaxHeat)
if altBest < Inf and
altBest < pathMaxHeat(m, ws.selfX, ws.selfY, m.commitTarget.x,
m.commitTarget.y) - TfilCommitMargin:
commitEnd = rrHyst
if commitEnd == rrNone:
dec m.commitTicks
if m.commitTicks == 0: m.replanReason = rrExpiry
if m.commitTicks == 0:
if TfilCommitArrival:
m.commitTicks = TfilCommitTicks # minimum dwell reached: renew, don't abandon
else:
m.replanReason = rrExpiry
else:
m.commitTicks = 0
m.replanReason = commitEnd
# j144: was the commitment we are about to replace still UNREACHED? A pick that
# replaces a target we had not yet got to is the owner's failure mode: the bot
# is still accelerating and the target flips under it. `picks == 0` means this
# is the first pick of the round, which is not a switch at all.
let midFlight = m.picks > 0 and not atTarget
if m.commitTicks == 0 and safeTiles.len > 0:
# Filter out the blocked tile from candidates
@@ -886,6 +998,20 @@ proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand =
if candidates.len == 0: candidates = safeTiles # all blocked → ignore block
let travelDeg = if ws.selfSpeed < -0.01: ws.selfHeading + 180.0
else: ws.selfHeading
# j144, no opposite-direction flip while still accelerating. Below the speed
# threshold the bot physically cannot complete a reversal before the bullet
# lands, so a mid-flight switch to the mirror side only destroys the dodge it
# already has. It is refused outright — and only for a MID-FLIGHT switch: if
# we are already standing on the committed tile (an arrival pick) the bot is
# free to go anywhere, and that is exactly the pick that must not be blocked.
if TfilNoRevSpeed > 0.0 and abs(ws.selfSpeed) < TfilNoRevSpeed and midFlight:
var offs: seq[float]
for t in candidates:
offs.add tileOffTravel(m, t.col, t.row, ws.selfX, ws.selfY, travelDeg)
let keep = norevPool(offs, TfilNoRevSpeed)
var narrowed: seq[ScoredTile]
for i in keep: narrowed.add candidates[i]
candidates = narrowed
var chosen = 0
if TfilNoRev and candidates.len >= 2:
# Soft no-reversal preference (arm C): down-weight — never filter — tiles
@@ -920,6 +1046,7 @@ proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand =
m.commitTarget = (x: m.marginX + (ct.col.float + 0.5) * GridSize,
y: m.marginY + (ct.row.float + 0.5) * GridSize)
m.commitTicks = TfilCommitTicks
m.commitAge = 0
m.commitLava = m.lavaAt(ct.col, ct.row)
m.blockedTile.active = false # clear after successful pick
@@ -930,6 +1057,7 @@ proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand =
while rd < -180.0: rd += 360.0
pickedThisTick = true
pickedRev = abs(rd) > 90.0
pickedMidFlight = midFlight
pickedInterval = m.callCount - m.lastPickCall
m.lastPickCall = m.callCount
inc m.picks
@@ -1002,7 +1130,8 @@ proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand =
",\"sp\":" & $ws.selfSpeed & ",\"ct\":" & $m.commitTicks &
",\"pick\":" & (if pickedThisTick: "1" else: "0") &
",\"reason\":\"" & reasonName(reason) & "\",\"rev\":" &
(if pickedRev: "1" else: "0") & ",\"interval\":" & $pickedInterval &
(if pickedRev: "1" else: "0") & ",\"mid\":" &
(if pickedMidFlight: "1" else: "0") & ",\"interval\":" & $pickedInterval &
",\"picks\":" & $m.picks & "}")
if pickedThisTick: m.replanReason = rrNone