From 844abe0bb739700c79efd98fc473a97ad6d2ad7e Mon Sep 17 00:00:00 2001 From: Davide Cappellini Date: Tue, 15 Sep 2026 07:52:32 +0200 Subject: [PATCH] feat(SNNBot): replace bin-based aimer with exemplar kernel regression Eliminates aim jitter by switching from 360-bin WTA to weighted circular mean over stored exemplars. Continuous float output, no bins, no hysteresis, no saturation. K=128 ring buffer, Hamming similarity with quadratic weighting. --- SNNBot_garage/src/SNNBot.nim | 55 +++++++--------- SNNBot_garage/src/reservoir.nim | 109 ++++++++++++-------------------- 2 files changed, 61 insertions(+), 103 deletions(-) diff --git a/SNNBot_garage/src/SNNBot.nim b/SNNBot_garage/src/SNNBot.nim index 0919f9a..1d6e151 100644 --- a/SNNBot_garage/src/SNNBot.nim +++ b/SNNBot_garage/src/SNNBot.nim @@ -46,7 +46,6 @@ const # ponytail: uniform exploration noise; upgrade to annealed Gaussian if convergence needs tuning FIRE_POWER = 1.0 # fixed firing power; bullet speed = 20 - 3*FIRE_POWER BULLET_SPEED = 20.0 - 3.0 * FIRE_POWER - HYSTERESIS = 1 # minimum score advantage to switch bins # ── SNN types ───────────────────────────────────────────────────────────────── @@ -216,7 +215,6 @@ type 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 - lastWinBin: int # last winning bin for hysteresis # ── aimTo helper ────────────────────────────────────────────────────────────── @@ -280,8 +278,7 @@ method onRoundStarted*(bot: SNNBot, e: RoundStartedEvent) = method onGameStarted*(bot: SNNBot, e: GameStartedEventForBot) = initSNN(bot.snn) - bot.res = initAimer() - bot.lastWinBin = -1 + bot.res = initBinaryAimer() # ── Reservoir helpers ───────────────────────────────────────────────────────── @@ -341,20 +338,19 @@ method run*(bot: SNNBot) = let relBearing = normalizeRelativeAngle(bot.enemyBearing - gunDir) bot.lastRelBearing = relBearing when USE_RESERVOIR: - let binInput = toBinaryInput(relBearing, bot.velDirDeg, bot.velSpeed, bot.hasLastPos) - let winBin = bot.res.forward(binInput) - # ponytail: hysteresis=1 prevents bin flickering; raise if still jittery, lower if gun lags - let activeBin = - if bot.lastWinBin >= 0 and bot.res.scores[bot.lastWinBin] + HYSTERESIS >= bot.res.scores[winBin]: - bot.lastWinBin # stick with current bin - else: - winBin # switch to new winner - bot.lastWinBin = activeBin - let aimAngle = bot.res.interpolatedAngle(activeBin) + let binInput = toBinaryInput(relBearing, bot.velDirDeg, bot.velSpeed, bot.hasLastPos) + let aimRaw = bot.res.forward(binInput) # [0,360) or -1.0 sentinel bot.lastBinInput = binInput - bot.targetAngle = gunDir + aimAngle bot.lastDecideGunDir = gunDir - echo "RES tick=" & $bot.tick & " bin=" & $activeBin & " aim=" & formatFloat(aimAngle, ffDecimal, 1) + if aimRaw < 0.0: + # cold start: use raw bearing directly + bot.targetAngle = bot.enemyBearing + echo "RES tick=" & $bot.tick & " cold-start aim=" & formatFloat(bot.enemyBearing, ffDecimal, 1) + else: + # aimRaw is absolute angle [0,360); convert to -180..180 relative then add gunDir + let aimRel = normalizeRelativeAngle(aimRaw - 180.0) # exemplars stored as 0-360, relative bearing is -180..180 + bot.targetAngle = gunDir + aimRel + echo "RES tick=" & $bot.tick & " aim=" & formatFloat(aimRaw, ffDecimal, 1) & " rel=" & formatFloat(aimRel, ffDecimal, 1) else: let inputs = encodeInputFull(relBearing, bot.velDirDeg, bot.velSpeed, bot.hasLastPos) # Multi-tick inference: accumulate sin/cos and spike counts over N_INFER ticks @@ -400,23 +396,16 @@ method run*(bot: SNNBot) = let absBearing = directionTo(myX, myY, futureX, futureY) let targetRel = normalizeRelativeAngle(absBearing - bot.lastDecideGunDir) when USE_RESERVOIR: - let winBin = block: - var best = 0; var bestS = -1 - for k in 0 ..< N_BINS: - if bot.res.scores[k] > bestS: bestS = bot.res.scores[k]; best = k - best - let aimAngle = bot.res.interpolatedAngle(winBin) - bot.res.learn(bot.lastBinInput, targetRel) - let correctBin = angleToBin(targetRel) - let readoutPopCorrect = block: - var c = 0 - for w in 0 ..< WORDS_IN: c += popcount(bot.res.readout[correctBin][w]).int - c - echo "RES tick=" & $bot.tick & " bin=" & $winBin & - " aim=" & formatFloat(aimAngle, ffDecimal, 1) & - " target=" & formatFloat(targetRel, ffDecimal, 1) & - " err=" & formatFloat(abs(normalizeRelativeAngle(aimAngle - targetRel)), ffDecimal, 1) & - " readout_pop_correct=" & $readoutPopCorrect + # correctAngle must be [0,360): targetRel is -180..180, shift it + var correctAngle = targetRel + 180.0 + let aimRaw = bot.res.forward(bot.lastBinInput) + bot.res.learn(bot.lastBinInput, correctAngle) + let aimErr = if aimRaw >= 0.0: abs(normalizeRelativeAngle(aimRaw - correctAngle)) else: -1.0 + echo "RES tick=" & $bot.tick & + " aim=" & (if aimRaw >= 0.0: formatFloat(aimRaw, ffDecimal, 1) else: "cold") & + " correct=" & formatFloat(correctAngle, ffDecimal, 1) & + " err=" & (if aimRaw >= 0.0: formatFloat(aimErr, ffDecimal, 1) else: "n/a") & + " exemplars=" & $bot.res.count else: let err = abs(normalizeRelativeAngle(gunDir - bot.enemyBearing)) bot.snn.superSpikeUpdate(bot.lastSpikes, bot.lastVSnap, bot.snn.preTrace, targetRel) diff --git a/SNNBot_garage/src/reservoir.nim b/SNNBot_garage/src/reservoir.nim index 638b6ae..b8095be 100644 --- a/SNNBot_garage/src/reservoir.nim +++ b/SNNBot_garage/src/reservoir.nim @@ -4,83 +4,52 @@ import std/[bitops, math] const INPUT_BITS* = 80 - N_BINS* = 360 - BIN_WIDTH* = 1.0 WORDS_IN* = 2 # 80 bits → 2 × uint64 (128 bits, only 80 used) + MAX_K* = 128 + MIN_SIM* = 3 type BitVec80* = array[WORDS_IN, uint64] - BinaryAimer* = object - readout*: array[N_BINS, BitVec80] # 360 bins × 80-bit weight vectors - scores*: array[N_BINS, int] + Exemplar* = object + pattern*: BitVec80 + angle*: float # degrees 0-360 + active*: bool -proc initAimer*(): BinaryAimer = - # All readout weights start at zero — no bin preferred + BinaryAimer* = object + exemplars*: array[MAX_K, Exemplar] + count*: int + nextSlot*: int + +proc initBinaryAimer*(): BinaryAimer = result = BinaryAimer() -proc forward*(a: var BinaryAimer, input: BitVec80): int = - ## Score each bin via popcount(input AND weights), return best bin - var bestBin = 0 - var bestScore = -1 - for k in 0 ..< N_BINS: - var score = 0 +proc forward*(aimer: var BinaryAimer, input: BitVec80): float = + ## Kernel regression: weighted circular mean over active exemplars. + ## Returns angle in [0,360) degrees, or -1.0 sentinel when cold (no weight). + var sinSum = 0.0 + var cosSum = 0.0 + var totalW = 0.0 + for i in 0 ..< MAX_K: + if not aimer.exemplars[i].active: continue + var sim = 0 for w in 0 ..< WORDS_IN: - score += popcount(input[w] and a.readout[k][w]).int - a.scores[k] = score - if score > bestScore: - bestScore = score - bestBin = k - result = bestBin + sim += popcount(input[w] and aimer.exemplars[i].pattern[w]).int + let excess = sim - MIN_SIM + if excess <= 0: continue + let weight = float(excess * excess) + let rad = degToRad(aimer.exemplars[i].angle) + sinSum += weight * sin(rad) + cosSum += weight * cos(rad) + totalW += weight + if totalW == 0.0: + return -1.0 + var angle = radToDeg(arctan2(sinSum, cosSum)) + if angle < 0.0: angle += 360.0 + result = angle -proc angleToBin*(angle: float): int = - var a = angle - while a < -180.0: a += 360.0 - while a >= 180.0: a -= 360.0 - result = clamp(int((a + 180.0) / BIN_WIDTH), 0, N_BINS - 1) - -proc binToAngle*(bin: int): float = - result = -180.0 + (float(bin) + 0.5) * BIN_WIDTH # bin center - -proc interpolatedAngle*(a: BinaryAimer, winnerBin: int): float = - ## Weighted centroid of winner + neighbors for sub-bin precision - let left = (winnerBin - 1 + N_BINS) mod N_BINS - let right = (winnerBin + 1) mod N_BINS - let sW = float(max(a.scores[winnerBin], 1)) - let sL = float(max(a.scores[left], 0)) - let sR = float(max(a.scores[right], 0)) - let total = sW + sL + sR - let aW = binToAngle(winnerBin) - let aL = binToAngle(left) - let aR = binToAngle(right) - # Circular mean - let sinAvg = (sW * sin(degToRad(aW)) + sL * sin(degToRad(aL)) + sR * sin(degToRad(aR))) / total - let cosAvg = (sW * cos(degToRad(aW)) + sL * cos(degToRad(aL)) + sR * cos(degToRad(aR))) / total - result = radToDeg(arctan2(sinAvg, cosAvg)) - -proc learn*(a: var BinaryAimer, input: BitVec80, correctAngle: float) = - ## WTA Hebbian: replace correct bin, OR neighbors for generalization, punish worst wrong bin (>2° away) - let correctBin = angleToBin(correctAngle) - - # Replace correct bin with input (prevents bit accumulation saturation) - for w in 0 ..< WORDS_IN: - a.readout[correctBin][w] = input[w] - - # OR neighbors ±1, ±2 for generalization (not correct bin itself) - for offset in [-2, -1, 1, 2]: - let bin = (correctBin + offset + N_BINS) mod N_BINS - for w in 0 ..< WORDS_IN: - a.readout[bin][w] = a.readout[bin][w] or input[w] - - # Punish highest-scoring wrong bin (must be >2 bins away from correct) - var worstBin = -1 - var worstScore = -1 - for k in 0 ..< N_BINS: - let dist = min(abs(k - correctBin), N_BINS - abs(k - correctBin)) # circular distance - if dist > 2 and a.scores[k] > worstScore: - worstScore = a.scores[k] - worstBin = k - - if worstBin >= 0: - for w in 0 ..< WORDS_IN: - a.readout[worstBin][w] = a.readout[worstBin][w] and (not input[w]) +proc learn*(aimer: var BinaryAimer, input: BitVec80, correctAngle: float) = + ## Ring-buffer store: write exemplar at nextSlot, advance mod MAX_K. + aimer.exemplars[aimer.nextSlot] = Exemplar(pattern: input, angle: correctAngle, active: true) + aimer.nextSlot = (aimer.nextSlot + 1) mod MAX_K + if aimer.count < MAX_K: inc aimer.count