fix(guns): per-bin wave queues unbreak GF/DecayGF/KNN learning; fix vbullet drops

Wave queues (guess_factor, decay_gf, knn_gun): predict() stored ONE wave
per tick while onResult() popped one per resolved bullet (~4/tick), so the
queue drained to empty within a few dozen ticks, ~3 of every 4 resolutions
returned without learning, and the survivor paired with a same-tick wave
(bearingDelta ~= 0) pinning the histogram at centre. PROOF: GF.vHits ==
HeadOn.vHits and DecayGF.vHits == HeadOn.vHits byte-for-byte in every one
of 50 rounds — the guns had degenerated to HeadOn.

Now each gun keeps a per-bin FIFO with an O(1) head cursor. At most one
push per (tick, bin) so the fire site's 5th predict() call is a no-op, and
onResult pops the oldest wave of its OWN bin via e.bulletPower. Aiming
math untouched (it was already correct: 0 deg = East, CCW+).

maxBullets 2048 -> 8192: the rack spawns 52 bullets/tick so the ring wrapped
every ~39 ticks while a long power-3 shot needs ~90, silently discarding
unresolved bullets and biasing every measured hit rate by range. Added a
droppedBullets counter so a future overflow is measurable, and wavePushes/
waveStarved counters on the three guns. After the fix: vDropped = 0 and
vStarved = 0 across all 48 recorded rounds.

fitnessFor is now exported, deterministic (enemies iterated in ascending id
order) and shared by the selector and the stats dump, replacing a hand-rolled
merge in ModularBot that never advanced its window head.

Round lines gain additive keys: vDropped, vStarved.
This commit is contained in:
2026-09-20 22:27:52 +02:00
parent a90a0cc9b5
commit 0cc682152d
5 changed files with 205 additions and 75 deletions
+56 -12
View File
@@ -5,6 +5,7 @@
import std/[math]
import gun_harness/gun_interface
import gun_harness/virtual_bullets as vb # PowerBins
const
MaxObs = 2000 # ring-buffer cap
@@ -25,10 +26,14 @@ type
KNNGun* = object
obs: seq[Obs]
obsHead: int # ring-buffer write index
waves: seq[KNNWave]
# One wave queue per power bin; matched on bulletSpeed / bulletPower so a
# resolved bullet only ever learns from a wave fired with the same power.
waves: array[len(vb.PowerBins), seq[KNNWave]]
waveHead: array[len(vb.PowerBins), int] # O(1) pop cursor
waveStoredTick: array[len(vb.PowerBins), int] # last tick a wave was queued for this bin
# per-tick cache
cachedTick: int
cachedWaveStored: bool
tickWave: KNNWave # wave template for the current tick (features computed once)
# rolling normalization ranges
featMin: array[7, float]
featMax: array[7, float]
@@ -36,12 +41,16 @@ type
lastSpeed: float
lastDirection: float # +1 or -1
timeSinceDirChange: int
wavePushes*: int
waveStarved*: int
debugGraphics*: bool
proc initKNNGun*(): KNNGun =
result.cachedTick = -1
result.lastDirection = 1.0
result.debugGraphics = false
for b in 0..<len(vb.PowerBins):
result.waveStoredTick[b] = -1
for i in 0..6:
result.featMin[i] = 1e18
result.featMax[i] = -1e18
@@ -105,6 +114,32 @@ proc euclidean(a, b: array[7, float]): float {.inline.} =
result += d * d
result = sqrt(result)
proc binForSpeed(spd: float): int {.inline.} =
## Map a virtual-bullet speed back to its power-bin index. All four bin speeds
## are exactly representable floats; the epsilon is belt-and-braces only.
for i in 0..<len(vb.PowerBins):
if abs(spd - bulletSpeed(vb.PowerBins[i])) < 1e-6:
return i
-1
proc binForPower(power: float): int {.inline.} =
## Map a FeedbackEvent.bulletPower back to its power-bin index.
for i in 0..<len(vb.PowerBins):
if abs(power - vb.PowerBins[i]) < 1e-6:
return i
-1
proc takeOldestWave(g: var KNNGun, binIdx: int): (bool, KNNWave) =
## Pop the oldest unresolved wave for this bin (O(1) amortized via waveHead).
if binIdx < 0 or g.waveHead[binIdx] >= g.waves[binIdx].len:
return (false, KNNWave())
result = (true, g.waves[binIdx][g.waveHead[binIdx]])
inc g.waveHead[binIdx]
if g.waveHead[binIdx] >= 64 and
g.waveHead[binIdx] * 2 >= g.waves[binIdx].len:
g.waves[binIdx] = g.waves[binIdx][g.waveHead[binIdx] .. g.waves[binIdx].high]
g.waveHead[binIdx] = 0
# ── Gun interface ─────────────────────────────────────────────────────────────
proc predict*(g: var KNNGun, state: WorldState, bulletSpd: float): GunPrediction =
@@ -119,8 +154,7 @@ proc predict*(g: var KNNGun, state: WorldState, bulletSpd: float): GunPrediction
# Track direction change — update state once per tick
if state.tick != g.cachedTick:
g.cachedTick = state.tick
g.cachedWaveStored = false
g.cachedTick = state.tick
let relHead = state.enemyHeading - bearing
let latVel = state.enemySpeed * sin(relHead)
@@ -131,18 +165,26 @@ proc predict*(g: var KNNGun, state: WorldState, bulletSpd: float): GunPrediction
else:
inc g.timeSinceDirChange
# Store wave once per tick
if not g.cachedWaveStored:
# Compute the tick's feature vector ONCE, before lastSpeed is advanced, so
# every power bin fired this tick shares identical features. lastSpeed is
# only advanced here (once/tick), not once per bin.
let feat = buildFeatures(state, g.lastSpeed, g.lastDirection, g.timeSinceDirChange)
g.updateMinMax(feat)
g.waves.add KNNWave(
g.tickWave = KNNWave(
fireX: state.selfX,
fireY: state.selfY,
fireBearing: bearing,
feat: feat,
)
g.lastSpeed = state.enemySpeed
g.cachedWaveStored = true
# Queue at most one wave per (tick, power bin). The fire site's extra predict()
# call for the selected bin lands on the same tick and reuses the queued wave.
let binIdx = binForSpeed(bulletSpd)
if binIdx >= 0 and g.waveStoredTick[binIdx] != state.tick:
g.waves[binIdx].add g.tickWave
g.waveStoredTick[binIdx] = state.tick
inc g.wavePushes
# Cold start — no data yet
if g.obs.len == 0:
@@ -236,10 +278,12 @@ proc predict*(g: var KNNGun, state: WorldState, bulletSpd: float): GunPrediction
)
proc onResult*(g: var KNNGun, e: FeedbackEvent) =
if g.waves.len == 0: return
let w = g.waves[0]
g.waves.delete(0)
let binIdx = binForPower(e.bulletPower)
if binIdx < 0: return
let (found, w) = g.takeOldestWave(binIdx)
if not found:
inc g.waveStarved
return
let speed = bulletSpeed(e.bulletPower)
let mea = arcsin(clamp(8.0 / speed, -1.0, 1.0))