diff --git a/ModularBot_garage/.env.example b/ModularBot_garage/.env.example index c6187cb..d682583 100644 --- a/ModularBot_garage/.env.example +++ b/ModularBot_garage/.env.example @@ -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 diff --git a/ModularBot_garage/src/env_report.nim b/ModularBot_garage/src/env_report.nim index 09ac515..6fc5338 100644 --- a/ModularBot_garage/src/env_report.nim +++ b/ModularBot_garage/src/env_report.nim @@ -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", diff --git a/common_libs/movements/the_floor_is_lava.nim b/common_libs/movements/the_floor_is_lava.nim index 5f08869..dbcf007 100644 --- a/common_libs/movements/the_floor_is_lava.nim +++ b/common_libs/movements/the_floor_is_lava.nim @@ -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.. 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.. 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 diff --git a/common_libs/tests/measure_tfil_arrival.nim b/common_libs/tests/measure_tfil_arrival.nim new file mode 100644 index 0000000..ef9df3f --- /dev/null +++ b/common_libs/tests/measure_tfil_arrival.nim @@ -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.. 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) | held135 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]} |" diff --git a/common_libs/tests/test_tfil_commit_env.nim b/common_libs/tests/test_tfil_commit_env.nim index 06944b3..55c5cb6 100644 --- a/common_libs/tests/test_tfil_commit_env.nim +++ b/common_libs/tests/test_tfil_commit_env.nim @@ -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" diff --git a/docs/movement_campaign.md b/docs/movement_campaign.md index 967d32a..5e5b683 100644 --- a/docs/movement_campaign.md +++ b/docs/movement_campaign.md @@ -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) | held135 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)* diff --git a/tools/ab/arms_tfil_commit.txt b/tools/ab/arms_tfil_commit.txt new file mode 100644 index 0000000..4cb82e4 --- /dev/null +++ b/tools/ab/arms_tfil_commit.txt @@ -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