fix(gun_harness): random tiebreak + drop AntiSurfer + raise MinObsBeforeCompete

- bestGun: replace first-index-wins argmax with random pick among guns
  within TieMargin (2%) of best rate. HeadOn at index 0 was silently
  winning every tie, starving Tsetlin/Linear/etc.
- MinObsBeforeCompete 15 -> 50 (Pattern entered competition on noise)
- add MinHitRateFloor 0.10: if no gun clears it, fall back to HeadOn
  instead of selecting the best of a bad field
- ModularBot: remove AntiSurfer gun (0% virtual hit rate everywhere),
  14 -> 13 guns, renumber ids and selection counters
This commit is contained in:
2026-09-20 22:07:49 +02:00
parent c034eb9d25
commit 343e631633
2 changed files with 55 additions and 48 deletions
+21 -5
View File
@@ -5,6 +5,7 @@
import std/math
import std/tables
import std/random
import gun_interface
const
@@ -12,7 +13,9 @@ const
WindowSize* = 100 ## rolling window ticks for fitness
MaxBullets* = 2048 ## hard cap; ponytail: ring buffer, resize if more guns added
MinHitRate* = 0.40 ## 40% threshold for acceptable power selection
MinObsBeforeCompete* = 15 ## min observations before a gun×bin enters competition
MinObsBeforeCompete* = 50 ## min observations before a gun×bin enters competition
TieMargin* = 0.02 ## guns within this hit-rate margin of best are tied
MinHitRateFloor* = 0.10 ## if best gun < this, fall back to gun 0 (HeadOn)
type
GunId* = int ## index into the guns seq
@@ -168,11 +171,11 @@ proc bestGun*(t: VirtualTracker, targetId: int = -1): GunId =
## Guns with fewer than MinObsBeforeCompete observations are skipped
## unless every gun is below threshold (then fall back to best of all).
## Uses per-enemy fitness when targetId >= 0 and data exists; else aggregate.
## Ties (within TieMargin) are broken randomly to avoid index-0 bias.
## ponytail: O(n*bins), fine for small gun counts
let fit = t.fitnessFor(targetId)
proc bestAmong(fit: seq[GunFitness], requireMin: bool): GunId =
var bestRate = -1.0
result = 0
for gunId in 0..<fit.len:
var maxCount = 0
for binIdx in 0..<len(PowerBins):
@@ -180,9 +183,22 @@ proc bestGun*(t: VirtualTracker, targetId: int = -1): GunId =
if requireMin and maxCount < MinObsBeforeCompete: continue
for binIdx in 0..<len(PowerBins):
let r = fit[gunId].bins[binIdx].hitRate()
if r > bestRate:
bestRate = r
result = gunId
if r > bestRate: bestRate = r
# Floor check: if nothing hits well enough, HeadOn is the safe default
if bestRate < MinHitRateFloor: return 0
var tied: seq[GunId]
for gunId in 0..<fit.len:
var maxCount = 0
for binIdx in 0..<len(PowerBins):
maxCount = max(maxCount, fit[gunId].bins[binIdx].count)
if requireMin and maxCount < MinObsBeforeCompete: continue
for binIdx in 0..<len(PowerBins):
let r = fit[gunId].bins[binIdx].hitRate()
if r >= bestRate - TieMargin:
tied.add(gunId)
break
if tied.len == 0: return 0
return tied[rand(tied.len - 1)]
var anyQualifies = false
for gunId in 0..<fit.len: