diff --git a/SNNBot_garage/src/SNNBot.nim b/SNNBot_garage/src/SNNBot.nim index 235e97c..8a494c6 100644 --- a/SNNBot_garage/src/SNNBot.nim +++ b/SNNBot_garage/src/SNNBot.nim @@ -9,7 +9,7 @@ # WAITING → aimTo() each tick; when error < 2° → EVALUATE # EVALUATE → measure error, compute SuperSpike/reservoir update, log, → DECIDE -import std/[math, random, os, strutils, bitops] +import std/[math, random, os, strutils] import robocode_tankroyale_botapi import radar_lock/radar_lock as radar_lock import reservoir @@ -201,7 +201,7 @@ type SNNBot = ref object of Bot snn: SNN - res: BinaryAimer + res: LeadGrid phase: Phase targetAngle: float # SNN output (absolute bearing) enemyBearing: float # last known enemy bearing @@ -217,8 +217,7 @@ type velDirDeg: float64 # velocity direction (degrees) from last scan delta velSpeed: float64 # speed (units/tick) from last scan delta 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) + lastVPerp: float # DECIDE-time vPerp, reused in EVALUATE lastAbsBearing: float # absolute bearing to enemy captured at DECIDE time roundTick: int # ticks elapsed in current round (reset each round) bulletsFired: int # count of bullets fired this round @@ -301,13 +300,12 @@ method onRoundStarted*(bot: SNNBot, e: RoundStartedEvent) = bot.lastAbsBearing = 0.0 bot.lastDecideGunDir = 0.0 bot.targetAngle = 0.0 - bot.lastInput = [0'u64, 0'u64] setTargetSpeed(0.0) setTurnRate(0.0) method onGameStarted*(bot: SNNBot, e: GameStartedEventForBot) = initSNN(bot.snn) - bot.res = initBinaryAimer() + bot.res = initLeadGrid() method onBulletFired*(bot: SNNBot, e: BulletFiredEvent) = @@ -319,50 +317,6 @@ method onBulletHit*(bot: SNNBot, e: BulletHitBotEvent) = inc bot.bulletsHit bot.hitRateEMA = bot.hitRateEMA * 0.85 + 1.0 * 0.15 -# ── Reservoir helpers ───────────────────────────────────────────────────────── - -proc toBinaryInput(relVelDir: float, velSpeed: float, distance: float, hasVel: bool): BitVec80 = - ## Physics-based encoding for lead generalization (issue #152). - ## Bits 0-15: v_perp signed thermometer — velSpeed*sin(relVelDir) in [-8,+8]. - ## Positive (target moves right): bits 0-7; negative (left): bits 8-15. - ## +3 sets bits 0,1,2; -3 sets bits 8,9,10. Zero overlap between signs. - ## Bits 16-31: v_parallel signed thermometer — velSpeed*cos(relVelDir) in [-8,+8]. - ## Positive = approaching (less lead needed), negative = receding. - ## Bits 32-39: distance thermometer, 8 bands of ~125px in [0,1000]. - ## Bits 40-47: total speed thermometer, 8 bands of 1 unit/tick. - ## Bits 48-79: unused. - result = [0'u64, 0'u64] - - template setThermo(baseIdx: int, value: float) = - ## Signed thermometer: positive → bits baseIdx..baseIdx+7, negative → bits baseIdx+8..baseIdx+15. - let mag = min(int(abs(value)), 8) - if value >= 0.0: - for b in 0 ..< mag: - let idx = baseIdx + b - result[idx div 64] = result[idx div 64] or (1'u64 shl (idx mod 64)) - else: - for b in 0 ..< mag: - let idx = baseIdx + 8 + b - result[idx div 64] = result[idx div 64] or (1'u64 shl (idx mod 64)) - - if hasVel: - let vPerp = velSpeed * sin(degToRad(relVelDir)) # lateral speed, range [-8,+8] - let vPar = velSpeed * cos(degToRad(relVelDir)) # approach speed, range [-8,+8] - setThermo(0, vPerp) - setThermo(16, vPar) - - # Total speed thermometer: bits 40-47 - let speedInt = clamp(int(velSpeed + 0.5), 0, 8) - for b in 0 ..< speedInt: - let idx = 40 + b - result[idx div 64] = result[idx div 64] or (1'u64 shl (idx mod 64)) - - # Distance thermometer: bits 32-39, 8 bands of ~125px over [0,1000] - let band = clamp(int(distance / 125.0), 0, 7) - for b in 0 .. band: - let idx = 32 + b - result[idx div 64] = result[idx div 64] or (1'u64 shl (idx mod 64)) - # ── Main loop ───────────────────────────────────────────────────────────────── method run*(bot: SNNBot) = @@ -390,20 +344,18 @@ method run*(bot: SNNBot) = var relVelDir = bot.velDirDeg - absBearing while relVelDir >= 180.0: relVelDir -= 360.0 while relVelDir < -180.0: relVelDir += 360.0 - let binInput = toBinaryInput(relVelDir, bot.velSpeed, bot.enemyDist, bot.hasLastPos) - let aimOffset = bot.res.forward(binInput) # lead correction offset or -999.0 sentinel - bot.lastBinInput = binInput + let vPerp = bot.velSpeed * sin(degToRad(relVelDir)) + let aimOffset = bot.res.forward(vPerp, bot.enemyDist) + bot.lastVPerp = vPerp bot.lastDecideGunDir = gunDir bot.lastAbsBearing = absBearing let rate = if bot.bulletsFired > 0: float(bot.bulletsHit) / float(bot.bulletsFired) * 100.0 else: 0.0 if aimOffset <= -999.0: - # cold start: aim directly at enemy (no lead) bot.targetAngle = absBearing - echo "RES tick=" & $bot.tick & " pwr=" & formatFloat(bot.currentFirePower, ffDecimal, 1) & " hitEMA=" & formatFloat(bot.hitRateEMA * 100.0, ffDecimal, 0) & "% cold-start aim=" & formatFloat(absBearing, ffDecimal, 1) & " fired=" & $bot.bulletsFired & " hit=" & $bot.bulletsHit & " rate=" & formatFloat(rate, ffDecimal, 1) & "% energy=" & formatFloat(getEnergy(), ffDecimal, 0) + echo "RES tick=" & $bot.tick & " pwr=" & formatFloat(bot.currentFirePower, ffDecimal, 1) & " hitEMA=" & formatFloat(bot.hitRateEMA * 100.0, ffDecimal, 0) & "% cold-start aim=" & formatFloat(absBearing, ffDecimal, 1) & " fired=" & $bot.bulletsFired & " hit=" & $bot.bulletsHit & " rate=" & formatFloat(rate, ffDecimal, 1) & "% energy=" & formatFloat(getEnergy(), ffDecimal, 0) & " cells=" & $bot.res.totalCount else: - # apply lead correction offset to current bearing bot.targetAngle = (absBearing + aimOffset + 360.0) mod 360.0 - echo "RES tick=" & $bot.tick & " pwr=" & formatFloat(bot.currentFirePower, ffDecimal, 1) & " hitEMA=" & formatFloat(bot.hitRateEMA * 100.0, ffDecimal, 0) & "% offset=" & formatFloat(aimOffset, ffDecimal, 1) & " aim=" & formatFloat(bot.targetAngle, ffDecimal, 1) & " fired=" & $bot.bulletsFired & " hit=" & $bot.bulletsHit & " rate=" & formatFloat(rate, ffDecimal, 1) & "% energy=" & formatFloat(getEnergy(), ffDecimal, 0) + echo "RES tick=" & $bot.tick & " pwr=" & formatFloat(bot.currentFirePower, ffDecimal, 1) & " hitEMA=" & formatFloat(bot.hitRateEMA * 100.0, ffDecimal, 0) & "% offset=" & formatFloat(aimOffset, ffDecimal, 1) & " aim=" & formatFloat(bot.targetAngle, ffDecimal, 1) & " fired=" & $bot.bulletsFired & " hit=" & $bot.bulletsHit & " rate=" & formatFloat(rate, ffDecimal, 1) & "% energy=" & formatFloat(getEnergy(), ffDecimal, 0) & " cells=" & $bot.res.totalCount else: let inputs = encodeInputFull(absBearing, bot.velDirDeg, bot.velSpeed, bot.hasLastPos) # Multi-tick inference: accumulate sin/cos and spike counts over N_INFER ticks @@ -462,35 +414,29 @@ method run*(bot: SNNBot) = let adaptiveDeadZone = 0.5 + (0.3 - 0.5) * t # lerp(0.5, 0.3, t) when USE_RESERVOIR: - # 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 + let producedOffset = bot.res.forward(bot.lastVPerp, bot.enemyDist) - # Learn if: error > dead zone OR input pattern changed significantly + # Learn if: cold start OR error > dead zone if producedOffset > -999.0: let aimErr = abs(normalizeRelativeAngle(producedOffset - correctOffset)) - if aimErr > adaptiveDeadZone or inputChanged: - bot.res.learn(bot.lastBinInput, correctOffset) + if aimErr > adaptiveDeadZone: + bot.res.learn(bot.lastVPerp, bot.enemyDist, correctOffset) else: - # Cold start: always learn - bot.res.learn(bot.lastBinInput, correctOffset) + bot.res.learn(bot.lastVPerp, bot.enemyDist, correctOffset) let aimErr = if producedOffset > -999.0: abs(normalizeRelativeAngle(producedOffset - correctOffset)) else: -1.0 let rate = if bot.bulletsFired > 0: (float(bot.bulletsHit) / float(bot.bulletsFired) * 100.0) else: 0.0 echo "RES tick=" & $bot.tick & " pwr=" & formatFloat(bot.currentFirePower, ffDecimal, 1) & " hitEMA=" & formatFloat(bot.hitRateEMA * 100.0, ffDecimal, 0) & "%" & " fired=" & $bot.bulletsFired & " hit=" & $bot.bulletsHit & - " rate=" & (if bot.bulletsFired > 0: formatFloat(float(bot.bulletsHit) / float(bot.bulletsFired) * 100.0, ffDecimal, 1) else: "0.0") & "%" & + " rate=" & (if bot.bulletsFired > 0: formatFloat(rate, ffDecimal, 1) else: "0.0") & "%" & " energy=" & formatFloat(getEnergy(), ffDecimal, 0) & " 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 + " vPerp=" & formatFloat(bot.lastVPerp, ffDecimal, 2) & + " cells=" & $bot.res.totalCount else: let targetRel = normalizeRelativeAngle(correctAngle - bot.lastDecideGunDir) bot.snn.superSpikeUpdate(bot.lastSpikes, bot.lastVSnap, bot.snn.preTrace, targetRel) @@ -514,8 +460,6 @@ method run*(bot: SNNBot) = let rate = if bot.bulletsFired > 0: (float(bot.bulletsHit) / float(bot.bulletsFired) * 100.0) else: 0.0 echo "tick=" & $bot.tick & " pwr=" & formatFloat(bot.currentFirePower, ffDecimal, 1) & " hitEMA=" & formatFloat(bot.hitRateEMA * 100.0, ffDecimal, 0) & "% fired=" & $bot.bulletsFired & " hit=" & $bot.bulletsHit & " rate=" & formatFloat(rate, ffDecimal, 1) & "% energy=" & formatFloat(getEnergy(), ffDecimal, 0) & " err=" & formatFloat(err, ffDecimal, 1) & "° infer_spk=" & $spikeCount & "/" & $(N_INFER * N_HID) & " maxV=" & formatFloat(maxV, ffDecimal, 3) & " |wih|=" & formatFloat(meanWih, ffDecimal, 4) & " |wOut|=" & formatFloat(meanWout, ffDecimal, 4) & " sin=" & formatFloat(bot.snn.lastSinOut, ffDecimal, 3) & " cos=" & formatFloat(bot.snn.lastCosOut, ffDecimal, 3) & " snnAngle=" & formatFloat(bot.snn.lastSnnAngle, ffDecimal, 1) - # Update lastInput for next EVALUATE cycle's change detection - bot.lastInput = bot.lastBinInput bot.phase = DECIDE # Radar lock diff --git a/SNNBot_garage/src/reservoir.nim b/SNNBot_garage/src/reservoir.nim index 926439e..34377d0 100644 --- a/SNNBot_garage/src/reservoir.nim +++ b/SNNBot_garage/src/reservoir.nim @@ -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