From 18f778056bad4cf38a04dacf2db73a050edd29f5 Mon Sep 17 00:00:00 2001 From: Davide Cappellini Date: Mon, 21 Sep 2026 22:41:01 +0200 Subject: [PATCH] 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. --- common_libs/gun_harness/selector.nim | 7 +- common_libs/gun_harness/virtual_bullets.nim | 140 +++++++++++++++++++- common_libs/tests/measure_hysteresis.nim | 73 ++++++++++ common_libs/tests/test_gun_harness.nim | 57 ++++++++ 4 files changed, 272 insertions(+), 5 deletions(-) create mode 100644 common_libs/tests/measure_hysteresis.nim diff --git a/common_libs/gun_harness/selector.nim b/common_libs/gun_harness/selector.nim index 82e382a..837d12b 100644 --- a/common_libs/gun_harness/selector.nim +++ b/common_libs/gun_harness/selector.nim @@ -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) diff --git a/common_libs/gun_harness/virtual_bullets.nim b/common_libs/gun_harness/virtual_bullets.nim index e237197..9f25fcb 100644 --- a/common_libs/gun_harness/virtual_bullets.nim +++ b/common_libs/gun_harness/virtual_bullets.nim @@ -34,6 +34,34 @@ const ## Dimensionless: only if the field collapsed vs its own recent best. SelectorWindow* = 256 ## ticks of per-tick bestRate kept for the RELATIVE floor reference + # ── selector hysteresis (anti-chatter) ───────────────────────────────────── + # + # Without these, `chooseFromFit` performs a fresh uniform draw among the tied + # set EVERY tick. With the 20% relative tie band that is most of the rack, so + # the turret re-aims on ~37% of ticks (live config log; offline replay of the + # same selection path: 54.3 switches / 100 ticks). The two constants below + # make the selector commit. + # + # Shipped values are the CONSERVATIVE end of a measured sweep against the real + # DrussGT (8-16 runs x 7 rounds per setting, server-side events sidecar). + # Offline replay of the same selection path gives the chatter column: + # dwell 0 / margin 0.00 : chatter 54.3/100t real 7.02% 217 dmg/run + # dwell 10 / margin 0.05 : chatter 1.95/100t real 6.22% 191 dmg/run + # dwell 30 / margin 0.15 : chatter 1.33/100t real 5.10% 156 dmg/run + # dwell 60 / margin 0.30 : real 5.72% 175 dmg/run (8 runs) + # The light setting cuts chatter ~28x and is the only one not distinguishable + # from the no-hysteresis baseline (overlapping per-run ranges, permutation + # p=0.18); the heavier settings cost real hit rate (moderate p=0.002). So the + # conservative setting ships. Set BOTH env knobs to 0 to disable hysteresis. + GunDwellTicks* = 10 ## TICKS: once selected, a gun is held for at least + ## this many ticks unless it becomes disqualified + ## (below the eligibility sample gate) or the field + ## collapses (existing floor path -> HeadOn). + GunSwitchMargin* = 0.05 ## DIMENSIONLESS fraction of the incumbent's score: a + ## challenger must beat the incumbent by more than + ## this (score > incumbent * (1 + margin)) before it is + ## allowed to displace it. A merely-tied gun cannot. + MetricEnvVar* = "GUN_VBULLET_METRIC" ## RUNTIME switch selecting how a virtual bullet is scored. Read once per ## process at module init, so the SAME compiled binary can be A/B'd by @@ -154,6 +182,12 @@ let ActivePooled* = envBool("GUN_SELECTOR_POOL", true) let ActiveRank* = parseRank(getEnv("GUN_SELECTOR_RANK", "")) let ActiveShrink* = max(0.0, envFloat("GUN_SELECTOR_SHRINK", 20.0)) +# Runtime hysteresis knobs, read once per process like the rest, so one binary +# can be swept. Defaults equal the shipped constants; setting BOTH to 0 +# reproduces the pre-hysteresis (pure per-tick) selection for A/B. +let ActiveDwellTicks* = max(0, envInt("GUN_SELECTOR_DWELL", GunDwellTicks)) +let ActiveSwitchMargin* = max(0.0, envFloat("GUN_SELECTOR_MARGIN", GunSwitchMargin)) + # Optional per-process seed so independent A/B runs use independent tie-breaks # (Nim's default rand() stream is identical in every process, which would make # "random" tie-breaks repeat across runs). Unset => leave the RNG untouched. @@ -199,6 +233,7 @@ type tiedCount*: int ## eligible guns within the tie band of bestRate (0 if floor fired) anyQualifies*: bool ## at least one gun reached MinObsBeforeCompete floorRate*: float ## the floor actually applied this tick + incumbentKept*: bool ## hysteresis retained the incumbent this tick VirtualTracker* = object bullets*: array[MaxBullets, VirtualBullet] @@ -212,12 +247,18 @@ type rateHistHead*: int rateHistCount*: int peakRateRef*: float ## max bestRate in rateHist; 0.0 = not enough history yet + # Hysteresis state (see `selectGun`): the incumbent gun and the tick it was + # chosen on. `-1` means no gun selected yet. Only the live `selectGun` path + # reads/writes these; `bestGun`/`chooseFromFit` stay memoryless. + currentGun*: GunId + currentSince*: int proc initTracker*(numGuns: int, metric = ActiveMetric): VirtualTracker = ## `metric` defaults to the process-wide `GUN_VBULLET_METRIC` switch; pass it ## explicitly only from tests that need both models in one process. result.numGuns = numGuns result.metric = metric + result.currentGun = -1 proc windowHits(fw: FitnessWindow, want: int): int = ## Hits among the most recent `want` samples, in ring order. Reading the last @@ -560,7 +601,9 @@ proc bestPower*(t: VirtualTracker, gunId: GunId, targetId: int = -1): (int, floa proc chooseFromFit*(fit: seq[GunFitness], diag: ptr SelectorDiag = nil, mode: SelectorMode = smAbsolute, - referenceRate = -1.0): GunId = + referenceRate = -1.0, + incumbent: GunId = -1, + switchMargin = 0.0): GunId = ## Core gun ranking over an already-resolved fitness seq. Split out from ## `bestGun` so the offline range can rank without copying a VirtualTracker, ## and so callers can request `diag` for the selection internals. @@ -574,7 +617,16 @@ proc chooseFromFit*(fit: seq[GunFitness], diag: ptr SelectorDiag = nil, ## * `referenceRate` (the recent field-best rate). Pooled over ## power bins, since one lucky bin is a poor ranker. ## `referenceRate` <= 0 disables the RELATIVE floor (no history yet). - ## Ties (within the band) are broken randomly to avoid index-0 bias. + ## + ## `incumbent` (>= 0) enables switch hysteresis: the incumbent is retained + ## unless the best eligible gun beats it by the RELATIVE `switchMargin` + ## (score > incumbentScore * (1 + margin)). Defaults keep the pure-ranking + ## behaviour the offline analyzer and the `bestGun` tests rely on. + ## + ## Ties (within the band) are broken randomly to avoid index-0 bias. The draw + ## runs ONLY when a switch is actually permitted — a tick that retains the + ## incumbent returns before `rand`, so the tie-break no longer re-decides + ## every tick. let pooled = if mode == smRelative: ActivePooled else: false var anyQualifies = false @@ -630,6 +682,33 @@ proc chooseFromFit*(fit: seq[GunFitness], diag: ptr SelectorDiag = nil, if scores[gunId] >= bestScore - tieBand: tied.add(gunId) if diag != nil: diag[].tiedCount = tied.len + + # ── switch hysteresis ────────────────────────────────────────────────────── + # A settled incumbent is displaced only by a challenger that clears the + # relative margin. This is what stops a merely-tied gun from churning the + # turret. `incumbent < 0` disables the rule (pure ranking). + if incumbent >= 0 and incumbent < fit.len: + var incumbentEligible = true + if requireMin and not gunEligible(fit[incumbent], true): + incumbentEligible = false + if incumbentEligible: + let incumbentScore = scores[incumbent] + let bar = incumbentScore * (1.0 + switchMargin) + if bestScore <= bar: + if diag != nil: diag[].incumbentKept = true + return incumbent + # Keep only genuine challengers (guns that clear the margin); if none do, + # the incumbent survives even when `bestScore` technically exceeds the bar + # (the top gun may sit inside the tie band but not be switched to). + var challengers: seq[GunId] + for g in tied: + if scores[g] > bar: challengers.add g + if challengers.len > 0: + tied = challengers + else: + if diag != nil: diag[].incumbentKept = true + return incumbent + if tied.len == 0: return 0 result = tied[rand(tied.len - 1)] @@ -639,6 +718,63 @@ proc bestGun*(t: VirtualTracker, targetId: int = -1, ## Uses per-enemy fitness when targetId >= 0 and data exists; else aggregate. ## `diag`, when non-nil, receives the selection internals (bestRate, floor, ## tie count) exactly as used by the decision. + ## + ## This is the PURE, memoryless ranking primitive: it starts a fresh tie-break + ## every call. The live bot uses `selectGun` (below), which wraps it with the + ## dwell/switch-margin hysteresis. Keeping this pure is what lets the offline + ## analyzer and the harness tests stay deterministic and side-effect free. result = chooseFromFit(t.fitnessFor(targetId), diag, mode = ActiveSelectorMode, referenceRate = t.peakRateRef) + +proc selectGun*(t: var VirtualTracker, targetId: int = -1, tick = 0, + diag: ptr SelectorDiag = nil): GunId = + ## Stateful, sticky gun selection — the LIVE path (`selector.selectShot` calls + ## this). Wraps the pure `chooseFromFit` ranking with two commitments: + ## + ## * minimum dwell — once selected, a gun is held for at least + ## `GUN_SELECTOR_DWELL` ticks unless it becomes disqualified (below the + ## eligibility sample gate) or the field collapses (existing floor path -> + ## HeadOn); + ## * switch margin — a challenger must beat the incumbent by + ## `GUN_SELECTOR_MARGIN` (a fraction of the incumbent's score) before it can + ## displace it, so a merely-tied gun does not. + ## + ## Setting BOTH knobs to 0 disables hysteresis entirely and reproduces the old + ## per-tick behaviour, so the same binary can A/B against the baseline. + ## + ## Seam: the incumbent lives in the `VirtualTracker` because the tracker + ## already owns all other selection state (`fitness`, the floor's + ## `peakRateRef` history) and is threaded through the whole live loop; the bot + ## does not need to know about it. `bestGun`/`chooseFromFit` stay pure for the + ## offline tools. + let fit = t.fitnessFor(targetId) + var local: SelectorDiag + let d = if diag != nil: diag else: addr local + + # Both zero == no hysteresis: pass no incumbent, matching the pre-change + # (pure per-tick) selector for a clean A/B. + let useIncumbent = ActiveDwellTicks > 0 or ActiveSwitchMargin > 0.0 + let incumbent = if useIncumbent: t.currentGun else: -1 + + let challenger = chooseFromFit(fit, d, ActiveSelectorMode, t.peakRateRef, + incumbent = incumbent, + switchMargin = ActiveSwitchMargin) + + # The floor collapses the field to HeadOn regardless of dwell. + if d[].floorFired: + if t.currentGun != 0: + t.currentGun = 0 + t.currentSince = tick + return 0 + + result = challenger + # Minimum dwell: hold the incumbent while it is still eligible. + if useIncumbent and t.currentGun >= 0 and t.currentGun < fit.len: + let eligible = (not d[].anyQualifies) or gunEligible(fit[t.currentGun], true) + if eligible and (tick - t.currentSince) < ActiveDwellTicks: + result = t.currentGun + + if result != t.currentGun: + t.currentGun = result + t.currentSince = tick diff --git a/common_libs/tests/measure_hysteresis.nim b/common_libs/tests/measure_hysteresis.nim new file mode 100644 index 0000000..01bede6 --- /dev/null +++ b/common_libs/tests/measure_hysteresis.nim @@ -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() diff --git a/common_libs/tests/test_gun_harness.nim b/common_libs/tests/test_gun_harness.nim index 17f8a46..09c7949 100644 --- a/common_libs/tests/test_gun_harness.nim +++ b/common_libs/tests/test_gun_harness.nim @@ -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()