Files
SirRoboGarage/common_libs/gun_harness/virtual_bullets.nim
T
SirStone e53690036b fix(guns): speed-sensitive caches, dead stop-shot branch, exact TM trace pairing
Four guns cached a whole prediction per tick while predict() is called once
per power bin, so every bin after the first (and the real fired shot, which
shares lastState) reused the power-1.0 lead. Fixed by caching only the
speed-INDEPENDENT derived state and recomputing the lead per requested speed:
- stop_shot: also fixes prevSpeed being written before it was read, which
  made abs(speed) < abs(prev) permanently false and the entire
  stop-prediction branch unreachable (it was just Linear).
- displacement: the cache key included bulletSpeed, so the guard missed on
  all four bins and the 15-tick window advanced ~4x/tick, making the
  inferred velocity ~4x too small.
- averaged_lead: tick cache removed outright. pattern_matcher: split into
  speed-independent match+path and per-call lead.

FeedbackEvent gains fireTick/powerBin (additive; only virtual_bullets
constructs one) so guns can pair feedback to the exact shot instead of
guessing by coordinates. tsetlin uses it: traces are now keyed exactly by
(fireTick, powerBin) with a 1024-slot ring, and the 10-frame window shifts
at most once per tick (it was shifting ~4-5x/tick, so isWarmedUp tripped
after ~2 ticks).

KNOWN INCOMPLETE: tsetlin still does not diverge from Linear in battle. The
two named bugs are fixed (a 600-tick sim shows trainedShots=2141,
traceMisses=0, and a fixed-input probe converges to a 9.6px correction), but
the TM's clause feedback itself is broken: ~131 of 1740 literals end up
included per clause, so its conjunction never fires. Sweeping TM_S,
TM_N_CLAUSES and a two-branch Type-I update did not change the correction
from 0. Needs a real TM fix or removal, not another bug fix.

First-ever guard tests for the gun selector: common_libs/tests/
test_gun_harness.nim (14 checks, headless, no Java). There were none before,
which is how six broken guns survived a full analysis cycle. Against the
previous HEAD, 5 of these checks FAIL - that is the regression guard.
2026-09-20 22:47:26 +02:00

249 lines
10 KiB
Nim
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## Virtual bullet tracker.
## Spawns virtual bullets per gun×power bin every tick (no real firing).
## Resolves by travel distance. Rolling window fitness per gun×power.
## Calls onResult() on the owning gun when a bullet resolves.
import std/math
import std/tables
import std/random
import std/algorithm
import gun_interface
const
PowerBins* = [1.0, 1.5, 2.0, 3.0] ## 4 bins; ponytail: fixed array, add runtime config if needed
WindowSize* = 100 ## rolling window ticks for fitness
MaxBullets* = 8192 ## hard cap; ring buffer. 52 spawns/tick and a
## full-map long shot (~90 ticks) need ~4700 slots;
## 8192 wraps only after ~157 ticks. Each VirtualBullet
## is 88 bytes, so this array costs ~704 KiB.
MinHitRate* = 0.40 ## 40% threshold for acceptable power selection
MinObsBeforeCompete* = 50 ## min observations before a gun×bin enters competition
TieMargin* = 0.02 ## guns within this hit-rate margin of best are tied
MinHitRateFloor* = 0.10 ## if best gun < this, fall back to gun 0 (HeadOn)
type
GunId* = int ## index into the guns seq
VirtualBullet* = object
gunId*: GunId
powerBin*: int ## index into PowerBins
targetId*: int ## enemy bot ID this bullet was aimed at
fireTick*: int ## tick this bullet was spawned; lets a gun pair its
## predict() trace with the exact resolution event
fireX*, fireY*: float
aimX*, aimY*: float ## predicted target (absolute)
bulletSpeed*: float
travelDist*: float ## accumulated px so far
fireDist*: float ## distance to target at fire time
active*: bool
FitnessWindow* = object
## Ring buffer of hit booleans.
hits*: array[WindowSize, bool]
count*: int ## total samples so far (capped at WindowSize for rate)
head*: int
GunFitness* = object
bins*: array[len(PowerBins), FitnessWindow]
VirtualTracker* = object
bullets*: array[MaxBullets, VirtualBullet]
head*: int ## ring buffer head
numGuns*: int
fitness*: Table[int, seq[GunFitness]] ## keyed by enemy bot ID, indexed by GunId
droppedBullets*: int ## unresolved bullets clobbered by the ring buffer (should stay 0)
proc initTracker*(numGuns: int): VirtualTracker =
result.numGuns = numGuns
proc hitRate*(fw: FitnessWindow): float =
## Returns fraction of hits in the rolling window. 0.0 when no data.
if fw.count == 0: return 0.0
let n = min(fw.count, WindowSize)
var h = 0
for i in 0..<n: h += (if fw.hits[i]: 1 else: 0)
result = h.float / n.float
proc record(fw: var FitnessWindow, hit: bool) =
fw.hits[fw.head] = hit
fw.head = (fw.head + 1) mod WindowSize
inc fw.count
proc spawnBullets*(t: var VirtualTracker, gunId: GunId,
predictions: array[len(PowerBins), GunPrediction],
state: WorldState, targetId: int) =
## Call once per gun per tick with predictions for all power bins.
## Lazily creates fitness entry for targetId on first spawn.
if targetId notin t.fitness:
t.fitness[targetId] = newSeq[GunFitness](t.numGuns)
for binIdx in 0..<len(PowerBins):
let power = PowerBins[binIdx]
let speed = bulletSpeed(power)
let pred = predictions[binIdx]
let fireDist = hypot(pred.x - state.selfX, pred.y - state.selfY)
let slot = t.head mod MaxBullets
# Measurement integrity: if the slot we are about to overwrite still holds an
# unresolved bullet, that bullet will never be scored. Count it instead of
# silently dropping it (non-zero after a battle means MaxBullets is too small).
if t.bullets[slot].active:
inc t.droppedBullets
t.bullets[slot] = VirtualBullet(
gunId: gunId,
powerBin: binIdx,
targetId: targetId,
fireTick: state.tick,
fireX: state.selfX,
fireY: state.selfY,
aimX: pred.x,
aimY: pred.y,
bulletSpeed: speed,
travelDist: 0.0,
fireDist: fireDist,
active: true,
)
t.head = (t.head + 1) mod MaxBullets
const StaleTicks* = 20 ## discard bullet if target not seen within this many ticks
proc tickBullets*(t: var VirtualTracker, state: WorldState,
enemies: Table[int, tuple[x, y: float, lastSeenTick: int, alive: bool]],
onResolved: proc(gunId: GunId, binIdx: int, e: FeedbackEvent)) =
## Advance all active bullets one tick. Resolve when bullet reaches target distance.
## Uses per-target enemy position from enemies table for accurate miss distance.
## Discards bullet without scoring if target is dead or stale (> StaleTicks).
for i in 0..<MaxBullets:
var b = addr t.bullets[i]
if not b.active: continue
b.travelDist += b.bulletSpeed
if b.travelDist < b.fireDist: continue
# Resolved: look up the correct enemy position
var ex, ey: float
if b.targetId in enemies:
let e = enemies[b.targetId]
if not e.alive or (state.tick - e.lastSeenTick) > StaleTicks:
b.active = false
continue
ex = e.x; ey = e.y
else:
# No data for this target — fall back to selected enemy in state
ex = state.enemyX; ey = state.enemyY
let dx = b.aimX - b.fireX
let dy = b.aimY - b.fireY
let dist = hypot(dx, dy)
let (bx, by) =
if dist < 1e-6: (b.aimX, b.aimY)
else: (b.fireX + dx / dist * b.travelDist,
b.fireY + dy / dist * b.travelDist)
let missDist = hypot(bx - ex, by - ey)
let hit = missDist < BotRadius
if b.targetId in t.fitness:
t.fitness[b.targetId][b.gunId].bins[b.powerBin].record(hit)
let fe = FeedbackEvent(
prediction: GunPrediction(x: b.aimX, y: b.aimY),
actualX: ex,
actualY: ey,
bulletPower: PowerBins[b.powerBin],
fireTick: b.fireTick,
powerBin: b.powerBin,
missDistance: missDist,
hit: hit,
)
onResolved(b.gunId, b.powerBin, fe)
b.active = false
proc fitnessFor*(t: VirtualTracker, targetId: int): seq[GunFitness] =
## Returns fitness seq for targetId, or merges all enemies as fallback.
##
## The fallback is a RECENCY-WEIGHTED AGGREGATE over the last WindowSize
## samples, NOT a pooled rate: each per-enemy window is replayed into one fresh
## window, so once the total exceeds WindowSize the earliest samples are
## overwritten by later ones. Enemies are visited in ascending target-id order
## so the result is identical on every run (std/tables iteration order is hash
## order and therefore nondeterministic).
## ponytail: merge is O(enemies*guns*bins*WindowSize), fine for small counts
if targetId >= 0 and targetId in t.fitness:
return t.fitness[targetId]
# Aggregate across all enemies, deterministically ordered.
result = newSeq[GunFitness](t.numGuns)
var enemyIds: seq[int]
for id in t.fitness.keys: enemyIds.add id
enemyIds.sort()
for id in enemyIds:
let perEnemy = t.fitness[id]
for gunId in 0..<t.numGuns:
for binIdx in 0..<len(PowerBins):
let src = perEnemy[gunId].bins[binIdx]
for k in 0..<min(src.count, WindowSize):
result[gunId].bins[binIdx].record(src.hits[k])
proc bestPower*(t: VirtualTracker, gunId: GunId, targetId: int = -1): (int, float) =
## Returns (binIdx, power) with highest power that has >= MinHitRate.
## Falls back to lowest power bin if nothing qualifies yet.
## Uses per-enemy fitness when targetId >= 0 and data exists; else aggregate.
let fit = t.fitnessFor(targetId)
result = (0, PowerBins[0])
# Cold gun (zero observations in every bin): fall back to the lowest power bin,
# as documented. Without this the countdown loop below would hit the empty
# highest bin first and wrongly return power 3.0.
var anyObs = false
for binIdx in 0..<len(PowerBins):
if fit[gunId].bins[binIdx].count > 0:
anyObs = true
break
if not anyObs:
return (0, PowerBins[0])
# Warm gun: unchanged — return the highest power bin clearing MinHitRate
# (or an empty bin, which the existing logic treats as acceptable).
for binIdx in countdown(len(PowerBins) - 1, 0):
let rate = fit[gunId].bins[binIdx].hitRate()
if rate >= MinHitRate or fit[gunId].bins[binIdx].count == 0:
return (binIdx, PowerBins[binIdx])
proc bestGun*(t: VirtualTracker, targetId: int = -1): GunId =
## Pick gun with highest hit rate across all power bins.
## Guns with fewer than MinObsBeforeCompete observations are skipped
## unless every gun is below threshold (then fall back to best of all).
## Uses per-enemy fitness when targetId >= 0 and data exists; else aggregate.
## Ties (within TieMargin) are broken randomly to avoid index-0 bias.
## ponytail: O(n*bins), fine for small gun counts
let fit = t.fitnessFor(targetId)
proc bestAmong(fit: seq[GunFitness], requireMin: bool): GunId =
var bestRate = -1.0
for gunId in 0..<fit.len:
var maxCount = 0
for binIdx in 0..<len(PowerBins):
maxCount = max(maxCount, fit[gunId].bins[binIdx].count)
if requireMin and maxCount < MinObsBeforeCompete: continue
for binIdx in 0..<len(PowerBins):
let r = fit[gunId].bins[binIdx].hitRate()
if r > bestRate: bestRate = r
# Floor check: if nothing hits well enough, HeadOn is the safe default
if bestRate < MinHitRateFloor: return 0
var tied: seq[GunId]
for gunId in 0..<fit.len:
var maxCount = 0
for binIdx in 0..<len(PowerBins):
maxCount = max(maxCount, fit[gunId].bins[binIdx].count)
if requireMin and maxCount < MinObsBeforeCompete: continue
for binIdx in 0..<len(PowerBins):
let r = fit[gunId].bins[binIdx].hitRate()
if r >= bestRate - TieMargin:
tied.add(gunId)
break
if tied.len == 0: return 0
return tied[rand(tied.len - 1)]
var anyQualifies = false
for gunId in 0..<fit.len:
for binIdx in 0..<len(PowerBins):
if fit[gunId].bins[binIdx].count >= MinObsBeforeCompete:
anyQualifies = true
break
if anyQualifies: break
result = if anyQualifies: fit.bestAmong(true) else: fit.bestAmong(false)