feat(SNNBot): offset-based exemplar learning for fast lead generalization

Exemplars now store lead correction offsets instead of absolute angles.
The system learns 'how much lead to apply' independently of bearing,
so one learned lead pattern generalizes to all positions on the
battlefield. Converges in a few ticks for constant-velocity targets.
This commit is contained in:
2026-09-16 08:12:54 +02:00
parent 01a30b7570
commit 69ffe4c44e
2 changed files with 26 additions and 24 deletions
+19 -15
View File
@@ -215,6 +215,7 @@ type
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 lastBinInput: BitVec80 # DECIDE-time binary input, reused in EVALUATE
lastInput: BitVec80 # previous EVALUATE-time input (for change detection) lastInput: BitVec80 # previous EVALUATE-time input (for change detection)
lastAbsBearing: float # absolute bearing to enemy captured at DECIDE time
# ── aimTo helper ────────────────────────────────────────────────────────────── # ── aimTo helper ──────────────────────────────────────────────────────────────
@@ -340,17 +341,18 @@ method run*(bot: SNNBot) =
bot.lastRelBearing = normalizeRelativeAngle(absBearing - gunDir) bot.lastRelBearing = normalizeRelativeAngle(absBearing - gunDir)
when USE_RESERVOIR: when USE_RESERVOIR:
let binInput = toBinaryInput(absBearing, bot.velDirDeg, bot.velSpeed, bot.hasLastPos) let binInput = toBinaryInput(absBearing, bot.velDirDeg, bot.velSpeed, bot.hasLastPos)
let aimRaw = bot.res.forward(binInput) # [0,360) or -1.0 sentinel let aimOffset = bot.res.forward(binInput) # lead correction offset or -999.0 sentinel
bot.lastBinInput = binInput bot.lastBinInput = binInput
bot.lastDecideGunDir = gunDir bot.lastDecideGunDir = gunDir
if aimRaw < 0.0: bot.lastAbsBearing = absBearing
# cold start: aim directly at enemy if aimOffset <= -999.0:
# cold start: aim directly at enemy (no lead)
bot.targetAngle = absBearing bot.targetAngle = absBearing
echo "RES tick=" & $bot.tick & " cold-start aim=" & formatFloat(absBearing, ffDecimal, 1) echo "RES tick=" & $bot.tick & " cold-start aim=" & formatFloat(absBearing, ffDecimal, 1)
else: else:
# aimRaw is absolute [0,360): use directly as target # apply lead correction offset to current bearing
bot.targetAngle = aimRaw bot.targetAngle = (absBearing + aimOffset + 360.0) mod 360.0
echo "RES tick=" & $bot.tick & " aim=" & formatFloat(aimRaw, ffDecimal, 1) echo "RES tick=" & $bot.tick & " offset=" & formatFloat(aimOffset, ffDecimal, 1) & " aim=" & formatFloat(bot.targetAngle, ffDecimal, 1)
else: else:
let inputs = encodeInputFull(absBearing, bot.velDirDeg, bot.velSpeed, bot.hasLastPos) let inputs = encodeInputFull(absBearing, bot.velDirDeg, bot.velSpeed, bot.hasLastPos)
# Multi-tick inference: accumulate sin/cos and spike counts over N_INFER ticks # Multi-tick inference: accumulate sin/cos and spike counts over N_INFER ticks
@@ -410,24 +412,26 @@ method run*(bot: SNNBot) =
let adaptiveDeadZone = 0.5 + (0.3 - 0.5) * t # lerp(0.5, 0.3, t) let adaptiveDeadZone = 0.5 + (0.3 - 0.5) * t # lerp(0.5, 0.3, t)
when USE_RESERVOIR: when USE_RESERVOIR:
let aimRaw = bot.res.forward(bot.lastBinInput) # correctOffset: how much lead to apply from current bearing
let correctOffset = normalizeRelativeAngle(correctAngle - bot.lastAbsBearing)
let producedOffset = bot.res.forward(bot.lastBinInput)
# Detect significant input change via Hamming distance # Detect significant input change via Hamming distance
let hammingDist = popcount(bot.lastBinInput[0] xor bot.lastInput[0]) + popcount(bot.lastBinInput[1] xor bot.lastInput[1]) let hammingDist = popcount(bot.lastBinInput[0] xor bot.lastInput[0]) + popcount(bot.lastBinInput[1] xor bot.lastInput[1])
let inputChanged = hammingDist > 2 # 3+ bits flipped = situation changed let inputChanged = hammingDist > 2 # 3+ bits flipped = situation changed
# Learn if: error > dead zone OR input pattern changed significantly # Learn if: error > dead zone OR input pattern changed significantly
if aimRaw >= 0.0: if producedOffset > -999.0:
let aimErr = abs(normalizeRelativeAngle(aimRaw - correctAngle)) let aimErr = abs(normalizeRelativeAngle(producedOffset - correctOffset))
if aimErr > adaptiveDeadZone or inputChanged: if aimErr > adaptiveDeadZone or inputChanged:
bot.res.learn(bot.lastBinInput, correctAngle) bot.res.learn(bot.lastBinInput, correctOffset)
else: else:
# Cold start: always learn # Cold start: always learn
bot.res.learn(bot.lastBinInput, correctAngle) bot.res.learn(bot.lastBinInput, correctOffset)
let aimErr = if aimRaw >= 0.0: abs(normalizeRelativeAngle(aimRaw - correctAngle)) else: -1.0 let aimErr = if producedOffset > -999.0: abs(normalizeRelativeAngle(producedOffset - correctOffset)) else: -1.0
echo "RES tick=" & $bot.tick & echo "RES tick=" & $bot.tick &
" aim=" & (if aimRaw >= 0.0: formatFloat(aimRaw, ffDecimal, 1) else: "cold") & " offset=" & (if producedOffset > -999.0: formatFloat(producedOffset, ffDecimal, 1) else: "cold") &
" correct=" & formatFloat(correctAngle, ffDecimal, 1) & " correctOffset=" & formatFloat(correctOffset, ffDecimal, 1) &
" err=" & (if aimRaw >= 0.0: formatFloat(aimErr, ffDecimal, 1) else: "n/a") & " err=" & (if producedOffset > -999.0: formatFloat(aimErr, ffDecimal, 1) else: "n/a") &
" hammingDist=" & $hammingDist & " hammingDist=" & $hammingDist &
" inputChanged=" & $inputChanged & " inputChanged=" & $inputChanged &
" exemplars=" & $bot.res.count " exemplars=" & $bot.res.count
+7 -9
View File
@@ -13,7 +13,7 @@ type
Exemplar* = object Exemplar* = object
pattern*: BitVec80 pattern*: BitVec80
angle*: float # degrees 0-360 offset*: float # lead correction in degrees, typically [-30, +30]
active*: bool active*: bool
BinaryAimer* = object BinaryAimer* = object
@@ -27,7 +27,7 @@ proc initBinaryAimer*(): BinaryAimer =
proc forward*(aimer: var BinaryAimer, input: BitVec80): float = proc forward*(aimer: var BinaryAimer, input: BitVec80): float =
## Kernel regression: weighted circular mean over active exemplars. ## Kernel regression: weighted circular mean over active exemplars.
## Weights by similarity^2 and exponential recency decay (0.95^age). ## Weights by similarity^2 and exponential recency decay (0.95^age).
## Returns angle in [0,360) degrees, or -1.0 sentinel when cold (no weight). ## Returns offset (lead correction) in degrees, or -999.0 sentinel when cold (no weight).
var sinSum = 0.0 var sinSum = 0.0
var cosSum = 0.0 var cosSum = 0.0
var totalW = 0.0 var totalW = 0.0
@@ -41,18 +41,16 @@ proc forward*(aimer: var BinaryAimer, input: BitVec80): float =
let age = (aimer.nextSlot - 1 - i + MAX_K) mod MAX_K let age = (aimer.nextSlot - 1 - i + MAX_K) mod MAX_K
let recency = pow(0.95, age.float) let recency = pow(0.95, age.float)
let weight = float(excess * excess) * recency let weight = float(excess * excess) * recency
let rad = degToRad(aimer.exemplars[i].angle) let rad = degToRad(aimer.exemplars[i].offset)
sinSum += weight * sin(rad) sinSum += weight * sin(rad)
cosSum += weight * cos(rad) cosSum += weight * cos(rad)
totalW += weight totalW += weight
if totalW == 0.0: if totalW == 0.0:
return -1.0 return -999.0
var angle = radToDeg(arctan2(sinSum, cosSum)) result = radToDeg(arctan2(sinSum, cosSum))
if angle < 0.0: angle += 360.0
result = angle
proc learn*(aimer: var BinaryAimer, input: BitVec80, correctAngle: float) = proc learn*(aimer: var BinaryAimer, input: BitVec80, offset: float) =
## Ring-buffer store: write exemplar at nextSlot, advance mod MAX_K. ## Ring-buffer store: write exemplar at nextSlot, advance mod MAX_K.
aimer.exemplars[aimer.nextSlot] = Exemplar(pattern: input, angle: correctAngle, active: true) aimer.exemplars[aimer.nextSlot] = Exemplar(pattern: input, offset: offset, active: true)
aimer.nextSlot = (aimer.nextSlot + 1) mod MAX_K aimer.nextSlot = (aimer.nextSlot + 1) mod MAX_K
if aimer.count < MAX_K: inc aimer.count if aimer.count < MAX_K: inc aimer.count