d2005abee9
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.
220 lines
11 KiB
Nim
220 lines
11 KiB
Nim
## 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]} |"
|