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:
2026-09-21 22:41:01 +02:00
parent 68e0375be2
commit 18f778056b
4 changed files with 272 additions and 5 deletions
+4 -3
View File
@@ -66,9 +66,10 @@ proc shouldFire*(currentGunDir, targetAngle, gunHeat, distPx: float): bool =
elif delta < -180.0: delta += 360.0
abs(delta) <= aimToleranceDeg(distPx) and gunHeat <= 0.0
proc selectShot*(t: VirtualTracker, targetId: int = -1): (GunId, int, float) =
proc selectShot*(t: var VirtualTracker, targetId: int = -1, tick = 0): (GunId, int, float) =
## Returns (gunId, powerBinIdx, power) — the shot to take this tick.
## Pass targetId to pick the best gun for that specific enemy.
let gunId = t.bestGun(targetId)
## Pass targetId to pick the best gun for that specific enemy. `tick` drives
## the minimum-dwell hysteresis (see `selectGun`).
let gunId = t.selectGun(targetId, tick)
let (binIdx, power) = t.bestPower(gunId, targetId)
result = (gunId, binIdx, power)