selector: arrival-accuracy tie-break measured NEGATIVE; randomness is load-bearing
Hypothesis under test (from the gun audit, which named the tie-band as "the lever that matters most"): `bmPath` is deliberately generous (2.3-3.6x `bmPoint`), so a gun can sit in the tied band on a ray that sweeps the target's path while its bullets ARRIVE badly. So: keep the `path`-ranked band (path beat point on real hit rate 7.43% vs 4.70%, z=5.56), but narrow the random draw inside it using a parallel `point` (arrival-accuracy) window. RESULT: NO EFFECT. Real DrussGT, ONE frozen binary (/tmp/ModularBot_tieband, md5 2c0c56e6...), env knobs only, 7 runs x 7 rounds per arm, server-side events sidecar, exact two-sided permutation test on per-run rates. arm runs shots real % dmg/run d p tbbase (shipped) 7 4128 7.17 175 -- -- tbpt path-rank + point-narrow 7 3938 7.08 165 +0.14 0.88 tbpc =commit control 7 3759 4.44 98 +2.74 0.0012 tbpt25 point margin 0.25 7 3683 5.59 119 +1.65 0.20 tbtie05 / tbtie40 (band width) 7 3937/3917 5.84/6.28 133/144 1.49/1.00 0.11/0.25 tbwin50 (SelectorWindow=50) 7 3983 6.05 139 +1.20 0.11 tbfloor10 (FloorPeakFrac=0.10) 7 3829 5.33 118 +2.12 0.11 tbpt vs base: fully overlapping ranges, p=0.88. This is a REAL null, not a dead arm - the mechanism was live, and it visibly changed the selected-gun mix (Pattern 24%->16%, Accel 6%->16%, Tsetlin ~0%->13%). CONTROL VALIDATED, AND THIS IS THE THIRD TIME: removing the random draw inside the band is SIGNIFICANTLY WORSE (4.44%, p=0.0012). Combined with the earlier hysteresis A/B (7.02% -> 5.10% for commitment) and the light-hysteresis result, the selector's per-tick randomness is now load-bearing on three independent measurements. Narrowing the band on ANY second virtual statistic has not helped. Every knob swept (band width, floor, window) is nominally worse than shipped at n=7; that is "no credible win" rather than "proven harm" (sd ~1.8pp, ~1pp resolution, underpowered). Shipped default stays `GUN_SELECTOR_TIEBREAK=off`; the feature is opt-in, fully guarded, and costs zero extra work on the default path (point windows are scored only when the mode is on). Guards: test_selector_tiebreak 19 (new, pure), test_gun_harness 39, test_vbullet_metric 11, test_adaptive_radar 41, test_tfil_ring_weights 24, test_power_policy 26, test_ram_decision 28, test_rack_membership 38, acceptance_offline_vs_online 12/12 PASS (offline path calls neither chooseFromFit nor the tie-break). STRATEGIC CONCLUSION: three selection-side attempts have now failed (hysteresis, commitment, point tie-break). The selector is at a local optimum and the remaining lever is the QUALITY OF THE GUNS, not the selection among them.
This commit is contained in:
@@ -128,6 +128,50 @@ proc parseSelectorMode*(value: string): SelectorMode =
|
||||
|
||||
let ActiveSelectorMode* = parseSelectorMode(getEnv(SelectorModeEnvVar, "relative"))
|
||||
|
||||
# ── arrival-accuracy tie-break (GUN_SELECTOR_TIEBREAK) ───────────────────────
|
||||
#
|
||||
# The shipped selector ranks guns by the `path` metric (the swept-ray score,
|
||||
# measurably the better coarse signal: 7.43% vs 4.70% real hit rate) and then
|
||||
# draws the shot UNIFORMLY at random inside a tie band built on that rate. The
|
||||
# randomness is load-bearing (replacing it with commitment to the virtual best
|
||||
# cost real hit rate, 7.02% -> 5.10%), but `path` is deliberately generous: it
|
||||
# asks "does the ray ever sweep the target's path", which a gun can satisfy
|
||||
# while its bullets ARRIVE poorly. Such a gun then sits inside the band and
|
||||
# gets picked.
|
||||
#
|
||||
# The tie-break below keeps the band construction on `path` (and therefore the
|
||||
# band's measured advantage) and makes the DRAW narrower by arrival accuracy:
|
||||
# each virtual bullet is additionally scored with the `point` model (the
|
||||
# prediction-accuracy score at the exact tick the bullet reaches its aim
|
||||
# distance) into a parallel `pointBins` window, and the tied set is restricted
|
||||
# to the guns whose point rate is within `GUN_SELECTOR_POINT_TIE` of the best
|
||||
# point rate in the band. It is still a random draw inside a band — just a
|
||||
# better-informed band — so the load-bearing randomness is preserved.
|
||||
#
|
||||
# `off` is the SHIPPED default, so an unset environment behaves exactly as
|
||||
# before, and the parallel point scoring is not even performed.
|
||||
const TieBreakEnvVar* = "GUN_SELECTOR_TIEBREAK"
|
||||
|
||||
type
|
||||
TieBreakMode* = enum
|
||||
tbOff ## DEFAULT: pure `path` band, uniform random draw.
|
||||
tbPoint ## narrow the `path` tie band to the point-accurate guns.
|
||||
tbPointCommit ## CONTROL (removes the randomness): take the best point rate
|
||||
## inside the band, deterministically. Measured to be worse
|
||||
## when done on the path rate; included so the point variant
|
||||
## can be compared against its own commitment control.
|
||||
|
||||
proc parseTieBreak*(value: string): TieBreakMode =
|
||||
## Empty / unknown values fall back to the shipped `off` and warn.
|
||||
case value.strip().toLowerAscii()
|
||||
of "", "off", "none", "0", "false": tbOff
|
||||
of "point", "arrival", "p": tbPoint
|
||||
of "commit", "pointcommit", "best": tbPointCommit
|
||||
else:
|
||||
stderr.writeLine("[gun_harness] unknown " & TieBreakEnvVar & "='" & value &
|
||||
"'; falling back to 'off' (valid: off|point|commit)")
|
||||
tbOff
|
||||
|
||||
# ── rack membership (melee vs 1v1) ────────────────────────────────────────────
|
||||
#
|
||||
# The selector can run two racks and switch between them on SERVER truth —
|
||||
@@ -246,6 +290,25 @@ let ActiveShrink* = max(0.0, envFloat("GUN_SELECTOR_SHRINK", 20.0))
|
||||
let ActiveDwellTicks* = max(0, envInt("GUN_SELECTOR_DWELL", GunDwellTicks))
|
||||
let ActiveSwitchMargin* = max(0.0, envFloat("GUN_SELECTOR_MARGIN", GunSwitchMargin))
|
||||
|
||||
# Arrival-accuracy tie-break: mode plus the RELATIVE width of the point band
|
||||
# (fraction of the best in-band point rate), matching the `RelTieMargin` style.
|
||||
let ActiveTieBreak* = parseTieBreak(getEnv(TieBreakEnvVar, ""))
|
||||
let ActivePointTie* = clamp(envFloat("GUN_SELECTOR_POINT_TIE", 0.5), 0.0, 1.0)
|
||||
|
||||
# A `point` tie-break needs the parallel point windows, which only the `path`
|
||||
# metric records (under `point` the primary ranking already IS arrival
|
||||
# accuracy, so the tie-break would be a no-op). Warn loudly rather than
|
||||
# silently measuring nothing.
|
||||
block:
|
||||
if ActiveTieBreak != tbOff and ActiveMetric == bmPoint:
|
||||
stderr.writeLine("[gun_harness] " & TieBreakEnvVar & "=" & $ActiveTieBreak &
|
||||
" has no effect under " & MetricEnvVar & "=point (the primary " &
|
||||
"ranking already uses arrival accuracy)")
|
||||
elif ActiveTieBreak != tbOff:
|
||||
# One audit line per process so a live run's log records which arm it ran.
|
||||
stderr.writeLine("[gun_harness] " & TieBreakEnvVar & "=" & $ActiveTieBreak &
|
||||
" pointTie=" & $ActivePointTie)
|
||||
|
||||
# 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.
|
||||
@@ -268,6 +331,8 @@ type
|
||||
travelDist*: float ## accumulated px so far
|
||||
fireDist*: float ## distance to target at fire time
|
||||
active*: bool
|
||||
pointScored*: bool ## the parallel point-model score for this flight has
|
||||
## been recorded (tie-break mode only)
|
||||
# --- path-metric bookkeeping (unused by the point metric) ---
|
||||
hitSeen*: bool ## a swept segment already touched the target
|
||||
bestMissDist*: float ## closest segment->target distance seen so far
|
||||
@@ -282,6 +347,11 @@ type
|
||||
|
||||
GunFitness* = object
|
||||
bins*: array[len(PowerBins), FitnessWindow]
|
||||
pointBins*: array[len(PowerBins), FitnessWindow]
|
||||
## Parallel `point`-model window, populated ONLY while the arrival-accuracy
|
||||
## tie-break is active. The `path` ranking continues to read `bins`; the
|
||||
## tie-break reads `pointBins` for the SAME bullets, so both scores come
|
||||
## from one flight with no extra virtual bullets.
|
||||
|
||||
SelectorDiag* = object
|
||||
## Optional observability for `chooseFromFit`/`bestGun`. Never needed by the
|
||||
@@ -292,6 +362,9 @@ type
|
||||
anyQualifies*: bool ## at least one gun reached MinObsBeforeCompete
|
||||
floorRate*: float ## the floor actually applied this tick
|
||||
incumbentKept*: bool ## hysteresis retained the incumbent this tick
|
||||
pointTiedCount*: int ## guns kept after the arrival-accuracy tie-break
|
||||
bestPointRate*: float ## best in-band point rate (the tie-break reference)
|
||||
pointTieFired*: bool ## the tie-break actually narrowed the band
|
||||
|
||||
VirtualTracker* = object
|
||||
bullets*: array[MaxBullets, VirtualBullet]
|
||||
@@ -371,6 +444,7 @@ proc spawnBullets*(t: var VirtualTracker, gunId: GunId,
|
||||
travelDist: 0.0,
|
||||
fireDist: fireDist,
|
||||
active: true,
|
||||
pointScored: false,
|
||||
hitSeen: false,
|
||||
bestMissDist: Inf,
|
||||
bestMissX: 0.0,
|
||||
@@ -426,6 +500,31 @@ proc gunRate*(fit: GunFitness, pooled: bool): float =
|
||||
let (h, n) = gunCounts(fit, pooled)
|
||||
result = if n > 0: h.float / n.float else: 0.0
|
||||
|
||||
proc pointCounts*(fit: GunFitness, pooled: bool): tuple[hits, n: int] =
|
||||
## Sample counts behind `pointRate`: the PARALLEL point-model window. All zero
|
||||
## unless the arrival-accuracy tie-break is active, which is what makes the
|
||||
## tie-break a graceful no-op on every existing caller.
|
||||
if pooled:
|
||||
for binIdx in 0..<len(PowerBins):
|
||||
let m = min(fit.pointBins[binIdx].count, min(ActiveWindow, WindowSize))
|
||||
result.n += m
|
||||
result.hits += windowHits(fit.pointBins[binIdx], m)
|
||||
else:
|
||||
var best = -1.0
|
||||
for binIdx in 0..<len(PowerBins):
|
||||
let m = min(fit.pointBins[binIdx].count, min(ActiveWindow, WindowSize))
|
||||
if m == 0: continue
|
||||
let h = windowHits(fit.pointBins[binIdx], m)
|
||||
let r = h.float / m.float
|
||||
if r > best:
|
||||
best = r
|
||||
result = (h, m)
|
||||
|
||||
proc pointRate*(fit: GunFitness, pooled: bool): float =
|
||||
## Arrival-accuracy rate over the parallel point window (0.0 when no data).
|
||||
let (h, n) = pointCounts(fit, pooled)
|
||||
result = if n > 0: h.float / n.float else: 0.0
|
||||
|
||||
proc rankScore(fit: GunFitness, pooled: bool, stat: RankStat,
|
||||
fieldRate, shrinkK: float): float =
|
||||
## Ranking statistic over the gun's window. All are monotone-ish in the mean,
|
||||
@@ -478,7 +577,8 @@ proc noteBestRate*(t: var VirtualTracker) =
|
||||
|
||||
proc tickBullets*(t: var VirtualTracker, state: WorldState,
|
||||
enemies: Table[int, tuple[x, y: float, lastSeenTick: int, alive: bool]],
|
||||
onResolved: proc(gunId: GunId, binIdx: int, e: FeedbackEvent)) =
|
||||
onResolved: proc(gunId: GunId, binIdx: int, e: FeedbackEvent),
|
||||
tieBreak: TieBreakMode = ActiveTieBreak) =
|
||||
## Advance all active bullets one tick.
|
||||
##
|
||||
## The `bmPoint` branch (default) is unchanged: resolve when the bullet
|
||||
@@ -563,6 +663,20 @@ proc tickBullets*(t: var VirtualTracker, state: WorldState,
|
||||
let by = b.fireY + uy * b.travelDist
|
||||
let segMiss = distPointToSegment(ex, ey, ax, ay, bx, by)
|
||||
|
||||
# Arrival-accuracy probe (tie-break only; skipped entirely when off). The
|
||||
# tick the bullet first reaches its fire-time aim distance is exactly the
|
||||
# tick `bmPoint` would resolve on, so this records the SAME outcome the
|
||||
# point model would have — just into a parallel window, leaving the path
|
||||
# score (and therefore the shipped ranking) untouched.
|
||||
if tieBreak != tbOff and not b.pointScored and b.travelDist >= b.fireDist:
|
||||
b.pointScored = true
|
||||
let (px, py) =
|
||||
if dist < 1e-6: (b.aimX, b.aimY)
|
||||
else: (b.fireX + ux * b.fireDist, b.fireY + uy * b.fireDist)
|
||||
let pMiss = hypot(px - ex, py - ey)
|
||||
if b.targetId in t.fitness:
|
||||
t.fitness[b.targetId][b.gunId].pointBins[b.powerBin].record(pMiss < BotRadius)
|
||||
|
||||
if not b.hitSeen:
|
||||
if segMiss < BotRadius:
|
||||
# First physical contact — freeze it so a later closer approach
|
||||
@@ -627,6 +741,11 @@ proc fitnessFor*(t: VirtualTracker, targetId: int): seq[GunFitness] =
|
||||
let src = perEnemy[gunId].bins[binIdx]
|
||||
for k in 0..<min(src.count, WindowSize):
|
||||
result[gunId].bins[binIdx].record(src.hits[k])
|
||||
# The parallel point window (tie-break) must be merged by the same rule,
|
||||
# or the aggregate fallback would silently lose the arrival score.
|
||||
let psrc = perEnemy[gunId].pointBins[binIdx]
|
||||
for k in 0..<min(psrc.count, WindowSize):
|
||||
result[gunId].pointBins[binIdx].record(psrc.hits[k])
|
||||
|
||||
proc bestPower*(t: VirtualTracker, gunId: GunId, targetId: int = -1): (int, float) =
|
||||
## Returns (binIdx, power). Prefers the HIGHEST power bin whose virtual hit
|
||||
@@ -760,7 +879,9 @@ proc chooseFromFit*(fit: seq[GunFitness], diag: ptr SelectorDiag = nil,
|
||||
incumbent: GunId = -1,
|
||||
switchMargin = 0.0,
|
||||
rackMode: RackMode = rm1v1,
|
||||
membership: openArray[RackMembership] = []): GunId =
|
||||
membership: openArray[RackMembership] = [],
|
||||
tieBreak: TieBreakMode = ActiveTieBreak,
|
||||
pointTieMargin: float = ActivePointTie): 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.
|
||||
@@ -789,6 +910,12 @@ proc chooseFromFit*(fit: seq[GunFitness], diag: ptr SelectorDiag = nil,
|
||||
## 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.
|
||||
##
|
||||
## `tieBreak` (see `TieBreakMode`) optionally narrows the tied set by the
|
||||
## PARALLEL arrival-accuracy window (`GunFitness.pointBins`, filled by
|
||||
## `tickBullets` only while the tie-break is active) before that random draw.
|
||||
## The default is the shipped `off`, and a fitness seq with no point data
|
||||
## leaves the band untouched, so every existing caller is byte-identical.
|
||||
let pooled = if mode == smRelative: ActivePooled else: false
|
||||
|
||||
let admitted = admittedGuns(fit.len, rackMode, membership)
|
||||
@@ -877,6 +1004,55 @@ proc chooseFromFit*(fit: seq[GunFitness], diag: ptr SelectorDiag = nil,
|
||||
return incumbent
|
||||
|
||||
if tied.len == 0: return 0
|
||||
|
||||
# ── arrival-accuracy tie-break (GUN_SELECTOR_TIEBREAK) ──────────────────────
|
||||
# `tied` is the `path` band (optionally already narrowed to the challengers
|
||||
# that cleared the switch margin). Re-order it by the parallel point window so
|
||||
# the uniform draw below lands on guns whose bullets actually ARRIVE, not just
|
||||
# on guns whose ray sweeps the target generously.
|
||||
#
|
||||
# tbPoint — narrow the band to guns within `pointTieMargin` of the best
|
||||
# in-band point rate, then keep the uniform random draw.
|
||||
# tbPointCommit — CONTROL: deterministically take the best point rate,
|
||||
# removing the random draw. Included so the narrowed band can
|
||||
# be compared against its own commitment control.
|
||||
#
|
||||
# A gun with NO point samples is KEPT: it cannot be judged, and dropping it
|
||||
# would turn "cold" into "bad". If no tied gun has any point data the band is
|
||||
# left untouched, so the tie-break is a strict no-op until the point window
|
||||
# warms up.
|
||||
if tieBreak != tbOff and tied.len > 1:
|
||||
var bestPoint = 0.0
|
||||
var anyPoint = false
|
||||
for g in tied:
|
||||
let (h, n) = pointCounts(fit[g], pooled = true)
|
||||
if n == 0: continue
|
||||
anyPoint = true
|
||||
bestPoint = max(bestPoint, h.float / n.float)
|
||||
if diag != nil: diag[].bestPointRate = bestPoint
|
||||
if anyPoint and bestPoint > 0.0:
|
||||
if tieBreak == tbPointCommit:
|
||||
var bestGun = tied[0]
|
||||
var bestP = -1.0
|
||||
for g in tied:
|
||||
let p = pointRate(fit[g], pooled = true)
|
||||
if p > bestP:
|
||||
bestP = p
|
||||
bestGun = g
|
||||
if diag != nil:
|
||||
diag[].pointTieFired = true
|
||||
diag[].pointTiedCount = 1
|
||||
return bestGun
|
||||
var kept: seq[GunId]
|
||||
for g in tied:
|
||||
let (h, n) = pointCounts(fit[g], pooled = true)
|
||||
if n == 0 or h.float / n.float >= bestPoint * (1.0 - pointTieMargin):
|
||||
kept.add g
|
||||
if kept.len > 0 and kept.len < tied.len:
|
||||
if diag != nil: diag[].pointTieFired = true
|
||||
tied = kept
|
||||
if diag != nil: diag[].pointTiedCount = kept.len
|
||||
|
||||
result = tied[rand(tied.len - 1)]
|
||||
|
||||
proc bestGun*(t: VirtualTracker, targetId: int = -1,
|
||||
|
||||
Reference in New Issue
Block a user