fix(SNNBot): enforce sparse reservoir state via k-Winners-Take-All

Dense firing (~50%) caused all readout bins to saturate identically.
Replace threshold-based firing with k-WTA: only top 50 neurons fire
per tick (~5% sparsity). Sparse patterns give low inter-angle overlap
-> readout bins can discriminate between different bearings.
This commit is contained in:
2026-09-14 23:08:06 +02:00
parent 45b167f2b3
commit 2963917072
+27 -20
View File
@@ -1,6 +1,6 @@
# ponytail: binary reservoir aimer — prototype; if reservoir projection is poor, scale RESERVOIR_SIZE to 4096 # ponytail: binary reservoir aimer — prototype; if reservoir projection is poor, scale RESERVOIR_SIZE to 4096
import std/[bitops, math] import std/[algorithm, bitops, math]
# ── Constants ────────────────────────────────────────────────────────────────── # ── Constants ──────────────────────────────────────────────────────────────────
@@ -11,6 +11,8 @@ const
INPUT_BITS* = 80 INPUT_BITS* = 80
SPARSITY_IN = 0.1 SPARSITY_IN = 0.1
SPARSITY_REC = 0.05 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 ────────────────────────────────────────────────────────────────────── # ── Types ──────────────────────────────────────────────────────────────────────
@@ -19,13 +21,12 @@ type
BitVec* = array[16, uint64] # 1024 bits BitVec* = array[16, uint64] # 1024 bits
Reservoir* = object Reservoir* = object
wIn: array[RESERVOIR_SIZE, BitVec80] wIn: array[RESERVOIR_SIZE, BitVec80]
wRec: array[RESERVOIR_SIZE, BitVec] wRec: array[RESERVOIR_SIZE, BitVec]
threshold: array[RESERVOIR_SIZE, int] state: BitVec
state: BitVec readout: array[N_BINS, BitVec]
readout: array[N_BINS, BitVec] scores*: array[N_BINS, int]
scores*: array[N_BINS, int] rngState: uint64
rngState: uint64
# ── PRNG ─────────────────────────────────────────────────────────────────────── # ── PRNG ───────────────────────────────────────────────────────────────────────
@@ -61,27 +62,33 @@ proc initReservoir*(seed: int): Reservoir =
for w in 0 ..< 16: for w in 0 ..< 16:
result.wRec[i][w] = sparseBits(result, SPARSITY_REC) result.wRec[i][w] = sparseBits(result, SPARSITY_REC)
# ponytail: threshold=1 gives ~50% firing; raise if reservoir saturates (all neurons fire every tick)
result.threshold[i] = 1
# readout and state are zero-initialized by default # readout and state are zero-initialized by default
# ── Forward ──────────────────────────────────────────────────────────────────── # ── Forward ────────────────────────────────────────────────────────────────────
proc forward*(r: var Reservoir, input: BitVec80): int = proc forward*(r: var Reservoir, input: BitVec80): int =
var newState: BitVec # Compute activation scores for all neurons
var activations: array[RESERVOIR_SIZE, int]
for i in 0 ..< RESERVOIR_SIZE: for i in 0 ..< RESERVOIR_SIZE:
let inScore = popcount(input[0] and r.wIn[i][0]) + let inScore = popcount(input[0] and r.wIn[i][0]).int +
popcount(input[1] and r.wIn[i][1]) popcount(input[1] and r.wIn[i][1]).int
var recScore = 0 var recScore = 0
for w in 0 ..< 16: for w in 0 ..< 16:
recScore += popcount(r.state[w] and r.wRec[i][w]) recScore += popcount(r.state[w] and r.wRec[i][w]).int
activations[i] = inScore + recScore
if inScore + recScore > r.threshold[i]: # k-WTA: find K-th highest activation via sort on a copy
let wordIdx = i shr 6 # i div 64 var sorted = activations
let bitIdx = i and 63 # i mod 64 sort(sorted, order = SortOrder.Descending)
newState[wordIdx] = newState[wordIdx] or (1'u64 shl bitIdx) 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 r.state = newState