Files
SirRoboGarage/common_libs/tests/measure_strafe_gates.nim
T
SirStone 1ea84f5b6e STRAFE: draw the heat field, real bullet danger, corrected ring comment
Three defects the owner hit as "no heat tiles anymore" under TR_MOVEMENT=strafe.

1. The strafe overlay drew ONLY the tiles on its strafe line, so the computed
   heat field was essentially invisible. It now draws the WHOLE field exactly as
   TFIL does (every non-zero tile, yellow->orange->red ramp by field max, integer
   value label) behind the same debugGraphics flag, with TR_STRAFE_HEAT_GRID=0 to
   hide it. The strafe overlays draw on top, unchanged.

2. STRAFE carried the SHIPPED bullet constants (core 10 / aura 5), so a bullet's
   own heat sat exactly ON PathDangerThreshold (10.0) and a bullet was never
   dangerous on its own in this mover; it only ever bit through its corridor.
   Defaults are now the retune's 20/10, exposed as TR_STRAFE_BULLET_CORE /
   TR_STRAFE_BULLET_AURA.

3. The ring mover's header documented CorridorHeat 5.0 / WallHotness 10.0 while
   the code has always been 10.0 / 15.0. A job read the comment and handed out
   sub-threshold heat values, which emptied the field. The comment now states the
   real values and their actual behaviour; no code values changed.

Also sets strafe's heat defaults to the retune shape (bullet 20/10, corridor 10,
wall 15/5, pillar 0), documented with the reason.

Gate A re-run (j110, offline DrussGT fixture, measure_strafe_gates.nim):
  corrected DEFAULT : 24.6% of picks with ZERO safe tile, mean 11.17 safe
  j108 shipped field: 63.4% / 3.70   (reproduced exactly)
  j108 ring retune  :  8.1% / 18.41  (reproduced exactly)
  bullet isolated   : 11.4% / 17.07
The corrected default beats the shipped field but is WORSE than j108's retune
row: the bullet retune alone costs 8.1 -> 11.4, the corridor/wall retune accounts
for the rest. That is the deliberate price of making a bullet dangerous.

Guards green: test_env_report 24 PASS, test_tfil_commit_env 30 PASS (shipped TFIL
default untouched, byte-for-byte), test_tfil_ring_weights 24 PASS. The three new
knobs are registered in the boot env report so the tree-scan guard stays clean.
2026-09-25 23:25:34 +02:00

347 lines
14 KiB
Nim

## 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..<states.len:
if i == 0 or i in starts: m.resetRound()
let cmd = m.computeMove(states[i])
let sign = if cmd.speed > 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..<states.len:
if i == 0 or i in starts:
m.resetRound()
prevPicks = 0
prevLastPick = i
prevFlips = 0
let cmd = m.computeMove(states[i])
let sign = if cmd.speed > 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..<offsets.len:
if n[oi] > 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..<Offsets.len:
if i > 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"