feat(SNNBot): grid accumulator replaces exemplar ring buffer

Ring buffer had amnesia — cycled out all data every 128 ticks,
preventing convergence. Grid accumulator permanently stores average
lead offsets indexed by (v_perp, distance). 136 cells, 1.5 KB.
Knowledge accumulates across rounds → convergence guaranteed for
stationary velocity patterns.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-09-16 23:22:00 +02:00
parent d7e499209c
commit 039b47b151
2 changed files with 62 additions and 113 deletions
+45 -40
View File
@@ -1,57 +1,62 @@
# ponytail: direct binary readout; add reservoir back when temporal features matter (step 3)
# ponytail: grid accumulator replaces ring buffer; add temporal features when needed (step 3)
import std/[bitops, math]
import std/math
const
INPUT_BITS* = 80
WORDS_IN* = 2 # 80 bits → 2 × uint64 (128 bits, only 80 used)
MAX_K* = 128
MIN_SIM* = 3
VPERP_BINS* = 17 # -8 to +8 inclusive (integer speed units)
DIST_BINS* = 8 # distance bands
DIST_BAND* = 125.0 # pixels per band
type
BitVec80* = array[WORDS_IN, uint64]
GridCell = object
sumSin: float
sumCos: float
count: int
Exemplar* = object
pattern*: BitVec80
offset*: float # lead correction in degrees, typically [-30, +30]
active*: bool
LeadGrid* = object
cells: array[VPERP_BINS * DIST_BINS, GridCell] # 17 × 8 = 136 cells
BinaryAimer* = object
exemplars*: array[MAX_K, Exemplar]
count*: int
nextSlot*: int
proc initLeadGrid*(): LeadGrid =
result = LeadGrid()
proc initBinaryAimer*(): BinaryAimer =
result = BinaryAimer()
proc cellIndex(vPerp: float, distance: float): int {.inline.} =
let vBin = clamp(int(vPerp + 8.5), 0, VPERP_BINS - 1)
let dBin = clamp(int(distance / DIST_BAND), 0, DIST_BINS - 1)
result = vBin * DIST_BINS + dBin
proc forward*(aimer: var BinaryAimer, input: BitVec80): float =
## Kernel regression: weighted circular mean over active exemplars.
## Weights by similarity^2 and exponential recency decay (0.95^age).
## Returns offset (lead correction) in degrees, or -999.0 sentinel when cold (no weight).
proc forward*(grid: var LeadGrid, vPerp, distance: float): float =
## Returns circular mean offset in degrees, or -999.0 when no data.
let idx = cellIndex(vPerp, distance)
let vBin = clamp(int(vPerp + 8.5), 0, VPERP_BINS - 1)
let dBin = clamp(int(distance / DIST_BAND), 0, DIST_BINS - 1)
# Accumulate weighted contributions: direct cell (w=1), cardinal neighbors (w=0.5), diagonals (w=0.25)
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:
sim += popcount(input[w] and aimer.exemplars[i].pattern[w]).int
let excess = sim - MIN_SIM
if excess <= 0: continue
let age = (aimer.nextSlot - 1 - i + MAX_K) mod MAX_K
let recency = pow(0.95, age.float)
let weight = float(excess * excess) * recency
let rad = degToRad(aimer.exemplars[i].offset)
sinSum += weight * sin(rad)
cosSum += weight * cos(rad)
totalW += weight
for dv in -1 .. 1:
for dd in -1 .. 1:
let vb = vBin + dv
let db = dBin + dd
if vb < 0 or vb >= VPERP_BINS or db < 0 or db >= DIST_BINS: continue
let c = grid.cells[vb * DIST_BINS + db]
if c.count == 0: continue
let w = if dv == 0 and dd == 0: 1.0
elif dv == 0 or dd == 0: 0.5
else: 0.25
sinSum += w * c.sumSin / float(c.count)
cosSum += w * c.sumCos / float(c.count)
totalW += w
if totalW == 0.0:
return -999.0
result = radToDeg(arctan2(sinSum, cosSum))
proc learn*(aimer: var BinaryAimer, input: BitVec80, offset: float) =
## Ring-buffer store: write exemplar at nextSlot, advance mod MAX_K.
aimer.exemplars[aimer.nextSlot] = Exemplar(pattern: input, offset: offset, active: true)
aimer.nextSlot = (aimer.nextSlot + 1) mod MAX_K
if aimer.count < MAX_K: inc aimer.count
proc learn*(grid: var LeadGrid, vPerp, distance, correctOffset: float) =
let idx = cellIndex(vPerp, distance)
grid.cells[idx].sumSin += sin(degToRad(correctOffset))
grid.cells[idx].sumCos += cos(degToRad(correctOffset))
inc grid.cells[idx].count
proc totalCount*(grid: LeadGrid): int =
for c in grid.cells:
result += c.count