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:
@@ -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_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)
|
||||
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_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
|
||||
|
||||
@@ -319,6 +319,11 @@ proc printEffectiveValues(ctx: EnvReportContext) =
|
||||
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_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)
|
||||
emit("TR_TFIL_HEAT_TIME", onOff(TfilHeatTime), sourceOf("TR_TFIL_HEAT_TIME"))
|
||||
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_WALL_RADIANCE",
|
||||
"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_PILLAR_ON",
|
||||
"TR_STRAFE_BAND", "TR_STRAFE_SPREAD", "TR_STRAFE_REACH",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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]} |"
|
||||
@@ -1,8 +1,11 @@
|
||||
## Guard test for the TFIL commitment knobs:
|
||||
## TR_TFIL_TILE_REPLAN (self | off | enemy) default `self` = shipped
|
||||
## TR_TFIL_COMMIT_TICKS (int) default 15 = shipped
|
||||
## TR_TFIL_NO_REV (0/1) default 0 = shipped
|
||||
## TR_TFIL_COMMIT_LOG (path) default off
|
||||
## TR_TFIL_TILE_REPLAN (self | off | enemy) default `self` = shipped
|
||||
## TR_TFIL_COMMIT_TICKS (int) default 15 = shipped
|
||||
## TR_TFIL_NO_REV (0/1) default 0 = shipped
|
||||
## 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:
|
||||
## 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]
|
||||
intervals: seq[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
|
||||
|
||||
proc setArm(tileReplan: string, commitTicks: string, noRev: string) =
|
||||
@@ -178,6 +185,21 @@ when declared(loadTfilCommitEnv):
|
||||
putEnv("TR_TFIL_NO_REV", noRev)
|
||||
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] =
|
||||
if not fileExists(path): return
|
||||
for rawLine in lines(path):
|
||||
@@ -188,11 +210,17 @@ when declared(loadTfilCommitEnv):
|
||||
var spSum = 0.0
|
||||
result.ticks = log.len
|
||||
for o in log:
|
||||
spSum += o["sp"].getFloat()
|
||||
let sp = o["sp"].getFloat()
|
||||
spSum += sp
|
||||
if o["pick"].getInt() == 1:
|
||||
inc result.picks
|
||||
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()
|
||||
for rr in TfilReplanReason:
|
||||
if reasonName(rr) == r: inc result.byReason[rr]
|
||||
@@ -220,6 +248,23 @@ when declared(loadTfilCommitEnv):
|
||||
loadTfilCommitEnv()
|
||||
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 ────────────────────────────────────────────────────────
|
||||
|
||||
proc testKnobParsing() =
|
||||
@@ -317,12 +362,96 @@ when declared(loadTfilCommitEnv):
|
||||
" expiry=", s.byReason[rrExpiry],
|
||||
" 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 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
testDefaultParity()
|
||||
when declared(loadTfilCommitEnv):
|
||||
testKnobParsing()
|
||||
testArms()
|
||||
testJ144()
|
||||
|
||||
if failures > 0:
|
||||
echo "\n", failures, " check(s) FAILED"
|
||||
|
||||
@@ -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.
|
||||
**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).
|
||||
|
||||
---
|
||||
|
||||
## 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)*
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user