d0750ab020
The default mover painted a 30/10 radiance blob on the arena centre even though the arena has NO physical pillar there, creating a 4x4 tile (144x144 px) exclusion zone over open centre floor. Set PillarHotness/PillarRadiance to 0/0 in the shipped default (matching the ring variant) and add TR_TFIL_PILLAR_ON=1 to restore the old 30/10 field for A/B without a rebuild; registered in env_report. Because the shipped default legitimately changed, the default-path parity golden (fixtures/tfil_commit_default.golden) was regenerated from the NEW default, with an explicit 'deliberate default change' note in the test so a future failure is treated as a real regression. Also register the three env reads job j102 added in common_libs/bitbrain (TR_BITBRAIN_MODE / _DECAY_EVERY / _DECAY_SHIFT), which the env-report guard was failing on. Verification: test_env_report all green; test_tfil_commit_env 30/30.
321 lines
14 KiB
Nim
321 lines
14 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
|
|
##
|
|
## 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
|
|
let recs = replay(loadStates(), loadRoundStarts())
|
|
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
|
|
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 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:
|
|
spSum += o["sp"].getFloat()
|
|
if o["pick"].getInt() == 1:
|
|
inc result.picks
|
|
result.intervals.add o["interval"].getInt()
|
|
if o["rev"].getInt() == 1: inc result.reversals
|
|
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))
|
|
|
|
# ── 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), "%"
|
|
|
|
# ── driver ───────────────────────────────────────────────────────────────────
|
|
|
|
testDefaultParity()
|
|
when declared(loadTfilCommitEnv):
|
|
testKnobParsing()
|
|
testArms()
|
|
|
|
if failures > 0:
|
|
echo "\n", failures, " check(s) FAILED"
|
|
quit(1)
|
|
echo "\nAll TFIL commit-env checks passed."
|