feat(SNNBot): physics-based input encoding for lead generalization

Replace (bearing, velDir, speed) with (relVelDir, speed, distance).
Raw bearing is irrelevant to lead offset — the correction depends on
how the target crosses the line of fire, not where it is. This lets
exemplars from one position generalize to all positions with similar
geometry.
This commit is contained in:
2026-09-16 21:48:06 +02:00
parent 57cbcc5699
commit 802e0e0056
2 changed files with 29 additions and 35 deletions
+28 -34
View File
@@ -1,6 +1,6 @@
# SNNBot — SNN core + aiming loop prototype (issue #152). # SNNBot — SNN core + aiming loop prototype (issue #152).
# 80-input population-coded → 12 LIF hidden → polar-coded (sin/cos) output decoded via atan2. # 80-input population-coded → 12 LIF hidden → polar-coded (sin/cos) output decoded via atan2.
# Inputs: bearing [0-35], velocity direction [36-71], speed [72-79]. # Inputs: relVelDir [0-35], speed [36-38], distance thermometer [39-46]. Raw bearing removed (irrelevant to lead).
# No movement, no firing. SuperSpike three-factor rule on all weights (issue #158). # No movement, no firing. SuperSpike three-factor rule on all weights (issue #158).
# Binary reservoir alternative toggled via USE_RESERVOIR const (issue #159). # Binary reservoir alternative toggled via USE_RESERVOIR const (issue #159).
# #
@@ -333,45 +333,39 @@ method onBulletHit*(bot: SNNBot, e: BulletHitBotEvent) =
# ── Reservoir helpers ───────────────────────────────────────────────────────── # ── Reservoir helpers ─────────────────────────────────────────────────────────
proc toBinaryInput(bearing: float, velDir: float, velSpeed: float, hasVel: bool): BitVec80 = proc toBinaryInput(bearing: float, velDir: float, velSpeed: float, distance: float, hasVel: bool): BitVec80 =
## Binary-native encoding using relative velocity direction for cross-round generalization. ## Physics-based encoding for lead generalization (issue #152).
## Bits 0-35: bearing (absolute, for range estimate) ## Raw bearing removed — irrelevant to lead offset.
## Bits 36-71: relative velocity direction = (velDir - bearing) mod 360 ## Bits 0-35: relVelDir = (velDir - bearing) normalized to [-180,180), 36 bins × 10°, 5 active (center ± 2 neighbors).
## This encodes lateral vs. radial motion regardless of which wall Walls is on. ## Bits 36-38: speed level (3 bits, center ± 1 neighbor).
## Bits 72-79: speed bands ## Bits 39-46: distance thermometer, 8 bands of ~125px in [0,1000].
## ponytail: relative-vel encoding; revert to absolute if aimer convergence regresses ## Bits 47-79: unused (sparse is fine for popcount similarity).
## ponytail: physics-based; revert to absolute bearing if convergence regresses
result = [0'u64, 0'u64] result = [0'u64, 0'u64]
# Bearing: bits 0-35 (36 bits, 10° bands)
# Activate center band + 2 neighbors on each side = 5 bits
let bNorm = (bearing + 180.0) / 10.0 # 0..36
let bCenter = int(bNorm) mod 36
for offset in -2 .. 2:
let idx = (bCenter + offset + 36) mod 36
let word = idx div 64
let bit = idx mod 64
result[word] = result[word] or (1'u64 shl bit)
if hasVel: if hasVel:
# Relative velocity direction: (velDir - bearing + 360) mod 360 # Relative velocity direction: how the target crosses our line of fire
# Encodes lateral/radial motion direction independent of which wall Walls is on var relVelDir = (velDir - bearing)
let relVel = ((velDir - bearing) + 360.0) mod 360.0 while relVelDir >= 180.0: relVelDir -= 360.0
let vNorm = relVel / 10.0 while relVelDir < -180.0: relVelDir += 360.0
let vNorm = (relVelDir + 180.0) / 10.0 # 0..36
let vCenter = int(vNorm) mod 36 let vCenter = int(vNorm) mod 36
for offset in -2 .. 2: for offset in -2 .. 2:
let idx = 36 + (vCenter + offset + 36) mod 36 let idx = (vCenter + offset + 36) mod 36 # bits 0-35
let word = idx div 64 result[idx div 64] = result[idx div 64] or (1'u64 shl (idx mod 64))
let bit = idx mod 64
result[word] = result[word] or (1'u64 shl bit)
# Speed: bits 72-79 (8 bits, 1 unit/tick bands) # Speed: bits 36-38, center ± 1 neighbor clamped to [0,2]
# Activate center + 1 neighbor on each side = 3 bits let sCenter = clamp(int(velSpeed), 0, 2)
let sCenter = clamp(int(velSpeed), 0, 7)
for offset in -1 .. 1: for offset in -1 .. 1:
let idx = 72 + clamp(sCenter + offset, 0, 7) let idx = 36 + clamp(sCenter + offset, 0, 2)
let word = idx div 64 result[idx div 64] = result[idx div 64] or (1'u64 shl (idx mod 64))
let bit = idx mod 64
result[word] = result[word] or (1'u64 shl bit) # Distance thermometer: bits 39-46, 8 bands of ~125px over [0,1000]
# All bits up to the current band set (inclusive)
let band = clamp(int(distance / 125.0), 0, 7)
for b in 0 .. band:
let idx = 39 + b
result[idx div 64] = result[idx div 64] or (1'u64 shl (idx mod 64))
# ── Main loop ───────────────────────────────────────────────────────────────── # ── Main loop ─────────────────────────────────────────────────────────────────
@@ -396,7 +390,7 @@ method run*(bot: SNNBot) =
let absBearing = bot.enemyBearing let absBearing = bot.enemyBearing
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.enemyDist, bot.hasLastPos)
let aimOffset = bot.res.forward(binInput) # lead correction offset or -999.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
+1 -1
View File
@@ -5,7 +5,7 @@ import std/[bitops, math]
const const
INPUT_BITS* = 80 INPUT_BITS* = 80
WORDS_IN* = 2 # 80 bits → 2 × uint64 (128 bits, only 80 used) WORDS_IN* = 2 # 80 bits → 2 × uint64 (128 bits, only 80 used)
MAX_K* = 128 MAX_K* = 64
MIN_SIM* = 3 MIN_SIM* = 3
type type