feat(SNNBot): add binary reservoir aimer as alternative to SuperSpike (#159)
New architecture: 1024 binary neurons in fixed random reservoir, 72-bin population-coded output, WTA Hebbian learning with binary ops. Forward pass: AND + popcount. Learning: OR (reinforce) / AND NOT (punish). No backprop, no floats in hot path. Toggle via USE_RESERVOIR const. Forecast: ~200-400 ticks to learn stationary target aiming. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
# ponytail: binary reservoir aimer — prototype; if reservoir projection is poor, scale RESERVOIR_SIZE to 4096
|
||||
|
||||
import std/[bitops, math]
|
||||
|
||||
# ── Constants ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const
|
||||
RESERVOIR_SIZE* = 1024
|
||||
N_BINS* = 72
|
||||
BIN_WIDTH* = 5.0
|
||||
INPUT_BITS* = 80
|
||||
SPARSITY_IN = 0.1
|
||||
SPARSITY_REC = 0.05
|
||||
|
||||
# ── Types ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
type
|
||||
BitVec80* = array[2, uint64] # 128 bits allocated, lower 80 used
|
||||
BitVec* = array[16, uint64] # 1024 bits
|
||||
|
||||
Reservoir* = object
|
||||
wIn: array[RESERVOIR_SIZE, BitVec80]
|
||||
wRec: array[RESERVOIR_SIZE, BitVec]
|
||||
threshold: array[RESERVOIR_SIZE, int]
|
||||
state: BitVec
|
||||
readout: array[N_BINS, BitVec]
|
||||
scores*: array[N_BINS, int]
|
||||
rngState: uint64
|
||||
|
||||
# ── PRNG ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
proc nextRand(r: var Reservoir): uint64 {.inline.} =
|
||||
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)
|
||||
|
||||
proc randomDecayMask(r: var Reservoir): uint64 =
|
||||
## ~1% bits set: AND 6 random words (1/2^6 = 1/64 ≈ 1.5% density).
|
||||
result = r.nextRand()
|
||||
for _ in 0 ..< 5:
|
||||
result = result and r.nextRand()
|
||||
|
||||
# ── 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)
|
||||
|
||||
let inPop = popcount(result.wIn[i][0]) + popcount(result.wIn[i][1])
|
||||
let recPop = block:
|
||||
var s = 0
|
||||
for w in 0 ..< 16: s += popcount(result.wRec[i][w])
|
||||
s
|
||||
# Threshold: ~50% of expected input votes + ~30% of expected recurrent votes
|
||||
result.threshold[i] = max(1, int(float(inPop) * 0.5 + float(recPop) * 0.3))
|
||||
|
||||
# readout and state are zero-initialized by default
|
||||
|
||||
# ── Forward ────────────────────────────────────────────────────────────────────
|
||||
|
||||
proc forward*(r: var Reservoir, input: BitVec80): int =
|
||||
var newState: BitVec
|
||||
|
||||
for i in 0 ..< RESERVOIR_SIZE:
|
||||
let inScore = popcount(input[0] and r.wIn[i][0]) +
|
||||
popcount(input[1] and r.wIn[i][1])
|
||||
var recScore = 0
|
||||
for w in 0 ..< 16:
|
||||
recScore += popcount(r.state[w] and r.wRec[i][w])
|
||||
|
||||
if inScore + recScore > r.threshold[i]:
|
||||
let wordIdx = i shr 6 # i div 64
|
||||
let bitIdx = i and 63 # i mod 64
|
||||
newState[wordIdx] = newState[wordIdx] or (1'u64 shl bitIdx)
|
||||
|
||||
r.state = newState
|
||||
|
||||
var bestBin = 0
|
||||
var bestScore = -1
|
||||
for k in 0 ..< N_BINS:
|
||||
var score = 0
|
||||
for w in 0 ..< 16:
|
||||
score += popcount(r.state[w] and r.readout[k][w])
|
||||
r.scores[k] = score
|
||||
if score > bestScore:
|
||||
bestScore = score
|
||||
bestBin = k
|
||||
|
||||
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 =
|
||||
## angle in -180..+180, map to bin 0..71
|
||||
var a = angle
|
||||
if a < -180.0: a += 360.0
|
||||
if a >= 180.0: a -= 360.0
|
||||
result = int((a + 180.0) / BIN_WIDTH) mod N_BINS
|
||||
|
||||
proc interpolatedAngle*(r: Reservoir, winnerBin: int): float =
|
||||
## Weighted circular centroid of winner ± 1 bins for sub-5° precision.
|
||||
let left = (winnerBin - 1 + N_BINS) mod N_BINS
|
||||
let right = (winnerBin + 1) mod N_BINS
|
||||
let sW = float(r.scores[winnerBin])
|
||||
let sL = float(r.scores[left])
|
||||
let sR = float(r.scores[right])
|
||||
let total = sW + sL + sR
|
||||
if total == 0.0: return binToAngle(winnerBin)
|
||||
let aW = degToRad(binToAngle(winnerBin))
|
||||
let aL = degToRad(binToAngle(left))
|
||||
let aR = degToRad(binToAngle(right))
|
||||
let sinAvg = (sW * sin(aW) + sL * sin(aL) + sR * sin(aR)) / total
|
||||
let cosAvg = (sW * cos(aW) + sL * cos(aL) + sR * cos(aR)) / total
|
||||
result = radToDeg(arctan2(sinAvg, cosAvg))
|
||||
|
||||
# ── Learning ───────────────────────────────────────────────────────────────────
|
||||
|
||||
proc learn*(r: var Reservoir, correctAngle: float) =
|
||||
let correctBin = angleToBin(correctAngle)
|
||||
|
||||
# Reinforce correct bin
|
||||
for w in 0 ..< 16:
|
||||
r.readout[correctBin][w] = r.readout[correctBin][w] or r.state[w]
|
||||
|
||||
# Punish highest-scoring wrong bin
|
||||
var worstBin = -1
|
||||
var worstScore = -1
|
||||
for k in 0 ..< N_BINS:
|
||||
if k != correctBin and r.scores[k] > worstScore:
|
||||
worstScore = r.scores[k]
|
||||
worstBin = k
|
||||
if worstBin >= 0:
|
||||
for w in 0 ..< 16:
|
||||
r.readout[worstBin][w] = r.readout[worstBin][w] and (not r.state[w])
|
||||
|
||||
# Decay: clear ~1.5% of bits per bin to prevent saturation
|
||||
for k in 0 ..< N_BINS:
|
||||
for w in 0 ..< 16:
|
||||
r.readout[k][w] = r.readout[k][w] and (not randomDecayMask(r))
|
||||
Reference in New Issue
Block a user