feat(SNNBot): adaptive fire power economy with hit rate EMA
Power ladder: 1.0→2.0→3.0 based on rolling hit rate EMA. Higher power = more damage per energy when hitting. Energy guard at 15. Exemplar buffer clears on power change to re-learn lead offset for new bullet speed. Break-even hit rate is 33% — below that, every shot is a net drain. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -43,8 +43,7 @@ const
|
||||
BETA = 1.0 # surrogate steepness (unitless; potentials are unitless)
|
||||
NOISE_AMP = 0.3 # exploration noise amplitude
|
||||
# ponytail: uniform exploration noise; upgrade to annealed Gaussian if convergence needs tuning
|
||||
FIRE_POWER = 1.0 # fixed firing power; bullet speed = 20 - 3*FIRE_POWER
|
||||
BULLET_SPEED = 20.0 - 3.0 * FIRE_POWER
|
||||
ENERGY_GUARD = 15.0 # don't fire below this energy
|
||||
|
||||
# ── SNN types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -216,8 +215,13 @@ type
|
||||
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
|
||||
bulletsFired: int # count of bullets fired (power > 0)
|
||||
bulletsHit: int # count of bullets that hit
|
||||
bulletsFired: int # count of bullets fired this round
|
||||
bulletsHit: int # count of bullets that hit this round
|
||||
hitRateEMA: float # exponential moving average of hit rate
|
||||
currentFirePower: float # dynamic fire power (1.0 / 2.0 / 3.0)
|
||||
bulletSpeed: float # 20 - 3 * currentFirePower
|
||||
pendingPower: float # candidate new power level
|
||||
powerChangeCounter: int # ticks the new power has been suggested
|
||||
|
||||
# ── aimTo helper ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -276,9 +280,15 @@ method onRoundStarted*(bot: SNNBot, e: RoundStartedEvent) =
|
||||
bot.velSpeed = 0.0
|
||||
bot.phase = DECIDE
|
||||
bot.tick = 0
|
||||
# Reset per-round stats (learning persists across rounds)
|
||||
bot.bulletsFired = 0
|
||||
bot.bulletsHit = 0
|
||||
# Reset per-round stats (learning and currentFirePower persist across rounds)
|
||||
bot.bulletsFired = 0
|
||||
bot.bulletsHit = 0
|
||||
bot.hitRateEMA = 0.5 # optimistic start
|
||||
bot.powerChangeCounter = 0
|
||||
# currentFirePower persists; init to 1.0 on first round (zero-value)
|
||||
if bot.currentFirePower == 0.0:
|
||||
bot.currentFirePower = 1.0
|
||||
bot.bulletSpeed = 20.0 - 3.0 * bot.currentFirePower
|
||||
bot.lastEnemyX = 0.0
|
||||
bot.lastEnemyY = 0.0
|
||||
bot.lastAbsBearing = 0.0
|
||||
@@ -292,8 +302,30 @@ method onGameStarted*(bot: SNNBot, e: GameStartedEventForBot) =
|
||||
initSNN(bot.snn)
|
||||
bot.res = initBinaryAimer()
|
||||
|
||||
proc selectFirePower(bot: SNNBot): float =
|
||||
## Power ladder with hysteresis: only commit after 8 consecutive same-direction evaluations.
|
||||
if getEnergy() < ENERGY_GUARD:
|
||||
return 0.0
|
||||
let desired =
|
||||
if bot.hitRateEMA > 0.45: 3.0
|
||||
elif bot.hitRateEMA > 0.30: 2.0
|
||||
else: 1.0
|
||||
if desired != bot.currentFirePower:
|
||||
if desired == bot.pendingPower:
|
||||
if bot.powerChangeCounter >= 8:
|
||||
return desired # commit
|
||||
# counter incremented at call site
|
||||
return bot.currentFirePower # hold
|
||||
return bot.currentFirePower
|
||||
|
||||
method onBulletFired*(bot: SNNBot, e: BulletFiredEvent) =
|
||||
inc bot.bulletsFired
|
||||
# decay EMA toward miss on each confirmed fire
|
||||
bot.hitRateEMA = bot.hitRateEMA * 0.93
|
||||
|
||||
method onBulletHit*(bot: SNNBot, e: BulletHitBotEvent) =
|
||||
inc bot.bulletsHit
|
||||
bot.hitRateEMA = bot.hitRateEMA * 0.85 + 1.0 * 0.15
|
||||
|
||||
# ── Reservoir helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -359,15 +391,15 @@ method run*(bot: SNNBot) =
|
||||
bot.lastBinInput = binInput
|
||||
bot.lastDecideGunDir = gunDir
|
||||
bot.lastAbsBearing = absBearing
|
||||
let rate = if bot.bulletsFired > 0: (float(bot.bulletsHit) / float(bot.bulletsFired) * 100.0) else: 0.0
|
||||
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 & " cold-start aim=" & formatFloat(absBearing, ffDecimal, 1) & " fired=" & $bot.bulletsFired & " hit=" & $bot.bulletsHit & " rate=" & formatFloat(rate, ffDecimal, 1) & "%"
|
||||
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)
|
||||
else:
|
||||
# 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) & " fired=" & $bot.bulletsFired & " hit=" & $bot.bulletsHit & " rate=" & formatFloat(rate, ffDecimal, 1) & "%"
|
||||
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)
|
||||
else:
|
||||
let inputs = encodeInputFull(absBearing, bot.velDirDeg, bot.velSpeed, bot.hasLastPos)
|
||||
# Multi-tick inference: accumulate sin/cos and spike counts over N_INFER ticks
|
||||
@@ -407,16 +439,43 @@ method run*(bot: SNNBot) =
|
||||
|
||||
# Fire gate: only fire when error within adaptive dead zone
|
||||
if err < adaptiveDeadZone:
|
||||
discard setFire(FIRE_POWER)
|
||||
inc bot.bulletsFired
|
||||
bot.phase = EVALUATE
|
||||
# Power selection with hysteresis
|
||||
let desired =
|
||||
if getEnergy() < ENERGY_GUARD: 0.0
|
||||
elif bot.hitRateEMA > 0.45: 3.0
|
||||
elif bot.hitRateEMA > 0.30: 2.0
|
||||
else: 1.0
|
||||
if desired != bot.currentFirePower and desired > 0.0:
|
||||
if desired == bot.pendingPower:
|
||||
inc bot.powerChangeCounter
|
||||
if bot.powerChangeCounter >= 8:
|
||||
let oldPower = bot.currentFirePower
|
||||
bot.currentFirePower = desired
|
||||
bot.bulletSpeed = 20.0 - 3.0 * desired
|
||||
bot.powerChangeCounter = 0
|
||||
bot.pendingPower = 0.0
|
||||
# old exemplars learned at different bullet speed — clear them
|
||||
if oldPower != desired:
|
||||
bot.res.count = 0
|
||||
bot.res.nextSlot = 0
|
||||
else:
|
||||
bot.pendingPower = desired
|
||||
bot.powerChangeCounter = 1
|
||||
elif desired == 0.0:
|
||||
discard setFire(0.0)
|
||||
# skip EVALUATE so we don't corrupt learning with a non-shot
|
||||
# stay in WAITING for next tick
|
||||
if desired > 0.0:
|
||||
discard setFire(bot.currentFirePower)
|
||||
# bulletsFired counted in onBulletFired event
|
||||
bot.phase = EVALUATE
|
||||
else:
|
||||
discard setFire(0.0)
|
||||
|
||||
of EVALUATE:
|
||||
# Predictive error signal: extrapolate enemy position at bullet impact time.
|
||||
if bot.hasLastPos:
|
||||
let travelTime = bot.enemyDist / BULLET_SPEED
|
||||
let travelTime = bot.enemyDist / bot.bulletSpeed
|
||||
let velRad = degToRad(bot.velDirDeg)
|
||||
let futureX = bot.lastEnemyX + cos(velRad) * bot.velSpeed * travelTime
|
||||
let futureY = bot.lastEnemyY + sin(velRad) * bot.velSpeed * travelTime
|
||||
@@ -446,13 +505,17 @@ method run*(bot: SNNBot) =
|
||||
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") & "%" &
|
||||
" 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 &
|
||||
" fired=" & $bot.bulletsFired & " hit=" & $bot.bulletsHit & " rate=" & formatFloat(rate, ffDecimal, 1) & "%"
|
||||
" exemplars=" & $bot.res.count
|
||||
else:
|
||||
let targetRel = normalizeRelativeAngle(correctAngle - bot.lastDecideGunDir)
|
||||
bot.snn.superSpikeUpdate(bot.lastSpikes, bot.lastVSnap, bot.snn.preTrace, targetRel)
|
||||
@@ -474,7 +537,7 @@ method run*(bot: SNNBot) =
|
||||
meanWout += abs(w)
|
||||
meanWout /= float(2 * N_HID)
|
||||
let rate = if bot.bulletsFired > 0: (float(bot.bulletsHit) / float(bot.bulletsFired) * 100.0) else: 0.0
|
||||
echo "tick=" & $bot.tick & " 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) & " fired=" & $bot.bulletsFired & " hit=" & $bot.bulletsHit & " rate=" & formatFloat(rate, ffDecimal, 1) & "%"
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user