## 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) ## ## 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.. 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..= 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] # ── driver ─────────────────────────────────────────────────────────────────── testDefaultParity() when declared(loadTfilCommitEnv): testKnobParsing() testArms() testJ144() if failures > 0: echo "\n", failures, " check(s) FAILED" quit(1) echo "\nAll TFIL commit-env checks passed."