fix(guns): recover the DrussGT regression with a radial-fraction range blend

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.
This commit is contained in:
2026-09-21 02:14:30 +02:00
parent 17c99f542f
commit e2ca2fc7d8
4 changed files with 158 additions and 30 deletions
+7 -4
View File
@@ -25,6 +25,7 @@ type
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
vt: VelocityTracker # enemy velocity history (base selection)
cachedTick: int # last tick bins were decayed
wavePushes*: int
waveStarved*: int
@@ -80,15 +81,17 @@ proc predict*(g: var DecayGFGun, state: WorldState, bulletSpeed: float): GunPred
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:
g.cachedTick = state.tick
g.vt.observe(state)
# Decay all bins once per tick
for i in 0..<GFBins:
g.bins[i] *= DecayRate
g.cachedTick = state.tick
# Base forecast: the GF learns the residual against a self-consistent base
# prediction (see lead_forecast.nim).
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.