From 0cc682152db71de061dbca535dfb14435f367112 Mon Sep 17 00:00:00 2001 From: Davide Cappellini Date: Sun, 20 Sep 2026 22:27:52 +0200 Subject: [PATCH] fix(guns): per-bin wave queues unbreak GF/DecayGF/KNN learning; fix vbullet drops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ModularBot_garage/src/ModularBot.nim | 35 ++++----- common_libs/gun_harness/virtual_bullets.nim | 34 +++++++-- common_libs/guns/decay_gf.nim | 61 ++++++++++++--- common_libs/guns/guess_factor.nim | 82 ++++++++++++++------- common_libs/guns/knn_gun.nim | 68 ++++++++++++++--- 5 files changed, 205 insertions(+), 75 deletions(-) diff --git a/ModularBot_garage/src/ModularBot.nim b/ModularBot_garage/src/ModularBot.nim index 4c0e695..779769e 100644 --- a/ModularBot_garage/src/ModularBot.nim +++ b/ModularBot_garage/src/ModularBot.nim @@ -199,24 +199,10 @@ method onRoundEnded*(bot: ModularBot, e: RoundEndedEventForBot) = ## Dump per-gun virtual bullet stats to /tmp/gun_stats.jsonl (one line per round). # Use lastKnownTargetId: currentTargetId is -1 if enemy died before round end let targetId = if bot.currentTargetId >= 0: bot.currentTargetId else: bot.lastKnownTargetId - # Aggregate fitness across all tracked enemies (or just the primary target if known). - # Use aggregate (targetId=-1) so we always have data even if target switched. - let fit = block: - var f: seq[vb.GunFitness] - if targetId >= 0 and targetId in bot.tracker.fitness: - f = bot.tracker.fitness[targetId] - else: - # aggregate across all enemies - f = newSeq[vb.GunFitness](bot.tracker.numGuns) - for perEnemy in bot.tracker.fitness.values: - for gid in 0..= 0 and targetId in t.fitness: return t.fitness[targetId] - # Aggregate across all enemies + # Aggregate across all enemies, deterministically ordered. result = newSeq[GunFitness](t.numGuns) - for perEnemy in t.fitness.values: + var enemyIds: seq[int] + for id in t.fitness.keys: enemyIds.add id + enemyIds.sort() + for id in enemyIds: + let perEnemy = t.fitness[id] for gunId in 0..= MinHitRate. diff --git a/common_libs/guns/decay_gf.nim b/common_libs/guns/decay_gf.nim index 985932b..614e3ac 100644 --- a/common_libs/guns/decay_gf.nim +++ b/common_libs/guns/decay_gf.nim @@ -1,14 +1,16 @@ ## Recency-weighted GF gun: exponential decay on histogram bins. ## decay=0.998/tick gives ~350-tick half-life — adapts to mid-battle strategy shifts. -## Everything else identical to guess_factor.nim. +## Everything else identical to guess_factor.nim, including per-power-bin wave queues. import std/math import gun_harness/gun_interface +import gun_harness/virtual_bullets as vb # PowerBins const GFBins = 31 GFPrior = 0.1 DecayRate = 0.998 # ponytail: single global decay, tune if adaptation too slow/fast + DecayWaveCompactAt = 64 type DWave = object @@ -17,14 +19,21 @@ type DecayGFGun* = object bins: array[GFBins, float] - waves: seq[DWave] - cachedTick: int - cachedWaveStored: bool + # One wave queue per power bin; a resolved bullet only learns from a wave + # queued for its own bin (matched on bulletSpeed / bulletPower). + waves: array[len(vb.PowerBins), seq[DWave]] + 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 + cachedTick: int # last tick bins were decayed + wavePushes*: int + waveStarved*: int debugGraphics*: bool proc initDecayGFGun*(): DecayGFGun = result.cachedTick = -1 result.debugGraphics = false + for b in 0..= g.waves[binIdx].len: + return (false, DWave()) + result = (true, g.waves[binIdx][g.waveHead[binIdx]]) + inc g.waveHead[binIdx] + if g.waveHead[binIdx] >= DecayWaveCompactAt 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 + proc predict*(g: var DecayGFGun, state: WorldState, bulletSpeed: float): GunPrediction = if bulletSpeed <= 0.0: return GunPrediction(x: state.enemyX, y: state.enemyY) @@ -57,12 +88,15 @@ proc predict*(g: var DecayGFGun, state: WorldState, bulletSpeed: float): GunPred # Decay all bins once per tick for i in 0..= 0 and g.waveStoredTick[binIdx] != state.tick: + g.waves[binIdx].add DWave(fireX: state.selfX, fireY: state.selfY, fireBearing: bearing) + g.waveStoredTick[binIdx] = state.tick + inc g.wavePushes let peak = g.peakBin() let peakGF = indexToGF(peak) @@ -76,10 +110,13 @@ proc predict*(g: var DecayGFGun, state: WorldState, bulletSpeed: float): GunPred ) proc onResult*(g: var DecayGFGun, e: FeedbackEvent) = - if g.waves.len == 0: return + let binIdx = binForPower(e.bulletPower) + if binIdx < 0: return - let w = g.waves[0] - g.waves.delete(0) + 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)) diff --git a/common_libs/guns/guess_factor.nim b/common_libs/guns/guess_factor.nim index 23eea71..8c8db22 100644 --- a/common_libs/guns/guess_factor.nim +++ b/common_libs/guns/guess_factor.nim @@ -1,14 +1,16 @@ ## Guess-factor gun: statistical targeting via GF histogram. ## Bins: 31, ranging GF -1 (max CW escape) to +1 (max CCW escape). -## Learns from virtual bullet outcomes; caches wave state per-tick. +## Learns from virtual bullet outcomes; queues one wave per (tick, power bin). import std/[math, strformat] import gun_harness/gun_interface +import gun_harness/virtual_bullets as vb # PowerBins: the four power bins the harness spawns const GFBins = 31 GFPrior = 0.1 DebugGF* = false + WaveCompactAt = 64 ## compact a bin's wave seq once this many entries are consumed type Wave = object @@ -18,15 +20,20 @@ type GFGun* = object bins: array[GFBins, float] - waves: seq[Wave] # pending unresolved waves - # per-tick cache: store wave only once across multiple power-bin calls - cachedTick: int - cachedWaveStored: bool + # One wave queue per power bin. The owning bin is fixed at push time (from the + # bulletSpeed argument) and at pop time (from FeedbackEvent.bulletPower), so a + # resolved bullet is always paired with a wave from its own bin. + waves: array[len(vb.PowerBins), seq[Wave]] + waveHead: array[len(vb.PowerBins), int] # O(1) pop cursor into waves[bin] + waveStoredTick: array[len(vb.PowerBins), int] # last tick a wave was queued for this bin + wavePushes*: int # total waves enqueued (== one per (tick, bin)) + waveStarved*: int # onResult found an empty queue for its own bin debugGraphics*: bool proc initGFGun*(): GFGun = - result.cachedTick = -1 result.debugGraphics = false + for b in 0..= g.waves[binIdx].len: + return (false, Wave()) + result = (true, g.waves[binIdx][g.waveHead[binIdx]]) + inc g.waveHead[binIdx] + # Amortized O(1): drop the consumed prefix once it dominates the queue. + if g.waveHead[binIdx] >= WaveCompactAt 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 + proc predict*(g: var GFGun, state: WorldState, bulletSpeed: float): GunPrediction = if bulletSpeed <= 0.0: return GunPrediction(x: state.enemyX, y: state.enemyY) @@ -57,18 +92,17 @@ proc predict*(g: var GFGun, state: WorldState, bulletSpeed: float): GunPredictio let bearing = arctan2(dy, dx) let mea = arcsin(clamp(8.0 / bulletSpeed, -1.0, 1.0)) - # Store one wave per tick regardless of how many power bins call us - if state.tick != g.cachedTick: - g.cachedTick = state.tick - g.cachedWaveStored = false - - if not g.cachedWaveStored: - g.waves.add Wave( + # 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(bulletSpeed) + if binIdx >= 0 and g.waveStoredTick[binIdx] != state.tick: + g.waves[binIdx].add Wave( fireX: state.selfX, fireY: state.selfY, fireBearing: bearing, ) - g.cachedWaveStored = true + g.waveStoredTick[binIdx] = state.tick + inc g.wavePushes let peak = g.peakBin() let peakGF = indexToGF(peak) @@ -78,7 +112,7 @@ proc predict*(g: var GFGun, state: WorldState, bulletSpeed: float): GunPredictio let py = state.selfY + sin(gfAngle) * dist when DebugGF: - echo fmt"[gf-dbg] predict: peakGF={peakGF:.2f} peakBin={peak} mea={radToDeg(mea):.1f}° aimAngle={radToDeg(gfAngle):.1f}° waves={g.waves.len}" + echo fmt"[gf-dbg] predict: peakGF={peakGF:.2f} peakBin={peak} mea={radToDeg(mea):.1f}° aimAngle={radToDeg(gfAngle):.1f}° waves={g.waves[binIdx].len}" GunPrediction( x: clamp(px, BotRadius, state.arenaWidth - BotRadius), @@ -86,17 +120,15 @@ proc predict*(g: var GFGun, state: WorldState, bulletSpeed: float): GunPredictio ) proc onResult*(g: var GFGun, e: FeedbackEvent) = - ## Called when a virtual bullet resolves. Match the wave by predicted point, - ## compute actual GF, and increment the histogram with a smoothing kernel. - ## We don't have the original wave tick here, so we use the prediction coords - ## to identify and remove the matching wave. - ## ponytail: O(n) scan over waves; waves list stays tiny (< a dozen at a time) - if g.waves.len == 0: - return + ## Called when a virtual bullet resolves. Pop the OLDEST unresolved wave from + ## this bullet's own power-bin queue, compute the actual GF, and smooth-add it. + let binIdx = binForPower(e.bulletPower) + if binIdx < 0: return - # Pop the oldest wave (FIFO matches bullet resolution order) - let w = g.waves[0] - g.waves.delete(0) + let (found, w) = g.takeOldestWave(binIdx) + if not found: + inc g.waveStarved + return # Recompute mea from the actual bullet power (correct per-bin, not the cached first-bin mea) let speed = bulletSpeed(e.bulletPower) diff --git a/common_libs/guns/knn_gun.nim b/common_libs/guns/knn_gun.nim index badfaff..ea9670e 100644 --- a/common_libs/guns/knn_gun.nim +++ b/common_libs/guns/knn_gun.nim @@ -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..= 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))