STRAFE movement: body pinned perpendicular to the threat, reversals by sign flip
New engine movements/strafe.nim, selected by TR_MOVEMENT=strafe (default stays tfil, byte-identical — test_tfil_commit_env.nim's 30 checks still pass). Design (the owner's): - AXIS = incoming bullet's direction when a bullet is in flight, else the perpendicular of the enemy bearing. The body heading is kept inside a band (TR_STRAFE_BAND, default 20 deg) around the perpendicular LINE; it turns only when outside the band, and never turns to face a movement target. - Candidate tiles on the perpendicular line through our position, both forward and backward, within TR_STRAFE_REACH px, with a perpendicular jitter of +/- TR_STRAFE_SPREAD tiles. A tile is acceptable when its path max heat is <= PathDangerThreshold, the SAME safety rule TFIL uses. - Move by SIGN only: setForward(+/-MaxSpeed>). Dwell is re-picked after a random number of ticks in [TR_STRAFE_DWELL_MIN, TR_STRAFE_DWELL_MAX], on arrival, or on a serious threat spike. - Heat machinery is REUSED from the shipped mover, not re-implemented: the exported heatDecay()/bulletMagScale() (j105 time-indexed model) and the PillarHotness/PillarRadiance globals (j106 pillar-free default). The heat shape is overridable via TR_STRAFE_CORRIDOR_HEAT/WALL_HOTNESS/WALL_RADIANCE (defaults = the shipped TFIL field). - GUI overlay: strafe line, threat axis, candidate tiles (safe/unsafe), chosen target, sign-coloured movement ray, and the heading band. Gates (offline, recorded DrussGT fixture, 20026 ticks): - A TILE AVAILABILITY: shipped heat field -> a safe tile exists on only 36.6% of picks (63.4% fall back to the least-hot tile); the ring retune (corridor 5, wall 10/5) raises it to 91.9%. - B PREDICTABILITY: reversal-interval entropy 5.84 bits vs TFIL 5.09; direction entropy 1.00 both; long-lag autocorrelation ~0 for both (no periodic component). Fewer reversals (710 vs 1453) and more full-speed ticks. measurements: common_libs/tests/measure_strafe_gates.nim Also registers TR_STRAFE_* in the boot env report (ModularBot_garage/src/ env_report.nim) and wires the engine into ModularBot.nim (hold -> strafe, ram trigger -> rammer).
This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
## 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)
|
||||
|
||||
# Field variant: the ring mover's retune, where no SINGLE soft source can poison
|
||||
# a path (corridor below the threshold, wall radiance == the threshold). This is
|
||||
# the same field shape the user's ring experiment used; we only test whether it
|
||||
# unblocks the strafe line.
|
||||
StrafeCorridorHeat = 5.0
|
||||
StrafeWallHotness = 10.0
|
||||
StrafeWallRadiance = 5.0
|
||||
let sfRetune = replayStrafe(states, starts)
|
||||
loadStrafeHeatEnv() # restore the shipped field 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("shipped TFIL heat (corridor 20, wall 30/10)", sf)
|
||||
gateA("ring retune (corridor 5, wall 10/5)", sfRetune)
|
||||
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"
|
||||
Reference in New Issue
Block a user