Files
SirRoboGarage/common_libs/tests/test_tfil_commit_env.nim
T
SirStone 39c90fd930 j145 TFIL: a turn-cost tiebreak among the SAFE tiles (default-off)
The picker scored candidates on pathMaxHeat alone and then drew uniformly
among the survivors, so a mirror-side tile was as likely as a straight-ahead
one. Added a continuous turn cost as a DRAW WEIGHT applied only after the
hard heat filter:

  w = max(1, round(1 + TR_TFIL_TURN_BIAS * (1 - max(0,|turn| - REF)/180)))

- TR_TFIL_TURN_BIAS (default 0) is the odds ratio straight-ahead vs 180 deg;
  TR_TFIL_TURN_REF_DEG (default 45) is where the penalty starts. Both
  default-off-effect: the default-path golden in test_tfil_commit_env.nim is
  unchanged and still passes.
- Turn cost is NEVER folded into the heat score. The filter stays hard.
- The draw stays random (j51 measured an argmin worse); every weight is
  floored at 1, so the pool can never be emptied and bias 0 is exactly the
  shipped uniform draw.
- |turn| now travels on the ScoredTile, and the commit log gained turn /
  minturn / promote so a caller can measure the regret of the draw.

Guards: 51 -> 66 checks (an absurd 99:1 bias never rescues an over-threshold
tile; mean |turn|, draw regret, >90 and mirror-side shares all fall; path
heat does not rise). env_report + .env.example updated.
2026-09-26 21:56:54 +02:00

673 lines
32 KiB
Nim

## 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_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)
## TR_TFIL_TURN_BIAS (float) default 0.0 = shipped (j145)
## TR_TFIL_TURN_REF_DEG (float) default 45.0 (j145)
##
## NO battle, NO Java, NO server. Run with:
## nim c -r --path:common_libs common_libs/tests/test_tfil_commit_env.nim
##
## The important check is #1: with EVERY knob unset the mover must reproduce,
## byte-for-byte, the move commands recorded from the PRE-CHANGE build. The
## golden `tests/fixtures/tfil_commit_default.golden` was generated from the
## shipped mover at commit f842ac0 by compiling THIS file with
## `-d:tfilGenGolden` against `git archive f842ac0` (see the golden's header).
## Regenerating it from the new code would defeat the check — only do that after
## a DELIBERATE change to the shipped defaults.
##
## *** DELIBERATE DEFAULT CHANGE (this commit): the virtual centre pillar was
## removed from the shipped default (PillarHotness/PillarRadiance 30/10 -> 0/0;
## TR_TFIL_PILLAR_ON=1 restores it). That legitimately changes the DEFAULT move
## path, so the golden was regenerated from the NEW default. This is NOT a
## regression being blessed: the fixture replay below is byte-for-byte stable
## for a fixed build, it simply runs against a different (pillar-free) field.
## If a future change makes this check fail, it is a REAL default-path diff
##
## The knob-dependent half of this file is wrapped in
## `when declared(loadTfilCommitEnv)` so the SAME file still compiles against
## the pre-change module and can regenerate the golden there. That is the whole
## point: the golden must come from the old code, not from the new one.
##
## The remaining checks prove the non-default arms actually do something (an
## A/B whose treatment did not apply is worthless) and that the soft
## no-reversal preference can never empty the candidate pool.
import std/[os, json, random, math, sequtils]
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
const fixtureRel = "tr_drussgt_vs_modularbot.jsonl"
const goldenPath = currentSourcePath().parentDir / "fixtures" /
"tfil_commit_default.golden"
const Seed = 20250923
const ArenaW = 800.0
const ArenaH = 600.0
var failures = 0
proc check(name: string, ok: bool) =
if ok: echo "PASS: ", name
else: echo "FAIL: ", name; inc failures
# ── fixture replay (works on BOTH the old and the new module) ────────────────
type
TickRec = object
call: int
spd, trn: float
tx, ty: float
ct: int
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()
proc replay(states: seq[WorldState], starts: seq[int]): seq[TickRec] =
## Drive the REAL computeMove over the recorded WorldState stream with a fixed
## RNG seed, exactly as `measure_tfil_heat_field.nim` does. Touches NO env
## knob, so it is identical on the old and the new module.
randomize(Seed)
var m = initTFIL()
for i in 0..<states.len:
if i == 0 or i in starts: m.resetRound()
let cmd = m.computeMove(states[i])
result.add TickRec(call: m.callCount, spd: cmd.speed, trn: cmd.turnRate,
tx: m.commitTarget.x, ty: m.commitTarget.y,
ct: m.commitTicks)
proc recLine(r: TickRec): string =
$r.call & " " & $r.spd & " " & $r.trn & " " & $r.tx & " " & $r.ty & " " & $r.ct
# ── golden generation (OLD module only) ──────────────────────────────────────
when defined(tfilGenGolden):
block:
let recs = replay(loadStates(), loadRoundStarts())
var g = "# TFIL default-path parity golden.\n"
g.add "# Originally generated from commit f842ac0 (shipped mover, BEFORE the commit knobs).\n"
g.add "# REGENERATED from the NEW default after the DELIBERATE removal of the virtual\n"
g.add "# centre pillar (PillarHotness/PillarRadiance 30/10 -> 0/0). Set\n"
g.add "# TR_TFIL_PILLAR_ON=1 to restore the old field. Not a regression: the shipped\n"
g.add "# default change is intentional, so the default path legitimately differs.\n"
g.add "# Format: call speed turnRate targetX targetY commitTicks\n"
for r in recs: g.add recLine(r) & "\n"
createDir(goldenPath.parentDir)
writeFile(goldenPath, g)
echo "wrote ", goldenPath, " (", recs.len, " ticks)"
quit(0)
# ── 1. default-path parity against the pre-change build ──────────────────────
proc testDefaultParity() =
# NOTE: the golden below was regenerated when the virtual centre pillar was
# removed from the shipped DEFAULT (DELIBERATE change, not an accidental
# regression — see the file header). With the pillar restored via
# TR_TFIL_PILLAR_ON=1 the old golden would no longer match, by design.
doAssert fileExists(goldenPath), "missing golden: " & goldenPath
# j134: the golden was generated from the SHIPPED detector. Force the shipped
# fire-detection path (`TR_FIRE_FIX=0`) for this check so it still proves the
# PRE-CHANGE path is byte-identical. The correction ON legitimately changes
# the path (it no longer drops an over-cap energy delta), which is the whole
# point of j134 — see `test_strafe_fire_fix.nim` for the per-behaviour checks.
when declared(TfilFireFix):
let savedFireFix = TfilFireFix
TfilFireFix = false
let recs = replay(loadStates(), loadRoundStarts())
when declared(TfilFireFix):
TfilFireFix = savedFireFix
var golden: seq[string]
for rawLine in lines(goldenPath):
if rawLine.startsWith("#"): continue
let line = rawLine.strip()
if line.len > 0: golden.add line
check "golden covers the whole fixture (>= 15000 ticks)", golden.len >= 15000
check "default replay covers the same number of ticks", recs.len == golden.len
var firstDiff = -1
for i in 0..<min(recs.len, golden.len):
if recLine(recs[i]) != golden[i]:
firstDiff = i
break
check "DEFAULT PATH IS BYTE-FOR-BYTE THE PRE-CHANGE MOVER (call/speed/turnRate/target/commitTicks)",
firstDiff < 0
if firstDiff >= 0:
echo " first divergence at tick index ", firstDiff, ": got [",
recLine(recs[firstDiff]), "] want [", golden[firstDiff], "]"
# ── everything below needs the NEW knob API ──────────────────────────────────
when declared(loadTfilCommitEnv):
type ArmStats = object
ticks: int
picks: int
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) =
putEnv("TR_TFIL_TILE_REPLAN", tileReplan)
putEnv("TR_TFIL_COMMIT_TICKS", commitTicks)
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):
let line = rawLine.strip()
if line.len > 0: result.add parseJson(line)
proc stats(log: seq[JsonNode]): ArmStats =
var spSum = 0.0
result.ticks = log.len
for o in log:
let sp = o["sp"].getFloat()
spSum += sp
if o["pick"].getInt() == 1:
inc result.picks
result.intervals.add o["interval"].getInt()
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]
if log.len > 0: result.meanSpeed = spSum / log.len.float
proc meanInterval(s: ArmStats): float =
if s.intervals.len == 0: return 0.0
var t = 0
for v in s.intervals: t += v
t.float / s.intervals.len.float
proc reversalRate(s: ArmStats): float =
if s.picks == 0: return 0.0
100.0 * s.reversals.float / s.picks.float
proc replayLogged(tag: string): ArmStats =
let logPath = getTempDir() / ("tfil_commit_" & tag & ".jsonl")
removeFile(logPath)
closeTfilCommitLog()
putEnv("TR_TFIL_COMMIT_LOG", logPath)
loadTfilCommitEnv()
discard replay(loadStates(), loadRoundStarts())
closeTfilCommitLog()
putEnv("TR_TFIL_COMMIT_LOG", "")
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() =
setArm("off", "15", "0")
check "TR_TFIL_TILE_REPLAN=off -> ttrOff", TfilTileReplanMode == ttrOff
setArm("enemy", "15", "0")
check "TR_TFIL_TILE_REPLAN=enemy -> ttrEnemy", TfilTileReplanMode == ttrEnemy
setArm("self", "15", "0")
check "TR_TFIL_TILE_REPLAN=self -> ttrSelf", TfilTileReplanMode == ttrSelf
setArm("bogus", "15", "0")
check "unknown TR_TFIL_TILE_REPLAN falls back to the shipped ttrSelf",
TfilTileReplanMode == ttrSelf
setArm("self", "30", "0")
check "TR_TFIL_COMMIT_TICKS=30 is honoured", TfilCommitTicks == 30
setArm("self", "0", "0")
check "TR_TFIL_COMMIT_TICKS=0 is clamped to >= 1", TfilCommitTicks == 1
setArm("self", "15", "1")
check "TR_TFIL_NO_REV=1 -> TfilNoRev", TfilNoRev
setArm("self", "15", "0")
check "TR_TFIL_NO_REV unset -> false (shipped)", not TfilNoRev
setArm("self", "15", "0")
# ── 3. the treatments actually bite ────────────────────────────────────────
proc testArms() =
# arm A: control (shipped defaults)
setArm("self", "15", "0")
let a = replayLogged("a")
check "arm A (control): tile-change replans dominate the picks",
a.byReason[rrTileSelf].float / max(1, a.picks).float > 0.5
check "arm A (control): mean decision interval is ~5 ticks (commitment cancelled by motion)",
meanInterval(a) < 8.0
check "arm A (control): reversal-pick rate is high (> 20%)",
reversalRate(a) > 20.0
# arm B: honour the commitment
setArm("off", "15", "0")
let b = replayLogged("b")
check "arm B (TR_TFIL_TILE_REPLAN=off): ZERO tile-change replans",
b.byReason[rrTileSelf] == 0 and b.byReason[rrTileEnemy] == 0
check "arm B: commitment is only ended by expiry or the danger valve (plus the first pick of each round)",
b.byReason[rrExpiry] + b.byReason[rrDanger] + b.byReason[rrInit] == b.picks
check "arm B: the danger valve is a real but minor part of the replans (<10%)",
b.byReason[rrDanger].float / max(1, b.picks).float < 0.10
check "arm B: decision interval rises to the commit length (~15)",
meanInterval(b) > 12.0
check "arm B: FEWER decisions than arm A", b.picks < a.picks
# arm C: B + soft no-reversal
setArm("off", "15", "1")
let c = replayLogged("c")
check "arm C: reversal-pick rate drops vs arm B",
reversalRate(c) < reversalRate(b)
check "arm C: the candidate pool is never emptied (pick count within 2% of arm B)",
abs(c.picks - b.picks).float <= 0.02 * b.picks.float
check "arm C: no tile-change replans (same commitment as B)",
c.byReason[rrTileSelf] == 0
# The preference is a WEIGHT, never a filter: no candidate can get weight 0.
let wAllBack = noRevWeights(@[91.0, 120.0, 180.0, -179.0, -91.0], 0.0)
check "arm C: no-reversal weights are never 0 (pool cannot empty)",
wAllBack.allIt(it >= 1)
check "arm C: an all-backward pool falls back to uniform (every weight 1)",
wAllBack.allIt(it == 1)
let wMixed = noRevWeights(@[0.0, 45.0, 90.0, 90.1, 180.0], 0.0)
check "arm C: forward tiles (<=90 deg) are down-weighted by 3:1",
wMixed[0] == NoRevForwardWeight and wMixed[1] == NoRevForwardWeight and
wMixed[2] == NoRevForwardWeight and wMixed[3] == 1 and wMixed[4] == 1
# arm D: keyed to the enemy's tile
setArm("enemy", "15", "0")
let d = replayLogged("d")
check "arm D (TR_TFIL_TILE_REPLAN=enemy): some replans are keyed to the TARGET's tile",
d.byReason[rrTileEnemy] > 0
check "arm D: no self-tile cancels", d.byReason[rrTileSelf] == 0
check "arm D: the replan trigger differs from arm A (different pick count)",
d.picks != a.picks and d.byReason[rrTileEnemy] > d.byReason[rrTileSelf]
# arm E: commit duration
setArm("off", "30", "0")
let e = replayLogged("e")
check "arm E (TR_TFIL_COMMIT_TICKS=30): interval rises to ~30",
meanInterval(e) > 27.0
check "arm E: fewer decisions than arm B (15)", e.picks < b.picks
setArm("self", "15", "0")
echo "\n arm diagnostics (offline fixture replay):"
for (nm, s) in [("A control", a), ("B commit", b), ("C commit+noRev", c),
("D enemyTile", d), ("E commit30", e)]:
echo " ", nm.alignLeft(15), " ticks=", s.ticks, " picks=", s.picks,
" interval=", meanInterval(s).formatFloat(ffDecimal, 2),
" tileSelf=", s.byReason[rrTileSelf],
" tileEnemy=", s.byReason[rrTileEnemy],
" danger=", s.byReason[rrDanger],
" 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]
# ── 5. j145: the turn-cost TIEBREAK among SAFE tiles (default OFF) ─────────
type PickRec = object
turn: float ## |heading change| from the travel direction to the pick
minTurn: float ## the smallest |turn| AVAILABLE in that candidate set —
## turn - minTurn is the regret of the draw, which is
## the confound-free form of the mechanism metric
pathHeat: float ## max lava on the straight path the bot was told to walk
promoted: bool ## the pick had to break the hard heat filter
reached: bool ## the commitment ended with the bot on the tile
const DangerThreshold = 10.0 ## PathDangerThreshold inside the mover
proc probePathHeat(m: TFILModule, fx, fy, tx, ty: float): float =
## Mirrors the mover's own sampler (PathSampleStep = 18, ~half a tile). The
## field is rebuilt from scratch every computeMove, so the `m.lava` visible
## just after the call is exactly the field the pick was made against.
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 / 18.0))
var h = 0.0
for si in 0..steps:
let f = si.float / steps.float
let (sc, sr) = m.tileAt(fx + ddx * f, fy + ddy * f)
h = max(h, m.lavaAt(sc, sr))
h
proc replayJ145(tag: string, bias, refDeg: float,
arrive: bool, norevSpeed: float): seq[PickRec] =
## Drive the REAL computeMove with the j145 knobs set through the env (and
## the j144 knobs forced through the vars, so no `.env` can be in the way),
## and record what each pick actually cost.
putEnv("TR_TFIL_TURN_BIAS", $bias)
putEnv("TR_TFIL_TURN_REF_DEG", $refDeg)
putEnv("TR_TFIL_COMMIT_LOG", "")
loadTfilCommitEnv()
TfilCommitArrival = arrive
TfilCommitMargin = 0.0
TfilNoRevSpeed = norevSpeed
randomize(Seed)
var m = initTFIL()
let states = loadStates()
let starts = loadRoundStarts()
var prev = (x: 0.0, y: 0.0)
var hadPicks = false
for i in 0..<states.len:
let ws = states[i]
if i == 0 or i in starts:
m.resetRound()
hadPicks = false
let before = m.picks
discard m.computeMove(ws)
if m.picks != before:
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
result.add PickRec(turn: abs(rd), minTurn: m.lastPickMinTurn,
pathHeat: probePathHeat(m, ws.selfX, ws.selfY,
m.commitTarget.x, m.commitTarget.y),
promoted: m.lastPickPromoted,
reached: hadPicks and
sqrt((ws.selfX-prev.x)^2 + (ws.selfY-prev.y)^2) < 18.0)
prev = m.commitTarget
hadPicks = true
putEnv("TR_TFIL_TURN_BIAS", "")
putEnv("TR_TFIL_TURN_REF_DEG", "")
loadTfilCommitEnv()
TfilCommitArrival = false
TfilNoRevSpeed = 0.0
discard tag
type TurnStats = object
picks: int
turnSum: float
minTurnSum: float
tookMin: int ## the draw landed on the smallest-turn candidate
bigTurn: int ## |turn| > 90 deg
flip: int ## |turn| > 135 deg — the opposite side
heatSum: float ## mean path heat of the tile we actually walked to
hotPicks: int ## ... over the hard threshold
badHot: int ## ... over the threshold WITHOUT the filter being
## broken — MUST be 0 at any turn bias
broken: int ## picks that had to promote a hot tile (fewer than
## two safe tiles existed) — the shipped fallback
reached: int
proc turnStats(p: seq[PickRec]): TurnStats =
result.picks = p.len
for r in p:
result.turnSum += r.turn
result.minTurnSum += r.minTurn
if r.turn <= r.minTurn + 0.5: inc result.tookMin
if r.turn > 90.0: inc result.bigTurn
if r.turn > 135.0: inc result.flip
result.heatSum += r.pathHeat
if r.pathHeat > DangerThreshold:
inc result.hotPicks
if not r.promoted: inc result.badHot
if r.promoted: inc result.broken
if r.reached: inc result.reached
proc meanTurn(s: TurnStats): float =
if s.picks == 0: return 0.0
s.turnSum / s.picks.float
proc meanRegret(s: TurnStats): float =
## How many degrees WORSE than the best available tile the draw actually was.
## Immune to the composition confound that the raw mean |turn| has (a bias
## arm makes different picks, so the two arms' candidate sets differ).
if s.picks == 0: return 0.0
(s.turnSum - s.minTurnSum) / s.picks.float
proc meanPathHeat(s: TurnStats): float =
if s.picks == 0: return 0.0
s.heatSum / s.picks.float
proc pct(n, d: int): string =
if d == 0: return "-"
(100.0 * n.float / d.float).formatFloat(ffDecimal, 1) & "%"
proc testJ145() =
# 5a. the shipped default is OFF — the parity check above is the proof
check "j145: the turn bias defaults to today's uniform draw (bias 0)",
TfilTurnBias == 0.0 and TfilTurnRefDeg == 45.0
# 5b. the weighting, in pure form
check "j145: bias 0 gives every safe tile weight 1 (byte-identical to the " &
"shipped uniform draw)", turnWeights(@[0.0, 91.0, 180.0], 0.0, 45.0) ==
@[1, 1, 1]
check "j145: the penalty is CONTINUOUS past the reference angle, where the " &
"binary TR_TFIL_NO_REV cannot see (bias 9, ref 45: 45/90/135/180 deg " &
"-> 10/8/6/3)", turnWeights(@[45.0, 90.0, 135.0, 180.0], 9.0, 45.0) ==
@[10, 8, 6, 3]
check "j145: a turn inside the reference angle is never penalised",
turnWeights(@[0.0, 20.0, 45.0], 5.0, 45.0) == @[6, 6, 6]
check "j145: the weight falls monotonically with the turn (ref 0, bias 9: " &
"0/30/60/90/120/180 deg -> 10/9/8/8/7/1)",
turnWeights(@[0.0, 30.0, 60.0, 90.0, 120.0, 180.0], 9.0, 0.0) ==
@[10, 9, 7, 6, 4, 1]
check "j145: the weight is floored at 1, so the pool can never be starved",
turnWeights(@[0.0, 180.0, 179.0], 9.0, 0.0)[1] >= 1 and
turnWeights(@[0.0, 180.0, 179.0], 9.0, 0.0) == @[10, 1, 1]
check "j145: `bias` IS the odds ratio — with ref 0 a straight-ahead safe tile " &
"is drawn 1+bias times as often as a 180 deg one (9 -> 10:1)",
turnWeights(@[0.0, 180.0], 9.0, 0.0) == @[10, 1]
# 5c. THE GATE: an absurd turn cost must not rescue a hot tile. The heat
# filter is UPSTREAM of the weighting, so an over-threshold pick can
# only ever be one the mover had to promote because nothing was safe.
let wild = turnStats(replayJ145("wild", 99.0, 45.0, true, 4.0))
check "j145: with an absurd turn bias (" & $wild.picks & " picks) NO tile " &
"over the heat threshold is ever chosen unless the filter had to be " &
"broken (" & $wild.badHot & " violations)",
wild.badHot == 0
check "j145: the over-threshold picks that do happen are only the promoted " &
"ones (" & $wild.hotPicks & "/" & $wild.picks & ", the shipped " &
"fewer-than-2-safe-tiles fallback)", wild.badHot == 0
# 5d. the mechanism: the turn really gets smaller, without paying for it in
# heat, and without emptying the pool
let off = turnStats(replayJ145("off", 0.0, 45.0, true, 4.0))
let mild = turnStats(replayJ145("mild", 9.0, 0.0, true, 4.0))
let firm = turnStats(replayJ145("firm", 39.0, 0.0, true, 4.0))
check "j145: with the bias on, the mean |turn| to the chosen tile falls " &
"(" & meanTurn(off).formatFloat(ffDecimal, 1) & " -> " &
meanTurn(mild).formatFloat(ffDecimal, 1) & " -> " &
meanTurn(firm).formatFloat(ffDecimal, 1) & " deg)",
meanTurn(mild) < meanTurn(off) * 0.95 and
meanTurn(firm) < meanTurn(off) * 0.95
check "j145: the REGRET of the draw (how many degrees worse than the best " &
"AVAILABLE candidate) falls " &
"(" & meanRegret(off).formatFloat(ffDecimal, 1) & " -> " &
meanRegret(mild).formatFloat(ffDecimal, 1) & " -> " &
meanRegret(firm).formatFloat(ffDecimal, 1) & " deg) — the " &
"confound-free form of the mechanism",
meanRegret(mild) < meanRegret(off) * 0.9 and
meanRegret(firm) < meanRegret(off) * 0.9
check "j145: the share of picks needing >90 deg of turn falls " &
"(" & pct(off.bigTurn, off.picks) & " -> " & pct(mild.bigTurn, mild.picks) &
" -> " & pct(firm.bigTurn, firm.picks) & ")",
mild.bigTurn < off.bigTurn
check "j145: a mirror-image tile no longer beats a straight-ahead one as " &
"readily — opposite-side picks fall " & pct(off.flip, off.picks) & " -> " &
pct(mild.flip, mild.picks) & " -> " & pct(firm.flip, firm.picks),
mild.flip.float < off.flip.float * 0.95
check "j145: SAFETY COST — the mean path heat of the chosen tile does not " &
"rise (bias off " & meanPathHeat(off).formatFloat(ffDecimal, 2) &
" vs bias 9 " & meanPathHeat(mild).formatFloat(ffDecimal, 2) &
" vs bias 39 " & meanPathHeat(firm).formatFloat(ffDecimal, 2) & ")",
meanPathHeat(mild) <= meanPathHeat(off) * 1.05 and
meanPathHeat(firm) <= meanPathHeat(off) * 1.05
check "j145: the bias never empties the pool — decisions stay within 5% of " &
"the same arm without it (" & $off.picks & " -> " & $mild.picks & " / " &
$firm.picks & ")",
abs(firm.picks.float - off.picks.float) <= 0.05 * off.picks.float
echo "\n j145 diagnostics (offline fixture replay, arrive+norev base):"
for (nm, s) in [("bias 0 (shipped)", off), ("bias 9 ref0", mild), ("bias 39 ref0", firm)]:
echo " ", nm.alignLeft(18), " picks=", s.picks,
" mean|turn|=", meanTurn(s).formatFloat(ffDecimal, 1),
" regret=", meanRegret(s).formatFloat(ffDecimal, 1),
" tookMin=", pct(s.tookMin, s.picks),
" >90deg=", pct(s.bigTurn, s.picks),
" >135deg=", pct(s.flip, s.picks),
" filter broken=", pct(s.broken, s.picks),
" mean path heat=", meanPathHeat(s).formatFloat(ffDecimal, 2),
" reached=", pct(s.reached, s.picks)
# ── driver ───────────────────────────────────────────────────────────────────
testDefaultParity()
when declared(loadTfilCommitEnv):
testKnobParsing()
testArms()
testJ144()
testJ145()
if failures > 0:
echo "\n", failures, " check(s) FAILED"
quit(1)
echo "\nAll TFIL commit-env checks passed."