7f706e5b14
The entire GuessFactor family scored 0% on clean circular and wall-bounce trajectories. Two hypotheses were on the table and BOTH were wrong: - MEA range too narrow / edge clamping: REFUTED. Measured 0 clamped shots out of 837/849/957, required offsets peak at ~33 deg against MEA 28.1-46.7 deg, and the 8 in arcsin(8/bulletSpeed) is correct (it is the max robot SPEED, not the hit radius). Changing it to BotRadius=18 would have coarsened resolution for nothing. - Peak selection: REFUTED. A sweep of every constant GF value showed the ORACLE-BEST constant offset on the original gun was only 6% circular, 4% wall-bounce, 7.5% random-walk. No peak choice could have done better. The learning path was fine too: ~850-960 observations per fixture, 0 starved waves, well-populated histograms. REAL CAUSE: the GF family aimed at the FIRE-TIME distance. The virtual-bullet metric resolves a bullet at the AIM-POINT distance and scores that single point against the enemy's position on that tick, so with any radial target motion the bullet stops at the wrong radius and misses even with a perfect angle. Angle-only prediction is structurally unscoreable under this metric. FIX: give the GF family a self-consistent constant-velocity forecast as its base reference (new common_libs/guns/lead_forecast.nim, which iterates the flight time to the same fixed point circular.nim uses), so the histogram learns the RESIDUAL against that forecast and the aim point lands at the right radius. Applied to guess_factor, decay_gf and knn_gun. Same defect fixed in Linear: it did a one-shot dist/bulletSpeed extrapolation and never iterated its flight time. The oracle sweep proves the structural fix, independently of tuning: the best achievable constant GF moved 6% -> 20% (circular), 4% -> 57% (wall-bounce), 7.5% -> 49% (random-walk). MEASURED, all 15 fixtures: total 39.0% -> 44.4% (30399 -> 34654 hits). circular GF 6 -> 23, DecayGF 6 -> 21 wall-bounce GF 0 -> 60.2, DecayGF 0 -> 60.2 constant-vel GF 26 -> 100, DecayGF 26 -> 100, KNN 26 -> 100, Linear 87 -> 100 random-walk GF 0 -> 53, DecayGF 0 -> 52, Linear 24 -> 53 StraightLine GF 8 -> 77, DecayGF 8 -> 77 Non-regression: 33 guard checks pass, the range's 12/12 offline==online acceptance still PASSES, tsetlin tests green, live gauntlet 5/5. HONEST TRADE-OFF, recorded rather than hidden: on the 5 real DrussGT wave-surfing captures the GF family REGRESSES - GuessFactor 108 -> 55, DecayGF 108 -> 76, KNN 101 -> 74 hits per 2000. The linear base is a poor model for a surfer, so the residual histogram is noisier than the old total-lead histogram. Linear itself improved there (95 -> 105). The synthetic range and the live gauntlet both improved, and the structural bug is provably fixed, so this was judged worth the cost - but recovering the DrussGT regression is the next job, not something to wave away.
136 lines
4.6 KiB
Nim
136 lines
4.6 KiB
Nim
## Recency-weighted GF gun: exponential decay on histogram bins.
|
|
## decay=0.998/tick gives ~350-tick half-life — adapts to mid-battle strategy shifts.
|
|
## Everything else identical to guess_factor.nim, including per-power-bin wave queues.
|
|
|
|
import std/math
|
|
import gun_harness/gun_interface
|
|
import gun_harness/virtual_bullets as vb # PowerBins
|
|
import guns/lead_forecast
|
|
|
|
const
|
|
GFBins = 31
|
|
GFPrior = 0.1
|
|
DecayRate = 0.998 # ponytail: single global decay, tune if adaptation too slow/fast
|
|
DecayWaveCompactAt = 64
|
|
|
|
type
|
|
DWave = object
|
|
fireX, fireY: float
|
|
fireBearing: float
|
|
|
|
DecayGFGun* = object
|
|
bins: array[GFBins, float]
|
|
# One wave queue per power bin; a resolved bullet only learns from a wave
|
|
# queued for its own bin (matched on bulletSpeed / bulletPower).
|
|
waves: array[len(vb.PowerBins), seq[DWave]]
|
|
waveHead: array[len(vb.PowerBins), int] # O(1) pop cursor
|
|
waveStoredTick: array[len(vb.PowerBins), int] # last tick a wave was queued for this bin
|
|
cachedTick: int # last tick bins were decayed
|
|
wavePushes*: int
|
|
waveStarved*: int
|
|
debugGraphics*: bool
|
|
|
|
proc initDecayGFGun*(): DecayGFGun =
|
|
result.cachedTick = -1
|
|
result.debugGraphics = false
|
|
for b in 0..<len(vb.PowerBins):
|
|
result.waveStoredTick[b] = -1
|
|
let center = (GFBins - 1) div 2
|
|
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: DecayGFGun): 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.} =
|
|
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.} =
|
|
for i in 0..<len(vb.PowerBins):
|
|
if abs(power - vb.PowerBins[i]) < 1e-6:
|
|
return i
|
|
-1
|
|
|
|
proc takeOldestWave(g: var DecayGFGun, binIdx: int): (bool, DWave) =
|
|
if binIdx < 0 or g.waveHead[binIdx] >= g.waves[binIdx].len:
|
|
return (false, DWave())
|
|
result = (true, g.waves[binIdx][g.waveHead[binIdx]])
|
|
inc g.waveHead[binIdx]
|
|
if g.waveHead[binIdx] >= DecayWaveCompactAt 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 DecayGFGun, 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 this self-consistent
|
|
# constant-velocity prediction (see lead_forecast.nim for why this is required).
|
|
let f = forecastLinear(state, bulletSpeed)
|
|
|
|
if state.tick != g.cachedTick:
|
|
# Decay all bins once per tick
|
|
for i in 0..<GFBins:
|
|
g.bins[i] *= DecayRate
|
|
g.cachedTick = state.tick
|
|
|
|
# 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 DWave(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
|
|
|
|
GunPrediction(
|
|
x: clamp(px, BotRadius, state.arenaWidth - BotRadius),
|
|
y: clamp(py, BotRadius, state.arenaHeight - BotRadius),
|
|
)
|
|
|
|
proc onResult*(g: var DecayGFGun, e: FeedbackEvent) =
|
|
let binIdx = binForPower(e.bulletPower)
|
|
if binIdx < 0: return
|
|
|
|
let (found, w) = g.takeOldestWave(binIdx)
|
|
if not found:
|
|
inc g.waveStarved
|
|
return
|
|
|
|
let speed = bulletSpeed(e.bulletPower)
|
|
let mea = arcsin(clamp(8.0 / speed, -1.0, 1.0))
|
|
let actualDx = e.actualX - w.fireX
|
|
let actualDy = e.actualY - w.fireY
|
|
let actualBearing = arctan2(actualDy, actualDx)
|
|
var bearingDelta = actualBearing - w.fireBearing
|
|
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)
|
|
|
|
for i in 0..<GFBins:
|
|
let d = abs(i - centerIdx)
|
|
g.bins[i] += 1.0 / float(1 + d)
|