From 69ffe4c44ebf7cd20ccf08cc09f3008aebeba267 Mon Sep 17 00:00:00 2001 From: Davide Cappellini Date: Wed, 16 Sep 2026 08:12:54 +0200 Subject: [PATCH] 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. --- SNNBot_garage/src/SNNBot.nim | 34 ++++++++++++++++++--------------- SNNBot_garage/src/reservoir.nim | 16 +++++++--------- 2 files changed, 26 insertions(+), 24 deletions(-) diff --git a/SNNBot_garage/src/SNNBot.nim b/SNNBot_garage/src/SNNBot.nim index 690f358..4895295 100644 --- a/SNNBot_garage/src/SNNBot.nim +++ b/SNNBot_garage/src/SNNBot.nim @@ -215,6 +215,7 @@ type lastDecideGunDir: float # gun heading captured at DECIDE time for EVALUATE lastBinInput: BitVec80 # DECIDE-time binary input, reused in EVALUATE lastInput: BitVec80 # previous EVALUATE-time input (for change detection) + lastAbsBearing: float # absolute bearing to enemy captured at DECIDE time # ── aimTo helper ────────────────────────────────────────────────────────────── @@ -340,17 +341,18 @@ method run*(bot: SNNBot) = bot.lastRelBearing = normalizeRelativeAngle(absBearing - gunDir) when USE_RESERVOIR: 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.lastDecideGunDir = gunDir - if aimRaw < 0.0: - # cold start: aim directly at enemy + bot.lastAbsBearing = absBearing + if aimOffset <= -999.0: + # cold start: aim directly at enemy (no lead) bot.targetAngle = absBearing echo "RES tick=" & $bot.tick & " cold-start aim=" & formatFloat(absBearing, ffDecimal, 1) else: - # aimRaw is absolute [0,360): use directly as target - bot.targetAngle = aimRaw - echo "RES tick=" & $bot.tick & " aim=" & formatFloat(aimRaw, ffDecimal, 1) + # apply lead correction offset to current bearing + bot.targetAngle = (absBearing + aimOffset + 360.0) mod 360.0 + echo "RES tick=" & $bot.tick & " offset=" & formatFloat(aimOffset, ffDecimal, 1) & " aim=" & formatFloat(bot.targetAngle, ffDecimal, 1) else: let inputs = encodeInputFull(absBearing, bot.velDirDeg, bot.velSpeed, bot.hasLastPos) # 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) 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 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 # Learn if: error > dead zone OR input pattern changed significantly - if aimRaw >= 0.0: - let aimErr = abs(normalizeRelativeAngle(aimRaw - correctAngle)) + if producedOffset > -999.0: + let aimErr = abs(normalizeRelativeAngle(producedOffset - correctOffset)) if aimErr > adaptiveDeadZone or inputChanged: - bot.res.learn(bot.lastBinInput, correctAngle) + bot.res.learn(bot.lastBinInput, correctOffset) else: # Cold start: always learn - bot.res.learn(bot.lastBinInput, correctAngle) - let aimErr = if aimRaw >= 0.0: abs(normalizeRelativeAngle(aimRaw - correctAngle)) else: -1.0 + bot.res.learn(bot.lastBinInput, correctOffset) + let aimErr = if producedOffset > -999.0: abs(normalizeRelativeAngle(producedOffset - correctOffset)) else: -1.0 echo "RES tick=" & $bot.tick & - " aim=" & (if aimRaw >= 0.0: formatFloat(aimRaw, ffDecimal, 1) else: "cold") & - " correct=" & formatFloat(correctAngle, ffDecimal, 1) & - " err=" & (if aimRaw >= 0.0: formatFloat(aimErr, ffDecimal, 1) else: "n/a") & + " offset=" & (if producedOffset > -999.0: formatFloat(producedOffset, ffDecimal, 1) else: "cold") & + " correctOffset=" & formatFloat(correctOffset, ffDecimal, 1) & + " err=" & (if producedOffset > -999.0: formatFloat(aimErr, ffDecimal, 1) else: "n/a") & " hammingDist=" & $hammingDist & " inputChanged=" & $inputChanged & " exemplars=" & $bot.res.count diff --git a/SNNBot_garage/src/reservoir.nim b/SNNBot_garage/src/reservoir.nim index f11959d..f14f305 100644 --- a/SNNBot_garage/src/reservoir.nim +++ b/SNNBot_garage/src/reservoir.nim @@ -13,7 +13,7 @@ type Exemplar* = object pattern*: BitVec80 - angle*: float # degrees 0-360 + offset*: float # lead correction in degrees, typically [-30, +30] active*: bool BinaryAimer* = object @@ -27,7 +27,7 @@ proc initBinaryAimer*(): BinaryAimer = 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 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 cosSum = 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 recency = pow(0.95, age.float) let weight = float(excess * excess) * recency - let rad = degToRad(aimer.exemplars[i].angle) + let rad = degToRad(aimer.exemplars[i].offset) sinSum += weight * sin(rad) cosSum += weight * cos(rad) totalW += weight if totalW == 0.0: - return -1.0 - var angle = radToDeg(arctan2(sinSum, cosSum)) - if angle < 0.0: angle += 360.0 - result = angle + return -999.0 + result = radToDeg(arctan2(sinSum, cosSum)) -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. - 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 if aimer.count < MAX_K: inc aimer.count