57cbcc5699
Fire P1 for first COLD_K=8 exemplars (cheap misses), always store P3-correct lead offsets using P3_SPEED=11 in EVALUATE travelTime. Switches to P3 once exemplar buffer has enough data. Best observed: wins rounds 1-2 back-to-back (relative velocity encoding + P1 warmup). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
548 lines
27 KiB
Nim
548 lines
27 KiB
Nim
# SNNBot — SNN core + aiming loop prototype (issue #152).
|
||
# 80-input population-coded → 12 LIF hidden → polar-coded (sin/cos) output decoded via atan2.
|
||
# Inputs: bearing [0-35], velocity direction [36-71], speed [72-79].
|
||
# No movement, no firing. SuperSpike three-factor rule on all weights (issue #158).
|
||
# Binary reservoir alternative toggled via USE_RESERVOIR const (issue #159).
|
||
#
|
||
# State machine:
|
||
# DECIDE → feed bearing+velocity into SNN/reservoir, store targetAngle, → WAITING
|
||
# WAITING → aimTo() each tick; when error < 2° → EVALUATE
|
||
# EVALUATE → measure error, compute SuperSpike/reservoir update, log, → DECIDE
|
||
|
||
import std/[math, random, os, strutils, bitops]
|
||
import robocode_tankroyale_botapi
|
||
import radar_lock/radar_lock as radar_lock
|
||
import reservoir
|
||
|
||
const USE_RESERVOIR* = true # ponytail: kept for SNN path fallback; remove when binary aimer is validated
|
||
|
||
# ── Constants ──────────────────────────────────────────────────────────────────
|
||
|
||
const botJsonPath = currentSourcePath().parentDir / "SNNBot.json"
|
||
|
||
const
|
||
N_IN = 80 # input neurons: bearing(36) + vel_dir(36) + speed(8)
|
||
N_HID = 12 # hidden LIF neurons
|
||
BAND_DEG = 10.0 # degrees per input band
|
||
BEARING_OFFSET = 0 # neurons 0-35: relative bearing
|
||
VEL_DIR_OFFSET = 36 # neurons 36-71: velocity direction
|
||
SPEED_OFFSET = 72 # neurons 72-79: speed bands
|
||
N_SPEED_BANDS = 8
|
||
SPEED_BAND_WIDTH = 1.0 # units/tick per band
|
||
MAX_SPEED = 8.0
|
||
LEAK = 0.9 # LIF membrane leak factor
|
||
THRESH = 0.08 # ponytail: THRESH=0.08 tuned for 80-input layer; raise if firing saturates
|
||
MAX_GUN_TURN = 20.0 # max gun turn per tick (degrees)
|
||
ETA = 0.1 # SuperSpike learning rate for hidden→output weights (r_0 from paper, bumped 2x)
|
||
ETA_IH = 0.02 # ponytail: ETA_IH=0.02 bumped 4x for input→hidden (was 0.005, too conservative)
|
||
# ponytail: separate input→hidden rate; add RMaxProp optimizer if convergence still unstable
|
||
N_INFER = 10 # inference window ticks per DECIDE
|
||
# ponytail: N_INFER=10, increase if output still noisy; decrease if too slow per tick
|
||
TRACE_DECAY = 0.9 # pre-synaptic trace decay (exponential low-pass)
|
||
W_CLAMP = 1.0 # ponytail: W_CLAMP=1.0, paper says ±0.1 but that's for mV-scale voltages; our unitless THRESH=1.0 needs larger weights
|
||
BETA = 1.0 # surrogate steepness (unitless; potentials are unitless)
|
||
NOISE_AMP = 0.3 # exploration noise amplitude
|
||
# ponytail: uniform exploration noise; upgrade to annealed Gaussian if convergence needs tuning
|
||
ENERGY_GUARD = 15.0 # don't fire below this energy
|
||
P3_SPEED = 11.0 # bullet speed for P3 (20 - 3*3); used for exemplar learning
|
||
COLD_K = 8 # exemplars needed before switching cold→warm (P1→P3)
|
||
|
||
# ── SNN types ─────────────────────────────────────────────────────────────────
|
||
|
||
type
|
||
SNN = object
|
||
wih: array[N_IN * N_HID, float] # 80×12 input→hidden weights
|
||
wSin: array[N_HID, float] # hidden→sin-channel weights
|
||
wCos: array[N_HID, float] # hidden→cos-channel weights
|
||
vHid: array[N_HID, float] # hidden membrane potentials
|
||
preTrace: array[N_IN, float] # pre-synaptic traces (low-pass of input spikes)
|
||
# Random feedback weights B[h, o] for hidden error projection; fixed, never updated.
|
||
# Indexed as bFb[h * 2 + o], o=0 → sin, o=1 → cos.
|
||
bFb: array[N_HID * 2, float]
|
||
tick: int
|
||
lastSinOut: float # last forward pass sin output (weighted sum)
|
||
lastCosOut: float # last forward pass cos output (weighted sum)
|
||
lastSnnAngle: float # last atan2 result (degrees)
|
||
|
||
proc initSNN(snn: var SNN) =
|
||
randomize()
|
||
for w in snn.wih.mitems: w = rand(0.2) - 0.1 # init within clamp ±0.1, room to grow to ±1.0
|
||
for w in snn.wSin.mitems: w = rand(0.2) - 0.1
|
||
for w in snn.wCos.mitems: w = rand(0.2) - 0.1
|
||
for b in snn.bFb.mitems: b = rand(2.0) - 1.0 # N(0,1)-ish; fixed forever
|
||
for t in snn.preTrace.mitems: t = 0.0
|
||
snn.tick = 0
|
||
|
||
proc encodeBearing(inputs: var array[N_IN, float], bearing: float, offset: int) =
|
||
## Population-code a -180..+180 angle into 36 neurons starting at offset.
|
||
## Triangular interpolation between lo and hi band.
|
||
let norm = ((bearing + 180.0) / BAND_DEG) # 0..36
|
||
let lo = int(norm) mod 36
|
||
let hi = (lo + 1) mod 36
|
||
let frac = norm - float(int(norm))
|
||
inputs[offset + lo] = 1.0 - frac
|
||
inputs[offset + hi] = frac
|
||
|
||
proc encodeInput(bearing: float): array[N_IN, float] =
|
||
## Bearing-only encode for overlay (velocity channels stay 0).
|
||
encodeBearing(result, bearing, BEARING_OFFSET)
|
||
|
||
proc encodeInputFull(bearing: float, velDirDeg: float, speed: float,
|
||
hasVel: bool): array[N_IN, float] =
|
||
## Full 80-neuron encode: bearing + velocity direction + speed.
|
||
encodeBearing(result, bearing, BEARING_OFFSET)
|
||
if hasVel:
|
||
encodeBearing(result, velDirDeg, VEL_DIR_OFFSET)
|
||
# Speed: triangular interpolation over N_SPEED_BANDS bands, clamp to [0, MAX_SPEED]
|
||
let s = speed.clamp(0.0, MAX_SPEED)
|
||
let norm = s / SPEED_BAND_WIDTH
|
||
let lo = min(int(norm), N_SPEED_BANDS - 1)
|
||
let hi = min(lo + 1, N_SPEED_BANDS - 1)
|
||
let frac = norm - float(int(norm))
|
||
result[SPEED_OFFSET + lo] += 1.0 - frac
|
||
result[SPEED_OFFSET + hi] += frac
|
||
|
||
proc surrogateDerivative(v: float): float {.inline.} =
|
||
## σ'(U) = (1 + |β(U − ϑ)|)^{-2} — peaks at threshold, gives gradient direction.
|
||
let x = BETA * (v - THRESH)
|
||
result = 1.0 / ((1.0 + abs(x)) * (1.0 + abs(x)))
|
||
|
||
proc forward(snn: var SNN, inputs: array[N_IN, float],
|
||
spikesOut: var array[N_HID, float],
|
||
vHidSnap: var array[N_HID, float],
|
||
sinOut: var float, cosOut: var float) =
|
||
## One SNN tick. Writes hidden spikes, pre-spike voltages, and raw sin/cos outputs.
|
||
## Caller accumulates sinOut/cosOut across N_INFER ticks, then calls atan2.
|
||
inc snn.tick
|
||
|
||
# Pre-synaptic trace: low-pass of input spikes
|
||
for i in 0 ..< N_IN:
|
||
snn.preTrace[i] = TRACE_DECAY * snn.preTrace[i] + inputs[i]
|
||
|
||
# Hidden layer: LIF update
|
||
for h in 0 ..< N_HID:
|
||
var wsum = 0.0
|
||
for i in 0 ..< N_IN:
|
||
wsum += inputs[i] * snn.wih[i * N_HID + h]
|
||
# ponytail: noise removed — SuperSpike surrogate derivative provides gradient direction, no exploration needed
|
||
snn.vHid[h] = LEAK * snn.vHid[h] + wsum
|
||
vHidSnap[h] = snn.vHid[h] # snapshot voltage before reset (for surrogate)
|
||
if snn.vHid[h] >= THRESH:
|
||
spikesOut[h] = 1.0
|
||
snn.vHid[h] = 0.0
|
||
else:
|
||
spikesOut[h] = 0.0
|
||
|
||
# Output: polar-coded via sin/cos channels (raw; caller does atan2)
|
||
sinOut = 0.0
|
||
cosOut = 0.0
|
||
for h in 0 ..< N_HID:
|
||
sinOut += spikesOut[h] * snn.wSin[h]
|
||
cosOut += spikesOut[h] * snn.wCos[h]
|
||
snn.lastSinOut = sinOut
|
||
snn.lastCosOut = cosOut
|
||
snn.lastSnnAngle = arctan2(sinOut, cosOut) * 180.0 / PI
|
||
|
||
proc superSpikeUpdate(snn: var SNN,
|
||
spikes: array[N_HID, float],
|
||
vSnap: array[N_HID, float],
|
||
preTrace: array[N_IN, float],
|
||
targetAngle: float) =
|
||
## SuperSpike three-factor weight update.
|
||
## Δw = η × pre_trace × σ'(U) × error
|
||
## spikes/vSnap/preTrace: captured at DECIDE time for this inference window.
|
||
## Output error: target_rate − actual_rate (rate-coded target).
|
||
## Hidden error: projected via fixed random feedback weights B.
|
||
|
||
# Target rates for sin/cos channels: map [-1,1] → [0,1]
|
||
let tSin = (sin(degToRad(targetAngle)) + 1.0) / 2.0
|
||
let tCos = (cos(degToRad(targetAngle)) + 1.0) / 2.0
|
||
|
||
# Normalize accumulated spike counts to rates in [0,1]
|
||
# Actual output activity using spike rates
|
||
var sinAct = 0.0; var cosAct = 0.0
|
||
for h in 0 ..< N_HID:
|
||
let rate = spikes[h] / float(N_INFER)
|
||
sinAct += rate * snn.wSin[h]
|
||
cosAct += rate * snn.wCos[h]
|
||
# Map actual output to [0,1] for rate comparison
|
||
let sinActNorm = (sinAct.clamp(-1.0, 1.0) + 1.0) / 2.0
|
||
let cosActNorm = (cosAct.clamp(-1.0, 1.0) + 1.0) / 2.0
|
||
|
||
# Output error (target_rate − actual_rate)
|
||
let errSin = tSin - sinActNorm
|
||
let errCos = tCos - cosActNorm
|
||
|
||
# Update hidden→output weights: Δw = η × rate_h × σ'(U_h) × error_o
|
||
for h in 0 ..< N_HID:
|
||
let sg = surrogateDerivative(vSnap[h])
|
||
let rate = spikes[h] / float(N_INFER)
|
||
snn.wSin[h] += ETA * rate * sg * errSin
|
||
snn.wSin[h] = snn.wSin[h].clamp(-W_CLAMP, W_CLAMP)
|
||
snn.wCos[h] += ETA * rate * sg * errCos
|
||
snn.wCos[h] = snn.wCos[h].clamp(-W_CLAMP, W_CLAMP)
|
||
|
||
# Update input→hidden weights: Δw = η × preTrace_j × σ'(U_h) × error_h
|
||
# Hidden error projected via fixed random feedback: error_h = Σ_o B[h,o] × error_o
|
||
for h in 0 ..< N_HID:
|
||
let sg = surrogateDerivative(vSnap[h])
|
||
let errHid = snn.bFb[h * 2 + 0] * errSin + snn.bFb[h * 2 + 1] * errCos
|
||
for i in 0 ..< N_IN:
|
||
snn.wih[i * N_HID + h] += ETA_IH * preTrace[i] * sg * errHid
|
||
snn.wih[i * N_HID + h] = snn.wih[i * N_HID + h].clamp(-W_CLAMP, W_CLAMP)
|
||
|
||
# ── Bot state machine ─────────────────────────────────────────────────────────
|
||
|
||
type
|
||
Phase = enum DECIDE, WAITING, EVALUATE
|
||
|
||
SNNBot = ref object of Bot
|
||
snn: SNN
|
||
res: BinaryAimer
|
||
phase: Phase
|
||
targetAngle: float # SNN output (absolute bearing)
|
||
enemyBearing: float # last known enemy bearing
|
||
enemyDist: float # last known distance to enemy
|
||
hasContact: bool
|
||
tick: int
|
||
lastSpikes: array[N_HID, float] # accumulated spike counts over N_INFER ticks
|
||
lastVSnap: array[N_HID, float] # hidden voltages before reset from last tick (for SuperSpike)
|
||
lastRelBearing: float # relative bearing at DECIDE time (fixed for learning)
|
||
lastEnemyX: float64 # previous tick enemy position (for velocity)
|
||
lastEnemyY: float64
|
||
hasLastPos: bool
|
||
velDirDeg: float64 # velocity direction (degrees) from last scan delta
|
||
velSpeed: float64 # speed (units/tick) from last scan delta
|
||
lastDecideGunDir: float # gun heading captured at DECIDE time for EVALUATE
|
||
lastBinInput: BitVec80 # DECIDE-time binary input, reused in EVALUATE
|
||
lastInput: BitVec80 # previous EVALUATE-time input (for change detection)
|
||
lastAbsBearing: float # absolute bearing to enemy captured at DECIDE time
|
||
bulletsFired: int # count of bullets fired this round
|
||
bulletsHit: int # count of bullets that hit this round
|
||
hitRateEMA: float # exponential moving average of hit rate
|
||
currentFirePower: float # dynamic fire power (1.0 / 2.0 / 3.0)
|
||
bulletSpeed: float # 20 - 3 * currentFirePower
|
||
lastBulletSpeed: float # bullet speed at last fire (for EVALUATE learning)
|
||
pendingPower: float # candidate new power level
|
||
powerChangeCounter: int # ticks the new power has been suggested
|
||
|
||
# ── aimTo helper ──────────────────────────────────────────────────────────────
|
||
|
||
proc aimTo(targetAngle, gunDir: float) {.inline.} =
|
||
let delta = normalizeRelativeAngle(targetAngle - gunDir)
|
||
setGunTurnRate(delta.clamp(-MAX_GUN_TURN, MAX_GUN_TURN))
|
||
|
||
# ── Debug overlay ─────────────────────────────────────────────────────────────
|
||
|
||
proc drawOverlay(bot: SNNBot, myX, myY, gunDir, enemyBearing, targetAngle: float) =
|
||
let LINE_LEN = bot.enemyDist + 50.0
|
||
# Convert degree bearing to SVG direction (SVG y-axis is inverted; 0=east, CCW+)
|
||
template toXY(bearing, len: float): (float, float) =
|
||
(myX + cos(degToRad(bearing)) * len,
|
||
myY + sin(degToRad(bearing)) * len)
|
||
|
||
# Green: enemy direction (truth)
|
||
setStrokeColor(GREEN)
|
||
setStrokeWidth(2.0)
|
||
let (ex, ey) = toXY(enemyBearing, LINE_LEN)
|
||
drawLine(myX, myY, ex, ey)
|
||
|
||
# Red: gun direction
|
||
setStrokeColor(RED)
|
||
let (gx, gy) = toXY(gunDir, LINE_LEN)
|
||
drawLine(myX, myY, gx, gy)
|
||
|
||
# Yellow: SNN target angle
|
||
setStrokeColor(YELLOW)
|
||
let (tx, ty) = toXY(targetAngle, LINE_LEN)
|
||
drawLine(myX, myY, tx, ty)
|
||
|
||
# ── Event handlers ────────────────────────────────────────────────────────────
|
||
|
||
method onScannedBot*(bot: SNNBot, e: ScannedBotEvent) =
|
||
let bx = getX(); let by = getY()
|
||
bot.enemyBearing = directionTo(bx, by, e.x, e.y)
|
||
bot.enemyDist = distanceTo(bx, by, e.x, e.y)
|
||
bot.hasContact = true
|
||
if bot.hasLastPos:
|
||
let dx = e.x - bot.lastEnemyX
|
||
let dy = e.y - bot.lastEnemyY
|
||
bot.velDirDeg = arctan2(dy, dx) * 180.0 / PI
|
||
bot.velSpeed = sqrt(dx * dx + dy * dy)
|
||
bot.lastEnemyX = e.x; bot.lastEnemyY = e.y
|
||
bot.hasLastPos = true
|
||
|
||
method onRoundStarted*(bot: SNNBot, e: RoundStartedEvent) =
|
||
setAdjustGunForBodyTurn(true)
|
||
setAdjustRadarForBodyTurn(true)
|
||
setAdjustRadarForGunTurn(true)
|
||
radar_lock.init()
|
||
bot.hasContact = false
|
||
bot.hasLastPos = false
|
||
bot.velDirDeg = 0.0
|
||
bot.velSpeed = 0.0
|
||
bot.phase = DECIDE
|
||
bot.tick = 0
|
||
# Reset per-round stats (learning and currentFirePower persist across rounds)
|
||
bot.bulletsFired = 0
|
||
bot.bulletsHit = 0
|
||
bot.hitRateEMA = 0.5 # optimistic start
|
||
bot.powerChangeCounter = 0
|
||
# currentFirePower persists; init to 3.0 on first round (zero-value)
|
||
if bot.currentFirePower == 0.0:
|
||
bot.currentFirePower = 3.0
|
||
bot.bulletSpeed = 20.0 - 3.0 * bot.currentFirePower
|
||
bot.lastBulletSpeed = bot.bulletSpeed
|
||
bot.lastEnemyX = 0.0
|
||
bot.lastEnemyY = 0.0
|
||
bot.lastAbsBearing = 0.0
|
||
bot.lastDecideGunDir = 0.0
|
||
bot.targetAngle = 0.0
|
||
bot.lastInput = [0'u64, 0'u64]
|
||
setTargetSpeed(0.0)
|
||
setTurnRate(0.0)
|
||
|
||
method onGameStarted*(bot: SNNBot, e: GameStartedEventForBot) =
|
||
initSNN(bot.snn)
|
||
bot.res = initBinaryAimer()
|
||
|
||
proc selectFirePower(bot: SNNBot): float =
|
||
## Power ladder with hysteresis: only commit after 8 consecutive same-direction evaluations.
|
||
if getEnergy() < ENERGY_GUARD:
|
||
return 0.0
|
||
let desired =
|
||
if bot.hitRateEMA > 0.45: 3.0
|
||
elif bot.hitRateEMA > 0.30: 2.0
|
||
else: 1.0
|
||
if desired != bot.currentFirePower:
|
||
if desired == bot.pendingPower:
|
||
if bot.powerChangeCounter >= 8:
|
||
return desired # commit
|
||
# counter incremented at call site
|
||
return bot.currentFirePower # hold
|
||
return bot.currentFirePower
|
||
|
||
method onBulletFired*(bot: SNNBot, e: BulletFiredEvent) =
|
||
inc bot.bulletsFired
|
||
# decay EMA toward miss on each confirmed fire
|
||
bot.hitRateEMA = bot.hitRateEMA * 0.93
|
||
|
||
method onBulletHit*(bot: SNNBot, e: BulletHitBotEvent) =
|
||
inc bot.bulletsHit
|
||
bot.hitRateEMA = bot.hitRateEMA * 0.85 + 1.0 * 0.15
|
||
|
||
# ── Reservoir helpers ─────────────────────────────────────────────────────────
|
||
|
||
proc toBinaryInput(bearing: float, velDir: float, velSpeed: float, hasVel: bool): BitVec80 =
|
||
## Binary-native encoding using relative velocity direction for cross-round generalization.
|
||
## Bits 0-35: bearing (absolute, for range estimate)
|
||
## Bits 36-71: relative velocity direction = (velDir - bearing) mod 360
|
||
## This encodes lateral vs. radial motion regardless of which wall Walls is on.
|
||
## Bits 72-79: speed bands
|
||
## ponytail: relative-vel encoding; revert to absolute if aimer convergence regresses
|
||
result = [0'u64, 0'u64]
|
||
|
||
# Bearing: bits 0-35 (36 bits, 10° bands)
|
||
# Activate center band + 2 neighbors on each side = 5 bits
|
||
let bNorm = (bearing + 180.0) / 10.0 # 0..36
|
||
let bCenter = int(bNorm) mod 36
|
||
for offset in -2 .. 2:
|
||
let idx = (bCenter + offset + 36) mod 36
|
||
let word = idx div 64
|
||
let bit = idx mod 64
|
||
result[word] = result[word] or (1'u64 shl bit)
|
||
|
||
if hasVel:
|
||
# Relative velocity direction: (velDir - bearing + 360) mod 360
|
||
# Encodes lateral/radial motion direction independent of which wall Walls is on
|
||
let relVel = ((velDir - bearing) + 360.0) mod 360.0
|
||
let vNorm = relVel / 10.0
|
||
let vCenter = int(vNorm) mod 36
|
||
for offset in -2 .. 2:
|
||
let idx = 36 + (vCenter + offset + 36) mod 36
|
||
let word = idx div 64
|
||
let bit = idx mod 64
|
||
result[word] = result[word] or (1'u64 shl bit)
|
||
|
||
# Speed: bits 72-79 (8 bits, 1 unit/tick bands)
|
||
# Activate center + 1 neighbor on each side = 3 bits
|
||
let sCenter = clamp(int(velSpeed), 0, 7)
|
||
for offset in -1 .. 1:
|
||
let idx = 72 + clamp(sCenter + offset, 0, 7)
|
||
let word = idx div 64
|
||
let bit = idx mod 64
|
||
result[word] = result[word] or (1'u64 shl bit)
|
||
|
||
# ── Main loop ─────────────────────────────────────────────────────────────────
|
||
|
||
method run*(bot: SNNBot) =
|
||
while isRunning():
|
||
inc bot.tick
|
||
setTargetSpeed(0.0)
|
||
setTurnRate(0.0)
|
||
|
||
if not bot.hasContact:
|
||
setRadarTurnRate(45.0)
|
||
go()
|
||
continue
|
||
|
||
let myX = getX()
|
||
let myY = getY()
|
||
let gunDir = getGunDirection()
|
||
|
||
case bot.phase
|
||
of DECIDE:
|
||
# absBearing: world-frame bearing [0,360) from directionTo — independent of bot heading
|
||
let absBearing = bot.enemyBearing
|
||
bot.lastRelBearing = normalizeRelativeAngle(absBearing - gunDir)
|
||
when USE_RESERVOIR:
|
||
let binInput = toBinaryInput(absBearing, bot.velDirDeg, bot.velSpeed, bot.hasLastPos)
|
||
let aimOffset = bot.res.forward(binInput) # lead correction offset or -999.0 sentinel
|
||
bot.lastBinInput = binInput
|
||
bot.lastDecideGunDir = gunDir
|
||
bot.lastAbsBearing = absBearing
|
||
let rate = if bot.bulletsFired > 0: float(bot.bulletsHit) / float(bot.bulletsFired) * 100.0 else: 0.0
|
||
if aimOffset <= -999.0:
|
||
# cold start: aim directly at enemy (no lead)
|
||
bot.targetAngle = absBearing
|
||
echo "RES tick=" & $bot.tick & " pwr=" & formatFloat(bot.currentFirePower, ffDecimal, 1) & " hitEMA=" & formatFloat(bot.hitRateEMA * 100.0, ffDecimal, 0) & "% cold-start aim=" & formatFloat(absBearing, ffDecimal, 1) & " fired=" & $bot.bulletsFired & " hit=" & $bot.bulletsHit & " rate=" & formatFloat(rate, ffDecimal, 1) & "% energy=" & formatFloat(getEnergy(), ffDecimal, 0)
|
||
else:
|
||
# apply lead correction offset to current bearing
|
||
bot.targetAngle = (absBearing + aimOffset + 360.0) mod 360.0
|
||
echo "RES tick=" & $bot.tick & " pwr=" & formatFloat(bot.currentFirePower, ffDecimal, 1) & " hitEMA=" & formatFloat(bot.hitRateEMA * 100.0, ffDecimal, 0) & "% offset=" & formatFloat(aimOffset, ffDecimal, 1) & " aim=" & formatFloat(bot.targetAngle, ffDecimal, 1) & " fired=" & $bot.bulletsFired & " hit=" & $bot.bulletsHit & " rate=" & formatFloat(rate, ffDecimal, 1) & "% energy=" & formatFloat(getEnergy(), ffDecimal, 0)
|
||
else:
|
||
let inputs = encodeInputFull(absBearing, bot.velDirDeg, bot.velSpeed, bot.hasLastPos)
|
||
# Multi-tick inference: accumulate sin/cos and spike counts over N_INFER ticks
|
||
var totalSin = 0.0; var totalCos = 0.0
|
||
var tickSpikes: array[N_HID, float]
|
||
var tickVSnap: array[N_HID, float]
|
||
for h in 0 ..< N_HID: bot.lastSpikes[h] = 0.0
|
||
for _ in 0 ..< N_INFER:
|
||
var sinT, cosT: float
|
||
bot.snn.forward(inputs, tickSpikes, tickVSnap, sinT, cosT)
|
||
totalSin += sinT; totalCos += cosT
|
||
for h in 0 ..< N_HID:
|
||
bot.lastSpikes[h] += tickSpikes[h] # accumulate counts
|
||
# Store last-tick voltages for learning
|
||
bot.lastVSnap = tickVSnap
|
||
bot.snn.lastSinOut = totalSin; bot.snn.lastCosOut = totalCos
|
||
bot.snn.lastSnnAngle = arctan2(totalSin, totalCos) * 180.0 / PI
|
||
bot.targetAngle = gunDir + bot.snn.lastSnnAngle
|
||
bot.lastDecideGunDir = gunDir
|
||
# Log total spike count across inference window
|
||
var spikeCount = 0
|
||
var maxV = 0.0
|
||
for h in 0 ..< N_HID:
|
||
spikeCount += int(bot.lastSpikes[h])
|
||
maxV = max(maxV, bot.snn.vHid[h])
|
||
echo "tick=" & $bot.tick & " infer_spk=" & $spikeCount & "/" & $(N_INFER * N_HID) & " maxV=" & formatFloat(maxV, ffDecimal, 3)
|
||
bot.phase = WAITING
|
||
|
||
of WAITING:
|
||
aimTo(bot.targetAngle, gunDir)
|
||
let err = abs(normalizeRelativeAngle(bot.targetAngle - gunDir))
|
||
|
||
# Adaptive dead zone: scale learning threshold by enemy velocity
|
||
# Stationary → 0.5° (stable), full speed (8 units/tick) → 0.3° (fast tracking)
|
||
let t = (bot.velSpeed / 8.0).clamp(0.0, 1.0)
|
||
let adaptiveDeadZone = 0.5 + (0.3 - 0.5) * t # lerp(0.5, 0.3, t)
|
||
|
||
# Fire gate: only fire when error within adaptive dead zone
|
||
if err < adaptiveDeadZone:
|
||
if getEnergy() >= ENERGY_GUARD:
|
||
# ponytail: P1 until COLD_K total exemplars exist; cross-round exemplar accumulation
|
||
let firePower = if bot.res.count < COLD_K: 1.0 else: bot.currentFirePower
|
||
let fireSpeed = 20.0 - 3.0 * firePower
|
||
bot.lastBulletSpeed = fireSpeed
|
||
discard setFire(firePower)
|
||
bot.phase = EVALUATE
|
||
else:
|
||
discard setFire(0.0)
|
||
|
||
of EVALUATE:
|
||
# Predictive error signal: extrapolate enemy position at bullet impact time.
|
||
if bot.hasLastPos:
|
||
# Always compute lead for P3_SPEED so exemplars teach P3-correct offsets,
|
||
# even when the actual shot was P1 (cold-start).
|
||
let travelTime = bot.enemyDist / P3_SPEED
|
||
let velRad = degToRad(bot.velDirDeg)
|
||
let futureX = bot.lastEnemyX + cos(velRad) * bot.velSpeed * travelTime
|
||
let futureY = bot.lastEnemyY + sin(velRad) * bot.velSpeed * travelTime
|
||
# correctAngle: absolute world-frame bearing [0,360) to predicted enemy position
|
||
let correctAngle = directionTo(myX, myY, futureX, futureY)
|
||
|
||
# Adaptive learning dead zone: scale by enemy velocity (same as fire gate)
|
||
let t = (bot.velSpeed / 8.0).clamp(0.0, 1.0)
|
||
let adaptiveDeadZone = 0.5 + (0.3 - 0.5) * t # lerp(0.5, 0.3, t)
|
||
|
||
when USE_RESERVOIR:
|
||
# correctOffset: how much lead to apply from current bearing
|
||
let correctOffset = normalizeRelativeAngle(correctAngle - bot.lastAbsBearing)
|
||
let producedOffset = bot.res.forward(bot.lastBinInput)
|
||
# Detect significant input change via Hamming distance
|
||
let hammingDist = popcount(bot.lastBinInput[0] xor bot.lastInput[0]) + popcount(bot.lastBinInput[1] xor bot.lastInput[1])
|
||
let inputChanged = hammingDist > 2 # 3+ bits flipped = situation changed
|
||
|
||
# Learn if: error > dead zone OR input pattern changed significantly
|
||
if producedOffset > -999.0:
|
||
let aimErr = abs(normalizeRelativeAngle(producedOffset - correctOffset))
|
||
if aimErr > adaptiveDeadZone or inputChanged:
|
||
bot.res.learn(bot.lastBinInput, correctOffset)
|
||
else:
|
||
# Cold start: always learn
|
||
bot.res.learn(bot.lastBinInput, correctOffset)
|
||
let aimErr = if producedOffset > -999.0: abs(normalizeRelativeAngle(producedOffset - correctOffset)) else: -1.0
|
||
let rate = if bot.bulletsFired > 0: (float(bot.bulletsHit) / float(bot.bulletsFired) * 100.0) else: 0.0
|
||
echo "RES tick=" & $bot.tick &
|
||
" pwr=" & formatFloat(bot.currentFirePower, ffDecimal, 1) &
|
||
" hitEMA=" & formatFloat(bot.hitRateEMA * 100.0, ffDecimal, 0) & "%" &
|
||
" fired=" & $bot.bulletsFired & " hit=" & $bot.bulletsHit &
|
||
" rate=" & (if bot.bulletsFired > 0: formatFloat(float(bot.bulletsHit) / float(bot.bulletsFired) * 100.0, ffDecimal, 1) else: "0.0") & "%" &
|
||
" energy=" & formatFloat(getEnergy(), ffDecimal, 0) &
|
||
" offset=" & (if producedOffset > -999.0: formatFloat(producedOffset, ffDecimal, 1) else: "cold") &
|
||
" correctOffset=" & formatFloat(correctOffset, ffDecimal, 1) &
|
||
" err=" & (if producedOffset > -999.0: formatFloat(aimErr, ffDecimal, 1) else: "n/a") &
|
||
" hammingDist=" & $hammingDist &
|
||
" inputChanged=" & $inputChanged &
|
||
" exemplars=" & $bot.res.count
|
||
else:
|
||
let targetRel = normalizeRelativeAngle(correctAngle - bot.lastDecideGunDir)
|
||
bot.snn.superSpikeUpdate(bot.lastSpikes, bot.lastVSnap, bot.snn.preTrace, targetRel)
|
||
let err = abs(normalizeRelativeAngle(gunDir - bot.enemyBearing))
|
||
# Compute verbose logging metrics
|
||
var spikeCount = 0
|
||
var maxV = 0.0
|
||
for h in 0 ..< N_HID:
|
||
spikeCount += int(bot.lastSpikes[h])
|
||
maxV = max(maxV, bot.snn.vHid[h])
|
||
var meanWih = 0.0
|
||
for w in bot.snn.wih:
|
||
meanWih += abs(w)
|
||
meanWih /= float(N_IN * N_HID)
|
||
var meanWout = 0.0
|
||
for w in bot.snn.wSin:
|
||
meanWout += abs(w)
|
||
for w in bot.snn.wCos:
|
||
meanWout += abs(w)
|
||
meanWout /= float(2 * N_HID)
|
||
let rate = if bot.bulletsFired > 0: (float(bot.bulletsHit) / float(bot.bulletsFired) * 100.0) else: 0.0
|
||
echo "tick=" & $bot.tick & " pwr=" & formatFloat(bot.currentFirePower, ffDecimal, 1) & " hitEMA=" & formatFloat(bot.hitRateEMA * 100.0, ffDecimal, 0) & "% fired=" & $bot.bulletsFired & " hit=" & $bot.bulletsHit & " rate=" & formatFloat(rate, ffDecimal, 1) & "% energy=" & formatFloat(getEnergy(), ffDecimal, 0) & " err=" & formatFloat(err, ffDecimal, 1) & "° infer_spk=" & $spikeCount & "/" & $(N_INFER * N_HID) & " maxV=" & formatFloat(maxV, ffDecimal, 3) & " |wih|=" & formatFloat(meanWih, ffDecimal, 4) & " |wOut|=" & formatFloat(meanWout, ffDecimal, 4) & " sin=" & formatFloat(bot.snn.lastSinOut, ffDecimal, 3) & " cos=" & formatFloat(bot.snn.lastCosOut, ffDecimal, 3) & " snnAngle=" & formatFloat(bot.snn.lastSnnAngle, ffDecimal, 1)
|
||
|
||
# Update lastInput for next EVALUATE cycle's change detection
|
||
bot.lastInput = bot.lastBinInput
|
||
bot.phase = DECIDE
|
||
|
||
# Radar lock
|
||
setRadarTurnRate(radar_lock.doRadar(getRadarDirection(), bot.enemyBearing))
|
||
|
||
# Debug overlay
|
||
drawOverlay(bot, myX, myY, gunDir, bot.enemyBearing, bot.targetAngle)
|
||
|
||
go()
|
||
|
||
# ── Entry point ───────────────────────────────────────────────────────────────
|
||
|
||
when isMainModule:
|
||
var bot = SNNBot(phase: DECIDE)
|
||
start(bot, botJsonPath)
|