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
+27 -7
View File
@@ -6,12 +6,16 @@
import std/math
import std/tables
import std/random
import std/algorithm
import gun_interface
const
PowerBins* = [1.0, 1.5, 2.0, 3.0] ## 4 bins; ponytail: fixed array, add runtime config if needed
WindowSize* = 100 ## rolling window ticks for fitness
MaxBullets* = 2048 ## hard cap; ponytail: ring buffer, resize if more guns added
MaxBullets* = 8192 ## hard cap; ring buffer. 52 spawns/tick and a
## full-map long shot (~90 ticks) need ~4700 slots;
## 8192 wraps only after ~157 ticks. Each VirtualBullet
## is 88 bytes, so this array costs ~704 KiB.
MinHitRate* = 0.40 ## 40% threshold for acceptable power selection
MinObsBeforeCompete* = 50 ## min observations before a gun×bin enters competition
TieMargin* = 0.02 ## guns within this hit-rate margin of best are tied
@@ -45,6 +49,7 @@ type
head*: int ## ring buffer head
numGuns*: int
fitness*: Table[int, seq[GunFitness]] ## keyed by enemy bot ID, indexed by GunId
droppedBullets*: int ## unresolved bullets clobbered by the ring buffer (should stay 0)
proc initTracker*(numGuns: int): VirtualTracker =
result.numGuns = numGuns
@@ -75,6 +80,11 @@ proc spawnBullets*(t: var VirtualTracker, gunId: GunId,
let pred = predictions[binIdx]
let fireDist = hypot(pred.x - state.selfX, pred.y - state.selfY)
let slot = t.head mod MaxBullets
# Measurement integrity: if the slot we are about to overwrite still holds an
# unresolved bullet, that bullet will never be scored. Count it instead of
# silently dropping it (non-zero after a battle means MaxBullets is too small).
if t.bullets[slot].active:
inc t.droppedBullets
t.bullets[slot] = VirtualBullet(
gunId: gunId,
powerBin: binIdx,
@@ -140,20 +150,30 @@ proc tickBullets*(t: var VirtualTracker, state: WorldState,
onResolved(b.gunId, b.powerBin, fe)
b.active = false
proc fitnessFor(t: VirtualTracker, targetId: int): seq[GunFitness] =
proc fitnessFor*(t: VirtualTracker, targetId: int): seq[GunFitness] =
## Returns fitness seq for targetId, or merges all enemies as fallback.
## ponytail: merge is O(enemies*guns*bins), fine for small counts
##
## The fallback is a RECENCY-WEIGHTED AGGREGATE over the last WindowSize
## samples, NOT a pooled rate: each per-enemy window is replayed into one fresh
## window, so once the total exceeds WindowSize the earliest samples are
## overwritten by later ones. Enemies are visited in ascending target-id order
## so the result is identical on every run (std/tables iteration order is hash
## order and therefore nondeterministic).
## ponytail: merge is O(enemies*guns*bins*WindowSize), fine for small counts
if targetId >= 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..<t.numGuns:
for binIdx in 0..<len(PowerBins):
let src = perEnemy[gunId].bins[binIdx]
var dst = addr result[gunId].bins[binIdx]
for k in 0..<min(src.count, WindowSize):
dst[].record(src.hits[k])
result[gunId].bins[binIdx].record(src.hits[k])
proc bestPower*(t: VirtualTracker, gunId: GunId, targetId: int = -1): (int, float) =
## Returns (binIdx, power) with highest power that has >= MinHitRate.