e2ca2fc7d8
The previous fix (learn the residual against a constant-velocity base) was structurally right but cost us on real wave-surfing movement: GF 108 -> 55, KNN 101 -> 74 on the classic DrussGT captures. Root cause: the linear base is a poor model for a surfer, so the residual histogram is noisier than the old total-lead histogram. FIX: blend the RANGE between a radial-only forecast and the geometric one by radialFrac (the fraction of recent per-tick motion that is radial), keeping the constant-velocity bearing. dist = radialDist + rf*(linearDist - radialDist). New VelocityTracker in common_libs/guns/lead_forecast.nim; the window default is 32 and results were identical at 16 and 40, so it is not tightly tuned. Nine candidate bases were measured and rejected WITH NUMBERS rather than by argument, which is why I trust the winner: velocity scaling 0.8 recovers DrussGT but destroys wall-bounce 241 -> 20 radial-only range excellent DrussGT, wall-bounce 241 -> 140 short-window averaged vel worse than both bases outright hard reversal/speed gates help DrussGT, lose nothing, but weaker than blend radial-fraction blend best on BOTH <- shipped Result (hits per 2000; classic-5 = classic DrussGT captures, tr-5 = the new closed-loop TR captures, synth-10 = the rest): base classic-5 GF/DGF tr-5 GF/DGF synth-10 GF/DGF current(prefix) 108 / 108 41 / 39 1302 / 1302 linear(postfix) 55 / 76 9 / 4 2702 / 2692 BLEND 171 / 100 86 / 87 2717 / 2703 Strictly better than both on classic-5 GF and on every synthetic bucket. The one figure below the old base is classic-5 DecayGF (108 -> 100, -8/2000, within noise) and that is stated plainly rather than hidden. TASK B - enemy energy in learners. KNN gains an 8th feature, enemyEnergy/100, on a FIXED [0,1] scale (not min-max) because threshold behaviour keys off absolute energy. Honest result: it is NEUTRAL on the target fixture (77 vs 77) and roughly neutral in aggregate. The base change, not the feature, moved that fixture. Tsetlin already encoded enemyEnergy and now scores 88/400 on energy-threshold-turner against Linear's 43/400 - a 2x margin, which is the 'can a TM learn a high-level pattern' question answered in gun form. TASK C - is the virtual-bullet metric itself faithful? Quantified: scoring the bullet's PATH against BotRadius instead of the single point at aim distance raises every gun by +31% (GF) to +86% (HeadOn), so the current model is PESSIMISTIC, and it RE-RANKS materially: Linear 9th -> 6th, AvgLead 7th -> 3rd, GuessFactor 4th -> 9th, DecayGF 6th -> 12th. The 12/12 offline==online acceptance still holds under the path model (verified with a temporary env hook driving both sides), so no red flag. VERDICT: do NOT switch. The point model is the standard virtual-bullet PREDICTION-ACCURACY fitness - the bullet must arrive at the predicted point at the right time - while the path model measures hypothetical hit chance against a target that never dodges, and in open-loop fixtures it over-credits directional guns (HeadOn 35% on DrussGT, 100% on constant-velocity) for exactly that reason. The models differ materially but the current one is not shown to be unfaithful FOR ITS PURPOSE. Because the metric drives gun SELECTION, this is now being A/B'd against real hit rate versus the live DrussGT boss, which is the only ground truth we have. Verified: 20 fixtures 35636/104000 (34.3%); 33 guard checks; 12/12 acceptance; tsetlin tests green; live gauntlet 5/5.
163 lines
6.5 KiB
Nim
163 lines
6.5 KiB
Nim
## 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; 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
|
|
import guns/lead_forecast
|
|
|
|
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
|
|
fireX, fireY: float
|
|
fireBearing: float # atan2(enemyY-selfY, enemyX-selfX) at fire tick (rad)
|
|
# mea not stored — recomputed from FeedbackEvent.bulletPower at resolution time
|
|
|
|
GFGun* = object
|
|
bins: array[GFBins, float]
|
|
# 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
|
|
vt: VelocityTracker # enemy velocity history (base selection)
|
|
cachedTick: int # last tick the velocity tracker was advanced
|
|
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.debugGraphics = false
|
|
result.cachedTick = -1
|
|
for b in 0..<len(vb.PowerBins):
|
|
result.waveStoredTick[b] = -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:
|
|
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)
|
|
|
|
proc indexToGF(idx: int): float {.inline.} =
|
|
float(idx) / float(GFBins - 1) * 2.0 - 1.0
|
|
|
|
proc peakBin(g: GFGun): int =
|
|
var best = 0
|
|
for i in 1..<GFBins:
|
|
if g.bins[i] > g.bins[best]:
|
|
best = i
|
|
best
|
|
|
|
proc binForSpeed(spd: float): int {.inline.} =
|
|
## Map a virtual-bullet speed back to its power-bin index. All four bin speeds
|
|
## are exactly representable floats; the epsilon is belt-and-braces only.
|
|
for i in 0..<len(vb.PowerBins):
|
|
if abs(spd - bulletSpeed(vb.PowerBins[i])) < 1e-6:
|
|
return i
|
|
-1
|
|
|
|
proc binForPower(power: float): int {.inline.} =
|
|
## Map a FeedbackEvent.bulletPower back to its power-bin index.
|
|
for i in 0..<len(vb.PowerBins):
|
|
if abs(power - vb.PowerBins[i]) < 1e-6:
|
|
return i
|
|
-1
|
|
|
|
proc takeOldestWave(g: var GFGun, binIdx: int): (bool, Wave) =
|
|
## Pop the oldest unresolved wave for this bin (O(1) amortized via waveHead).
|
|
## Returns (false, default) when the bin's queue is empty.
|
|
if binIdx < 0 or g.waveHead[binIdx] >= 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)
|
|
|
|
let mea = arcsin(clamp(8.0 / bulletSpeed, -1.0, 1.0))
|
|
# Base forecast: the GF learns the residual against a self-consistent base
|
|
# prediction, so the aim point sits at the radius the bullet actually travels
|
|
# to (see lead_forecast.nim for why this is required, and why the range is
|
|
# radial-fraction blended rather than a plain constant-velocity lead).
|
|
if state.tick != g.cachedTick:
|
|
g.cachedTick = state.tick
|
|
g.vt.observe(state)
|
|
let f = forecastRadialBlend(state, bulletSpeed, g.vt)
|
|
|
|
# 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: f.bearing,
|
|
)
|
|
g.waveStoredTick[binIdx] = state.tick
|
|
inc g.wavePushes
|
|
|
|
let peak = g.peakBin()
|
|
let peakGF = indexToGF(peak)
|
|
let gfAngle = f.bearing + peakGF * mea
|
|
let px = state.selfX + cos(gfAngle) * f.dist
|
|
let py = state.selfY + sin(gfAngle) * f.dist
|
|
|
|
when DebugGF:
|
|
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),
|
|
y: clamp(py, BotRadius, state.arenaHeight - BotRadius),
|
|
)
|
|
|
|
proc onResult*(g: var GFGun, e: FeedbackEvent) =
|
|
## 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
|
|
|
|
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)
|
|
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
|
|
let actualBearing = arctan2(actualDy, actualDx)
|
|
var bearingDelta = actualBearing - w.fireBearing
|
|
# Normalize to [-PI, PI]
|
|
while bearingDelta > PI: bearingDelta -= 2.0*PI
|
|
while bearingDelta < -PI: bearingDelta += 2.0*PI
|
|
|
|
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)
|
|
g.bins[i] += 1.0 / float(1 + dist)
|