1ea84f5b6e
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.
347 lines
14 KiB
Nim
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"
|