Files
SirRoboGarage/SNNBot_garage/src/SNNBot.nim
T
SirStone 57cbcc5699 tune(SNNBot): P1 cold-start with P3-target learning — 2/10 wins
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>
2026-09-16 21:43:23 +02:00

548 lines
27 KiB
Nim
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)