## 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. ## ## 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.. 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..= 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."