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:
@@ -0,0 +1,73 @@
|
||||
## Offline chatter measurement for the gun selector's hysteresis.
|
||||
##
|
||||
## Replays real recorded DrussGT movement (and the synthetic set) through the 13
|
||||
## ModularBot guns and drives the SAME stateful selector the live bot uses
|
||||
## (`selectGun`), counting how often the chosen gun changes. This is the
|
||||
## deterministic analogue of counting the live `[config]` gun-switch lines: the
|
||||
## replay feeds the identical per-tick fitness the live tracker builds, so the
|
||||
## switch sequence is the same up to the seeded tie-break RNG.
|
||||
##
|
||||
## The env knobs (`GUN_SELECTOR_DWELL`, `GUN_SELECTOR_MARGIN`) are read once per
|
||||
## process at module init, so sweep by running the binary under different values:
|
||||
##
|
||||
## nim c -r common_libs/tests/measure_hysteresis.nim # shipped
|
||||
## GUN_SELECTOR_DWELL=0 GUN_SELECTOR_MARGIN=0 \
|
||||
## common_libs/tests/measure_hysteresis # pre-hysteresis baseline
|
||||
##
|
||||
## This is a MEASUREMENT tool, not a test; it always exits 0.
|
||||
|
||||
import std/[os, strformat, algorithm, random]
|
||||
import gun_harness/offline_range
|
||||
import gun_harness/virtual_bullets
|
||||
import range_guns
|
||||
|
||||
const
|
||||
repoRoot = currentSourcePath().parentDir.parentDir.parentDir
|
||||
fixturesDir = repoRoot / "tools" / "fixtures"
|
||||
|
||||
type RunStat = object
|
||||
name: string
|
||||
ticks: int
|
||||
switches: int
|
||||
|
||||
proc measure(path: string, seed: int): RunStat =
|
||||
let fx = loadFixture(path)
|
||||
let drivers = buildAllGunDrivers(seed = seed, enableTmSelector = false)
|
||||
var last = -1
|
||||
var t = 0
|
||||
var switches = 0
|
||||
discard replayFixture(fx, drivers,
|
||||
liveActual = (fx.meta.source == "live"),
|
||||
tickCb = proc(tr: ptr VirtualTracker) =
|
||||
let g = selectGun(tr[], fx.enemyId, t)
|
||||
if last >= 0 and g != last: inc switches
|
||||
last = g
|
||||
inc t)
|
||||
RunStat(name: extractFilename(path), ticks: fx.states.len, switches: switches)
|
||||
|
||||
proc main() =
|
||||
randomize(12345)
|
||||
var paths: seq[string]
|
||||
for f in walkFiles(fixturesDir / "drussgt_vs_*.jsonl"): paths.add f
|
||||
for f in walkFiles(fixturesDir / "tr_drussgt_vs_*.jsonl"): paths.add f
|
||||
paths.sort()
|
||||
|
||||
var totalTicks = 0
|
||||
var totalSwitches = 0
|
||||
echo fmt"dwell={ActiveDwellTicks} ticks margin={ActiveSwitchMargin:.3f} " &
|
||||
fmt"mode={ActiveSelectorMode} rank={ActiveRank}"
|
||||
echo "fixture ticks switches sw/100t"
|
||||
echo "----------------------------------------------------------------"
|
||||
var stats: seq[RunStat]
|
||||
for p in paths:
|
||||
let s = measure(p, seed = 12345)
|
||||
stats.add s
|
||||
totalTicks += s.ticks
|
||||
totalSwitches += s.switches
|
||||
echo fmt"{s.name:<34}{s.ticks:>7}{s.switches:>10}{100.0*float(s.switches)/float(max(1,s.ticks)):>9.2f}"
|
||||
echo "----------------------------------------------------------------"
|
||||
let label = "TOTAL"
|
||||
echo fmt"{label:<34}{totalTicks:>7}{totalSwitches:>10}{100.0*float(totalSwitches)/float(max(1,totalTicks)):>9.2f}"
|
||||
|
||||
when isMainModule:
|
||||
main()
|
||||
Reference in New Issue
Block a user