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:
@@ -66,9 +66,10 @@ proc shouldFire*(currentGunDir, targetAngle, gunHeat, distPx: float): bool =
|
|||||||
elif delta < -180.0: delta += 360.0
|
elif delta < -180.0: delta += 360.0
|
||||||
abs(delta) <= aimToleranceDeg(distPx) and gunHeat <= 0.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.
|
## Returns (gunId, powerBinIdx, power) — the shot to take this tick.
|
||||||
## Pass targetId to pick the best gun for that specific enemy.
|
## Pass targetId to pick the best gun for that specific enemy. `tick` drives
|
||||||
let gunId = t.bestGun(targetId)
|
## the minimum-dwell hysteresis (see `selectGun`).
|
||||||
|
let gunId = t.selectGun(targetId, tick)
|
||||||
let (binIdx, power) = t.bestPower(gunId, targetId)
|
let (binIdx, power) = t.bestPower(gunId, targetId)
|
||||||
result = (gunId, binIdx, power)
|
result = (gunId, binIdx, power)
|
||||||
|
|||||||
@@ -34,6 +34,34 @@ const
|
|||||||
## Dimensionless: only if the field collapsed vs its own recent best.
|
## Dimensionless: only if the field collapsed vs its own recent best.
|
||||||
SelectorWindow* = 256 ## ticks of per-tick bestRate kept for the RELATIVE floor reference
|
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"
|
MetricEnvVar* = "GUN_VBULLET_METRIC"
|
||||||
## RUNTIME switch selecting how a virtual bullet is scored. Read once per
|
## 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
|
## 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 ActiveRank* = parseRank(getEnv("GUN_SELECTOR_RANK", ""))
|
||||||
let ActiveShrink* = max(0.0, envFloat("GUN_SELECTOR_SHRINK", 20.0))
|
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
|
# 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
|
# (Nim's default rand() stream is identical in every process, which would make
|
||||||
# "random" tie-breaks repeat across runs). Unset => leave the RNG untouched.
|
# "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)
|
tiedCount*: int ## eligible guns within the tie band of bestRate (0 if floor fired)
|
||||||
anyQualifies*: bool ## at least one gun reached MinObsBeforeCompete
|
anyQualifies*: bool ## at least one gun reached MinObsBeforeCompete
|
||||||
floorRate*: float ## the floor actually applied this tick
|
floorRate*: float ## the floor actually applied this tick
|
||||||
|
incumbentKept*: bool ## hysteresis retained the incumbent this tick
|
||||||
|
|
||||||
VirtualTracker* = object
|
VirtualTracker* = object
|
||||||
bullets*: array[MaxBullets, VirtualBullet]
|
bullets*: array[MaxBullets, VirtualBullet]
|
||||||
@@ -212,12 +247,18 @@ type
|
|||||||
rateHistHead*: int
|
rateHistHead*: int
|
||||||
rateHistCount*: int
|
rateHistCount*: int
|
||||||
peakRateRef*: float ## max bestRate in rateHist; 0.0 = not enough history yet
|
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 =
|
proc initTracker*(numGuns: int, metric = ActiveMetric): VirtualTracker =
|
||||||
## `metric` defaults to the process-wide `GUN_VBULLET_METRIC` switch; pass it
|
## `metric` defaults to the process-wide `GUN_VBULLET_METRIC` switch; pass it
|
||||||
## explicitly only from tests that need both models in one process.
|
## explicitly only from tests that need both models in one process.
|
||||||
result.numGuns = numGuns
|
result.numGuns = numGuns
|
||||||
result.metric = metric
|
result.metric = metric
|
||||||
|
result.currentGun = -1
|
||||||
|
|
||||||
proc windowHits(fw: FitnessWindow, want: int): int =
|
proc windowHits(fw: FitnessWindow, want: int): int =
|
||||||
## Hits among the most recent `want` samples, in ring order. Reading the last
|
## 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,
|
proc chooseFromFit*(fit: seq[GunFitness], diag: ptr SelectorDiag = nil,
|
||||||
mode: SelectorMode = smAbsolute,
|
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
|
## Core gun ranking over an already-resolved fitness seq. Split out from
|
||||||
## `bestGun` so the offline range can rank without copying a VirtualTracker,
|
## `bestGun` so the offline range can rank without copying a VirtualTracker,
|
||||||
## and so callers can request `diag` for the selection internals.
|
## 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
|
## * `referenceRate` (the recent field-best rate). Pooled over
|
||||||
## power bins, since one lucky bin is a poor ranker.
|
## power bins, since one lucky bin is a poor ranker.
|
||||||
## `referenceRate` <= 0 disables the RELATIVE floor (no history yet).
|
## `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
|
let pooled = if mode == smRelative: ActivePooled else: false
|
||||||
|
|
||||||
var anyQualifies = false
|
var anyQualifies = false
|
||||||
@@ -630,6 +682,33 @@ proc chooseFromFit*(fit: seq[GunFitness], diag: ptr SelectorDiag = nil,
|
|||||||
if scores[gunId] >= bestScore - tieBand:
|
if scores[gunId] >= bestScore - tieBand:
|
||||||
tied.add(gunId)
|
tied.add(gunId)
|
||||||
if diag != nil: diag[].tiedCount = tied.len
|
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
|
if tied.len == 0: return 0
|
||||||
result = tied[rand(tied.len - 1)]
|
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.
|
## Uses per-enemy fitness when targetId >= 0 and data exists; else aggregate.
|
||||||
## `diag`, when non-nil, receives the selection internals (bestRate, floor,
|
## `diag`, when non-nil, receives the selection internals (bestRate, floor,
|
||||||
## tie count) exactly as used by the decision.
|
## 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,
|
result = chooseFromFit(t.fitnessFor(targetId), diag,
|
||||||
mode = ActiveSelectorMode,
|
mode = ActiveSelectorMode,
|
||||||
referenceRate = t.peakRateRef)
|
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
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -84,6 +84,59 @@ proc testBestGunDeterministicWinner() =
|
|||||||
check "gun with clearly best rate and >= MinObsBeforeCompete wins deterministically",
|
check "gun with clearly best rate and >= MinObsBeforeCompete wins deterministically",
|
||||||
allTwo
|
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() =
|
proc testBestPowerCold() =
|
||||||
var t = initTracker(3)
|
var t = initTracker(3)
|
||||||
let (bin, power) = t.bestPower(0, -1)
|
let (bin, power) = t.bestPower(0, -1)
|
||||||
@@ -357,6 +410,10 @@ randomize()
|
|||||||
testColdBestGun()
|
testColdBestGun()
|
||||||
testRandomTiebreak()
|
testRandomTiebreak()
|
||||||
testBestGunDeterministicWinner()
|
testBestGunDeterministicWinner()
|
||||||
|
testHysteresisDwell()
|
||||||
|
testHysteresisSwitchMargin()
|
||||||
|
testHysteresisDisqualified()
|
||||||
|
testHysteresisFloor()
|
||||||
testBestPowerCold()
|
testBestPowerCold()
|
||||||
testBestPowerWarmBin3()
|
testBestPowerWarmBin3()
|
||||||
testFitnessForDeterministic()
|
testFitnessForDeterministic()
|
||||||
|
|||||||
Reference in New Issue
Block a user