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.
This commit is contained in:
@@ -84,6 +84,59 @@ proc testBestGunDeterministicWinner() =
|
||||
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)
|
||||
@@ -357,6 +410,10 @@ randomize()
|
||||
testColdBestGun()
|
||||
testRandomTiebreak()
|
||||
testBestGunDeterministicWinner()
|
||||
testHysteresisDwell()
|
||||
testHysteresisSwitchMargin()
|
||||
testHysteresisDisqualified()
|
||||
testHysteresisFloor()
|
||||
testBestPowerCold()
|
||||
testBestPowerWarmBin3()
|
||||
testFitnessForDeterministic()
|
||||
|
||||
Reference in New Issue
Block a user