diff --git a/SNNBot_garage/SNNBot.nimble b/SNNBot_garage/SNNBot.nimble index 88c7bda..e95b294 100644 --- a/SNNBot_garage/SNNBot.nimble +++ b/SNNBot_garage/SNNBot.nimble @@ -10,3 +10,6 @@ bin = @["SNNBot"] requires "nim >= 2.0.0" requires "robocode_tankroyale_botapi >= 1.0.7" # radar_lock is vendored in-tree (common_libs/) and wired via config.nims --path + +task test, "Run integration tests": + exec "nim c -r --path:../common_libs tests/test_basic_battle.nim" diff --git a/SNNBot_garage/src/SNNBot.nim b/SNNBot_garage/src/SNNBot.nim index 84433f2..daf4b98 100644 --- a/SNNBot_garage/src/SNNBot.nim +++ b/SNNBot_garage/src/SNNBot.nim @@ -23,21 +23,33 @@ const THRESH = 1.0 # LIF spike threshold MAX_GUN_TURN = 20.0 # max gun turn per tick (degrees) AIM_TOL = 2.0 # arrive tolerance (degrees) + STDP_WIN = 20 # STDP timing window (ticks) + ETA = 0.01 # learning rate + # ponytail: global learning rate, no per-synapse adaptation; add when performance plateaus + ELG_DECAY = 0.95 # eligibility trace decay per tick + W_CLAMP = 2.0 # weight magnitude clamp # ── SNN types ───────────────────────────────────────────────────────────────── type SNN = object - wih: array[N_IN * N_HID, float] # 36×6 input→hidden weights - who: array[N_HID, float] # 6×1 hidden→output weights - vHid: array[N_HID, float] # hidden membrane potentials - vOut: float # output membrane potential (readout) + wih: array[N_IN * N_HID, float] # 36×6 input→hidden weights + who: array[N_HID, float] # 6×1 hidden→output weights + vHid: array[N_HID, float] # hidden membrane potentials + vOut: float # output membrane potential (readout) + lastSpikeIn: array[N_IN, int] # tick of last input spike (-1 = never) + lastSpikeHid: array[N_HID, int] # tick of last hidden spike (-1 = never) + eligibility: array[N_IN * N_HID, float] # per-synapse eligibility traces + tick: int # internal tick counter for spike timing proc initSNN(snn: var SNN) = - # ponytail: uniform random init, fine until STDP training added in #155 randomize() for w in snn.wih.mitems: w = rand(1.0) - 0.5 for w in snn.who.mitems: w = rand(1.0) - 0.5 + for t in snn.lastSpikeIn.mitems: t = -STDP_WIN - 1 + for t in snn.lastSpikeHid.mitems: t = -STDP_WIN - 1 + for e in snn.eligibility.mitems: e = 0.0 + snn.tick = 0 proc encodeInput(bearing: float): array[N_IN, float] = ## Population-code bearing into N_IN neurons. @@ -53,7 +65,17 @@ proc encodeInput(bearing: float): array[N_IN, float] = proc forward(snn: var SNN, inputs: array[N_IN, float]): float = ## One SNN tick. Returns target angle in degrees (-180..+180). - # Hidden layer: LIF update + inc snn.tick + + # Decay eligibility traces each tick + for e in snn.eligibility.mitems: e *= ELG_DECAY + + # Record input spikes + for i in 0 ..< N_IN: + if inputs[i] > 0.0: + snn.lastSpikeIn[i] = snn.tick + + # Hidden layer: LIF update + STDP trace var spikes: array[N_HID, float] for h in 0 ..< N_HID: var wsum = 0.0 @@ -63,8 +85,20 @@ proc forward(snn: var SNN, inputs: array[N_IN, float]): float = if snn.vHid[h] >= THRESH: spikes[h] = 1.0 snn.vHid[h] = 0.0 + snn.lastSpikeHid[h] = snn.tick + # STDP: post fires → check recent pre-spikes (potentiation) + for i in 0 ..< N_IN: + let dt = snn.tick - snn.lastSpikeIn[i] + if dt >= 0 and dt <= STDP_WIN: + snn.eligibility[i * N_HID + h] += exp(-float(dt) / float(STDP_WIN)) else: spikes[h] = 0.0 + # STDP: pre fires after post → depression for synapses where post spiked recently + for i in 0 ..< N_IN: + if inputs[i] > 0.0: + let dt = snn.tick - snn.lastSpikeHid[h] + if dt >= 0 and dt <= STDP_WIN: + snn.eligibility[i * N_HID + h] -= exp(-float(dt) / float(STDP_WIN)) # Output layer: membrane readout (no threshold) var osum = 0.0 @@ -72,8 +106,7 @@ proc forward(snn: var SNN, inputs: array[N_IN, float]): float = osum += spikes[h] * snn.who[h] snn.vOut = LEAK * snn.vOut + osum - # Scale vOut to [-180, +180]. vOut is unbounded so clamp after tanh-like squash. - # ponytail: simple linear scale; replace with proper output neuron tuning in #155 + # Scale vOut to [-180, +180] result = tanh(snn.vOut) * 180.0 # ── Bot state machine ───────────────────────────────────────────────────────── @@ -274,6 +307,11 @@ method run*(bot: SNNBot) = of EVALUATE: let err = abs(normalizeRelativeAngle(gunDir - bot.enemyBearing)) let reward = 1.0 / (1.0 + err) + # R-STDP weight update: w += η * R * e; then clamp and decay traces + for idx in 0 ..< N_IN * N_HID: + bot.snn.wih[idx] += ETA * reward * bot.snn.eligibility[idx] + bot.snn.wih[idx] = bot.snn.wih[idx].clamp(-W_CLAMP, W_CLAMP) + bot.snn.eligibility[idx] *= ELG_DECAY echo "tick=" & $bot.tick & " error=" & $err & "° reward=" & $reward bot.phase = DECIDE