Files
SirStone 18f778056b gun selector: hysteresis measured NEGATIVE, shipped at the lightest setting
Hypothesis under test: the selector chatters (~54 switches/100 ticks) and that
chatter suppresses firing, so committing to the virtual-best gun should raise
real hit rate. MEASURED AGAINST THE REAL DRUSSGT: it does not.

  setting              switches/100t   real hit %   dmg/run   shots/run
  no hysteresis 0/0         54.29        7.02%        217       244.8
  light 10/0.05              1.95        6.22%        191       235.3
  moderate 30/0.15           1.33        5.10%        156       233.9
  aggressive 60/0.30           -         5.72%        175       242.5
(16 runs x 7 rounds per config except aggressive = 8; server-side events
sidecar; permutation test baseline-vs-moderate p=0.002, baseline-vs-light
p=0.18.)

Hysteresis cuts chatter 28-54x but every variant fires slightly FEWER shots and
deals LESS damage than baseline. Mechanism [INFERRED, consistent with
docs/gun_rack_analysis.md 2/4]: the per-tick random tie-break among the tied
band is a hedge, and hysteresis destroys it by committing to the virtual-best
gun - which is not the real-best, because the virtual metric is a weak,
sign-unstable ranker. The chattering was load-bearing.

Shipped: GunDwellTicks=10, GunSwitchMargin=0.05 (GUN_SELECTOR_DWELL /
GUN_SELECTOR_MARGIN) - the only setting within the baseline's run-to-run spread.
GUN_SELECTOR_DWELL=0 GUN_SELECTOR_MARGIN=0 reproduces the pre-change selector
exactly.

Seam: VirtualTracker, which already owns the other selection state (fitness,
the relative floor's peakRateRef), so the bot needs no new fields. bestGun and
chooseFromFit stay pure/memoryless, which is why the existing random-tiebreak
test needed no change.

Guards: test_gun_harness 39/39 (33 original + 6 new hysteresis checks),
test_vbullet_metric, test_power_selection, acceptance_offline_vs_online 12/12.
2026-09-21 22:41:01 +02:00

442 lines
18 KiB
Nim
Raw Permalink 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, os]
import gun_harness/gun_interface
import gun_harness/virtual_bullets
import gun_harness/selector
import gun_harness/offline_range
import guns/stop_shot
import guns/displacement
import guns/averaged_lead
import guns/pattern_matcher
import guns/head_on
import guns/linear
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
# ── sticky selector (hysteresis) ──────────────────────────────────────────────
# `selectGun` is the live path; `bestGun`/`chooseFromFit` stay memoryless. These
# checks pin the dwell, switch-margin, disqualification and floor behaviours.
proc testHysteresisDwell() =
var t = initTracker(2)
seedWindow(t, 7, gunId = 0, binIdx = 0, hits = 60, misses = 40) # 60%
seedWindow(t, 7, gunId = 1, binIdx = 0, hits = 100, misses = 0) # 100%
t.currentGun = 0
t.currentSince = 0
check "hysteresis: a clearly better gun is held off inside the dwell window",
t.selectGun(7, tick = 5) == 0
check "hysteresis: dwell expiry lets the better gun take over",
t.selectGun(7, tick = GunDwellTicks) == 1
proc testHysteresisSwitchMargin() =
# 80% incumbent vs 83% challenger: inside a 5% relative margin -> hold.
var t = initTracker(2)
seedWindow(t, 7, gunId = 0, binIdx = 0, hits = 80, misses = 20)
seedWindow(t, 7, gunId = 1, binIdx = 0, hits = 83, misses = 17)
t.currentGun = 0
t.currentSince = 0
check "hysteresis: a challenger inside the switch margin does not displace the incumbent",
t.selectGun(7, tick = 100) == 0
# 80% incumbent vs 90% challenger: beyond the margin -> switch.
var t2 = initTracker(2)
seedWindow(t2, 7, gunId = 0, binIdx = 0, hits = 80, misses = 20)
seedWindow(t2, 7, gunId = 1, binIdx = 0, hits = 90, misses = 10)
t2.currentGun = 0
t2.currentSince = 0
check "hysteresis: a challenger beyond the switch margin displaces the incumbent",
t2.selectGun(7, tick = 100) == 1
proc testHysteresisDisqualified() =
var t = initTracker(2)
seedWindow(t, 7, gunId = 0, binIdx = 0, hits = 10, misses = 0) # < MinObsBeforeCompete
seedWindow(t, 7, gunId = 1, binIdx = 0, hits = 60, misses = 0)
t.currentGun = 0
t.currentSince = 0
check "hysteresis: a disqualified incumbent is replaced even inside dwell",
t.selectGun(7, tick = 3) == 1
proc testHysteresisFloor() =
var t = initTracker(2)
seedWindow(t, 7, gunId = 0, binIdx = 0, hits = 0, misses = 60)
seedWindow(t, 7, gunId = 1, binIdx = 0, hits = 0, misses = 60)
t.currentGun = 1
t.currentSince = 0
t.peakRateRef = 1.0
check "hysteresis: floor collapse returns HeadOn even mid-dwell",
t.selectGun(7, tick = 3) == 0
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)
# ── range-aware firing gate (Task B) ────────────────────────────────────────
proc testToleranceStrictlyDecreases() =
# In the unclamped band the tolerance must fall monotonically with distance.
# 100..10000 px sits strictly inside [floor, ceiling] for the shipped
# SafetyFactor, so clamping cannot mask a flat or rising curve.
let dists = [100.0, 200.0, 400.0, 800.0, 1600.0, 3200.0, 10000.0]
var ok = true
for i in 0..<dists.high:
if aimToleranceDeg(dists[i]) <= aimToleranceDeg(dists[i+1]): ok = false
check "range gate: tolerance strictly decreases as distance grows (unclamped)",
ok
proc testToleranceClamps() =
let near = aimToleranceDeg(1.0) # point blank -> ceiling
let far = aimToleranceDeg(1.0e9) # effectively infinite -> floor
let mid = aimToleranceDeg(500.0)
check "range gate: point-blank clamps to MaxAimThresholdDeg",
near == MaxAimThresholdDeg
check "range gate: extreme range clamps to MinAimThresholdDeg",
far == MinAimThresholdDeg
check "range gate: mid-range tolerance is strictly inside the clamps",
mid > MinAimThresholdDeg and mid < MaxAimThresholdDeg
proc testToleranceFormula() =
let d = 500.0
let expected = radToDeg(arctan(BotRadius * SafetyFactor / d))
check "range gate: tolerance matches radToDeg(arctan(BotRadius*SF/dist))",
abs(aimToleranceDeg(d) - expected) < 1e-9
proc testPerfectAlignmentAlwaysFires() =
var allPass = true
for d in [0.0, 1.0, 100.0, 1000.0, 1.0e9]:
if not shouldFire(100.0, 100.0, 0.0, d): allPass = false
check "range gate: a perfectly aligned cool gun passes at every distance",
allPass
proc testGrossMisalignmentFailsLongRange() =
# 5 deg is far outside the ~0.8 deg cone at 800 px.
check "range gate: gross misalignment fails at long range",
not shouldFire(100.0, 105.0, 0.0, 800.0)
proc testDegenerateDistance() =
let t = aimToleranceDeg(0.0)
check "range gate: distPx = 0 falls back to the ceiling",
t == MaxAimThresholdDeg
check "range gate: distPx = 0 yields a finite, non-NaN tolerance",
t == t and t < Inf and t > -Inf
check "range gate: NaN distance also falls back to the ceiling",
aimToleranceDeg(NaN) == MaxAimThresholdDeg
# ── offline range (Task 5) ────────────────────────────────────────────────────
proc testFixtureRoundTrip() =
let fx = synthesizeConstantVelocity(ticks = 40)
let path = getTempDir() / "gun_range_roundtrip.jsonl"
saveFixture(path, fx)
let back = loadFixture(path)
check "range: fixture round-trip preserves tick count",
back.states.len == fx.states.len
var same = back.states.len == fx.states.len
for i in 0..<min(back.states.len, fx.states.len):
let a = fx.states[i]
let b = back.states[i]
if a.tick != b.tick or abs(a.enemyX - b.enemyX) > 1e-9 or
abs(a.enemyY - b.enemyY) > 1e-9 or
abs(a.enemyHeading - b.enemyHeading) > 1e-9 or
abs(a.enemySpeed - b.enemySpeed) > 1e-9 or
abs(a.selfX - b.selfX) > 1e-9:
same = false
check "range: fixture round-trip preserves per-tick fields", same
check "range: fixture round-trip defaults arena to 800x600",
back.meta.arenaW == 800.0 and back.meta.arenaH == 600.0
check "range: fixture round-trip preserves enemy id", back.enemyId == fx.enemyId
removeFile(path)
proc testFixtureEndMarkerRoundTrip() =
let fx = synthesizeStationary(ticks = 10)
var withEnd = fx
withEnd.enemyDied = true
let path = getTempDir() / "gun_range_end.jsonl"
saveFixture(path, withEnd)
let back = loadFixture(path)
check "range: fixture round-trip preserves the enemyDied end marker", back.enemyDied
removeFile(path)
proc testReplayDeterminism() =
let fx = synthesizeCircular(ticks = 80)
let r1 = replayFixture(fx, @[makeDriver("HeadOn", HeadOnGun()),
makeDriver("Linear", LinearGun())])
let r2 = replayFixture(fx, @[makeDriver("HeadOn", HeadOnGun()),
makeDriver("Linear", LinearGun())])
var same = r1.len == r2.len
for i in 0..<r1.len:
if r1[i].name != r2[i].name or r1[i].shots != r2[i].shots or
r1[i].hits != r2[i].hits:
same = false
for b in 0..<len(PowerBins):
if r1[i].bins[b].shots != r2[i].bins[b].shots or
r1[i].bins[b].hits != r2[i].bins[b].hits:
same = false
check "range: replaying the same fixture twice is byte-identical (deterministic guns)",
same
proc testRangeGroundTruthStationary() =
let fx = synthesizeStationary(ticks = 120)
let r = replayFixture(fx, @[makeDriver("HeadOn", HeadOnGun())])
# The last bullets are still in flight when the fixture ends, so shots < 480;
# every resolved shot must still be a hit.
check "range: stationary enemy -> HeadOn scores 100%",
r[0].shots > 300 and r[0].hits == r[0].shots
proc testRangeConstantVelocityLinearWins() =
# Model-specific property: under the point model HeadOn aims at the current
# position and misses a moving target, while Linear leads it. (Under the
# shipped path model every gun scores 100% here, so the check pins the
# metric it was calibrated against.)
let fx = synthesizeConstantVelocity(ticks = 120)
let r = replayFixture(fx, @[makeDriver("HeadOn", HeadOnGun()),
makeDriver("Linear", LinearGun())],
metric = bmPoint)
check "range: constant velocity -> Linear beats HeadOn (point model)",
r[1].hitRate() > r[0].hitRate()
proc testEnergyThresholdFixtureRule() =
# RULE e(t) = max(5, 50 - 0.5*t); e drops below 30 at t = 41.
let fx = synthesizeEnergyThresholdTurner(ticks = 100, e0 = 50.0,
decay = 0.5, threshold = 30.0)
let idx = 41
check "range: energy-threshold fixture crosses the rule threshold at t=41",
fx.states[idx].enemyEnergy < 30.0 and
fx.states[idx-1].enemyEnergy >= 30.0
# ── driver ───────────────────────────────────────────────────────────────────
randomize()
testColdBestGun()
testRandomTiebreak()
testBestGunDeterministicWinner()
testHysteresisDwell()
testHysteresisSwitchMargin()
testHysteresisDisqualified()
testHysteresisFloor()
testBestPowerCold()
testBestPowerWarmBin3()
testFitnessForDeterministic()
testDroppedBullets()
testStopShotSpeedSensitivity()
testDisplacementSpeedSensitivity()
testAveragedLeadSpeedSensitivity()
testPatternMatcherSpeedSensitivity()
testToleranceStrictlyDecreases()
testToleranceClamps()
testToleranceFormula()
testPerfectAlignmentAlwaysFires()
testGrossMisalignmentFailsLongRange()
testDegenerateDistance()
testFixtureRoundTrip()
testFixtureEndMarkerRoundTrip()
testReplayDeterminism()
testRangeGroundTruthStationary()
testRangeConstantVelocityLinearWins()
testEnergyThresholdFixtureRule()
if failures > 0:
echo "\n", failures, " check(s) FAILED"
quit(1)
echo "\nAll gun-harness checks passed."