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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user