diff --git a/common_libs/gun_harness/virtual_bullets.nim b/common_libs/gun_harness/virtual_bullets.nim index 2e8ced9..2ec7ad2 100644 --- a/common_libs/gun_harness/virtual_bullets.nim +++ b/common_libs/gun_harness/virtual_bullets.nim @@ -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.. 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.. 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, diff --git a/common_libs/tests/test_selector_tiebreak.nim b/common_libs/tests/test_selector_tiebreak.nim new file mode 100644 index 0000000..36e781c --- /dev/null +++ b/common_libs/tests/test_selector_tiebreak.nim @@ -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..= 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.. 0: + echo "\n", failures, " check(s) FAILED" + quit(1) +echo "\nAll selector tie-break checks passed." diff --git a/docs/gun_rack_analysis.md b/docs/gun_rack_analysis.md index f7c4b7b..83cb291 100644 --- a/docs/gun_rack_analysis.md +++ b/docs/gun_rack_analysis.md @@ -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 diff --git a/docs/gun_rack_summary.md b/docs/gun_rack_summary.md index bf6608c..99a5b07 100644 --- a/docs/gun_rack_summary.md +++ b/docs/gun_rack_summary.md @@ -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.