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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user