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:
@@ -5,6 +5,7 @@
|
|||||||
import std/math
|
import std/math
|
||||||
import gun_harness/gun_interface
|
import gun_harness/gun_interface
|
||||||
import gun_harness/virtual_bullets as vb # PowerBins
|
import gun_harness/virtual_bullets as vb # PowerBins
|
||||||
|
import guns/lead_forecast
|
||||||
|
|
||||||
const
|
const
|
||||||
GFBins = 31
|
GFBins = 31
|
||||||
@@ -78,11 +79,10 @@ proc predict*(g: var DecayGFGun, state: WorldState, bulletSpeed: float): GunPred
|
|||||||
if bulletSpeed <= 0.0:
|
if bulletSpeed <= 0.0:
|
||||||
return GunPrediction(x: state.enemyX, y: state.enemyY)
|
return GunPrediction(x: state.enemyX, y: state.enemyY)
|
||||||
|
|
||||||
let dx = state.enemyX - state.selfX
|
let mea = arcsin(clamp(8.0 / bulletSpeed, -1.0, 1.0))
|
||||||
let dy = state.enemyY - state.selfY
|
# Base forecast: the GF learns the residual against this self-consistent
|
||||||
let dist = sqrt(dx*dx + dy*dy)
|
# constant-velocity prediction (see lead_forecast.nim for why this is required).
|
||||||
let bearing = arctan2(dy, dx)
|
let f = forecastLinear(state, bulletSpeed)
|
||||||
let mea = arcsin(clamp(8.0 / bulletSpeed, -1.0, 1.0))
|
|
||||||
|
|
||||||
if state.tick != g.cachedTick:
|
if state.tick != g.cachedTick:
|
||||||
# Decay all bins once per tick
|
# Decay all bins once per tick
|
||||||
@@ -94,15 +94,15 @@ proc predict*(g: var DecayGFGun, state: WorldState, bulletSpeed: float): GunPred
|
|||||||
# call for the selected bin lands on the same tick and reuses the queued wave.
|
# call for the selected bin lands on the same tick and reuses the queued wave.
|
||||||
let binIdx = binForSpeed(bulletSpeed)
|
let binIdx = binForSpeed(bulletSpeed)
|
||||||
if binIdx >= 0 and g.waveStoredTick[binIdx] != state.tick:
|
if binIdx >= 0 and g.waveStoredTick[binIdx] != state.tick:
|
||||||
g.waves[binIdx].add DWave(fireX: state.selfX, fireY: state.selfY, fireBearing: bearing)
|
g.waves[binIdx].add DWave(fireX: state.selfX, fireY: state.selfY, fireBearing: f.bearing)
|
||||||
g.waveStoredTick[binIdx] = state.tick
|
g.waveStoredTick[binIdx] = state.tick
|
||||||
inc g.wavePushes
|
inc g.wavePushes
|
||||||
|
|
||||||
let peak = g.peakBin()
|
let peak = g.peakBin()
|
||||||
let peakGF = indexToGF(peak)
|
let peakGF = indexToGF(peak)
|
||||||
let gfAngle = bearing + peakGF * mea
|
let gfAngle = f.bearing + peakGF * mea
|
||||||
let px = state.selfX + cos(gfAngle) * dist
|
let px = state.selfX + cos(gfAngle) * f.dist
|
||||||
let py = state.selfY + sin(gfAngle) * dist
|
let py = state.selfY + sin(gfAngle) * f.dist
|
||||||
|
|
||||||
GunPrediction(
|
GunPrediction(
|
||||||
x: clamp(px, BotRadius, state.arenaWidth - BotRadius),
|
x: clamp(px, BotRadius, state.arenaWidth - BotRadius),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import std/[math, strformat]
|
import std/[math, strformat]
|
||||||
import gun_harness/gun_interface
|
import gun_harness/gun_interface
|
||||||
import gun_harness/virtual_bullets as vb # PowerBins: the four power bins the harness spawns
|
import gun_harness/virtual_bullets as vb # PowerBins: the four power bins the harness spawns
|
||||||
|
import guns/lead_forecast
|
||||||
|
|
||||||
const
|
const
|
||||||
GFBins = 31
|
GFBins = 31
|
||||||
@@ -86,11 +87,11 @@ proc predict*(g: var GFGun, state: WorldState, bulletSpeed: float): GunPredictio
|
|||||||
if bulletSpeed <= 0.0:
|
if bulletSpeed <= 0.0:
|
||||||
return GunPrediction(x: state.enemyX, y: state.enemyY)
|
return GunPrediction(x: state.enemyX, y: state.enemyY)
|
||||||
|
|
||||||
let dx = state.enemyX - state.selfX
|
let mea = arcsin(clamp(8.0 / bulletSpeed, -1.0, 1.0))
|
||||||
let dy = state.enemyY - state.selfY
|
# Base forecast: the GF learns the residual against this self-consistent
|
||||||
let dist = sqrt(dx*dx + dy*dy)
|
# constant-velocity prediction, so the aim point sits at the radius the bullet
|
||||||
let bearing = arctan2(dy, dx)
|
# actually travels to (see lead_forecast.nim for why this is required).
|
||||||
let mea = arcsin(clamp(8.0 / bulletSpeed, -1.0, 1.0))
|
let f = forecastLinear(state, bulletSpeed)
|
||||||
|
|
||||||
# Queue at most one wave per (tick, power bin). The fire site's extra predict()
|
# 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.
|
# 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(
|
g.waves[binIdx].add Wave(
|
||||||
fireX: state.selfX,
|
fireX: state.selfX,
|
||||||
fireY: state.selfY,
|
fireY: state.selfY,
|
||||||
fireBearing: bearing,
|
fireBearing: f.bearing,
|
||||||
)
|
)
|
||||||
g.waveStoredTick[binIdx] = state.tick
|
g.waveStoredTick[binIdx] = state.tick
|
||||||
inc g.wavePushes
|
inc g.wavePushes
|
||||||
|
|
||||||
let peak = g.peakBin()
|
let peak = g.peakBin()
|
||||||
let peakGF = indexToGF(peak)
|
let peakGF = indexToGF(peak)
|
||||||
let gfAngle = bearing + peakGF * mea
|
let gfAngle = f.bearing + peakGF * mea
|
||||||
# Aim from self at gfAngle, at current dist (angular targeting)
|
let px = state.selfX + cos(gfAngle) * f.dist
|
||||||
let px = state.selfX + cos(gfAngle) * dist
|
let py = state.selfY + sin(gfAngle) * f.dist
|
||||||
let py = state.selfY + sin(gfAngle) * dist
|
|
||||||
|
|
||||||
when DebugGF:
|
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}"
|
echo fmt"[gf-dbg] predict: peakGF={peakGF:.2f} peakBin={peak} mea={radToDeg(mea):.1f}° aimAngle={radToDeg(gfAngle):.1f}° waves={g.waves[binIdx].len}"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import std/[math]
|
import std/[math]
|
||||||
import gun_harness/gun_interface
|
import gun_harness/gun_interface
|
||||||
import gun_harness/virtual_bullets as vb # PowerBins
|
import gun_harness/virtual_bullets as vb # PowerBins
|
||||||
|
import guns/lead_forecast
|
||||||
|
|
||||||
const
|
const
|
||||||
MaxObs = 2000 # ring-buffer cap
|
MaxObs = 2000 # ring-buffer cap
|
||||||
@@ -148,9 +149,11 @@ proc predict*(g: var KNNGun, state: WorldState, bulletSpd: float): GunPrediction
|
|||||||
|
|
||||||
let dx = state.enemyX - state.selfX
|
let dx = state.enemyX - state.selfX
|
||||||
let dy = state.enemyY - state.selfY
|
let dy = state.enemyY - state.selfY
|
||||||
let dist = sqrt(dx*dx + dy*dy)
|
|
||||||
let bearing = arctan2(dy, dx)
|
let bearing = arctan2(dy, dx)
|
||||||
let mea = arcsin(clamp(8.0 / bulletSpd, -1.0, 1.0))
|
let mea = arcsin(clamp(8.0 / bulletSpd, -1.0, 1.0))
|
||||||
|
# Base forecast: the KNN learns the GF residual against this self-consistent
|
||||||
|
# constant-velocity prediction (see lead_forecast.nim for why this is required).
|
||||||
|
let f = forecastLinear(state, bulletSpd)
|
||||||
|
|
||||||
# Track direction change — update state once per tick
|
# Track direction change — update state once per tick
|
||||||
if state.tick != g.cachedTick:
|
if state.tick != g.cachedTick:
|
||||||
@@ -182,15 +185,19 @@ proc predict*(g: var KNNGun, state: WorldState, bulletSpd: float): GunPrediction
|
|||||||
# call for the selected bin lands on the same tick and reuses the queued wave.
|
# call for the selected bin lands on the same tick and reuses the queued wave.
|
||||||
let binIdx = binForSpeed(bulletSpd)
|
let binIdx = binForSpeed(bulletSpd)
|
||||||
if binIdx >= 0 and g.waveStoredTick[binIdx] != state.tick:
|
if binIdx >= 0 and g.waveStoredTick[binIdx] != state.tick:
|
||||||
g.waves[binIdx].add g.tickWave
|
# fireBearing is the base forecast bearing, which is per-power (flight time
|
||||||
|
# differs per bin); override the shared per-tick template here.
|
||||||
|
var w = g.tickWave
|
||||||
|
w.fireBearing = f.bearing
|
||||||
|
g.waves[binIdx].add w
|
||||||
g.waveStoredTick[binIdx] = state.tick
|
g.waveStoredTick[binIdx] = state.tick
|
||||||
inc g.wavePushes
|
inc g.wavePushes
|
||||||
|
|
||||||
# Cold start — no data yet
|
# Cold start — no data yet: fall back to the self-consistent linear forecast.
|
||||||
if g.obs.len == 0:
|
if g.obs.len == 0:
|
||||||
return GunPrediction(
|
return GunPrediction(
|
||||||
x: clamp(state.selfX + cos(bearing) * dist, BotRadius, state.arenaWidth - BotRadius),
|
x: clamp(f.x, BotRadius, state.arenaWidth - BotRadius),
|
||||||
y: clamp(state.selfY + sin(bearing) * dist, BotRadius, state.arenaHeight - BotRadius),
|
y: clamp(f.y, BotRadius, state.arenaHeight - BotRadius),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Build query feature vector (use current state)
|
# Build query feature vector (use current state)
|
||||||
@@ -202,8 +209,8 @@ proc predict*(g: var KNNGun, state: WorldState, bulletSpd: float): GunPrediction
|
|||||||
let n = g.obs.len
|
let n = g.obs.len
|
||||||
if n < 5:
|
if n < 5:
|
||||||
return GunPrediction(
|
return GunPrediction(
|
||||||
x: clamp(state.selfX + cos(bearing) * dist, BotRadius, state.arenaWidth - BotRadius),
|
x: clamp(f.x, BotRadius, state.arenaWidth - BotRadius),
|
||||||
y: clamp(state.selfY + sin(bearing) * dist, BotRadius, state.arenaHeight - BotRadius),
|
y: clamp(f.y, BotRadius, state.arenaHeight - BotRadius),
|
||||||
)
|
)
|
||||||
let k = max(5, min(int(sqrt(float(n))), KCap))
|
let k = max(5, min(int(sqrt(float(n))), KCap))
|
||||||
|
|
||||||
@@ -235,8 +242,8 @@ proc predict*(g: var KNNGun, state: WorldState, bulletSpd: float): GunPrediction
|
|||||||
|
|
||||||
if filled == 0:
|
if filled == 0:
|
||||||
return GunPrediction(
|
return GunPrediction(
|
||||||
x: clamp(state.selfX + cos(bearing) * dist, BotRadius, state.arenaWidth - BotRadius),
|
x: clamp(f.x, BotRadius, state.arenaWidth - BotRadius),
|
||||||
y: clamp(state.selfY + sin(bearing) * dist, BotRadius, state.arenaHeight - BotRadius),
|
y: clamp(f.y, BotRadius, state.arenaHeight - BotRadius),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Inverse-distance weights, Gaussian (same as DrussGT getBearingGaussian)
|
# Inverse-distance weights, Gaussian (same as DrussGT getBearingGaussian)
|
||||||
@@ -268,9 +275,9 @@ proc predict*(g: var KNNGun, state: WorldState, bulletSpd: float): GunPrediction
|
|||||||
bestScore = score
|
bestScore = score
|
||||||
bestGF = testGF
|
bestGF = testGF
|
||||||
|
|
||||||
let aimAngle = bearing + clamp(bestGF, -1.0, 1.0) * mea
|
let aimAngle = f.bearing + clamp(bestGF, -1.0, 1.0) * mea
|
||||||
let px = state.selfX + cos(aimAngle) * dist
|
let px = state.selfX + cos(aimAngle) * f.dist
|
||||||
let py = state.selfY + sin(aimAngle) * dist
|
let py = state.selfY + sin(aimAngle) * f.dist
|
||||||
|
|
||||||
GunPrediction(
|
GunPrediction(
|
||||||
x: clamp(px, BotRadius, state.arenaWidth - BotRadius),
|
x: clamp(px, BotRadius, state.arenaWidth - BotRadius),
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
## Shared self-consistent constant-velocity forecast used by the Linear gun and
|
||||||
|
## the GuessFactor family (guess_factor, decay_gf, knn_gun).
|
||||||
|
##
|
||||||
|
## Why this exists: the virtual-bullet metric resolves a bullet when its travel
|
||||||
|
## distance reaches the distance to its aim point, then scores that single point
|
||||||
|
## against the enemy's position on that tick. A gun that places its aim point at
|
||||||
|
## the FIRE-time distance therefore stops at the wrong radius whenever the target
|
||||||
|
## has moved radially over the flight, and misses even when its angle is
|
||||||
|
## perfect. Measured symptoms:
|
||||||
|
## * the whole GF family scored ~0% on the circular/wall-bounce/random-walk
|
||||||
|
## fixtures while the model-fitting guns scored 40-100%;
|
||||||
|
## * the Linear gun scored 87% (p3.0 = 63/100) on the constant-velocity
|
||||||
|
## fixture where a self-consistent forecast scores 100%.
|
||||||
|
## The GF angle range was never clamped (0/837 shots), so the earlier
|
||||||
|
## "MEA too narrow" hypothesis was wrong.
|
||||||
|
##
|
||||||
|
## Fix: iterate the flight time until the predicted point sits at the distance
|
||||||
|
## the bullet actually travels (the same fixed point circular.nim uses). The GF
|
||||||
|
## family additionally measures its histogram as the residual of the actual
|
||||||
|
## bearing against this forecast's bearing, so it learns the deviation from a
|
||||||
|
## base model instead of having to encode the whole lead angle.
|
||||||
|
##
|
||||||
|
## Coordinate system: 0° = East, CCW positive (Tank Royale standard).
|
||||||
|
|
||||||
|
import std/math
|
||||||
|
import gun_harness/gun_interface
|
||||||
|
|
||||||
|
type
|
||||||
|
BaseForecast* = object
|
||||||
|
x*, y*: float ## absolute predicted enemy position
|
||||||
|
dist*: float ## distance from shooter to the predicted position
|
||||||
|
bearing*: float ## bearing from shooter to the predicted position (rad)
|
||||||
|
|
||||||
|
proc forecastLinear*(state: WorldState, bulletSpeed: float): BaseForecast =
|
||||||
|
## Constant-velocity forecast with self-consistent flight time. The enemy is
|
||||||
|
## assumed to keep its current heading/speed; the flight time is the fixed
|
||||||
|
## point t = |predictedPos(t) - self| / bulletSpeed (5 iterations, matching
|
||||||
|
## circular.nim). Enemy speed (< 8 px/tick) is always below bulletSpeed
|
||||||
|
## (>= 11), so the iteration contracts.
|
||||||
|
let d0 = hypot(state.enemyX - state.selfX, state.enemyY - state.selfY)
|
||||||
|
let hr = degToRad(state.enemyHeading)
|
||||||
|
let v = state.enemySpeed
|
||||||
|
var t = if bulletSpeed > 0.0: d0 / bulletSpeed else: 0.0
|
||||||
|
var ex = state.enemyX
|
||||||
|
var ey = state.enemyY
|
||||||
|
for _ in 0..4:
|
||||||
|
ex = state.enemyX + cos(hr) * v * t
|
||||||
|
ey = state.enemyY + sin(hr) * v * t
|
||||||
|
if bulletSpeed > 0.0:
|
||||||
|
t = hypot(ex - state.selfX, ey - state.selfY) / bulletSpeed
|
||||||
|
result.x = ex
|
||||||
|
result.y = ey
|
||||||
|
result.dist = hypot(ex - state.selfX, ey - state.selfY)
|
||||||
|
result.bearing = arctan2(ey - state.selfY, ex - state.selfX)
|
||||||
@@ -3,20 +3,20 @@
|
|||||||
|
|
||||||
import std/math
|
import std/math
|
||||||
import gun_harness/gun_interface
|
import gun_harness/gun_interface
|
||||||
|
import guns/lead_forecast
|
||||||
|
|
||||||
type LinearGun* = object
|
type LinearGun* = object
|
||||||
debugGraphics*: bool
|
debugGraphics*: bool
|
||||||
|
|
||||||
proc predict*(g: var LinearGun, state: WorldState, bulletSpeed: float): GunPrediction =
|
proc predict*(g: var LinearGun, state: WorldState, bulletSpeed: float): GunPrediction =
|
||||||
let dist = hypot(state.enemyX - state.selfX, state.enemyY - state.selfY)
|
# Self-consistent constant-velocity forecast: iterate the flight time until the
|
||||||
let ticksToArrive = dist / bulletSpeed
|
# predicted point sits at the distance the virtual bullet actually travels.
|
||||||
let headingRad = degToRad(state.enemyHeading)
|
# Without the iteration the bullet resolves early/late whenever the target
|
||||||
var px = state.enemyX + cos(headingRad) * state.enemySpeed * ticksToArrive
|
# moves radially, which is why this gun scored 87% (p3.0 = 63/100) on the
|
||||||
var py = state.enemyY + sin(headingRad) * state.enemySpeed * ticksToArrive
|
# constant-velocity fixture where a self-consistent forecast scores 100%.
|
||||||
# Clamp to arena bounds
|
let f = forecastLinear(state, bulletSpeed)
|
||||||
px = clamp(px, 0.0, state.arenaWidth)
|
GunPrediction(x: clamp(f.x, 0.0, state.arenaWidth),
|
||||||
py = clamp(py, 0.0, state.arenaHeight)
|
y: clamp(f.y, 0.0, state.arenaHeight))
|
||||||
GunPrediction(x: px, y: py)
|
|
||||||
|
|
||||||
proc onResult*(g: var LinearGun, e: FeedbackEvent) =
|
proc onResult*(g: var LinearGun, e: FeedbackEvent) =
|
||||||
discard # analytical gun — no learning
|
discard # analytical gun — no learning
|
||||||
|
|||||||
Reference in New Issue
Block a user