## STRAFE gates — the two CHEAP GATES the spec demands, run OFFLINE on the ## recorded DrussGT fixture (no Java, no server, no battle). ## ## nim c -r --nimcache:/tmp/nc_j108 --path:common_libs \ ## common_libs/tests/measure_strafe_gates.nim ## ## GATE A TILE AVAILABILITY: on every STRAFE pick, how many of the tiles on the ## perpendicular line are SAFE (path max heat <= PathDangerThreshold), ## and how often the safe pool is EMPTY (the mover then falls back to ## the least-hot tile). If the line is usually blocked the design fails ## by construction and this prints it. ## ## GATE B PREDICTABILITY: the main risk. A constant heading running back and ## forth on one line is exactly what a pattern-matching gun exploits. ## Both movers are replayed over the SAME fixture with the SAME seed and ## compared on: ## * reversal-interval entropy (bits) — higher = less periodic ## * direction entropy (bits) — higher = less biased ## * max |autocorrelation| of the signed direction over lags 20..60 ## * mean |turnRate| and the fraction of ticks with no turn at all ## * mean |speed| around a reversal (the j85 speed-collapse test) ## ## The fixture was recorded with DrussGT as the SUBJECT and ModularBot as the ## adversary (see the file's meta line), so `sx/sy/sh` are OUR recorded states ## and `ex/ey/ee` are DrussGT's. Replaying it drives each mover open-loop; that ## is the standard repo practice for movement gates. import std/[os, json, math, random, strformat, strutils, algorithm, tables, sequtils] import gun_harness/gun_interface import movements/the_floor_is_lava import movements/strafe const repoRoot = currentSourcePath().parentDir.parentDir.parentDir const fixtureRel = "tr_drussgt_vs_modularbot.jsonl" const Seed = 20250923 const ArenaW = 800.0 const ArenaH = 600.0 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() # ── stats helpers ──────────────────────────────────────────────────────────── proc entropy(vals: openArray[int]): float = ## Empirical Shannon entropy (bits) over the distinct values in `vals`. if vals.len == 0: return 0.0 var counts = initCountTable[int]() for v in vals: counts.inc(v) for c in counts.values: let p = c.float / vals.len.float result -= p * log2(p) proc meanF(s: openArray[float]): float = if s.len == 0: return 0.0 var t = 0.0 for v in s: t += v t / s.len.float proc autocorrAt(x: seq[float], k: int): float = let n = x.len if n < k + 2: return 0.0 let mu = meanF(x) var denom = 0.0 for v in x: denom += (v - mu) * (v - mu) if denom <= 0.0: return 0.0 var num = 0.0 for i in 0..<(n - k): num += (x[i] - mu) * (x[i + k] - mu) num / denom proc autocorrPeak(x: seq[float], loLag, hiLag: int): tuple[r: float, lag: int] = ## Max |autocorr| over a LONG-lag window, with the lag that produced it. ## Long lags beat persistence: a periodic reversal shows a peak here, while a ## random telegraph has already decayed to ~0. for k in loLag..hiLag: let r = autocorrAt(x, k) if abs(r) > abs(result.r): result = (r, k) proc coeffVar(vals: openArray[int]): float = ## Coefficient of variation (sd/mean) of the reversal intervals. if vals.len == 0: return 0.0 let mu = meanF(vals.mapIt(it.float)) if mu <= 0.0: return 0.0 var acc = 0.0 for v in vals: acc += (v.float - mu) * (v.float - mu) sqrt(acc / vals.len.float) / mu proc reversalIntervals(signs: seq[float]): seq[int] = ## Intervals (in ticks) between successive sign flips of the commanded speed. var last = 0.0 var lastIdx = -1 for i, s in signs: if s == 0.0: continue if last != 0.0 and s != last: if lastIdx >= 0: result.add i - lastIdx lastIdx = i last = s proc directionEntropy(signs: seq[float]): float = ## Bernoulli entropy of the commanded direction (forward vs backward). var fwd, bwd: int for s in signs: if s > 0.0: inc fwd elif s < 0.0: inc bwd if fwd + bwd == 0: return 0.0 entropy(@[fwd, bwd]) # ── replays ────────────────────────────────────────────────────────────────── type Replay = object signs: seq[float] ## commanded speed sign per tick turn: seq[float] ## turnRate per tick absSpeed: seq[float] ## |speed| per tick revIdx: seq[int] ## tick indices of the reversals # STRAFE-only picks: int pickIntervals: seq[int] safeCounts: seq[int] candCounts: seq[int] fallbackPicks: int lineDirFlips: int proc replayTfil(states: seq[WorldState], starts: seq[int]): Replay = randomize(Seed) var m = initTFIL() var prevSign = 0.0 for i in 0.. 0.0: 1.0 elif cmd.speed < 0.0: -1.0 else: 0.0 result.signs.add sign result.turn.add cmd.turnRate result.absSpeed.add abs(cmd.speed) if sign != 0.0 and prevSign != 0.0 and sign != prevSign: result.revIdx.add i if sign != 0.0: prevSign = sign proc replayStrafe(states: seq[WorldState], starts: seq[int]): Replay = randomize(Seed) var m = initStrafe() var prevSign = 0.0 var prevPicks = 0 var prevLastPick = 0 var prevFlips = 0 for i in 0.. 0.0: 1.0 elif cmd.speed < 0.0: -1.0 else: 0.0 result.signs.add sign result.turn.add cmd.turnRate result.absSpeed.add abs(cmd.speed) if sign != 0.0 and prevSign != 0.0 and sign != prevSign: result.revIdx.add i if sign != 0.0: prevSign = sign if m.picks > prevPicks: inc result.picks result.safeCounts.add m.lastSafeCount result.candCounts.add m.lastCandCount if m.lastSafeCount == 0: inc result.fallbackPicks result.pickIntervals.add i - prevLastPick prevLastPick = i prevPicks = m.picks if m.lineDirFlips > prevFlips: result.lineDirFlips += m.lineDirFlips - prevFlips prevFlips = m.lineDirFlips proc reversalSpeedProfile(rep: Replay, offsets: seq[int]): seq[float] = result = newSeq[float](offsets.len) var n = newSeq[int](offsets.len) for i in rep.revIdx: for oi, off in offsets: let j = i + off if j >= 0 and j < rep.absSpeed.len: result[oi] += rep.absSpeed[j] inc n[oi] for oi in 0.. 0: result[oi] /= n[oi].float else: result[oi] = NaN proc fmtF(x: float, d = 3): string = if x.classify == fcNan: "-" else: formatFloat(x, ffDecimal, d) # ── driver ─────────────────────────────────────────────────────────────────── let states = loadStates() let starts = loadRoundStarts() let nTicks = states.len let tfil = replayTfil(states, starts) let sf = replayStrafe(states, starts) # ── reference fields for the Gate A comparison (j108's two rows) ─────────── # NOTE: `sf` above already ran with the CURRENT strafe defaults (the corrected # retune: bullet 20/10, corridor 10, wall 15/5). The two replays below # reproduce j108's historical rows so the numbers are comparable on one binary. # j108 row 1: the SHIPPED TFIL field (bullet 10/5, corridor 20, wall 30/10). StrafeBulletCore = 10.0 StrafeBulletAura = 5.0 StrafeCorridorHeat = 20.0 StrafeWallHotness = 30.0 StrafeWallRadiance = 10.0 let sfShipped = replayStrafe(states, starts) # j108 row 2: the ring mover's retune field (bullet 10/5, corridor 5, wall 10/5). StrafeBulletCore = 10.0 StrafeBulletAura = 5.0 StrafeCorridorHeat = 5.0 StrafeWallHotness = 10.0 StrafeWallRadiance = 5.0 let sfRetune = replayStrafe(states, starts) # Isolation of DEFECT 2 alone: the ring retune's soft sources (corridor 5, wall # 10/5) with ONLY the bullet heat raised to 20/10, so the gap between the # corrected default and j108's retune row can be attributed. StrafeBulletCore = 20.0 StrafeBulletAura = 10.0 StrafeCorridorHeat = 5.0 StrafeWallHotness = 10.0 StrafeWallRadiance = 5.0 let sfBulletOnly = replayStrafe(states, starts) loadStrafeHeatEnv() # restore the CURRENT strafe defaults for any later use echo "STRAFE gates — offline fixture ", fixtureRel, " (", nTicks, " ticks, ", starts.len, " rounds), seed=", Seed echo "" # ── GATE A ─────────────────────────────────────────────────────────────────── echo "=== GATE A — TILE AVAILABILITY (every STRAFE pick) ===" proc gateA(tag: string, rep: Replay) = let picks = rep.picks echo "--- field: ", tag, " ---" echo " picks total : ", picks if picks == 0: return echo " picks with ZERO safe tile: ", rep.fallbackPicks, " (", fmtF(100.0 * rep.fallbackPicks.float / picks.float, 1), "%)" echo " mean candidates on line : ", fmtF(meanF(rep.candCounts.mapIt(it.float)), 2) echo " mean SAFE candidates : ", fmtF(meanF(rep.safeCounts.mapIt(it.float)), 2) var anySafe = 0 for s in rep.safeCounts: if s > 0: inc anySafe echo " line has a SAFE tile at : ", fmtF(100.0 * anySafe.float / picks.float, 1), "% of picks" var hist = initCountTable[int]() for s in rep.safeCounts: hist.inc(min(s, 12)) var line = "" for k in 0..12: line.add &"{k}:{hist.getOrDefault(k,0)} " echo " safe-count dist (0..12+) : ", line gateA("STRAFE DEFAULT (corrected: bullet 20/10, corridor 10, wall 15/5)", sf) gateA("j108 shipped TFIL field (bullet 10/5, corridor 20, wall 30/10)", sfShipped) gateA("j108 ring retune (bullet 10/5, corridor 5, wall 10/5)", sfRetune) gateA("bullet retune ISOLATED (bullet 20/10, corridor 5, wall 10/5)", sfBulletOnly) echo "" # ── GATE B ─────────────────────────────────────────────────────────────────── echo "=== GATE B — PREDICTABILITY (STRAFE vs shipped TFIL) ===" let Offsets = @[-6, -5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10] proc reportMover(name: string, rep: Replay, strafeExtra: bool) = let revInt = reversalIntervals(rep.signs) let revEnt = entropy(revInt) let dirEnt = directionEntropy(rep.signs) var nzTurn = 0 for t in rep.turn: if abs(t) < 0.5: inc nzTurn let prof = reversalSpeedProfile(rep, Offsets) echo "--- ", name, " ---" echo " reversals : ", rep.revIdx.len echo " mean reversal interval : ", fmtF(meanF(revInt.mapIt(it.float)), 2), " ticks" echo " reversal-interval CV : ", fmtF(coeffVar(revInt), 3) echo " REVERSAL-INTERVAL ENTROPY: ", fmtF(revEnt, 3), " bits" echo " DIRECTION ENTROPY : ", fmtF(dirEnt, 3), " bits" echo " autocorr lag-1 : ", fmtF(autocorrAt(rep.signs,1), 3), " (persistence)" let pk = autocorrPeak(rep.signs, 20, 60) echo " max |autocorr| (20..60) : ", fmtF(pk.r, 3), " at lag ", pk.lag, " (periodicity)" echo " mean |turnRate| : ", fmtF(meanF(rep.turn.mapIt(abs(it))), 2), " deg/tick" echo " ticks with no turn (<0.5): ", fmtF(100.0 * nzTurn.float / rep.turn.len.float, 1), "%" echo " mean |speed| : ", fmtF(meanF(rep.absSpeed), 2), " px/tick" if strafeExtra: echo " pick interval entropy : ", fmtF(entropy(rep.pickIntervals), 3), " bits" echo " mean pick interval : ", fmtF(meanF(rep.pickIntervals.mapIt(it.float)), 2), " ticks" echo " line-axis orientation flips: ", rep.lineDirFlips var cells = "" for i in 0.. 0: cells.add " " cells.add &"{Offsets[i]:+d}:{fmtF(prof[i],1)}" echo " |speed| around reversal : ", cells echo "" reportMover("STRAFE", sf, true) reportMover("TFIL (shipped default)", tfil, false) # ── honest read ────────────────────────────────────────────────────────────── echo "=== HONEST READ ===" let revIntS = reversalIntervals(sf.signs) let revIntT = reversalIntervals(tfil.signs) let revEntS = entropy(revIntS) let revEntT = entropy(revIntT) let dirEntS = directionEntropy(sf.signs) let dirEntT = directionEntropy(tfil.signs) echo "reversal-interval entropy: STRAFE ", fmtF(revEntS,2), " vs TFIL ", fmtF(revEntT,2), " bits" echo "direction entropy : STRAFE ", fmtF(dirEntS,2), " vs TFIL ", fmtF(dirEntT,2), " bits" echo "max |autocorr| (20..60) : STRAFE ", fmtF(autocorrPeak(sf.signs,20,60).r,2), " at lag ", autocorrPeak(sf.signs,20,60).lag, " vs TFIL ", fmtF(autocorrPeak(tfil.signs,20,60).r,2), " at lag ", autocorrPeak(tfil.signs,20,60).lag echo "mean |turnRate| : STRAFE ", fmtF(meanF(sf.turn.mapIt(abs(it))),2), " vs TFIL ", fmtF(meanF(tfil.turn.mapIt(abs(it))),2), " deg/tick" echo "mean |speed| : STRAFE ", fmtF(meanF(sf.absSpeed),2), " vs TFIL ", fmtF(meanF(tfil.absSpeed),2), " px/tick"