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

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

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

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

strafe is untouched: it imports only heatDecay/bulletMagScale/Pillar*, none
of which this touches. TR_MOVEMENT default stays strafe. Registered in
env_report.nim + knownEnvNames() + .env.example. Arms pre-registered in
docs/movement_campaign.md and tools/ab/arms_tfil_commit.txt.
This commit is contained in:
2026-09-26 21:07:09 +02:00
parent d4a5d100e7
commit d2005abee9
7 changed files with 728 additions and 34 deletions
+135 -6
View File
@@ -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"