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.
This commit is contained in:
@@ -0,0 +1 @@
|
||||
--path:".."
|
||||
@@ -0,0 +1,236 @@
|
||||
## First guard tests for the gun selector + the speed-sensitivity checks for the
|
||||
## per-tick caching bug class.
|
||||
##
|
||||
## Headless: no Java, no server, no battle. Run with plain
|
||||
## nim c -r common_libs/tests/test_gun_harness.nim
|
||||
##
|
||||
## Selection tests seed the tracker's exported fitness windows directly instead of
|
||||
## dragging virtual bullets through spawnBullets/tickBullets. That is deliberate:
|
||||
## it makes exact hit-rates (and therefore tie/rng/floor behaviour) deterministic
|
||||
## and fast. The spawn/tick pipeline itself is exercised by the droppedBullets
|
||||
## test below and by the full gauntlet.
|
||||
|
||||
import std/[math, random, tables]
|
||||
import gun_harness/gun_interface
|
||||
import gun_harness/virtual_bullets
|
||||
import gun_harness/selector
|
||||
import guns/stop_shot
|
||||
import guns/displacement
|
||||
import guns/averaged_lead
|
||||
import guns/pattern_matcher
|
||||
|
||||
var failures = 0
|
||||
|
||||
proc check(name: string, ok: bool) =
|
||||
if ok:
|
||||
echo "PASS: ", name
|
||||
else:
|
||||
echo "FAIL: ", name
|
||||
inc failures
|
||||
|
||||
proc recordHit(fw: var FitnessWindow, hit: bool) =
|
||||
fw.hits[fw.head] = hit
|
||||
fw.head = (fw.head + 1) mod WindowSize
|
||||
inc fw.count
|
||||
|
||||
proc seedWindow(t: var VirtualTracker, targetId, gunId, binIdx, hits, misses: int) =
|
||||
## Narrowly-scoped test helper: write `hits`/`misses` samples straight into a
|
||||
## gun×bin fitness window (fields are exported by virtual_bullets).
|
||||
if targetId notin t.fitness:
|
||||
t.fitness[targetId] = newSeq[GunFitness](t.numGuns)
|
||||
var fw = addr t.fitness[targetId][gunId].bins[binIdx]
|
||||
for _ in 0..<hits: recordHit(fw[], true)
|
||||
for _ in 0..<misses: recordHit(fw[], false)
|
||||
|
||||
proc ws(tick: int, ex, ey, espeed, eheading: float): WorldState =
|
||||
WorldState(selfX: 100.0, selfY: 100.0, enemyX: ex, enemyY: ey,
|
||||
enemySpeed: espeed, enemyHeading: eheading,
|
||||
arenaWidth: 1000.0, arenaHeight: 1000.0, tick: tick)
|
||||
|
||||
proc pointsDiffer(a, b: GunPrediction): bool =
|
||||
abs(a.x - b.x) > 0.5 or abs(a.y - b.y) > 0.5
|
||||
|
||||
# ── selector guards ──────────────────────────────────────────────────────────
|
||||
|
||||
proc testColdBestGun() =
|
||||
var t = initTracker(3)
|
||||
check "bestGun on a cold tracker returns 0 (HeadOn)", t.bestGun(-1) == 0
|
||||
|
||||
proc testRandomTiebreak() =
|
||||
# Two guns with an identical, well-observed hit rate: the tiebreak must expose
|
||||
# both ids. Before the random tiebreak landed this always returned index 0.
|
||||
var t = initTracker(2)
|
||||
seedWindow(t, 7, gunId = 0, binIdx = 0, hits = 50, misses = 0)
|
||||
seedWindow(t, 7, gunId = 1, binIdx = 0, hits = 50, misses = 0)
|
||||
var seen: array[2, bool]
|
||||
for _ in 0..<500:
|
||||
let g = t.bestGun(-1)
|
||||
if g >= 0 and g < 2: seen[g] = true
|
||||
check "random tiebreak returns BOTH tied gun ids (no index-0 determinism)",
|
||||
seen[0] and seen[1]
|
||||
|
||||
proc testBestGunDeterministicWinner() =
|
||||
# gun 2 clearly best and past MinObsBeforeCompete; must win every call.
|
||||
var t = initTracker(3)
|
||||
seedWindow(t, 7, gunId = 0, binIdx = 0, hits = 25, misses = 25) # 50 obs, 50%
|
||||
seedWindow(t, 7, gunId = 1, binIdx = 0, hits = 0, misses = 0) # cold, skipped
|
||||
seedWindow(t, 7, gunId = 2, binIdx = 0, hits = 50, misses = 0) # 50 obs, 100%
|
||||
var allTwo = true
|
||||
for _ in 0..<100:
|
||||
if t.bestGun(-1) != 2: allTwo = false
|
||||
check "gun with clearly best rate and >= MinObsBeforeCompete wins deterministically",
|
||||
allTwo
|
||||
|
||||
proc testBestPowerCold() =
|
||||
var t = initTracker(3)
|
||||
let (bin, power) = t.bestPower(0, -1)
|
||||
check "bestPower on a zero-observation gun returns bin 0 / power 1.0",
|
||||
bin == 0 and power == 1.0
|
||||
|
||||
proc testBestPowerWarmBin3() =
|
||||
var t = initTracker(3)
|
||||
seedWindow(t, 7, gunId = 0, binIdx = 3, hits = 50, misses = 0) # 100% >= MinHitRate
|
||||
let (bin, power) = t.bestPower(0, -1)
|
||||
check "bestPower on a warm gun whose bin 3 rate >= MinHitRate returns bin 3",
|
||||
bin == 3 and power == 3.0
|
||||
|
||||
proc testFitnessForDeterministic() =
|
||||
# Same per-enemy data inserted in opposite orders must aggregate identically.
|
||||
# Before fitnessFor sorted enemy ids, std/tables hash order leaked in.
|
||||
var t1 = initTracker(2)
|
||||
seedWindow(t1, 5, gunId = 0, binIdx = 0, hits = 10, misses = 5)
|
||||
seedWindow(t1, 3, gunId = 0, binIdx = 0, hits = 5, misses = 10)
|
||||
|
||||
var t2 = initTracker(2)
|
||||
seedWindow(t2, 3, gunId = 0, binIdx = 0, hits = 5, misses = 10)
|
||||
seedWindow(t2, 5, gunId = 0, binIdx = 0, hits = 10, misses = 5)
|
||||
|
||||
var same = true
|
||||
for _ in 0..<20:
|
||||
let r1 = t1.fitnessFor(-1)[0].bins[0].hitRate()
|
||||
let r2 = t2.fitnessFor(-1)[0].bins[0].hitRate()
|
||||
if r1 != r2: same = false
|
||||
let expected = 15.0 / 30.0
|
||||
check "fitnessFor is deterministic across insertion orders",
|
||||
same and abs(t1.fitnessFor(-1)[0].bins[0].hitRate() - expected) < 1e-12
|
||||
|
||||
proc testDroppedBullets() =
|
||||
var t = initTracker(1)
|
||||
let state = ws(0, 500.0, 500.0, 0.0, 0.0)
|
||||
let preds = [GunPrediction(x: 500.0, y: 500.0),
|
||||
GunPrediction(x: 500.0, y: 500.0),
|
||||
GunPrediction(x: 500.0, y: 500.0),
|
||||
GunPrediction(x: 500.0, y: 500.0)]
|
||||
# Fill the ring exactly (4 bullets per spawn, no tickBullets -> never resolve).
|
||||
for _ in 0..<(MaxBullets div len(PowerBins)):
|
||||
t.spawnBullets(0, preds, state, 5)
|
||||
check "droppedBullets stays 0 until the ring wraps", t.droppedBullets == 0
|
||||
t.spawnBullets(0, preds, state, 5)
|
||||
check "droppedBullets counts unresolved bullets clobbered by the ring",
|
||||
t.droppedBullets == 4
|
||||
|
||||
# ── caching-bug speed sensitivity (Task 5) ───────────────────────────────────
|
||||
|
||||
proc testStopShotSpeedSensitivity() =
|
||||
let spd0 = bulletSpeed(PowerBins[0])
|
||||
let spd3 = bulletSpeed(PowerBins[3])
|
||||
|
||||
var ss = initStopShotGun()
|
||||
# Constant speed 4: warm two frames, then compare on the same tick. Before the
|
||||
# fix the tick-only cache returned bin 0's lead for every bin.
|
||||
discard ss.predict(ws(1, 400.0, 100.0, 4.0, 0.0), spd0)
|
||||
discard ss.predict(ws(2, 400.0, 100.0, 4.0, 0.0), spd0)
|
||||
let s3 = ws(3, 400.0, 100.0, 4.0, 0.0)
|
||||
let p0 = ss.predict(s3, spd0)
|
||||
let p3 = ss.predict(s3, spd3)
|
||||
check "stop_shot: same tick, different bulletSpeed -> different point",
|
||||
pointsDiffer(p0, p3)
|
||||
|
||||
# Task 1a: deceleration is actually detected (8 -> 4 px/tick). The old ordering
|
||||
# made prev == speed, so this branch was unreachable and the gun was Linear.
|
||||
var ss2 = initStopShotGun()
|
||||
discard ss2.predict(ws(1, 400.0, 100.0, 8.0, 0.0), spd0)
|
||||
let pd = ss2.predict(ws(2, 400.0, 100.0, 4.0, 0.0), spd0)
|
||||
# Stop point is 400 + 4 + 2 = 406 px (BrakeDecel=2); linear lead would be ~470.
|
||||
check "stop_shot: deceleration branch reaches the simulated stop point",
|
||||
abs(pd.x - 406.0) < 1.0
|
||||
|
||||
proc testDisplacementSpeedSensitivity() =
|
||||
let spd0 = bulletSpeed(PowerBins[0])
|
||||
let spd3 = bulletSpeed(PowerBins[3])
|
||||
|
||||
var dg = initDisplacementGun()
|
||||
# Warm 16 ticks emulating the real harness: 4 predict() calls (one per power
|
||||
# bin) on every tick. Feed 16 ticks of constant +5 px/tick motion so the
|
||||
# 15-tick window is ready.
|
||||
for tick in 1..16:
|
||||
for bin in 0..<len(PowerBins):
|
||||
discard dg.predict(ws(tick, 300.0 + 5.0 * tick.float, 200.0, 5.0, 0.0),
|
||||
bulletSpeed(PowerBins[bin]))
|
||||
let s17 = ws(17, 300.0 + 5.0 * 17.0, 200.0, 5.0, 0.0)
|
||||
let d0 = dg.predict(s17, spd0)
|
||||
let d3 = dg.predict(s17, spd3)
|
||||
check "displacement: same tick, different bulletSpeed -> different point",
|
||||
pointsDiffer(d0, d3)
|
||||
|
||||
# The real displacement bug: the speed-in-key cache advanced the ring ~4x per
|
||||
# tick, so the nominal 15-tick window spanned ~4 ticks. Sampling once per tick
|
||||
# means 4 calls/tick must be identical to 1 call/tick.
|
||||
var dgMulti = initDisplacementGun()
|
||||
var dgOnce = initDisplacementGun()
|
||||
for tick in 1..16:
|
||||
let s = ws(tick, 300.0 + 5.0 * tick.float, 200.0, 5.0, 0.0)
|
||||
for bin in 0..<len(PowerBins):
|
||||
discard dgMulti.predict(s, bulletSpeed(PowerBins[bin]))
|
||||
discard dgOnce.predict(s, spd0)
|
||||
let s18 = ws(18, 300.0 + 5.0 * 18.0, 200.0, 5.0, 0.0)
|
||||
let a = dgMulti.predict(s18, spd0)
|
||||
let b = dgOnce.predict(s18, spd0)
|
||||
check "displacement: ring advances exactly once per tick (4 calls == 1 call)",
|
||||
not pointsDiffer(a, b)
|
||||
|
||||
proc testAveragedLeadSpeedSensitivity() =
|
||||
let spd0 = bulletSpeed(PowerBins[0])
|
||||
let spd3 = bulletSpeed(PowerBins[3])
|
||||
|
||||
var al = initAveragedLeadGun()
|
||||
discard al.predict(ws(1, 400.0, 100.0, 3.0, 0.0), spd0) # warm circular's omega
|
||||
let s2 = ws(2, 400.0, 100.0, 3.0, 0.0)
|
||||
let a0 = al.predict(s2, spd0)
|
||||
let a3 = al.predict(s2, spd3)
|
||||
check "averaged_lead: same tick, different bulletSpeed -> different point",
|
||||
pointsDiffer(a0, a3)
|
||||
|
||||
proc testPatternMatcherSpeedSensitivity() =
|
||||
let spd0 = bulletSpeed(PowerBins[0])
|
||||
let spd3 = bulletSpeed(PowerBins[3])
|
||||
|
||||
var pm = PatternMatcherGun()
|
||||
for tick in 1..25:
|
||||
discard pm.predict(ws(tick, 300.0 + 5.0 * tick.float, 200.0, 5.0, 0.0), spd0)
|
||||
let s26 = ws(26, 300.0 + 5.0 * 26.0, 200.0, 5.0, 0.0)
|
||||
let m0 = pm.predict(s26, spd0)
|
||||
let m3 = pm.predict(s26, spd3)
|
||||
check "pattern_matcher: same tick, different bulletSpeed -> different point",
|
||||
pointsDiffer(m0, m3)
|
||||
|
||||
# ── driver ───────────────────────────────────────────────────────────────────
|
||||
|
||||
randomize()
|
||||
|
||||
testColdBestGun()
|
||||
testRandomTiebreak()
|
||||
testBestGunDeterministicWinner()
|
||||
testBestPowerCold()
|
||||
testBestPowerWarmBin3()
|
||||
testFitnessForDeterministic()
|
||||
testDroppedBullets()
|
||||
testStopShotSpeedSensitivity()
|
||||
testDisplacementSpeedSensitivity()
|
||||
testAveragedLeadSpeedSensitivity()
|
||||
testPatternMatcherSpeedSensitivity()
|
||||
|
||||
if failures > 0:
|
||||
echo "\n", failures, " check(s) FAILED"
|
||||
quit(1)
|
||||
echo "\nAll gun-harness checks passed."
|
||||
Reference in New Issue
Block a user