fix(GFGun): head-on prior + per-bin mea in onResult

- Cold-start bug: uniform bins[0..30]=0.1 made peakBin() always return
  0 (first-wins tie), giving GF=-1 (max CW escape) before any learning.
  Fixed with a triangular head-on bump at bin 15 (GF=0) as the prior.
- onResult now recomputes mea from FeedbackEvent.bulletPower instead of
  the stale first-bin mea cached by predict; correct per-power-bin GF.
- Add DebugGF const (default false) with [gf-dbg] echoes in predict/onResult.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-09-20 01:05:22 +02:00
parent 1ed7797cb6
commit 78723fce13
+24 -8
View File
@@ -2,18 +2,19 @@
## Bins: 31, ranging GF -1 (max CW escape) to +1 (max CCW escape).
## Learns from virtual bullet outcomes; caches wave state per-tick.
import std/math
import std/[math, strformat]
import gun_harness/gun_interface
const
GFBins = 31
GFPrior = 0.1
GFBins = 31
GFPrior = 0.1
DebugGF* = false
type
Wave = object
fireX, fireY: float
fireBearing: float # atan2(enemyY-selfY, enemyX-selfX) at fire tick (rad)
mea: float # max escape angle (rad)
# mea not stored — recomputed from FeedbackEvent.bulletPower at resolution time
GFGun* = object
bins: array[GFBins, float]
@@ -24,8 +25,12 @@ type
proc initGFGun*(): GFGun =
result.cachedTick = -1
# Seed with a head-on prior: triangular bump at bin 15 (GF=0).
# Prevents the cold-start tie-break to GF=-1 (bin 0) that poisons early fitness.
let center = (GFBins - 1) div 2 # = 15
for i in 0..<GFBins:
result.bins[i] = GFPrior
let d = abs(i - center)
result.bins[i] = GFPrior + 0.5 / float(1 + d)
proc gfToIndex(gf: float): int {.inline.} =
clamp(int(round((gf + 1.0) * 0.5 * float(GFBins - 1))), 0, GFBins - 1)
@@ -60,15 +65,19 @@ proc predict*(g: var GFGun, state: WorldState, bulletSpeed: float): GunPredictio
fireX: state.selfX,
fireY: state.selfY,
fireBearing: bearing,
mea: mea,
)
g.cachedWaveStored = true
let gfAngle = bearing + indexToGF(g.peakBin()) * mea
let peak = g.peakBin()
let peakGF = indexToGF(peak)
let gfAngle = bearing + peakGF * mea
# Aim from self at gfAngle, at current dist (angular targeting)
let px = state.selfX + cos(gfAngle) * dist
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}"
GunPrediction(
x: clamp(px, BotRadius, state.arenaWidth - BotRadius),
y: clamp(py, BotRadius, state.arenaHeight - BotRadius),
@@ -87,6 +96,10 @@ proc onResult*(g: var GFGun, e: FeedbackEvent) =
let w = g.waves[0]
g.waves.delete(0)
# Recompute mea from the actual bullet power (correct per-bin, not the cached first-bin mea)
let speed = bulletSpeed(e.bulletPower)
let mea = arcsin(clamp(8.0 / speed, -1.0, 1.0))
# Compute actual bearing from fire position to where the enemy actually was
let actualDx = e.actualX - w.fireX
let actualDy = e.actualY - w.fireY
@@ -96,9 +109,12 @@ proc onResult*(g: var GFGun, e: FeedbackEvent) =
while bearingDelta > PI: bearingDelta -= 2.0*PI
while bearingDelta < -PI: bearingDelta += 2.0*PI
let gf = if w.mea > 1e-10: clamp(bearingDelta / w.mea, -1.0, 1.0) else: 0.0
let gf = if mea > 1e-10: clamp(bearingDelta / mea, -1.0, 1.0) else: 0.0
let centerIdx = gfToIndex(gf)
when DebugGF:
echo fmt"[gf-dbg] onResult: fireBearing={radToDeg(w.fireBearing):.1f}° actualBearing={radToDeg(actualBearing):.1f}° delta={radToDeg(bearingDelta):.1f}° MEA={radToDeg(mea):.1f}° GF={gf:.2f} peakBin={centerIdx}"
# Triangular smoothing kernel over adjacent bins
for i in 0..<GFBins:
let dist = abs(i - centerIdx)