fix(guns): GF family aimed at the wrong RADIUS, not the wrong angle

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.
This commit is contained in:
2026-09-21 00:56:02 +02:00
parent 8e2be6a4c6
commit 7f706e5b14
5 changed files with 101 additions and 40 deletions
+10 -10
View File
@@ -5,6 +5,7 @@
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
@@ -86,11 +87,11 @@ proc predict*(g: var GFGun, state: WorldState, bulletSpeed: float): GunPredictio
if bulletSpeed <= 0.0:
return GunPrediction(x: state.enemyX, y: state.enemyY)
let dx = state.enemyX - state.selfX
let dy = state.enemyY - state.selfY
let dist = sqrt(dx*dx + dy*dy)
let bearing = arctan2(dy, dx)
let mea = arcsin(clamp(8.0 / bulletSpeed, -1.0, 1.0))
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, so the aim point sits at the radius the bullet
# actually travels to (see lead_forecast.nim for why this is required).
let f = forecastLinear(state, bulletSpeed)
# 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.
@@ -99,17 +100,16 @@ proc predict*(g: var GFGun, state: WorldState, bulletSpeed: float): GunPredictio
g.waves[binIdx].add Wave(
fireX: state.selfX,
fireY: state.selfY,
fireBearing: bearing,
fireBearing: f.bearing,
)
g.waveStoredTick[binIdx] = state.tick
inc g.wavePushes
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
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}"