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
+3
View File
@@ -111,6 +111,9 @@ TR_TFIL_TILE_REPLAN=self # self | enemy | off: when a dodge commitment is can
TR_TFIL_COMMIT_TICKS=15 # ticks to commit to a dodge point before replanning TR_TFIL_COMMIT_TICKS=15 # ticks to commit to a dodge point before replanning
TR_TFIL_NO_REV=off # on = never reverse direction inside a corridor 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_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)
TR_TFIL_COMMIT_MARGIN=0.0 # lava an alternative tile must be cooler by before it wins the tile
TR_TFIL_NOREV_SPEED=0.0 # px/tick; below this, a mid-flight switch may not turn the bot around
TR_TFIL_HEAT_TIME=off # on = index bullet heat by time (flat field when off) TR_TFIL_HEAT_TIME=off # on = index bullet heat by time (flat field when off)
TR_TFIL_HEAT_TAU=9.0 # ticks a tracked bullet's heat lives for TR_TFIL_HEAT_TAU=9.0 # ticks a tracked bullet's heat lives for
TR_TFIL_HEAT_POWER_GAIN=1.0 # scale of the heat a bullet paints, per firepower TR_TFIL_HEAT_POWER_GAIN=1.0 # scale of the heat a bullet paints, per firepower
+7 -1
View File
@@ -319,6 +319,11 @@ proc printEffectiveValues(ctx: EnvReportContext) =
emit("TR_TFIL_COMMIT_TICKS", $TfilCommitTicks, sourceOf("TR_TFIL_COMMIT_TICKS")) emit("TR_TFIL_COMMIT_TICKS", $TfilCommitTicks, sourceOf("TR_TFIL_COMMIT_TICKS"))
emit("TR_TFIL_NO_REV", onOff(TfilNoRev), sourceOfPresence("TR_TFIL_NO_REV")) emit("TR_TFIL_NO_REV", onOff(TfilNoRev), sourceOfPresence("TR_TFIL_NO_REV"))
emit("TR_TFIL_COMMIT_LOG", TfilCommitLogPath, sourceOfPresence("TR_TFIL_COMMIT_LOG")) emit("TR_TFIL_COMMIT_LOG", TfilCommitLogPath, sourceOfPresence("TR_TFIL_COMMIT_LOG"))
emit("TR_TFIL_COMMIT_ARRIVAL", onOff(TfilCommitArrival),
sourceOf("TR_TFIL_COMMIT_ARRIVAL"))
emit("TR_TFIL_COMMIT_MARGIN", $TfilCommitMargin,
sourceOf("TR_TFIL_COMMIT_MARGIN"))
emit("TR_TFIL_NOREV_SPEED", $TfilNoRevSpeed, sourceOf("TR_TFIL_NOREV_SPEED"))
# time-indexed bullet heat (default off = shipped flat model) # time-indexed bullet heat (default off = shipped flat model)
emit("TR_TFIL_HEAT_TIME", onOff(TfilHeatTime), sourceOf("TR_TFIL_HEAT_TIME")) emit("TR_TFIL_HEAT_TIME", onOff(TfilHeatTime), sourceOf("TR_TFIL_HEAT_TIME"))
emit("TR_TFIL_HEAT_TAU", $TfilHeatTau, sourceOf("TR_TFIL_HEAT_TAU")) emit("TR_TFIL_HEAT_TAU", $TfilHeatTau, sourceOf("TR_TFIL_HEAT_TAU"))
@@ -633,7 +638,8 @@ proc knownEnvNames*(): seq[string] =
"TR_TFIL_RANGE_K", "TR_TFIL_CORRIDOR_HEAT", "TR_TFIL_WALL_HOTNESS", "TR_TFIL_RANGE_K", "TR_TFIL_CORRIDOR_HEAT", "TR_TFIL_WALL_HOTNESS",
"TR_TFIL_WALL_RADIANCE", "TR_TFIL_WALL_RADIANCE",
"TR_TFIL_TILE_REPLAN", "TR_TFIL_COMMIT_TICKS", "TR_TFIL_NO_REV", "TR_TFIL_TILE_REPLAN", "TR_TFIL_COMMIT_TICKS", "TR_TFIL_NO_REV",
"TR_TFIL_COMMIT_LOG", "TR_TFIL_COMMIT_LOG", "TR_TFIL_COMMIT_ARRIVAL", "TR_TFIL_COMMIT_MARGIN",
"TR_TFIL_NOREV_SPEED",
"TR_TFIL_HEAT_TIME", "TR_TFIL_HEAT_TAU", "TR_TFIL_HEAT_POWER_GAIN", "TR_TFIL_HEAT_TIME", "TR_TFIL_HEAT_TAU", "TR_TFIL_HEAT_POWER_GAIN",
"TR_TFIL_PILLAR_ON", "TR_TFIL_PILLAR_ON",
"TR_STRAFE_BAND", "TR_STRAFE_SPREAD", "TR_STRAFE_REACH", "TR_STRAFE_BAND", "TR_STRAFE_SPREAD", "TR_STRAFE_REACH",
+156 -27
View File
@@ -79,7 +79,8 @@ type
ttrSelf, ttrOff, ttrEnemy ttrSelf, ttrOff, ttrEnemy
TfilReplanReason* = enum 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 = proc tileReplanName*(m: TfilTileReplan): string =
case m case m
@@ -95,16 +96,38 @@ proc reasonName*(r: TfilReplanReason): string =
of rrTileEnemy: "tile_enemy" of rrTileEnemy: "tile_enemy"
of rrDanger: "danger" of rrDanger: "danger"
of rrExpiry: "expiry" of rrExpiry: "expiry"
of rrArrival: "arrival"
of rrHyst: "hyst"
const const
DefaultTfilCommitTicks = CommitTicks ## 15 — the shipped commitment length DefaultTfilCommitTicks = CommitTicks ## 15 — the shipped commitment length
NoRevForwardWeight = 3 ## forward:backward weight ratio (arm C) 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 var
TfilTileReplanMode*: TfilTileReplan = ttrSelf TfilTileReplanMode*: TfilTileReplan = ttrSelf
TfilCommitTicks*: int = DefaultTfilCommitTicks TfilCommitTicks*: int = DefaultTfilCommitTicks
TfilNoRev*: bool = false TfilNoRev*: bool = false
TfilCommitLogPath*: string = "" 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). ## j134: the shared fire-detection correction (`TR_FIRE_FIX`, default on).
## Off = the shipped `prev - energy` detector byte-for-byte. ## Off = the shipped `prev - energy` detector byte-for-byte.
TfilFireFix*: bool = true TfilFireFix*: bool = true
@@ -136,6 +159,9 @@ proc loadTfilCommitEnv*() =
TfilCommitTicks = max(1, getEnvInt("TR_TFIL_COMMIT_TICKS", DefaultTfilCommitTicks)) TfilCommitTicks = max(1, getEnvInt("TR_TFIL_COMMIT_TICKS", DefaultTfilCommitTicks))
TfilNoRev = getEnvBool("TR_TFIL_NO_REV", false) TfilNoRev = getEnvBool("TR_TFIL_NO_REV", false)
TfilCommitLogPath = getEnv("TR_TFIL_COMMIT_LOG", "") 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) TfilFireFix = getEnvBool("TR_FIRE_FIX", true)
loadTfilCommitEnv() loadTfilCommitEnv()
@@ -251,6 +277,7 @@ type
fire: FireTracker ## shared energy-drop detector (j134) fire: FireTracker ## shared energy-drop detector (j134)
commitTarget: tuple[x, y: float] ## world coords of committed dodge point commitTarget: tuple[x, y: float] ## world coords of committed dodge point
commitTicks: int ## ticks remaining on commitment commitTicks: int ## ticks remaining on commitment
commitAge: int ## total ticks held on the current target
commitLava: float ## lava at commit time (for spike detection) commitLava: float ## lava at commit time (for spike detection)
blockedTile: tuple[col, row: int; active: bool] ## excluded from next pick after danger replan blockedTile: tuple[col, row: int; active: bool] ## excluded from next pick after danger replan
cachedHull: seq[tuple[x, y: float]] cachedHull: seq[tuple[x, y: float]]
@@ -291,6 +318,7 @@ proc resetRound*(m: var TFILModule) =
m.bullets = @[] m.bullets = @[]
m.fire.reset() m.fire.reset()
m.commitTicks = 0 m.commitTicks = 0
m.commitAge = 0
m.cachedHull = @[] m.cachedHull = @[]
m.cachedInsideTiles = @[] m.cachedInsideTiles = @[]
m.blockedTile = (col: 0, row: 0, active: false) 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 while a < -180.0: a += 360.0
result.add (if abs(a) <= 90.0: NoRevForwardWeight else: 1) 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 = proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand =
if m.cols == 0: if m.cols == 0:
m.initGrid(ws.arenaWidth, ws.arenaHeight) 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) m.fire.prevEnergySet(ei.id, ei.energy)
# Tile-change replan — see the knob rationale at the top of the file. # 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 case TfilTileReplanMode
of ttrSelf: of ttrSelf:
let curTileCol = clamp(int((ws.selfX - m.marginX) / GridSize), 0, m.cols - 1) 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 # log-only bookkeeping for this tick
var pickedThisTick = false var pickedThisTick = false
var pickedRev = false var pickedRev = false
var pickedMidFlight = false
var pickedInterval = 0 var pickedInterval = 0
# Hull + inside-tiles: only recompute on replan tick (commitTicks == 0) # Hull + inside-tiles: only recompute on replan tick (commitTicks == 0)
type TileRef = tuple[col, row: int] type TileRef = tuple[col, row: int]
if m.commitTicks == 0: if m.commitTicks == 0:
let hull = computeReachableHull(ws.selfX, ws.selfY, ws.selfHeading, ws.selfSpeed, 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 # store as named-field seq to match cachedHull type
m.cachedHull = @[] m.cachedHull = @[]
for p in hull: m.cachedHull.add (x: p[0], y: p[1]) 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 PathSampleStep = 18.0 # ~half a tile
const PathDangerThreshold = 10.0 # max lava on path; above this = unsafe const PathDangerThreshold = 10.0 # max lava on path; above this = unsafe
type ScoredTile = tuple[col, row: int; pathMaxHeat: float] 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] var scoredTiles: seq[ScoredTile]
for t in coolTiles: for t in coolTiles:
let tx = m.marginX + (t.col.float + 0.5) * GridSize let tx = m.marginX + (t.col.float + 0.5) * GridSize
let ty = m.marginY + (t.row.float + 0.5) * GridSize let ty = m.marginY + (t.row.float + 0.5) * GridSize
let ddx = tx - ws.selfX scoredTiles.add (col: t.col, row: t.row,
let ddy = ty - ws.selfY pathMaxHeat: pathMaxHeat(m, ws.selfX, ws.selfY, tx, ty))
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)
# Sort by pathMaxHeat ascending (insertion sort — small N) # Sort by pathMaxHeat ascending (insertion sort — small N)
for i in 1..<scoredTiles.len: for i in 1..<scoredTiles.len:
@@ -857,24 +931,62 @@ proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand =
safeTiles.add blockedTiles[i] safeTiles.add blockedTiles[i]
blockedTiles = blockedTiles[promote ..< blockedTiles.len] 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: 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 let ticksElapsed = TfilCommitTicks - m.commitTicks
if ticksElapsed >= MinCommitTicks: if ticksElapsed >= MinCommitTicks:
let (cc, cr) = m.tileAt(m.commitTarget.x, m.commitTarget.y) let (cc, cr) = m.tileAt(m.commitTarget.x, m.commitTarget.y)
let curLava = m.lavaAt(cc, cr) let curLava = m.lavaAt(cc, cr)
if curLava > m.commitLava + DangerReplanThreshold: 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.blockedTile = (col: cc, row: cr, active: true)
m.commitTicks = 0 # replan commitEnd = rrDanger
m.replanReason = rrDanger elif TfilCommitArrival:
else: if atTarget:
dec m.commitTicks # Reached. Only now is a new target allowed.
if m.commitTicks == 0: m.replanReason = rrExpiry commitEnd = rrArrival
else: 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 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: if m.commitTicks == 0 and safeTiles.len > 0:
# Filter out the blocked tile from candidates # 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 if candidates.len == 0: candidates = safeTiles # all blocked → ignore block
let travelDeg = if ws.selfSpeed < -0.01: ws.selfHeading + 180.0 let travelDeg = if ws.selfSpeed < -0.01: ws.selfHeading + 180.0
else: ws.selfHeading 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 var chosen = 0
if TfilNoRev and candidates.len >= 2: if TfilNoRev and candidates.len >= 2:
# Soft no-reversal preference (arm C): down-weight — never filter — tiles # 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, m.commitTarget = (x: m.marginX + (ct.col.float + 0.5) * GridSize,
y: m.marginY + (ct.row.float + 0.5) * GridSize) y: m.marginY + (ct.row.float + 0.5) * GridSize)
m.commitTicks = TfilCommitTicks m.commitTicks = TfilCommitTicks
m.commitAge = 0
m.commitLava = m.lavaAt(ct.col, ct.row) m.commitLava = m.lavaAt(ct.col, ct.row)
m.blockedTile.active = false # clear after successful pick 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 while rd < -180.0: rd += 360.0
pickedThisTick = true pickedThisTick = true
pickedRev = abs(rd) > 90.0 pickedRev = abs(rd) > 90.0
pickedMidFlight = midFlight
pickedInterval = m.callCount - m.lastPickCall pickedInterval = m.callCount - m.lastPickCall
m.lastPickCall = m.callCount m.lastPickCall = m.callCount
inc m.picks inc m.picks
@@ -1002,7 +1130,8 @@ proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand =
",\"sp\":" & $ws.selfSpeed & ",\"ct\":" & $m.commitTicks & ",\"sp\":" & $ws.selfSpeed & ",\"ct\":" & $m.commitTicks &
",\"pick\":" & (if pickedThisTick: "1" else: "0") & ",\"pick\":" & (if pickedThisTick: "1" else: "0") &
",\"reason\":\"" & reasonName(reason) & "\",\"rev\":" & ",\"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 & "}") ",\"picks\":" & $m.picks & "}")
if pickedThisTick: m.replanReason = rrNone if pickedThisTick: m.replanReason = rrNone
+219
View File
@@ -0,0 +1,219 @@
## j144 — the TFIL arrival-commitment mechanism ruler.
##
## Answers ONE question, offline, on the recorded DrussGT fixture
## (tools/fixtures/tr_drussgt_vs_modularbot.jsonl, 20026 ticks): does the j144
## fix stop the pathology the owner watched in the live GUI?
##
## *"when the tile to go is selected in just a few ticks, the bot is still
## accelerating and the target changes even if the path is still good, and
## choose a tile that is opposite way, in the meantime bullet arrived"*
##
## Decomposed into measurable quantities, per arm:
## * picks — how many times a target was chosen
## * mean hold — mean ticks a target is held (ticks/picks)
## * held < needed — fraction of commitments abandoned after
## FEWER ticks than the distance physically
## needs at MaxSpeed (the "still accelerating"
## tell: the bot was never going to get there)
## * rev / 100 ticks — picks >90 deg off the travel direction
## * rev while slow — those picks made at |speed| < MaxSpeed/2
## (THE OWNER'S FAILURE MODE)
## * opposite switch — a switch >90 deg off the travel direction
## * opposite while mid-flight — ... made while the previous target had NOT
## yet been reached (the target flips under a
## bot that is still building speed)
## * reached — fraction of commitments that ended with
## the bot actually on the committed tile
##
## This is a STATIC REPLAY of a veto, not a win claim: the live A/B decides
## damage/run and round wins (see docs/movement_campaign.md).
##
## nim c -r --path:common_libs common_libs/tests/measure_tfil_arrival.nim
import std/[os, json, random, math, strformat]
import std/strutils except fromHex # `fromHex` would clash with color.fromHex
import gun_harness/gun_interface
# Private-field access: include (do NOT import) the shipped mover.
include movements/the_floor_is_lava
const
repoRoot = currentSourcePath().parentDir.parentDir.parentDir
fixtureRel = "tr_drussgt_vs_modularbot.jsonl"
Seed = 20250923
ArenaW = 800.0
ArenaH = 600.0
HalfMax = MaxSpeed / 2.0 ## 4.0 px/tick: "still accelerating"
ArriveR = 18.0 ## must match ArriveRadius in the mover
# ── fixture ──────────────────────────────────────────────────────────────────
proc loadStates(): seq[WorldState] =
let path = repoRoot / "tools" / "fixtures" / fixtureRel
for rawLine in lines(path):
let line = rawLine.strip()
if line.len == 0: continue
let n = parseJson(line)
if n.hasKey("meta") or n.hasKey("end"): continue
let ex = n["ex"].getFloat()
let ey = n["ey"].getFloat()
result.add WorldState(
enemyX: ex, enemyY: ey,
enemyHeading: n["eh"].getFloat(), enemySpeed: n["es"].getFloat(),
enemyEnergy: n["ee"].getFloat(),
selfX: n["sx"].getFloat(), selfY: n["sy"].getFloat(),
selfHeading: n["sh"].getFloat(), selfSpeed: n["ss"].getFloat(),
selfEnergy: n["se"].getFloat(),
arenaWidth: ArenaW, arenaHeight: ArenaH,
tick: n["tick"].getInt(),
enemies: @[EnemyInfo(id: 1, x: ex, y: ey,
heading: n["eh"].getFloat(), speed: n["es"].getFloat(),
energy: n["ee"].getFloat())])
proc loadRoundStarts(): seq[int] =
let side = repoRoot / "tools" / "fixtures" / "drussgt_meta" /
(fixtureRel & ".rounds.json")
if not fileExists(side): return
for r in parseFile(side)["rounds"]:
result.add r["startTick"].getInt()
# ── per-arm accumulation ─────────────────────────────────────────────────────
type ArmStats = object
arm: string
env: string
ticks: int
picks: int
holdSum: int
rev: int ## >90 deg off travel direction
revSlow: int ## ... at |speed| < HalfMax
oppSwitch: int ## >90 deg off travel direction
oppMidFlight: int ## ... while the previous target was unreached
oppFlip: int ## ... AND more than 135 deg off: a true FLIP to
## the mirror side, which is what the owner watched
oppAngleSum: float ## sum |angle| over the mid-flight rearward
## switches — severity, not just count
oppMidSlow: int ## ... and made at |speed| < MaxSpeed/2: the owner's
## failure mode, exactly as the guard test counts it
reached: int ## commitments that ended on the committed tile
neededSum: int ## sum of ceil(distAtPick / MaxSpeed)
neededBeatsHold:int ## commitments held for FEWER ticks than needed
byReason: array[TfilReplanReason, int]
proc closeCommitment(s: var ArmStats, held: int, reached: bool) =
## `held` = ticks the target was held; `reached` = the bot ended inside the
## 18px arrival radius of the tile it was driving to.
s.holdSum += held
if reached: inc s.reached
# ── the replay ───────────────────────────────────────────────────────────────
proc replayArm(arm, envspec: string): ArmStats =
result.arm = arm
result.env = envspec
for tok in envspec.splitWhitespace():
let kv = tok.split('=', 1)
putEnv(kv[0], kv[1])
putEnv("TR_TFIL_COMMIT_LOG", "") # the module's own log, not this ruler's
loadTfilCommitEnv()
loadTfilHeatEnv()
randomize(Seed)
var m = initTFIL()
let states = loadStates()
let starts = loadRoundStarts()
var held = 0 # ticks the current target has been held
var distAtPick = 0.0 # distance to the target when it was chosen
var open = false # a commitment is currently being held
for i in 0..<states.len:
let ws = states[i]
if i == 0 or i in starts:
if open: result.closeCommitment(held, true) # round end: count the dwell
m.resetRound()
held = 0
open = false
let before = m.picks
let prevTarget = m.commitTarget
let prevHadPicks = before > 0
discard m.computeMove(ws)
inc result.ticks
if m.picks == before:
if open: inc held
continue
# ── a pick happened on this tick: close the previous commitment ──────────
let travelDeg = if ws.selfSpeed < -0.01: ws.selfHeading + 180.0
else: ws.selfHeading
var rd = arctan2(m.commitTarget.y - ws.selfY, m.commitTarget.x - ws.selfX) *
180.0 / PI - travelDeg
while rd > 180.0: rd -= 360.0
while rd < -180.0: rd += 360.0
if open:
let remD = sqrt((ws.selfX - prevTarget.x)^2 + (ws.selfY - prevTarget.y)^2)
result.closeCommitment(held, remD < ArriveR)
let needed = int(ceil(distAtPick / MaxSpeed))
result.neededSum += needed
if held < needed: inc result.neededBeatsHold
inc result.picks
let d = sqrt((m.commitTarget.x - ws.selfX)^2 + (m.commitTarget.y - ws.selfY)^2)
distAtPick = d
held = 0
open = true
if abs(rd) > 90.0:
inc result.rev
if abs(ws.selfSpeed) < HalfMax: inc result.revSlow
if prevHadPicks:
inc result.oppSwitch
let remD = sqrt((ws.selfX - prevTarget.x)^2 + (ws.selfY - prevTarget.y)^2)
if remD >= ArriveR:
inc result.oppMidFlight
result.oppAngleSum += abs(rd)
if abs(rd) > 135.0: inc result.oppFlip
if abs(ws.selfSpeed) < HalfMax: inc result.oppMidSlow
# the reason is stashed on the module until the next pick is logged; read it
# from the live field the same way the mover's own JSONL log does
for rr in TfilReplanReason:
if m.replanReason == rr and rr != rrNone: inc result.byReason[rr]
if open: result.closeCommitment(held, true)
for tok in envspec.splitWhitespace():
putEnv(tok.split('=', 1)[0], "")
# ── reporting ────────────────────────────────────────────────────────────────
proc f(x: float, d = 2): string = formatFloat(x, ffDecimal, d)
proc pct(n, d: int): string =
if d == 0: return "-"
f(100.0 * n.float / d.float, 1) & "%"
let arms = [
("tfil (shipped)", ""),
("commit-only", "TR_TFIL_TILE_REPLAN=off"),
("arrive", "TR_TFIL_COMMIT_ARRIVAL=1"),
("arrive+hyst", "TR_TFIL_COMMIT_ARRIVAL=1 TR_TFIL_COMMIT_MARGIN=10"),
("arrive+hyst+norev", "TR_TFIL_COMMIT_ARRIVAL=1 TR_TFIL_COMMIT_MARGIN=10 TR_TFIL_NOREV_SPEED=4"),
("norev-alone", "TR_TFIL_NOREV_SPEED=4"),
]
echo "j144 TFIL arrival-commitment mechanism ruler — offline fixture replay\n"
echo "fixture: tools/fixtures/", fixtureRel, "\n"
var rows: seq[ArmStats]
for (name, envspec) in arms:
rows.add replayArm(name, envspec)
echo &"| arm | picks | mean hold (ticks) | held<needed | reached | rev/100 ticks | rev while slow | opposite switch | opposite mid-flight | mean abs(angle) | flips (>135 deg) | opp mid-flight while SLOW |"
echo "|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|"
for s in rows:
echo &"| {s.arm} | {s.picks} | {f(s.holdSum.float / max(1, s.picks).float)} | {pct(s.neededBeatsHold, s.picks)} | {pct(s.reached, s.picks)} | {f(100.0 * s.rev.float / max(1.0, s.ticks.float), 1)} | {s.revSlow} ({pct(s.revSlow, max(1, s.picks))}) | {s.oppSwitch} | {s.oppMidFlight} | {f(s.oppAngleSum.float / max(1.0, s.oppMidFlight.float), 1)} deg | {s.oppFlip} | {s.oppMidSlow} |"
echo "\n### ticks held vs ticks needed to reach the tile (mean needed = "
echo f(rows[0].neededSum.float / max(1, rows[0].picks).float, 1), " ticks on the shipped arm)\n"
echo &"| arm | mean needed | mean held | mean held - needed | abandoned early |"
echo "|---|---:|---:|---:|---:|"
for s in rows:
let need = s.neededSum.float / max(1, s.picks).float
let heldM = s.holdSum.float / max(1, s.picks).float
echo &"| {s.arm} | {f(need,1)} | {f(heldM,1)} | {f(heldM - need,1)} | {pct(s.neededBeatsHold, s.picks)} |"
echo "\n### why each commitment ended (picks only)\n"
echo "| arm | tile_self | tile_enemy | danger | expiry | arrival | hyst |"
echo "|---|---:|---:|---:|---:|---:|---:|"
for s in rows:
echo &"| {s.arm} | {s.byReason[rrTileSelf]} | {s.byReason[rrTileEnemy]} | {s.byReason[rrDanger]} | {s.byReason[rrExpiry]} | {s.byReason[rrArrival]} | {s.byReason[rrHyst]} |"
+135 -6
View File
@@ -1,8 +1,11 @@
## Guard test for the TFIL commitment knobs: ## Guard test for the TFIL commitment knobs:
## TR_TFIL_TILE_REPLAN (self | off | enemy) default `self` = shipped ## TR_TFIL_TILE_REPLAN (self | off | enemy) default `self` = shipped
## TR_TFIL_COMMIT_TICKS (int) default 15 = shipped ## TR_TFIL_COMMIT_TICKS (int) default 15 = shipped
## TR_TFIL_NO_REV (0/1) default 0 = shipped ## TR_TFIL_NO_REV (0/1) default 0 = shipped
## TR_TFIL_COMMIT_LOG (path) default off ## TR_TFIL_COMMIT_LOG (path) default off
## TR_TFIL_COMMIT_ARRIVAL (0/1) default 0 = shipped (j144)
## TR_TFIL_COMMIT_MARGIN (float) default 0.0 = shipped (j144)
## TR_TFIL_NOREV_SPEED (float) default 0.0 = shipped (j144)
## ##
## NO battle, NO Java, NO server. Run with: ## NO battle, NO Java, NO server. Run with:
## nim c -r --path:common_libs common_libs/tests/test_tfil_commit_env.nim ## nim c -r --path:common_libs common_libs/tests/test_tfil_commit_env.nim
@@ -170,6 +173,10 @@ when declared(loadTfilCommitEnv):
byReason: array[TfilReplanReason, int] byReason: array[TfilReplanReason, int]
intervals: seq[int] intervals: seq[int]
reversals: int reversals: int
revSlow: int ## reversal picks made at |speed| < MaxSpeed/2
revMidSlow:int ## ... on a target we had NOT yet reached (j144: the
## owner's failure mode: the target flips opposite
## while the bot is still accelerating)
meanSpeed: float meanSpeed: float
proc setArm(tileReplan: string, commitTicks: string, noRev: string) = proc setArm(tileReplan: string, commitTicks: string, noRev: string) =
@@ -178,6 +185,21 @@ when declared(loadTfilCommitEnv):
putEnv("TR_TFIL_NO_REV", noRev) putEnv("TR_TFIL_NO_REV", noRev)
loadTfilCommitEnv() loadTfilCommitEnv()
proc clearJ144() =
## The shipped default for every j144 knob: present but OFF. Setting the
## var directly (not through the env) is the honest way to prove the
## defaults, because putEnv("") is indistinguishable from unset.
when declared(TfilCommitArrival):
TfilCommitArrival = false
TfilCommitMargin = 0.0
TfilNoRevSpeed = 0.0
proc setJ144(arrival: bool, margin: float, norevSpeed: float) =
when declared(TfilCommitArrival):
TfilCommitArrival = arrival
TfilCommitMargin = margin
TfilNoRevSpeed = norevSpeed
proc parseLog(path: string): seq[JsonNode] = proc parseLog(path: string): seq[JsonNode] =
if not fileExists(path): return if not fileExists(path): return
for rawLine in lines(path): for rawLine in lines(path):
@@ -188,11 +210,17 @@ when declared(loadTfilCommitEnv):
var spSum = 0.0 var spSum = 0.0
result.ticks = log.len result.ticks = log.len
for o in log: for o in log:
spSum += o["sp"].getFloat() let sp = o["sp"].getFloat()
spSum += sp
if o["pick"].getInt() == 1: if o["pick"].getInt() == 1:
inc result.picks inc result.picks
result.intervals.add o["interval"].getInt() result.intervals.add o["interval"].getInt()
if o["rev"].getInt() == 1: inc result.reversals let rev = o["rev"].getInt() == 1
if rev: inc result.reversals
# `mid` is j144's field: the pick replaced a target we had not reached.
let mid = o.hasKey("mid") and o["mid"].getInt() == 1
if rev and mid and abs(sp) < MaxSpeed / 2.0: inc result.revMidSlow
if rev and abs(sp) < MaxSpeed / 2.0: inc result.revSlow
let r = o["reason"].getStr() let r = o["reason"].getStr()
for rr in TfilReplanReason: for rr in TfilReplanReason:
if reasonName(rr) == r: inc result.byReason[rr] if reasonName(rr) == r: inc result.byReason[rr]
@@ -220,6 +248,23 @@ when declared(loadTfilCommitEnv):
loadTfilCommitEnv() loadTfilCommitEnv()
result = stats(parseLog(logPath)) result = stats(parseLog(logPath))
proc replayJ144(tag: string, arrival: bool, margin, norevSpeed: float): ArmStats =
## The same replay, but the j144 knobs are forced through the module vars
## AFTER the env reload (these arms are not env-driven here, so nothing can
## be confused with a `.env` the owner might have lying around).
let logPath = getTempDir() / ("tfil_commit_" & tag & ".jsonl")
removeFile(logPath)
closeTfilCommitLog()
putEnv("TR_TFIL_COMMIT_LOG", logPath)
loadTfilCommitEnv()
setJ144(arrival, margin, norevSpeed)
discard replay(loadStates(), loadRoundStarts())
closeTfilCommitLog()
putEnv("TR_TFIL_COMMIT_LOG", "")
loadTfilCommitEnv()
clearJ144()
result = stats(parseLog(logPath))
# ── 2. knob parsing ──────────────────────────────────────────────────────── # ── 2. knob parsing ────────────────────────────────────────────────────────
proc testKnobParsing() = proc testKnobParsing() =
@@ -317,12 +362,96 @@ when declared(loadTfilCommitEnv):
" expiry=", s.byReason[rrExpiry], " expiry=", s.byReason[rrExpiry],
" rev=", reversalRate(s).formatFloat(ffDecimal, 1), "%" " rev=", reversalRate(s).formatFloat(ffDecimal, 1), "%"
# ── 4. j144: arrival-based commitment + hysteresis + no-reversal (default OFF) ──
proc testJ144() =
# 4a. the shipped default is OFF for all three knobs
clearJ144()
check "j144: every new knob defaults to today's behaviour (off / 0 / 0)",
not TfilCommitArrival and TfilCommitMargin == 0.0 and TfilNoRevSpeed == 0.0
# 4b. the CONTROL carries the owner's reported pathology — without this the
# checks below would be vacuously true.
setArm("self", "15", "0")
let ctl = replayJ144("j144_ctl", false, 0.0, 0.0)
check "j144 CONTROL: opposite-direction switches DO happen while the bot is " &
"still accelerating toward an unreached target (> 20) — the owner's bug",
ctl.revMidSlow > 20
check "j144 CONTROL: the commitment is abandoned long before it could arrive " &
"(mean hold < 6 ticks)", meanInterval(ctl) < 6.0
# 4c. arrival: the tile-boundary crossing no longer ends the commitment
let ar = replayJ144("j144_arrive", true, 0.0, 0.0)
check "j144 ARRIVAL: zero tile-change replans (the boundary no longer cancels)",
ar.byReason[rrTileSelf] == 0
check "j144 ARRIVAL: commitments DO end by reaching the tile",
ar.byReason[rrArrival] > 0
check "j144 ARRIVAL: the mean hold rises well past the ~19 ticks the distance needs",
meanInterval(ar) > 15.0 and meanInterval(ar) > meanInterval(ctl) * 2.0
check "j144 ARRIVAL: fewer decisions than the control", ar.picks < ctl.picks
check "j144 ARRIVAL: the danger valve is still live (a genuine threat can break it)",
ar.byReason[rrDanger] > 0
# 4d. hysteresis: "the path is still good" must not be able to switch us
let hy = replayJ144("j144_hyst", true, 10.0, 0.0)
check "j144 HYSTERESIS: margin-triggered switches exist and are labelled",
hy.byReason[rrHyst] > 0
check "j144 HYSTERESIS: the margin releases the commitment EARLIER than waiting " &
"for arrival, never on a boundary crossing",
hy.byReason[rrTileSelf] == 0 and meanInterval(hy) < meanInterval(ar)
check "j144 HYSTERESIS: the mean hold stays far above the control's",
meanInterval(hy) > meanInterval(ctl) * 2.0
check "j144 HYSTERESIS: a margin of 0 leaves the arrival arm's decisions unchanged",
replayJ144("j144_hyst0", true, 0.0, 0.0).picks == ar.picks
# 4e. THE decisive guard. `norevPool` carries the invariant in PURE form:
# whenever a forward (<=90 deg) candidate exists, a slow mid-flight
# switch can never take a rearward one. These three fail on any
# implementation that filters without the all-rearward escape.
check "j144 NO-REV: with a forward candidate available the slow switch is " &
"NEVER rearward", norevPool(@[10.0, 200.0], 4.0) == @[0]
check "j144 NO-REV: the all-rearward case is never empty (no starvation) and " &
"takes the LEAST-bad turn", norevPool(@[170.0, 100.0, 140.0], 4.0) == @[1]
check "j144 NO-REV: the knob off (threshold 0) keeps every candidate",
norevPool(@[10.0, 200.0], 0.0) == @[0, 1]
check "j144 NO-REV: exactly 90 deg either way still counts as forward",
norevPool(@[90.0, -90.0, 91.0], 4.0) == @[0, 1]
# 4f. and the same guarantee measured on the recorded fixture
let full = replayJ144("j144_full", true, 10.0, 4.0)
let nrOnly = replayJ144("j144_nr_only", false, 0.0, 4.0)
check "j144 FULL FIX: opposite-direction slow mid-flight switches fall by " &
">=75% vs the control (" & $ctl.revMidSlow & " -> " & $full.revMidSlow & ")",
full.revMidSlow * 4 <= ctl.revMidSlow
check "j144 FULL FIX: no-rev cuts them further on top of arrive+hyst (" &
$hy.revMidSlow & " -> " & $full.revMidSlow & ")",
full.revMidSlow < hy.revMidSlow
check "j144 NO-REV ALONE: the same holds with only the knob set (no arrival, " &
"no margin): " & $nrOnly.revMidSlow & " vs control " & $ctl.revMidSlow &
" (>=40% cut)", nrOnly.revMidSlow * 10 <= ctl.revMidSlow * 6
check "j144 FULL FIX: total reversal picks at low speed are down vs control",
full.revSlow < ctl.revSlow
check "j144 NO-REV: the pool is never emptied — decisions stay within 5% of the " &
"same-margin arm without it", abs(full.picks.float - hy.picks.float) <= 0.05 * hy.picks.float
echo "\n j144 diagnostics (offline fixture replay):"
for (nm, s) in [("control", ctl), ("arrive", ar), ("arrive+hyst", hy),
("arrive+hyst+norev", full), ("norev alone", nrOnly)]:
echo " ", nm.alignLeft(18), " picks=", s.picks,
" interval=", meanInterval(s).formatFloat(ffDecimal, 2),
" revSlow=", s.revSlow,
" revMidSlow=", s.revMidSlow,
" arrival=", s.byReason[rrArrival],
" hyst=", s.byReason[rrHyst],
" danger=", s.byReason[rrDanger]
# ── driver ─────────────────────────────────────────────────────────────────── # ── driver ───────────────────────────────────────────────────────────────────
testDefaultParity() testDefaultParity()
when declared(loadTfilCommitEnv): when declared(loadTfilCommitEnv):
testKnobParsing() testKnobParsing()
testArms() testArms()
testJ144()
if failures > 0: if failures > 0:
echo "\n", failures, " check(s) FAILED" echo "\n", failures, " check(s) FAILED"
+169
View File
@@ -2653,3 +2653,172 @@ checks. **INFERRED:** that the correction size (`3*power`, `damage`) is
unchanged — it is read straight from the server events, not re-derived. unchanged — it is read straight from the server events, not re-derived.
**NOT MEASURED:** the live movement/damage effect of the fix (the change is **NOT MEASURED:** the live movement/damage effect of the fix (the change is
~1.11% of fires, far below any panel's MDE; no panel was run, per scope). ~1.11% of fires, far below any panel's MDE; no panel was run, per scope).
---
## TFIL commitment: arrival-based + reversal hysteresis (j144)
> **Pre-registration — written and committed BEFORE any battle.** The arms file
> `tools/ab/arms_tfil_commit.txt` and this section's protocol are the frozen
> binary's provenance; the live numbers are appended below afterwards.
### 1. The owner's report, and the mechanism confirmed in the code
Owner, live GUI with `TR_MOVEMENT=tfil` (verbatim):
> *"TFIL move: i see that when the tile to go is selected in just a few ticks,
> the bot is still accelerating and the target changes even if the path is still
> good, and choose a tile that is opposite way, in the meantime bullet arrived
> and hit the bot."*
Read against `common_libs/movements/the_floor_is_lava.nim`, all four of his
observations are correct and they are all the same bug:
**(a) what ends a commitment early.** Three exits exist. The dominant one is the
self-tile crossing, at the top of `computeMove`:
```nim
of ttrSelf:
...
if curTileCol != m.lastTileCol or curTileRow != m.lastTileRow:
m.commitTicks = 0
```
With `GridSize = 36` and speed up to 8 px/tick the bot crosses a boundary every
~5 ticks, so the 15-tick commitment is cancelled by the very motion it commands.
**(b) a mere boundary crossing DOES re-plan, and it dominates.** The offline
replay on the recorded DrussGT fixture (20026 ticks) attributes **3793 of 3946
picks (96.1%)** to `rrTileSelf` — the 96.9% an earlier job measured is still
true of the current code, within RNG noise. The mean decision interval is
**5.06 ticks**.
**(c) the new target CAN be the mirror direction while the speed is still low.**
Nothing in the picker constrains the direction of a new target relative to the
current travel direction — `chosen = rand(candidates.high)` is a uniform draw
over every safe tile. And the tile-crossing cancel fires while the bot is still
accelerating toward a target it has not reached, so the new pick lands exactly
in that window. Measured on the fixture: **1287 mid-flight switches to a tile
more than 90 deg off the travel direction, 394 of them at |speed| < 4 px/tick**
(half of `MaxSpeed`).
**(d) nothing compares the committed tile against the best alternative.** The
commitment block only asks one question — "has the committed tile's lava risen
by more than `DangerReplanThreshold` (25)?" — and otherwise just decrements a
counter. There is no notion of "a better tile exists" at all.
### 2. What the EARLIER A/B (`cc11ede`) covered — and what it did not
`cc11ede` ran the five-arm commitment A/B at 10-14 runs/arm on real DrussGT
(490 rounds) and found **no arm beat the shipped mover on damage/run or round
wins** (best p = 0.16, and the D arm's promising +20.99 in block 1 decayed to
+3.36 in the replication block). That result stands and this section does not
reinterpret it.
But those arms tested something **adjacent**, not this fix:
| `cc11ede` arm | what it changed | what it did NOT do |
|---|---|---|
| B `TR_TFIL_TILE_REPLAN=off` | removes the boundary cancel | keeps a **fixed 15-tick dwell** — the target is still abandoned long before the bot arrives |
| C `B + TR_TFIL_NO_REV=1` | soft 3:1 down-weight of rearward tiles | a **weight, never a filter**: a rearward tile can still win, and it does not know whether the target was reached |
| D `TR_TFIL_TILE_REPLAN=enemy` | re-keys the cancel to the enemy's tile | still a boundary cancel, just a different tile |
| E `TR_TFIL_COMMIT_TICKS=30` | doubles the dwell | still fixed-length, never arrival-based |
None of them made the commitment **arrival-based**, none compared the committed
tile against the best alternative (**hysteresis**), and none conditioned the
no-reversal rule on the bot's **speed** (arm C's preference is speed-blind and
soft). This section's fix is exactly the part their arms left untested — and,
per the numbers below, the part that actually removes the pathology. The honest
reading of `cc11ede` is: *the levers it pulled do not work*, not *the mechanism
does not exist*.
### 3. The protocol (pre-registered)
* Harness: `tools/ab/tournament_run.sh` + `tournament_analyze.py`, FROZEN panel
`tools/ab/panel_movement.txt` (15 opponents, unchanged), `TR_MOVEMENT=tfil`
pinned explicitly on every arm.
* Arms: `tfil` (reference) vs `arrive` vs `arrive_hyst` vs `arrive_hyst_norev`.
* **Verdict metrics (standing campaign convention, unchanged):** damage/run and
ROUND WINS. An arm is better only if one improves with the cross-opponent test
at p < 0.05 while the other does not degrade. The incoming hit rate is the
**mechanism being claimed**, never the verdict.
* One frozen binary from `git archive HEAD`; every arm differs only by its env
dict. No per-arm rebuild.
* Power: see the results table's MDE. The unit of evidence is the NUMBER OF
OPPONENTS (15), and runs/arm only shrink each opponent's error bar.
### 4. The offline gate (cheap, and it is a VETO not a win claim)
`common_libs/tests/measure_tfil_arrival.nim` replays the recorded fixture through
the REAL `computeMove` and measures the owner's failure mode directly.
| arm | picks | mean hold (ticks) | held<needed | reached | rev/100 ticks | rev while slow | opposite switch | opposite mid-flight | mean abs(angle) | flips (>135 deg) | opp mid-flight while SLOW |
|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|
| tfil (shipped) | 3946 | 4.08 | 92.8% | 3.3% | 6.6 | 416 (10.5%) | 1323 | 1287 | 140.0 deg | 733 | 394 |
| commit-only | 1372 | 13.60 | 64.7% | 5.0% | 2.7 | 275 (20.0%) | 543 | 521 | 140.8 deg | 308 | 257 |
| arrive | 511 | 38.19 | 14.3% | 13.5% | 1.0 | 97 (19.0%) | 195 | 177 | 144.3 deg | 117 | 89 |
| arrive+hyst | 810 | 23.72 | 41.9% | 16.3% | 1.6 | 127 (15.7%) | 315 | 266 | 137.6 deg | 144 | 102 |
| arrive+hyst+norev | 802 | 23.97 | 40.5% | 17.2% | 1.4 | 98 (12.2%) | 278 | 225 | 138.5 deg | 124 | 64 |
| norev-alone | 3945 | 4.08 | 92.5% | 2.8% | 5.8 | 244 (6.2%) | 1164 | 1133 | 141.7 deg | 694 | 230 |
| arm | mean needed | mean held | mean held - needed | abandoned early |
|---|---:|---:|---:|---:|
| tfil (shipped) | 19.0 | 4.1 | -15.0 | 92.8% |
| commit-only | 20.2 | 13.6 | -6.6 | 64.7% |
| arrive | 20.4 | 38.2 | 17.8 | 14.3% |
| arrive+hyst | 19.8 | 23.7 | 3.9 | 41.9% |
| arrive+hyst+norev | 19.0 | 24.0 | 5.0 | 40.5% |
| norev-alone | 18.8 | 4.1 | -14.7 | 92.5% |
| arm | tile_self | tile_enemy | danger | expiry | arrival | hyst |
|---|---:|---:|---:|---:|---:|---:|
| tfil (shipped) | 3793 | 0 | 22 | 116 | 0 | 0 |
| commit-only | 0 | 0 | 86 | 1271 | 0 | 0 |
| arrive | 0 | 0 | 159 | 284 | 53 | 0 |
| arrive+hyst | 0 | 0 | 141 | 148 | 115 | 391 |
| arrive+hyst+norev | 0 | 0 | 147 | 155 | 122 | 363 |
| norev-alone | 3793 | 0 | 20 | 117 | 0 | 0 |
Reading the offline table, column by column, against the owner's report:
* **mean hold** 4.1 → 24.0 ticks (the distance needs ~19), and **"held < needed"**
— the share of commitments dropped before the bot could physically arrive —
falls from **92.8% to 40.5%**. That is the "the commitment is broken after only
a few ticks" complaint, measured.
* **reached** — the share of commitments that actually end with the bot standing
on the tile it chose — rises **3.3% → 17.2% (5.2x)**.
* **opposite mid-flight while SLOW** — the exact failure mode, a switch to a tile
more than 90 deg off the travel direction, made at |speed| < 4 px/tick, on a
target not yet reached — falls **394 → 64 (−84%)**. With
`TR_TFIL_NOREV_SPEED` set on its own it is 394 → 230 (−42%).
* The residual 64 is not leakage: it is the **all-rearward case** where every
safe tile is behind the bot (boxed in, or the field only offers rearward
space). There the reversal is unavoidable and the code takes the *least bad*
turn instead of a uniform draw — `norevPool` never returns an empty pool, and
its invariant is unit-tested: **with a forward candidate available, a slow
mid-flight switch is never rearward.**
* `commit-only` (`TR_TFIL_TILE_REPLAN=off`, i.e. what `cc11ede`'s arm B already
tried) sits in the middle: it removes the boundary cancel but keeps the fixed
dwell, so the hold is still 6.6 ticks short of what the distance needs and
64.7% of commitments are still abandoned early. **That is the concrete reason
the earlier A/B could not have found this fix.**
**Veto result: PASS.** The mechanism the owner reported is present in the shipped
mover at the rate he describes, and the fix removes most of it. This is a static
replay of a recorded game — it says the *decision logic* changed, nothing about
whether that is worth points. Only the live A/B below can say that.
**Guards** (`common_libs/tests/test_tfil_commit_env.nim`, 51 checks, all green):
* the byte-for-byte default-parity guard still passes with **all three new knobs
unset** — the shipped default path is unchanged, golden included;
* four new `norevPool` invariant checks (fails on any implementation that filters
without the all-rearward escape);
* a control check that the pathology is really there (`> 20`, measured 394), so
the improvement checks cannot pass vacuously;
* `TR_TFIL_COMMIT_ARRIVAL` → zero `rrTileSelf` and non-zero `rrArrival`;
`TR_TFIL_COMMIT_MARGIN` → non-zero `rrHyst`, never on a boundary crossing.
### 5. LIVE A/B — pre-registered arms
*(results appended below after the battles)*
+39
View File
@@ -0,0 +1,39 @@
# ─────────────────────────────────────────────────────────────────────────────
# arms_tfil_commit.txt — j144: the TFIL COMMITMENT fix, four arms on the frozen
# movement panel (tools/ab/panel_movement.txt), all with TR_MOVEMENT=tfil pinned
# EXPLICITLY.
#
# Owner's report (live GUI, TR_MOVEMENT=tfil): "when the tile to go is selected
# in just a few ticks, the bot is still accelerating and the target changes even
# if the path is still good, and choose a tile that is opposite way, in the
# meantime bullet arrived and hit the bot."
#
# Pre-registered in docs/movement_campaign.md ("TFIL commitment: arrival-based +
# reversal hysteresis") BEFORE any of these battles ran. Reference is `tfil`.
#
# The three knobs are cumulative so the arms isolate one ingredient each:
# ARRIVAL — hold the committed tile until the bot is ON it (a boundary
# crossing stops cancelling the commitment; the tick knob becomes a
# MINIMUM dwell)
# MARGIN — only leave the committed tile if the best alternative is at least
# this much cooler, on the same pathMaxHeat scale the picker uses
# (10.0 = one PathDangerThreshold level)
# NOREV — while |speed| < 4.0 (half of MaxSpeed 8) a mid-flight switch may
# not take a tile more than 90 deg off the travel direction
#
# Format: name | ENV=value ENV=value | label
# ─────────────────────────────────────────────────────────────────────────────
# 1. THE ARM TO BEAT — the shipped tfil defaults, explicitly selected.
tfil | TR_MOVEMENT=tfil | shipped tfil defaults (control / reference)
# 2. arrival only — the commitment ends when the tile is REACHED, not on a
# boundary crossing.
arrive | TR_MOVEMENT=tfil TR_TFIL_COMMIT_ARRIVAL=1 | hold the dodge tile until it is reached
# 3. + hysteresis — "the path is still good" can no longer switch us.
arrive_hyst | TR_MOVEMENT=tfil TR_TFIL_COMMIT_ARRIVAL=1 TR_TFIL_COMMIT_MARGIN=10 | arrival + a 10-lava margin before switching
# 4. THE FULL FIX — arrival + hysteresis + no opposite-direction flip while the
# bot is still accelerating.
arrive_hyst_norev | TR_MOVEMENT=tfil TR_TFIL_COMMIT_ARRIVAL=1 TR_TFIL_COMMIT_MARGIN=10 TR_TFIL_NOREV_SPEED=4 | the full fix