refactor(SNNBot): replace reservoir with direct binary readout
Delete 1024-neuron reservoir — it added noise, not features. Direct 80-bit input → 72 output bins via popcount + WTA Hebbian. Same learning rule (OR reinforce, AND NOT punish), zero indirection. Reservoir adds value for temporal features (step 3), not now.
This commit is contained in:
@@ -9,12 +9,12 @@
|
|||||||
# WAITING → aimTo() each tick; when error < 2° → EVALUATE
|
# WAITING → aimTo() each tick; when error < 2° → EVALUATE
|
||||||
# EVALUATE → measure error, compute SuperSpike/reservoir update, log, → DECIDE
|
# EVALUATE → measure error, compute SuperSpike/reservoir update, log, → DECIDE
|
||||||
|
|
||||||
import std/[math, random, os, strutils]
|
import std/[math, random, os, strutils, bitops]
|
||||||
import robocode_tankroyale_botapi
|
import robocode_tankroyale_botapi
|
||||||
import radar_lock/radar_lock as radar_lock
|
import radar_lock/radar_lock as radar_lock
|
||||||
import reservoir
|
import reservoir
|
||||||
|
|
||||||
const USE_RESERVOIR* = true
|
const USE_RESERVOIR* = true # ponytail: kept for SNN path fallback; remove when binary aimer is validated
|
||||||
|
|
||||||
# ── Constants ──────────────────────────────────────────────────────────────────
|
# ── Constants ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -198,7 +198,7 @@ type
|
|||||||
|
|
||||||
SNNBot = ref object of Bot
|
SNNBot = ref object of Bot
|
||||||
snn: SNN
|
snn: SNN
|
||||||
res: Reservoir
|
res: BinaryAimer
|
||||||
phase: Phase
|
phase: Phase
|
||||||
targetAngle: float # SNN output (absolute bearing)
|
targetAngle: float # SNN output (absolute bearing)
|
||||||
enemyBearing: float # last known enemy bearing
|
enemyBearing: float # last known enemy bearing
|
||||||
@@ -214,6 +214,7 @@ type
|
|||||||
velDirDeg: float64 # velocity direction (degrees) from last scan delta
|
velDirDeg: float64 # velocity direction (degrees) from last scan delta
|
||||||
velSpeed: float64 # speed (units/tick) from last scan delta
|
velSpeed: float64 # speed (units/tick) from last scan delta
|
||||||
lastDecideGunDir: float # gun heading captured at DECIDE time for EVALUATE
|
lastDecideGunDir: float # gun heading captured at DECIDE time for EVALUATE
|
||||||
|
lastBinInput: BitVec80 # DECIDE-time binary input, reused in EVALUATE
|
||||||
|
|
||||||
# ── aimTo helper ──────────────────────────────────────────────────────────────
|
# ── aimTo helper ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -375,7 +376,7 @@ method onRoundStarted*(bot: SNNBot, e: RoundStartedEvent) =
|
|||||||
|
|
||||||
method onGameStarted*(bot: SNNBot, e: GameStartedEventForBot) =
|
method onGameStarted*(bot: SNNBot, e: GameStartedEventForBot) =
|
||||||
initSNN(bot.snn)
|
initSNN(bot.snn)
|
||||||
bot.res = initReservoir(42)
|
bot.res = initAimer()
|
||||||
|
|
||||||
# ── Reservoir helpers ─────────────────────────────────────────────────────────
|
# ── Reservoir helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -413,6 +414,7 @@ method run*(bot: SNNBot) =
|
|||||||
let binInput = toBitVec80(inputs)
|
let binInput = toBitVec80(inputs)
|
||||||
let winBin = bot.res.forward(binInput)
|
let winBin = bot.res.forward(binInput)
|
||||||
let aimAngle = bot.res.interpolatedAngle(winBin)
|
let aimAngle = bot.res.interpolatedAngle(winBin)
|
||||||
|
bot.lastBinInput = binInput
|
||||||
bot.targetAngle = gunDir + aimAngle
|
bot.targetAngle = gunDir + aimAngle
|
||||||
bot.lastDecideGunDir = gunDir
|
bot.lastDecideGunDir = gunDir
|
||||||
echo "RES tick=" & $bot.tick & " bin=" & $winBin & " aim=" & formatFloat(aimAngle, ffDecimal, 1)
|
echo "RES tick=" & $bot.tick & " bin=" & $winBin & " aim=" & formatFloat(aimAngle, ffDecimal, 1)
|
||||||
@@ -460,18 +462,23 @@ method run*(bot: SNNBot) =
|
|||||||
let absBearing = directionTo(myX, myY, futureX, futureY)
|
let absBearing = directionTo(myX, myY, futureX, futureY)
|
||||||
let targetRel = normalizeRelativeAngle(absBearing - bot.lastDecideGunDir)
|
let targetRel = normalizeRelativeAngle(absBearing - bot.lastDecideGunDir)
|
||||||
when USE_RESERVOIR:
|
when USE_RESERVOIR:
|
||||||
# state/scores already set by DECIDE's forward(); re-forwarding would corrupt them
|
|
||||||
let winBin = block:
|
let winBin = block:
|
||||||
var best = 0; var bestS = -1
|
var best = 0; var bestS = -1
|
||||||
for k in 0 ..< N_BINS:
|
for k in 0 ..< N_BINS:
|
||||||
if bot.res.scores[k] > bestS: bestS = bot.res.scores[k]; best = k
|
if bot.res.scores[k] > bestS: bestS = bot.res.scores[k]; best = k
|
||||||
best
|
best
|
||||||
let aimAngle = bot.res.interpolatedAngle(winBin)
|
let aimAngle = bot.res.interpolatedAngle(winBin)
|
||||||
bot.res.learn(targetRel)
|
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 &
|
echo "RES tick=" & $bot.tick & " bin=" & $winBin &
|
||||||
" aim=" & formatFloat(aimAngle, ffDecimal, 1) &
|
" aim=" & formatFloat(aimAngle, ffDecimal, 1) &
|
||||||
" target=" & formatFloat(targetRel, ffDecimal, 1) &
|
" target=" & formatFloat(targetRel, ffDecimal, 1) &
|
||||||
" err=" & formatFloat(abs(aimAngle - targetRel), ffDecimal, 1)
|
" err=" & formatFloat(abs(aimAngle - targetRel), ffDecimal, 1) &
|
||||||
|
" readout_pop_correct=" & $readoutPopCorrect
|
||||||
else:
|
else:
|
||||||
let err = abs(normalizeRelativeAngle(gunDir - bot.enemyBearing))
|
let err = abs(normalizeRelativeAngle(gunDir - bot.enemyBearing))
|
||||||
bot.snn.superSpikeUpdate(bot.lastSpikes, bot.lastVSnap, bot.snn.preTrace, targetRel)
|
bot.snn.superSpikeUpdate(bot.lastSpikes, bot.lastVSnap, bot.snn.preTrace, targetRel)
|
||||||
|
|||||||
+45
-122
@@ -1,157 +1,80 @@
|
|||||||
# ponytail: binary reservoir aimer — prototype; if reservoir projection is poor, scale RESERVOIR_SIZE to 4096
|
# ponytail: direct binary readout; add reservoir back when temporal features matter (step 3)
|
||||||
|
|
||||||
import std/[algorithm, bitops, math]
|
import std/[bitops, math]
|
||||||
|
|
||||||
# ── Constants ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const
|
const
|
||||||
RESERVOIR_SIZE* = 1024
|
INPUT_BITS* = 80
|
||||||
N_BINS* = 72
|
N_BINS* = 72
|
||||||
BIN_WIDTH* = 5.0
|
BIN_WIDTH* = 5.0
|
||||||
INPUT_BITS* = 80
|
WORDS_IN* = 2 # 80 bits → 2 × uint64 (128 bits, only 80 used)
|
||||||
SPARSITY_IN = 0.1
|
|
||||||
SPARSITY_REC = 0.05
|
|
||||||
K_ACTIVE = 50 # ~5% of RESERVOIR_SIZE fire per tick
|
|
||||||
# ponytail: K_ACTIVE=50 gives ~5% sparsity; increase if readout can't discriminate, decrease if patterns overlap too much
|
|
||||||
|
|
||||||
# ── Types ──────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
type
|
type
|
||||||
BitVec80* = array[2, uint64] # 128 bits allocated, lower 80 used
|
BitVec80* = array[WORDS_IN, uint64]
|
||||||
BitVec* = array[16, uint64] # 1024 bits
|
|
||||||
|
|
||||||
Reservoir* = object
|
BinaryAimer* = object
|
||||||
wIn: array[RESERVOIR_SIZE, BitVec80]
|
readout*: array[N_BINS, BitVec80] # 72 bins × 80-bit weight vectors
|
||||||
wRec: array[RESERVOIR_SIZE, BitVec]
|
|
||||||
state: BitVec
|
|
||||||
readout: array[N_BINS, BitVec]
|
|
||||||
scores*: array[N_BINS, int]
|
scores*: array[N_BINS, int]
|
||||||
rngState: uint64
|
|
||||||
|
|
||||||
# ── PRNG ───────────────────────────────────────────────────────────────────────
|
proc initAimer*(): BinaryAimer =
|
||||||
|
# All readout weights start at zero — no bin preferred
|
||||||
proc nextRand(r: var Reservoir): uint64 {.inline.} =
|
result = BinaryAimer()
|
||||||
var x = r.rngState
|
|
||||||
x = x xor (x shl 13)
|
|
||||||
x = x xor (x shr 7)
|
|
||||||
x = x xor (x shl 17)
|
|
||||||
r.rngState = x
|
|
||||||
return x
|
|
||||||
|
|
||||||
proc sparseBits(r: var Reservoir, density: float): uint64 =
|
|
||||||
## uint64 with approximately density*64 bits set via repeated AND.
|
|
||||||
## density=0.1 → ~1 AND needed for 1/2, but we use a direct Bernoulli approach.
|
|
||||||
## ponytail: simple loop over 64 bits; replace with table-lookup if init is hot
|
|
||||||
result = 0'u64
|
|
||||||
let thresh = uint64(density * float(high(uint64)))
|
|
||||||
for bit in 0 ..< 64:
|
|
||||||
if r.nextRand() < thresh:
|
|
||||||
result = result or (1'u64 shl bit)
|
|
||||||
|
|
||||||
# ── Init ───────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
proc initReservoir*(seed: int): Reservoir =
|
|
||||||
result.rngState = uint64(seed) or 1'u64 # avoid zero state
|
|
||||||
|
|
||||||
for i in 0 ..< RESERVOIR_SIZE:
|
|
||||||
# Input masks: 80 bits across two uint64s (word 0: bits 0-63, word 1: bits 64-79)
|
|
||||||
result.wIn[i][0] = sparseBits(result, SPARSITY_IN)
|
|
||||||
# Only bits 0-15 of word 1 are meaningful (global bits 64-79)
|
|
||||||
result.wIn[i][1] = sparseBits(result, SPARSITY_IN) and 0x0000_0000_0000_FFFF'u64
|
|
||||||
|
|
||||||
for w in 0 ..< 16:
|
|
||||||
result.wRec[i][w] = sparseBits(result, SPARSITY_REC)
|
|
||||||
|
|
||||||
# readout and state are zero-initialized by default
|
|
||||||
|
|
||||||
# ── Forward ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
proc forward*(r: var Reservoir, input: BitVec80): int =
|
|
||||||
# Compute activation scores for all neurons
|
|
||||||
var activations: array[RESERVOIR_SIZE, int]
|
|
||||||
for i in 0 ..< RESERVOIR_SIZE:
|
|
||||||
let inScore = popcount(input[0] and r.wIn[i][0]).int +
|
|
||||||
popcount(input[1] and r.wIn[i][1]).int
|
|
||||||
var recScore = 0
|
|
||||||
for w in 0 ..< 16:
|
|
||||||
recScore += popcount(r.state[w] and r.wRec[i][w]).int
|
|
||||||
activations[i] = inScore + recScore
|
|
||||||
|
|
||||||
# k-WTA: find K-th highest activation via sort on a copy
|
|
||||||
var sorted = activations
|
|
||||||
sort(sorted, order = SortOrder.Descending)
|
|
||||||
let kThreshold = sorted[min(K_ACTIVE - 1, RESERVOIR_SIZE - 1)]
|
|
||||||
|
|
||||||
# Fire exactly K_ACTIVE neurons (tie-break: first in index order)
|
|
||||||
var newState: BitVec
|
|
||||||
var count = 0
|
|
||||||
for i in 0 ..< RESERVOIR_SIZE:
|
|
||||||
if activations[i] >= kThreshold and count < K_ACTIVE:
|
|
||||||
newState[i shr 6] = newState[i shr 6] or (1'u64 shl (i and 63))
|
|
||||||
inc count
|
|
||||||
|
|
||||||
r.state = newState
|
|
||||||
|
|
||||||
|
proc forward*(a: var BinaryAimer, input: BitVec80): int =
|
||||||
|
## Score each bin via popcount(input AND weights), return best bin
|
||||||
var bestBin = 0
|
var bestBin = 0
|
||||||
var bestScore = -1
|
var bestScore = -1
|
||||||
for k in 0 ..< N_BINS:
|
for k in 0 ..< N_BINS:
|
||||||
var score = 0
|
var score = 0
|
||||||
for w in 0 ..< 16:
|
for w in 0 ..< WORDS_IN:
|
||||||
score += popcount(r.state[w] and r.readout[k][w])
|
score += popcount(input[w] and a.readout[k][w]).int
|
||||||
r.scores[k] = score
|
a.scores[k] = score
|
||||||
if score > bestScore:
|
if score > bestScore:
|
||||||
bestScore = score
|
bestScore = score
|
||||||
bestBin = k
|
bestBin = k
|
||||||
|
result = bestBin
|
||||||
return bestBin
|
|
||||||
|
|
||||||
# ── Angle helpers ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
proc binToAngle*(bin: int): float =
|
|
||||||
## Bin 0 = -180°, Bin 36 = 0°, Bin 71 = +175°
|
|
||||||
result = -180.0 + float(bin) * BIN_WIDTH
|
|
||||||
|
|
||||||
proc angleToBin*(angle: float): int =
|
proc angleToBin*(angle: float): int =
|
||||||
## angle in -180..+180, map to bin 0..71
|
|
||||||
var a = angle
|
var a = angle
|
||||||
if a < -180.0: a += 360.0
|
while a < -180.0: a += 360.0
|
||||||
if a >= 180.0: a -= 360.0
|
while a >= 180.0: a -= 360.0
|
||||||
result = int((a + 180.0) / BIN_WIDTH) mod N_BINS
|
result = clamp(int((a + 180.0) / BIN_WIDTH), 0, N_BINS - 1)
|
||||||
|
|
||||||
proc interpolatedAngle*(r: Reservoir, winnerBin: int): float =
|
proc binToAngle*(bin: int): float =
|
||||||
## Weighted circular centroid of winner ± 1 bins for sub-5° precision.
|
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 left = (winnerBin - 1 + N_BINS) mod N_BINS
|
||||||
let right = (winnerBin + 1) mod N_BINS
|
let right = (winnerBin + 1) mod N_BINS
|
||||||
let sW = float(r.scores[winnerBin])
|
let sW = float(max(a.scores[winnerBin], 1))
|
||||||
let sL = float(r.scores[left])
|
let sL = float(max(a.scores[left], 0))
|
||||||
let sR = float(r.scores[right])
|
let sR = float(max(a.scores[right], 0))
|
||||||
let total = sW + sL + sR
|
let total = sW + sL + sR
|
||||||
if total == 0.0: return binToAngle(winnerBin)
|
let aW = binToAngle(winnerBin)
|
||||||
let aW = degToRad(binToAngle(winnerBin))
|
let aL = binToAngle(left)
|
||||||
let aL = degToRad(binToAngle(left))
|
let aR = binToAngle(right)
|
||||||
let aR = degToRad(binToAngle(right))
|
# Circular mean
|
||||||
let sinAvg = (sW * sin(aW) + sL * sin(aL) + sR * sin(aR)) / total
|
let sinAvg = (sW * sin(degToRad(aW)) + sL * sin(degToRad(aL)) + sR * sin(degToRad(aR))) / total
|
||||||
let cosAvg = (sW * cos(aW) + sL * cos(aL) + sR * cos(aR)) / total
|
let cosAvg = (sW * cos(degToRad(aW)) + sL * cos(degToRad(aL)) + sR * cos(degToRad(aR))) / total
|
||||||
result = radToDeg(arctan2(sinAvg, cosAvg))
|
result = radToDeg(arctan2(sinAvg, cosAvg))
|
||||||
|
|
||||||
# ── Learning ───────────────────────────────────────────────────────────────────
|
proc learn*(a: var BinaryAimer, input: BitVec80, correctAngle: float) =
|
||||||
|
## WTA Hebbian: reinforce correct bin, punish worst wrong bin
|
||||||
proc learn*(r: var Reservoir, correctAngle: float) =
|
|
||||||
let correctBin = angleToBin(correctAngle)
|
let correctBin = angleToBin(correctAngle)
|
||||||
|
|
||||||
# Reinforce correct bin
|
# Reinforce: OR input into correct bin
|
||||||
for w in 0 ..< 16:
|
for w in 0 ..< WORDS_IN:
|
||||||
r.readout[correctBin][w] = r.readout[correctBin][w] or r.state[w]
|
a.readout[correctBin][w] = a.readout[correctBin][w] or input[w]
|
||||||
|
|
||||||
# Punish highest-scoring wrong bin
|
# Find highest-scoring WRONG bin
|
||||||
var worstBin = -1
|
var worstBin = -1
|
||||||
var worstScore = -1
|
var worstScore = -1
|
||||||
for k in 0 ..< N_BINS:
|
for k in 0 ..< N_BINS:
|
||||||
if k != correctBin and r.scores[k] > worstScore:
|
if k != correctBin and a.scores[k] > worstScore:
|
||||||
worstScore = r.scores[k]
|
worstScore = a.scores[k]
|
||||||
worstBin = k
|
worstBin = k
|
||||||
if worstBin >= 0:
|
|
||||||
for w in 0 ..< 16:
|
|
||||||
r.readout[worstBin][w] = r.readout[worstBin][w] and (not r.state[w])
|
|
||||||
|
|
||||||
# ponytail: decay removed; add back if readout weights saturate (all scores converge to same value)
|
# Punish: AND NOT input from worst wrong bin
|
||||||
|
if worstBin >= 0:
|
||||||
|
for w in 0 ..< WORDS_IN:
|
||||||
|
a.readout[worstBin][w] = a.readout[worstBin][w] and (not input[w])
|
||||||
|
|||||||
Reference in New Issue
Block a user