racks: separate melee and 1v1 gun racks, plus per-mode real hit-rate data
The user's plan: "separate racks for melee and 1v1, so the bot switches from those based on the situation, and we can put the guns we want in one or both racks." MECHANISM - `RackMode` (rm1v1/rmMelee) derived from SERVER TRUTH: `rackMode(enemyCount)` = 1v1 when the count is 1, melee otherwise. This is the SAME `getEnemyCount()` value the radar already uses, so there is now ONE definition of the mode. (Using the tracker's known-enemy count was a previous bug in the radar: it read 1 before the second enemy was scanned.) - `RackMembership` per gun: both (default) | 1v1 | melee | off. - The selector ranks only admitted guns - including the floor path and the incumbent-hysteresis path. - Empty filtered set FALLS BACK to the full rack, so the bot can never end up with no gun. - Env-overridable at process start, no rebuild: `TR_RACK_<GUN>` for all 14 guns (TR_RACK_HEADON, TR_RACK_LINEAR, ... TR_RACK_TMSELECT), values both|1v1|melee|off. Empty/unknown -> both + a stderr warning, never fatal. - `[rack] mode=<1v1|melee> active=<guns> overrides=<...>` logged once per mode change, never per tick. DEFAULT IS UNCHANGED: every gun ships `rmBoth`, so behaviour is byte-identical until the user re-racks anything. Verified by the unit test's default-config selection parity (RNG draw for RNG draw) and by `test_gun_harness` 39 and acceptance 12/12. `chooseFromFit` iterates the admitted list in ascending id order, so the random tie-break draws are unchanged. NO TUNING DONE, deliberately: we had no per-gun melee hit-rate data, and an earlier 15-paired-run experiment found pruning neutral-to-negative on hit rate (p=0.57/0.21). So all guns stay `both` and the membership pass waits for data. PER-MODE DATA PLUMBING (this is what unblocks that pass): per-gun real shot accounting is now split by the rack in force at fire time, adding to gun_stats.jsonl: realShots1v1, realHits1v1, realHitRate1v1, realShotsMelee, realHitsMelee, realHitRateMelee. Verification: test_rack_membership 38/38 (new, pure, no battle); test_gun_harness 39, test_vbullet_metric 11, test_power_selection 3, test_adaptive_radar 41, test_tfil_ring_weights 24, test_power_policy 26, test_ram_decision 28; acceptance_offline_vs_online 12/12 VERDICT PASS; ModularBot compiles. The live `[rack]` line was observed switching 1v1 -> melee when the enemy died. The offline range never calls the selector (only spawnBullets/tickBullets/ reportFor), so mode filtering cannot change the offline result and no offline mode parameter was needed - confirmed by reasoning over the source and by 12/12.
This commit is contained in:
@@ -128,6 +128,64 @@ proc parseSelectorMode*(value: string): SelectorMode =
|
||||
|
||||
let ActiveSelectorMode* = parseSelectorMode(getEnv(SelectorModeEnvVar, "relative"))
|
||||
|
||||
# ── rack membership (melee vs 1v1) ────────────────────────────────────────────
|
||||
#
|
||||
# The selector can run two racks and switch between them on SERVER truth —
|
||||
# `getEnemyCount() == 1` -> 1v1, `> 1` -> melee (`rackMode`) — the same value
|
||||
# the live radar uses to pick RadarLock vs AdaptiveMelee. Each gun declares
|
||||
# which rack(s) admit it. DEFAULT IS `rmBoth` FOR EVERY GUN, so an unset
|
||||
# environment reproduces the pre-change (single-rack) selection byte-for-byte.
|
||||
#
|
||||
# The membership test lives here, next to `chooseFromFit`, because it filters
|
||||
# that function's candidate set. The env parsing / gun-name table live in
|
||||
# selector.nim, which owns the rack's gun names (this low-level module is
|
||||
# generic over `fit.len` and does not).
|
||||
|
||||
type
|
||||
RackMode* = enum
|
||||
rm1v1 ## exactly one enemy alive (server truth)
|
||||
rmMelee ## two or more enemies alive (server truth)
|
||||
|
||||
RackMembership* = enum
|
||||
rmBoth ## DEFAULT: eligible in 1v1 AND melee
|
||||
rmOnly1v1 ## eligible only while exactly one enemy is alive
|
||||
rmOnlyMelee ## eligible only in melee (2+ enemies)
|
||||
rmOff ## never eligible (removed from the selector's rack)
|
||||
|
||||
proc admits*(m: RackMembership, mode: RackMode): bool =
|
||||
## True when membership `m` includes rack `mode`.
|
||||
case m
|
||||
of rmBoth: true
|
||||
of rmOnly1v1: mode == rm1v1
|
||||
of rmOnlyMelee: mode == rmMelee
|
||||
of rmOff: false
|
||||
|
||||
proc rackMode*(enemyCount: int): RackMode =
|
||||
## THE single definition of the mode, derived from server truth. The live
|
||||
## radar derives its target mode from the same count in ModularBot.run.
|
||||
if enemyCount == 1: rm1v1 else: rmMelee
|
||||
|
||||
proc rackAdmitted*(gunId: int, mode: RackMode,
|
||||
membership: openArray[RackMembership]): bool =
|
||||
## True when `gunId` is a candidate in `mode`. An id past the membership table
|
||||
## is admitted, so an empty table means "no filtering".
|
||||
if gunId < 0: return false
|
||||
if gunId < membership.len: return membership[gunId].admits(mode)
|
||||
true
|
||||
|
||||
proc admittedGuns*(fitLen: int, mode: RackMode,
|
||||
membership: openArray[RackMembership]): seq[GunId] =
|
||||
## Gun ids whose membership admits `mode`. GRACEFUL DEGRADATION: if the
|
||||
## filtered set is EMPTY (e.g. every gun is `off`, or no membership table is
|
||||
## supplied), return the full rack so the selector can never end up with no
|
||||
## gun.
|
||||
for gunId in 0..<fitLen:
|
||||
if gunId < membership.len and not membership[gunId].admits(mode):
|
||||
continue
|
||||
result.add gunId
|
||||
if result.len == 0:
|
||||
for gunId in 0..<fitLen: result.add gunId
|
||||
|
||||
# ── runtime ranking knobs (A/B without rebuilding) ────────────────────────────
|
||||
#
|
||||
# Every knob below defaults to the SHIPPED constant, so an unset environment
|
||||
@@ -700,11 +758,18 @@ proc chooseFromFit*(fit: seq[GunFitness], diag: ptr SelectorDiag = nil,
|
||||
mode: SelectorMode = smAbsolute,
|
||||
referenceRate = -1.0,
|
||||
incumbent: GunId = -1,
|
||||
switchMargin = 0.0): GunId =
|
||||
switchMargin = 0.0,
|
||||
rackMode: RackMode = rm1v1,
|
||||
membership: openArray[RackMembership] = []): 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.
|
||||
##
|
||||
## `rackMode`/`membership` restrict the candidate set to the guns whose rack
|
||||
## membership admits the mode. An empty `membership` (the default) admits every
|
||||
## gun, so existing callers and the offline path are unchanged; an empty
|
||||
## FILTERED set also falls back to the full rack.
|
||||
##
|
||||
## Guns with fewer than MinObsBeforeCompete observations are skipped unless
|
||||
## every gun is below threshold (then fall back to best of all).
|
||||
##
|
||||
@@ -726,8 +791,11 @@ proc chooseFromFit*(fit: seq[GunFitness], diag: ptr SelectorDiag = nil,
|
||||
## every tick.
|
||||
let pooled = if mode == smRelative: ActivePooled else: false
|
||||
|
||||
let admitted = admittedGuns(fit.len, rackMode, membership)
|
||||
if admitted.len == 0: return 0
|
||||
|
||||
var anyQualifies = false
|
||||
for gunId in 0..<fit.len:
|
||||
for gunId in admitted:
|
||||
if gunEligible(fit[gunId], true):
|
||||
anyQualifies = true
|
||||
break
|
||||
@@ -739,7 +807,7 @@ proc chooseFromFit*(fit: seq[GunFitness], diag: ptr SelectorDiag = nil,
|
||||
var bestRate = 0.0
|
||||
var fieldSum = 0.0
|
||||
var fieldN = 0
|
||||
for gunId in 0..<fit.len:
|
||||
for gunId in admitted:
|
||||
if requireMin and not gunEligible(fit[gunId], true): continue
|
||||
let (h, n) = gunCounts(fit[gunId], pooled)
|
||||
if n == 0: continue
|
||||
@@ -756,15 +824,16 @@ proc chooseFromFit*(fit: seq[GunFitness], diag: ptr SelectorDiag = nil,
|
||||
else: 0.0
|
||||
if diag != nil: diag[].floorRate = floorRate
|
||||
|
||||
# No hit at all, or the field collapsed below its own recent peak: HeadOn.
|
||||
# No hit at all, or the field collapsed below its own recent peak: the first
|
||||
# admitted gun (HeadOn by default).
|
||||
if bestRate <= 0.0 or bestRate < floorRate:
|
||||
if diag != nil: diag[].floorFired = true
|
||||
return 0
|
||||
return admitted[0]
|
||||
|
||||
# Ranking scores (the active statistic) and the best of them.
|
||||
var scores = newSeq[float](fit.len)
|
||||
var bestScore = 0.0
|
||||
for gunId in 0..<fit.len:
|
||||
for gunId in admitted:
|
||||
if requireMin and not gunEligible(fit[gunId], true): continue
|
||||
let s = rankScore(fit[gunId], pooled, ActiveRank, fieldRate, ActiveShrink)
|
||||
scores[gunId] = s
|
||||
@@ -774,7 +843,7 @@ proc chooseFromFit*(fit: seq[GunFitness], diag: ptr SelectorDiag = nil,
|
||||
if mode == smAbsolute: TieMargin
|
||||
else: bestScore * ActiveRelTie
|
||||
var tied: seq[GunId]
|
||||
for gunId in 0..<fit.len:
|
||||
for gunId in admitted:
|
||||
if requireMin and not gunEligible(fit[gunId], true): continue
|
||||
if scores[gunId] >= bestScore - tieBand:
|
||||
tied.add(gunId)
|
||||
@@ -784,7 +853,8 @@ proc chooseFromFit*(fit: seq[GunFitness], diag: ptr SelectorDiag = nil,
|
||||
# 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:
|
||||
if incumbent >= 0 and incumbent < fit.len and
|
||||
rackAdmitted(incumbent, rackMode, membership):
|
||||
var incumbentEligible = true
|
||||
if requireMin and not gunEligible(fit[incumbent], true):
|
||||
incumbentEligible = false
|
||||
@@ -810,7 +880,9 @@ proc chooseFromFit*(fit: seq[GunFitness], diag: ptr SelectorDiag = nil,
|
||||
result = tied[rand(tied.len - 1)]
|
||||
|
||||
proc bestGun*(t: VirtualTracker, targetId: int = -1,
|
||||
diag: ptr SelectorDiag = nil): GunId =
|
||||
diag: ptr SelectorDiag = nil,
|
||||
rackMode: RackMode = rm1v1,
|
||||
membership: openArray[RackMembership] = []): GunId =
|
||||
## Pick gun with highest hit rate across all power bins.
|
||||
## Uses per-enemy fitness when targetId >= 0 and data exists; else aggregate.
|
||||
## `diag`, when non-nil, receives the selection internals (bestRate, floor,
|
||||
@@ -822,10 +894,13 @@ proc bestGun*(t: VirtualTracker, targetId: int = -1,
|
||||
## analyzer and the harness tests stay deterministic and side-effect free.
|
||||
result = chooseFromFit(t.fitnessFor(targetId), diag,
|
||||
mode = ActiveSelectorMode,
|
||||
referenceRate = t.peakRateRef)
|
||||
referenceRate = t.peakRateRef,
|
||||
rackMode = rackMode, membership = membership)
|
||||
|
||||
proc selectGun*(t: var VirtualTracker, targetId: int = -1, tick = 0,
|
||||
diag: ptr SelectorDiag = nil): GunId =
|
||||
diag: ptr SelectorDiag = nil,
|
||||
rackMode: RackMode = rm1v1,
|
||||
membership: openArray[RackMembership] = []): GunId =
|
||||
## Stateful, sticky gun selection — the LIVE path (`selector.selectShot` calls
|
||||
## this). Wraps the pure `chooseFromFit` ranking with two commitments:
|
||||
##
|
||||
@@ -852,22 +927,29 @@ proc selectGun*(t: var VirtualTracker, targetId: int = -1, tick = 0,
|
||||
# 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
|
||||
# A rack change (1v1 <-> melee) can retire the incumbent; do not let a gun the
|
||||
# current rack excludes survive on hysteresis.
|
||||
let incumbentAdmitted =
|
||||
t.currentGun < 0 or rackAdmitted(t.currentGun, rackMode, membership)
|
||||
let incumbent = if useIncumbent and incumbentAdmitted: t.currentGun else: -1
|
||||
|
||||
let challenger = chooseFromFit(fit, d, ActiveSelectorMode, t.peakRateRef,
|
||||
incumbent = incumbent,
|
||||
switchMargin = ActiveSwitchMargin)
|
||||
switchMargin = ActiveSwitchMargin,
|
||||
rackMode = rackMode, membership = membership)
|
||||
|
||||
# The floor collapses the field to HeadOn regardless of dwell.
|
||||
# The floor collapses the field to the first admitted gun regardless of dwell.
|
||||
if d[].floorFired:
|
||||
if t.currentGun != 0:
|
||||
t.currentGun = 0
|
||||
let floorGun = challenger
|
||||
if t.currentGun != floorGun:
|
||||
t.currentGun = floorGun
|
||||
t.currentSince = tick
|
||||
return 0
|
||||
return floorGun
|
||||
|
||||
result = challenger
|
||||
# Minimum dwell: hold the incumbent while it is still eligible.
|
||||
if useIncumbent and t.currentGun >= 0 and t.currentGun < fit.len:
|
||||
# Minimum dwell: hold the incumbent while it is still eligible AND admitted.
|
||||
if useIncumbent and incumbentAdmitted 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
|
||||
|
||||
Reference in New Issue
Block a user