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,
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
## Pure guard for the selector's ARRIVAL-ACCURACY TIE-BREAK
|
||||
## (`GUN_SELECTOR_TIEBREAK`, see common_libs/gun_harness/virtual_bullets.nim).
|
||||
##
|
||||
## No Java, no server, no battle:
|
||||
## nim c -r common_libs/tests/test_selector_tiebreak.nim
|
||||
##
|
||||
## Two things are pinned here:
|
||||
## 1. the RANKING rule — `chooseFromFit` narrows a `path` tie band by the
|
||||
## parallel `point` (arrival-accuracy) window while still drawing the shot
|
||||
## at random inside the narrowed band;
|
||||
## 2. the RECORDING rule — `tickBullets` fills that parallel window from the
|
||||
## exact tick the bullet reaches its aim distance, leaving the `path` score
|
||||
## untouched.
|
||||
|
||||
import std/[math, random, tables, strformat]
|
||||
import gun_harness/gun_interface
|
||||
import gun_harness/virtual_bullets
|
||||
|
||||
var failures = 0
|
||||
proc check(name: string, ok: bool) =
|
||||
if ok: echo "PASS: ", name
|
||||
else: echo "FAIL: ", name; inc failures
|
||||
|
||||
proc recordHit(fw: var FitnessWindow, hit: bool) =
|
||||
fw.hits[fw.head] = hit
|
||||
fw.head = (fw.head + 1) mod WindowSize
|
||||
inc fw.count
|
||||
|
||||
proc seed(fit: var GunFitness, binIdx, hits, misses: int, point: bool) =
|
||||
## Record `hits`/`misses` into the gun's path window (or the parallel point
|
||||
## window when `point`).
|
||||
var fw = if point: addr fit.pointBins[binIdx] else: addr fit.bins[binIdx]
|
||||
for _ in 0..<hits: recordHit(fw[], true)
|
||||
for _ in 0..<misses: recordHit(fw[], false)
|
||||
|
||||
proc mkFit(rates: openArray[tuple[pHits, pMiss, aHits, aMiss: int]]): seq[GunFitness] =
|
||||
## One gun per entry: (path hits, path misses, point hits, point misses) in
|
||||
## power bin 0. Both windows get the same >= MinObsBeforeCompete sample count.
|
||||
result = newSeq[GunFitness](rates.len)
|
||||
for i, r in rates:
|
||||
result[i].seed(0, r.pHits, r.pMiss, point = false)
|
||||
result[i].seed(0, r.aHits, r.aMiss, point = true)
|
||||
|
||||
# ── parsing / default ─────────────────────────────────────────────────────────
|
||||
|
||||
proc testParsing() =
|
||||
check "tiebreak parse: empty -> shipped default (off)", parseTieBreak("") == tbOff
|
||||
check "tiebreak parse: 'off' -> tbOff", parseTieBreak("off") == tbOff
|
||||
check "tiebreak parse: 'point' -> tbPoint", parseTieBreak("point") == tbPoint
|
||||
check "tiebreak parse: 'commit' -> tbPointCommit", parseTieBreak("commit") == tbPointCommit
|
||||
check "tiebreak parse: case/space insensitive", parseTieBreak(" PoInT ") == tbPoint
|
||||
check "tiebreak parse: unknown -> off (safe fallback, warns)",
|
||||
parseTieBreak("definitely-not-a-mode") == tbOff
|
||||
|
||||
# ── ranking rule ──────────────────────────────────────────────────────────────
|
||||
|
||||
proc testNarrowingKeepsRandomness() =
|
||||
## Three path-tied guns (10.0 / 9.5 / 9.0 %), all inside the 20% relative
|
||||
## band. Arrival accuracy is sharply different: 1 / 4 / 3 %. With a 0.5
|
||||
## relative point margin the band must lose gun 0 (0.01 < 0.5 * 0.04) but keep
|
||||
## the uniform random draw over guns 1 and 2.
|
||||
randomize(20240921)
|
||||
let fit = mkFit([(100, 0, 1, 99), (95, 5, 40, 60), (90, 10, 30, 70)])
|
||||
var seen: array[3, int]
|
||||
var narrowed = 0
|
||||
for _ in 0..<400:
|
||||
var d: SelectorDiag
|
||||
let g = chooseFromFit(fit, addr d, mode = smRelative,
|
||||
tieBreak = tbPoint, pointTieMargin = 0.5)
|
||||
if g >= 0 and g < 3: inc seen[g]
|
||||
if d.pointTieFired and d.pointTiedCount == 2: inc narrowed
|
||||
check "tiebreak(point): the bad-arrival gun is never drawn", seen[0] == 0
|
||||
let bothDrawn = seen[1] > 0 and seen[2] > 0
|
||||
check "tiebreak(point): both point-accurate tied guns are still drawn (randomness kept)", bothDrawn
|
||||
check "tiebreak(point): the band really was narrowed (diag)", narrowed == 400
|
||||
echo fmt" point draws: gun0={seen[0]} gun1={seen[1]} gun2={seen[2]} (n=400)"
|
||||
|
||||
proc testOffIsBaseline() =
|
||||
## With the tie-break OFF the identical fitness set must still expose the
|
||||
## bad-arrival gun — i.e. the shipped behaviour is unchanged.
|
||||
randomize(20240921)
|
||||
let fit = mkFit([(100, 0, 1, 99), (95, 5, 40, 60), (90, 10, 30, 70)])
|
||||
var seen: array[3, int]
|
||||
for _ in 0..<400:
|
||||
let g = chooseFromFit(fit, nil, mode = smRelative, tieBreak = tbOff)
|
||||
if g >= 0 and g < 3: inc seen[g]
|
||||
check "tiebreak(off): the bad-arrival gun is still drawn (no behaviour change)",
|
||||
seen[0] > 0 and seen[1] > 0 and seen[2] > 0
|
||||
echo fmt" off draws: gun0={seen[0]} gun1={seen[1]} gun2={seen[2]} (n=400)"
|
||||
|
||||
proc testCommitIsDeterministic() =
|
||||
## Control arm: `tbPointCommit` removes the random draw and takes the best
|
||||
## in-band arrival rate.
|
||||
randomize(1)
|
||||
let fit = mkFit([(100, 0, 1, 99), (95, 5, 40, 60), (90, 10, 30, 70)])
|
||||
var allOne = true
|
||||
for _ in 0..<200:
|
||||
if chooseFromFit(fit, nil, mode = smRelative,
|
||||
tieBreak = tbPointCommit) != 1: allOne = false
|
||||
check "tiebreak(commit): deterministically returns the best arrival gun", allOne
|
||||
|
||||
proc testColdPointIsNoOp() =
|
||||
## With no point samples at all the tie-break must leave the band untouched.
|
||||
randomize(7)
|
||||
let fit = mkFit([(100, 0, 0, 0), (95, 5, 0, 0), (90, 10, 0, 0)])
|
||||
var seen: array[3, int]
|
||||
var fired = 0
|
||||
for _ in 0..<400:
|
||||
var d: SelectorDiag
|
||||
let g = chooseFromFit(fit, addr d, mode = smRelative, tieBreak = tbPoint)
|
||||
if g >= 0 and g < 3: inc seen[g]
|
||||
if d.pointTieFired: inc fired
|
||||
check "tiebreak(cold point data): band untouched, all tied guns drawn",
|
||||
seen[0] > 0 and seen[1] > 0 and seen[2] > 0
|
||||
check "tiebreak(cold point data): never reports a narrowing", fired == 0
|
||||
|
||||
proc testColdGunIsKept() =
|
||||
## A gun with ZERO point samples cannot be judged and must be KEPT, while a
|
||||
## gun with a measured bad arrival rate is dropped.
|
||||
randomize(11)
|
||||
let fit = mkFit([(100, 0, 0, 0), (95, 5, 40, 60), (90, 10, 1, 99)])
|
||||
var seen: array[3, int]
|
||||
for _ in 0..<400:
|
||||
let g = chooseFromFit(fit, nil, mode = smRelative,
|
||||
tieBreak = tbPoint, pointTieMargin = 0.5)
|
||||
if g >= 0 and g < 3: inc seen[g]
|
||||
check "tiebreak: a cold-on-point gun is kept (not judged bad)", seen[0] > 0
|
||||
check "tiebreak: a measured bad-arrival gun is dropped", seen[2] == 0
|
||||
check "tiebreak: the point-accurate gun is still drawn", seen[1] > 0
|
||||
echo fmt" cold/mixed draws: gun0={seen[0]} gun1={seen[1]} gun2={seen[2]}"
|
||||
|
||||
# ── recording rule (tickBullets) ──────────────────────────────────────────────
|
||||
|
||||
proc mkState(tick: int, ex, ey: float): WorldState =
|
||||
WorldState(selfX: 100.0, selfY: 100.0,
|
||||
enemyX: ex, enemyY: ey, enemySpeed: 0.0, enemyHeading: 0.0,
|
||||
arenaWidth: 1000.0, arenaHeight: 1000.0, tick: tick)
|
||||
|
||||
proc runOneBullet(tieBreak: TieBreakMode): tuple[samples, hits: int] =
|
||||
## Spawn one bullet per power bin aimed at a stationary enemy 200 px away, then
|
||||
## tick until it passes its aim distance. The parallel window must fill only
|
||||
## when the tie-break is active.
|
||||
let targetId = 7
|
||||
var t = initTracker(1, bmPath)
|
||||
let s0 = mkState(0, 300.0, 100.0)
|
||||
let preds = [GunPrediction(x: 300.0, y: 100.0), GunPrediction(x: 300.0, y: 100.0),
|
||||
GunPrediction(x: 300.0, y: 100.0), GunPrediction(x: 300.0, y: 100.0)]
|
||||
t.spawnBullets(0, preds, s0, targetId)
|
||||
for i in 1..<80:
|
||||
let st = mkState(i, 300.0, 100.0)
|
||||
var enemies: Table[int, tuple[x, y: float, lastSeenTick: int, alive: bool]]
|
||||
enemies[targetId] = (x: st.enemyX, y: st.enemyY, lastSeenTick: st.tick, alive: true)
|
||||
t.tickBullets(st, enemies,
|
||||
proc(gid: GunId, bin: int, e: FeedbackEvent) = discard, tieBreak = tieBreak)
|
||||
let fit = t.fitnessFor(targetId)
|
||||
for bin in 0..<len(PowerBins):
|
||||
let n = min(fit[0].pointBins[bin].count, WindowSize)
|
||||
result.samples += n
|
||||
for k in 0..<n:
|
||||
if fit[0].pointBins[bin].hits[k]: inc result.hits
|
||||
|
||||
proc testRecording() =
|
||||
let on = runOneBullet(tbPoint)
|
||||
let off = runOneBullet(tbOff)
|
||||
check "recording: tie-break OFF records no parallel samples (zero overhead)",
|
||||
off.samples == 0
|
||||
check "recording: tie-break ON records one parallel sample per bullet",
|
||||
on.samples == len(PowerBins)
|
||||
check "recording: an aimed stationary target is a point hit",
|
||||
on.hits == on.samples
|
||||
|
||||
# ── driver ────────────────────────────────────────────────────────────────────
|
||||
|
||||
testParsing()
|
||||
testNarrowingKeepsRandomness()
|
||||
testOffIsBaseline()
|
||||
testCommitIsDeterministic()
|
||||
testColdPointIsNoOp()
|
||||
testColdGunIsKept()
|
||||
testRecording()
|
||||
|
||||
if failures > 0:
|
||||
echo "\n", failures, " check(s) FAILED"
|
||||
quit(1)
|
||||
echo "\nAll selector tie-break checks passed."
|
||||
@@ -715,6 +715,58 @@ constructor, so ties resolved **identically across process restarts** — the
|
||||
startup seed plus a `GUN_SELECTOR_SEED` override. Evidence: unseeded runs vary
|
||||
across processes, seeded runs are identical. **[MEASURED]**.
|
||||
|
||||
### 6.8 The arrival-accuracy tie-band does not beat the shipped band
|
||||
|
||||
**Hypothesis.** Rank by `path` (robust, keeps its measured advantage) but narrow
|
||||
the tied random draw by **arrival accuracy** (`point`): a gun whose ray sweeps
|
||||
the target generously can sit in the band while its bullets arrive badly, so
|
||||
making the band informative should improve the real hit rate without removing
|
||||
the load-bearing randomness.
|
||||
|
||||
**Implementation** (`GUN_SELECTOR_TIEBREAK`, `common_libs/gun_harness/`,
|
||||
default `off`): each virtual bullet is additionally scored with the `point`
|
||||
model at the exact tick it reaches its aim distance into a parallel
|
||||
`GunFitness.pointBins` window, and the `path` tie band is narrowed to the guns
|
||||
within `GUN_SELECTOR_POINT_TIE` (default 0.5) of the best in-band point rate. The
|
||||
uniform random draw over the narrowed band is kept. `GUN_SELECTOR_TIEBREAK=point`
|
||||
selects it; `=commit` is the no-randomness control. `off` performs no parallel
|
||||
scoring at all, so the shipped path is byte-identical. The recording rule and
|
||||
the ranking rule are pinned by `common_libs/tests/test_selector_tiebreak.nim`
|
||||
(19 checks, no battle).
|
||||
|
||||
**Live A/B** vs the real DrussGT, one frozen binary, 7 runs x 7 rounds per arm,
|
||||
server-side events sidecar (~200-260 shots/run). `d = base - arm` (positive =
|
||||
arm worse); p is the exact two-sided permutation test on per-run rates.
|
||||
**[MEASURED]** (`/tmp/battle_tb*_r*.log`, `/tmp/events_tb*_r*.json`):
|
||||
|
||||
| Arm | Runs | Shots | Real % | dmg/run | d | p |
|
||||
|---|---:|---:|---:|---:|---:|---:|
|
||||
| `tbbase` (shipped) | 7 | 4,128 | **7.17** | **175** | — | — |
|
||||
| `tbpt` (path + point narrow) | 7 | 3,938 | 7.08 | 165 | +0.14 | 0.88 |
|
||||
| `tbpc` (`=commit` control) | 7 | 3,759 | 4.44 | 98 | +2.74 | **0.0012** |
|
||||
| `tbpt25` (point margin 0.25) | 7 | 3,683 | 5.59 | 119 | +1.65 | 0.20 |
|
||||
| `tbtie05` (`TIE=0.05`) | 7 | 3,937 | 5.84 | 133 | +1.49 | 0.11 |
|
||||
| `tbtie40` (`TIE=0.40`) | 7 | 3,917 | 6.28 | 144 | +1.00 | 0.25 |
|
||||
| `tbwin50` (`WINDOW=50`) | 7 | 3,983 | 6.05 | 139 | +1.20 | 0.11 |
|
||||
| `tbfloor10` (`FLOOR=0.10`) | 7 | 3,829 | 5.33 | 118 | +2.12 | 0.11 |
|
||||
|
||||
The mechanism DID fire — the tie-break re-shaped the selection mix (over all 7
|
||||
runs: Pattern 24%→16%, Accel 6%→16%, Tsetlin ~0%→13% under `tbpt`; the
|
||||
no-randomness control `tbpc` collapses to HeadOn 43% vs 27%) — but it **did not
|
||||
improve the real hit rate**: 7.08% vs 7.17%, fully
|
||||
overlapping per-run ranges (base 5.29-8.73, arm 3.71-10.39), p = 0.88. The
|
||||
no-randomness control `tbpc` is **significantly worse** (4.44%, p = 0.0012),
|
||||
which independently replicates the earlier "commitment to the virtual best
|
||||
costs real hit rate" result and validates that the arm was live. Every knob
|
||||
variant (`TIE`, `FLOOR`, `WINDOW`) is also nominally *worse* than the shipped
|
||||
values, none credibly better. **Verdict: clean negative — the shipped selector
|
||||
is unchanged** (`GUN_SELECTOR_TIEBREAK` defaults to `off`).
|
||||
|
||||
This is consistent with §2: the virtual rate is a poor ranker, and the
|
||||
selector's value is its **floor/tie hedging**, not the ordering it computes.
|
||||
Narrowing the band with a second virtual statistic changes *which* guns are
|
||||
drawn without making that draw any better.
|
||||
|
||||
---
|
||||
|
||||
## 7. Known caveats and open problems
|
||||
|
||||
@@ -78,3 +78,10 @@ distributions and paired permutation p = 0.57 / 0.21. Being below overall does
|
||||
(`randomize()` reached only incidentally via the Tsetlin constructor); now an
|
||||
explicit startup seed plus a `GUN_SELECTOR_SEED` override makes seeded runs
|
||||
reproducible and unseeded runs vary.
|
||||
8. **The arrival-accuracy tie-band does not beat the shipped band** (7 runs x 7
|
||||
rounds/arm vs DrussGT, one frozen binary): path-ranking + point-narrowing
|
||||
7.08% vs shipped 7.17% (overlapping, p = 0.88), while the no-randomness
|
||||
control (`GUN_SELECTOR_TIEBREAK=commit`) is significantly worse at 4.44%
|
||||
(p = 0.0012). The `TIE`/`FLOOR`/`WINDOW` sweep is also nominally worse at
|
||||
every setting. **Shipped selector unchanged**; `GUN_SELECTOR_TIEBREAK`
|
||||
defaults to `off`. Detail: [`gun_rack_analysis.md`](gun_rack_analysis.md) §6.8.
|
||||
|
||||
Reference in New Issue
Block a user