Files
SirRoboGarage/common_libs/tests/test_gun_harness.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

237 lines
9.6 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.
## 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."