TM radial gun: registered (default OFF) + label-bias fix that removes the bias but

retracts its own earlier learning claim

=== TASK 1: REGISTERED AS GUN 14, DEFAULT `off` ===
The radial TM gun is now a first-class rack member (`TMPATTERN`, id 14), forceable
alone with `TR_RACK_TMPATTERN=both` plus every other `TR_RACK_*=off`.
DEFAULT IS `off`, and the justification matters: `both` would let it compete for
selection AND (because the shared VirtualTracker ring is order-sensitive) shift
every other gun's learning order, so it CANNOT leave the default path unchanged.
With `off` its predict and spawnBullets are additionally GATED on rack admission
(the only gun wired that way), so the shipped default never spawns it at all:
zero cost, zero ring perturbation.
Live proof: 1-round battle with only TMPATTERN racked ->
  `gun 14 (TMPattern): vShots=400 selected=104 other-gun selections=0`.
Default-path-unchanged proof: parity checks that the 15-gun default bestGun/
selectGun equals the old 14-gun rack RNG-draw-for-RNG-draw, that gun 14 is never
selected by default, and acceptance 12/12.
Cost: 0.36 ms/tick (predict 0.30 + onResult 0.05) ~= 3% of the 13.16 ms budget.
Tsetlin in the same harness is 1.62 ms/tick, so the new gun is ~4.5x cheaper.

=== TASK 2: THE LABEL-BIAS FIX - AND A RETRACTION ===
Root cause confirmed: under bmPoint a SHORT radial correction resolves the virtual
bullet BEFORE the base arrival tick, so the label was dropped (labelMisses).
Fix: defer the label in a pending queue and flush it once the arrival tick is
recorded; labels still come from the BASE arrival tick.
  labelMisses        4,281,695  ->  0
  training samples   1,071,824  ->  5,345,847  (x5)
  radial head acc         48.8% ->  57.0%   (shuffled control 20.0%)
  bmPoint hit rate     9.4/5.8% ->  9.1/5.7%  (unchanged, within noise)
So the fix IMPROVES LEARNING but NOT the metric.

**RETRACTION OF THE PREVIOUS JOB'S CLAIM.** It reported the radial head's 48.8%
against a 36.7% majority baseline and concluded "conditional learning, not a
constant bias". With the bias removed, the correctly-measured majority baseline is
**58.2%** - so the head at 57.0% is AT/BELOW majority. The earlier apparent
conditional learning was PARTLY AN ARTEFACT OF THE BIASED SAMPLE. The bmPoint
metric win is real (TMRadial > Linear early 16/2 p=0.0013, overall 18/0 p<0.0001;
> shuffled 18/0 p<0.0001) but it comes from a NET-POSITIVE AVERAGE RADIAL SHIFT,
not from beating a majority classifier. Recorded plainly rather than left standing.

Guards: test_tm_pattern_registration 20 (new), test_tm_pattern_rack_live 4 (new),
test_gun_harness 39, test_vbullet_metric 11, test_power_selection 3 (the SIGSEGV is
gone - the knn_gun rewrite is now committed), test_adaptive_radar 41,
test_tfil_ring_weights 24, test_power_policy 26, test_ram_decision 28,
test_rack_membership 38, test_selector_tiebreak 19, test_tm_pattern_learning 3,
acceptance_offline_vs_online 12/12. ModularBot compiles (release).

Note: `common_libs/tests/range_guns.nim` still builds 14 offline drivers (the
offline sweep constructs TmPatternGun directly and acceptance only inspects ids
0..13), so nothing breaks - but a future job wanting it in the offline rack must
add a 15th driver and mirror the live admission gating. gun_stats.jsonl now emits
15 rows; downstream tooling should ignore id 14.
This commit is contained in:
2026-09-22 01:58:33 +02:00
parent 394b3deeed
commit 589a230106
7 changed files with 675 additions and 89 deletions
+15 -6
View File
@@ -19,21 +19,30 @@ import virtual_bullets
# TR_RACK_TSETLIN=1v1 -> 1v1 rack only
# TR_RACK_DISPLACE=melee -> melee rack only
# TR_RACK_KNN=off -> removed from both racks
# TR_RACK_TMPATTERN=off (shipped default for the new TM pattern gun)
#
# The mode itself is derived from SERVER truth (`getEnemyCount()`), never from
# the tracker's known-enemy count, by `rackMode` in virtual_bullets — the same
# transition the radar uses. Defaults are all-`both`, so an unset environment
# preserves the pre-change single-rack selection byte-for-byte.
# transition the radar uses. Every gun except TMPATTERN defaults to `both`, so an
# unset environment preserves the pre-change single-rack selection byte-for-byte;
# TMPATTERN defaults to `off` so it cannot alter that selection.
const
RackGunNames*: array[14, string] = [
RackGunNames*: array[15, string] = [
"HEADON", "LINEAR", "TSETLIN", "CIRCULAR", "GUESSFACTOR", "PATTERN",
"WALLBOUNCE", "ACCEL", "STOPSHOT", "DISPLACE", "AVGLEAD", "DECAYGF",
"KNN", "TMSELECT"]
"KNN", "TMSELECT", "TMPATTERN"]
RackEnvPrefix* = "TR_RACK_"
DefaultRackMembership*: array[14, RackMembership] = [
## Defaults are all-`both` EXCEPT the new TM pattern gun (id 14), which ships
## `off`: it is registered and forceable (`TR_RACK_TMPATTERN=both|1v1|melee`)
## but never spawns a virtual bullet unless explicitly enabled, so the shared
## VirtualTracker ring head — and therefore every other gun's learning order
## and the default selection sequence — is byte-for-byte unchanged. Defaulting
## it to `both` would let it compete for selection and change the default rack.
DefaultRackMembership*: array[15, RackMembership] = [
rmBoth, rmBoth, rmBoth, rmBoth, rmBoth, rmBoth, rmBoth,
rmBoth, rmBoth, rmBoth, rmBoth, rmBoth, rmBoth, rmBoth]
rmBoth, rmBoth, rmBoth, rmBoth, rmBoth, rmBoth, rmBoth,
rmOff]
proc parseRackMembership*(value: string): RackMembership =
## Parse a `TR_RACK_<GUN>` value. Empty / unknown values fall back to the
+133 -65
View File
@@ -70,6 +70,13 @@ const
TM_SOFT_BETA* = parseFloat(TM_SOFT_BETA_DEF)
TM_TRACE_SLOTS = 1024
POS_RING = 512
## Deferred-label queue (Task 2): a virtual bullet whose radial correction
## aimed SHORT resolves BEFORE its BASE arrival tick, when the arrival-tick
## position is not yet in `posRing`. Instead of dropping the sample
## (`labelMisses`), the trace is copied here and resolved on the first later
## `predict` tick at which the base arrival tick's position exists, so every
## fired virtual bullet contributes an unbiased training sample.
TM_PENDING_SLOTS = 1024
DebugTMPattern* = false
## ── radial head (Task 2) ────────────────────────────────────────────────
## Radial label = (enemy radius at the BASE arrival tick) - (base fire
@@ -125,12 +132,26 @@ type
heading: float
valid: bool
PendingResolve = object
## A fired virtual bullet whose label was not yet resolvable at resolution
## time. `trace` is a COPY of the fire-time trace (features + clause
## caches), so the deferred training update is identical to an immediate
## one, just later.
arrivalTick: int
powerBin: int
power: float
trace: TmPatternTrace
TmPatternGun* = object
teams: array[TM_CLASSES, seq[int16]]
radTeams: array[TM_CLASSES, seq[int16]]
revTeams: array[2, seq[int16]]
targetMode*: TmTargetMode
traces: array[TM_TRACE_SLOTS, TmPatternTrace]
# ── deferred labels (Task 2) ──
pending: array[TM_PENDING_SLOTS, PendingResolve]
pendingCount: int
pendingDropped*: int
# ── history ──
posRing: array[POS_RING, PosSample]
lastTick: int
@@ -268,6 +289,14 @@ proc initTmPatternGun*(): TmPatternGun =
randomize()
result.debugGraphics = false
proc initTmRadialGun*(): TmPatternGun =
## The RACK-REGISTERED instance: the RADIAL target mode, which is the
## control-validated winner under `bmPoint` (see tm_pattern_sweep_results.md,
## Round 2 Task 2). The gun type carries all three heads; the live rack only
## ever selects this radial-mode instance.
result = initTmPatternGun()
result.targetMode = tmRadial
proc isWarmedUp*(g: TmPatternGun): bool {.inline.} = true
proc resetLearning*(g: var TmPatternGun) =
@@ -284,6 +313,7 @@ proc resetLearning*(g: var TmPatternGun) =
g.sinceReversal = 0
g.radialFracSm = 0.0
g.latPersist = 0
g.pendingCount = 0
proc tmUpdateHistory(g: var TmPatternGun, state: WorldState) =
if state.tick == g.lastTick: return
@@ -442,6 +472,90 @@ proc tmChooseAt(votes: openArray[float], centre: int, margin: float,
proc tmChooseClass(g: var TmPatternGun, votes: array[TM_CLASSES, float]): int =
tmChooseAt(votes, (TM_CLASSES - 1) div 2, TM_CONF_MARGIN, g.totalObs)
proc tmResolveTrace(g: var TmPatternGun, t: TmPatternTrace, power: float) =
## One label + one TM update for a fired virtual bullet, using the enemy
## position recorded at the BASE arrival tick. `t` is a value copy of the
## fire-time trace, so this is safe to call either from `onResult` (the label
## is already resolvable) or from `tmFlushPending` (the label was deferred
## because the bullet resolved before its base arrival tick).
let s = ((t.arrivalTick mod POS_RING) + POS_RING) mod POS_RING
let speed = bulletSpeed(power)
let mea = arcsin(clamp(8.0 / speed, -1.0, 1.0))
let actualBearing = arctan2(g.posRing[s].y - t.fireY, g.posRing[s].x - t.fireX)
var delta = actualBearing - t.baseBearing
while delta > PI: delta -= 2.0 * PI
while delta < -PI: delta += 2.0 * PI
let gf = if mea > 1e-10: clamp(delta / mea, -1.0, 1.0) else: 0.0
# The shuffled control randomises ONLY the head the current mode is claiming.
let shuffleGF = g.shuffleLabels and g.targetMode == tmGF
let shuffleRad = g.shuffleLabels and g.targetMode == tmRadial
let shuffleRev = g.shuffleLabels and g.targetMode == tmReversal
let winner = if shuffleGF: rand(TM_CLASSES - 1) else: gfToBucket(gf)
inc g.labelHist[winner]
if t.warm:
inc g.classTotal
if winner == t.chosen: inc g.classCorrect
# Radial label: enemy radius at the base arrival tick minus the base fire
# distance. Independent of our own aim, so it is a clean target.
let actualRadius = hypot(g.posRing[s].x - t.fireX, g.posRing[s].y - t.fireY)
let radDelta = actualRadius - t.fireDist
let radWinner = if shuffleRad: rand(TM_CLASSES - 1) else: radToBucket(radDelta)
inc g.radLabelHist[radWinner]
if t.warm:
inc g.radTotal
if radWinner == t.radChosen: inc g.radCorrect
# Reversal label: net heading turn over the flight, opposite to the direction
# the enemy was turning at fire time.
let dh = normDeg(g.posRing[s].heading - t.fireHeading)
let netTurn = if dh > TM_REV_TURN_DEG: 1 elif dh < -TM_REV_TURN_DEG: -1 else: 0
let revWinner =
if shuffleRev: rand(1)
elif t.fireTurn != 0 and netTurn != 0 and netTurn != t.fireTurn: 1
else: 0
inc g.revLabelHist[revWinner]
if t.warm:
inc g.revTotal
if revWinner == t.revChosen: inc g.revCorrect
case g.targetMode
of tmGF:
for c in 0..<TM_CLASSES:
let d = if c == winner: 1.0 else: -1.0
g.teams[c].tmLearnDir(t.lits, t.cache[c], t.votes[c], d)
of tmRadial:
for c in 0..<TM_CLASSES:
let d = if c == radWinner: 1.0 else: -1.0
g.radTeams[c].tmLearnDir(t.lits, t.radCache[c], t.radVotes[c], d)
of tmReversal:
for c in 0..<TM_CLASSES:
let d = if c == winner: 1.0 else: -1.0
g.teams[c].tmLearnDir(t.lits, t.cache[c], t.votes[c], d)
for c in 0..<2:
let d = if c == revWinner: 1.0 else: -1.0
g.revTeams[c].tmLearnDir(t.lits, t.revCache[c], t.revVotes[c], d)
inc g.totalObs
inc g.trainCalls
proc tmFlushPending(g: var TmPatternGun) =
## Resolve every deferred trace whose BASE arrival tick is now recorded in
## `posRing`. Called once per `predict` right after `tmUpdateHistory`, so the
## just-written current tick is visible. Entries are compacted in place.
if g.pendingCount == 0: return
var w = 0
for i in 0..<g.pendingCount:
let p = addr g.pending[i]
let s = ((p.arrivalTick mod POS_RING) + POS_RING) mod POS_RING
if g.posRing[s].valid and g.posRing[s].tick == p.arrivalTick:
g.tmResolveTrace(p.trace, p.power)
else:
if w != i: g.pending[w] = g.pending[i]
inc w
g.pendingCount = w
proc predict*(g: var TmPatternGun, state: WorldState, bulletSpeed: float):
GunPrediction =
inc g.predictCalls
@@ -454,6 +568,9 @@ proc predict*(g: var TmPatternGun, state: WorldState, bulletSpeed: float):
g.currentTarget = tid
g.tmUpdateHistory(state)
# Deferred-label flush (Task 2): resolve any fired bullet whose BASE arrival
# tick is now in the ring, before the cold-start gate reads totalObs.
g.tmFlushPending()
if bulletSpeed <= 0.0:
return GunPrediction(x: state.enemyX, y: state.enemyY)
@@ -560,69 +677,20 @@ proc onResult*(g: var TmPatternGun, e: FeedbackEvent) =
# Clean label: enemy position at the BASE arrival tick from our own history.
let s = ((t.arrivalTick mod POS_RING) + POS_RING) mod POS_RING
if not g.posRing[s].valid or g.posRing[s].tick != t.arrivalTick:
inc g.labelMisses
t.alive = false
return
let speed = bulletSpeed(e.bulletPower)
let mea = arcsin(clamp(8.0 / speed, -1.0, 1.0))
let actualBearing = arctan2(g.posRing[s].y - t.fireY, g.posRing[s].x - t.fireX)
var delta = actualBearing - t.baseBearing
while delta > PI: delta -= 2.0 * PI
while delta < -PI: delta += 2.0 * PI
let gf = if mea > 1e-10: clamp(delta / mea, -1.0, 1.0) else: 0.0
# The shuffled control randomises ONLY the head the current mode is claiming.
let shuffleGF = g.shuffleLabels and g.targetMode == tmGF
let shuffleRad = g.shuffleLabels and g.targetMode == tmRadial
let shuffleRev = g.shuffleLabels and g.targetMode == tmReversal
let winner = if shuffleGF: rand(TM_CLASSES - 1) else: gfToBucket(gf)
inc g.labelHist[winner]
if t.warm:
inc g.classTotal
if winner == t.chosen: inc g.classCorrect
# Radial label: enemy radius at the base arrival tick minus the base fire
# distance. Independent of our own aim, so it is a clean target.
let actualRadius = hypot(g.posRing[s].x - t.fireX, g.posRing[s].y - t.fireY)
let radDelta = actualRadius - t.fireDist
let radWinner = if shuffleRad: rand(TM_CLASSES - 1) else: radToBucket(radDelta)
inc g.radLabelHist[radWinner]
if t.warm:
inc g.radTotal
if radWinner == t.radChosen: inc g.radCorrect
# Reversal label: net heading turn over the flight, opposite to the direction
# the enemy was turning at fire time.
let dh = normDeg(g.posRing[s].heading - t.fireHeading)
let netTurn = if dh > TM_REV_TURN_DEG: 1 elif dh < -TM_REV_TURN_DEG: -1 else: 0
let revWinner =
if shuffleRev: rand(1)
elif t.fireTurn != 0 and netTurn != 0 and netTurn != t.fireTurn: 1
else: 0
inc g.revLabelHist[revWinner]
if t.warm:
inc g.revTotal
if revWinner == t.revChosen: inc g.revCorrect
case g.targetMode
of tmGF:
for c in 0..<TM_CLASSES:
let d = if c == winner: 1.0 else: -1.0
g.teams[c].tmLearnDir(t.lits, t.cache[c], t.votes[c], d)
of tmRadial:
for c in 0..<TM_CLASSES:
let d = if c == radWinner: 1.0 else: -1.0
g.radTeams[c].tmLearnDir(t.lits, t.radCache[c], t.radVotes[c], d)
of tmReversal:
for c in 0..<TM_CLASSES:
let d = if c == winner: 1.0 else: -1.0
g.teams[c].tmLearnDir(t.lits, t.cache[c], t.votes[c], d)
for c in 0..<2:
let d = if c == revWinner: 1.0 else: -1.0
g.revTeams[c].tmLearnDir(t.lits, t.revCache[c], t.revVotes[c], d)
inc g.totalObs
inc g.trainCalls
if g.posRing[s].valid and g.posRing[s].tick == t.arrivalTick:
g.tmResolveTrace(t[], e.bulletPower)
else:
# DEFER (Task 2): the bullet resolved BEFORE its BASE arrival tick, which
# happens whenever the radial correction aimed SHORT. The arrival-tick
# position is not recorded yet, so keep a COPY of the trace and train on it
# once that tick is in the ring (`tmFlushPending`). Dropping it here is what
# biased the training set toward only the resolvable (long/centre) aims.
if g.pendingCount < TM_PENDING_SLOTS:
g.pending[g.pendingCount] = PendingResolve(
arrivalTick: t.arrivalTick, powerBin: binIdx,
power: e.bulletPower, trace: t[])
inc g.pendingCount
else:
inc g.pendingDropped
inc g.labelMisses
t.alive = false
@@ -0,0 +1,175 @@
## MEASUREMENT ONLY — per-tick cost of the rack-registered TM pattern gun
## (id 14, radial mode) in isolation, following the `runPerGun` pattern of
## `measure_parallel_vbullet_cost.nim`.
##
## Drives ONE gun through a real DrussGT fixture with a one-gun VirtualTracker,
## timing `predict` (4 power bins/tick) and `onResult` (one call per resolved
## virtual bullet) separately, then reports ms/tick against the ~13.16 ms live
## budget (76 ticks/s). Tsetlin is measured the same way as the reference the
## brief quotes (~5.3 ms/tick).
##
## Usage:
## nim c -r -d:release common_libs/tests/measure_tm_pattern_cost.nim
import std/[os, strformat, tables, math, times, monotimes, random]
import gun_harness/offline_range
import gun_harness/gun_interface
import gun_harness/virtual_bullets as vb
import guns/tm_pattern
import guns/tsetlin
const
RepoRoot = currentSourcePath().parentDir.parentDir.parentDir
FixturePath = RepoRoot / "tools" / "fixtures" / "tr_drussgt_vs_crazy.jsonl"
type
CostResult = object
predictNs: int64
onResultNs: int64
ticks: int
predictCalls: int
onCalls: int
proc measureGun(fx: Fixture, predictCb: proc(state: WorldState, bulletSpeed: float): GunPrediction {.closure.},
resultCb: proc(e: FeedbackEvent) {.closure.},
metric: BulletMetric, warmup, measure: int): CostResult =
var tracker = vb.initTracker(1, metric)
let acc = new(CostResult)
let ticks = fx.states.len
for si in 0..<ticks:
let state = fx.states[si]
var preds: array[len(vb.PowerBins), GunPrediction]
let t0 = getMonoTime()
for b in 0..<len(vb.PowerBins):
preds[b] = predictCb(state, bulletSpeed(vb.PowerBins[b]))
let t1 = getMonoTime()
tracker.spawnBullets(0, preds, state, fx.enemyId)
var enemyPositions: Table[int, tuple[x, y: float, lastSeenTick: int, alive: bool]]
enemyPositions[fx.enemyId] = (x: state.enemyX, y: state.enemyY,
lastSeenTick: state.tick, alive: true)
tracker.tickBullets(state, enemyPositions,
proc(gunId: GunId, binIdx: int, e: FeedbackEvent) =
let s0 = getMonoTime()
resultCb(e)
let s1 = getMonoTime()
if si >= warmup and si < warmup + measure:
acc.onResultNs += (s1 - s0).inNanoseconds
inc acc.onCalls)
if si >= warmup and si < warmup + measure:
inc acc.ticks
acc.predictNs += (t1 - t0).inNanoseconds
inc acc.predictCalls, len(vb.PowerBins)
result = acc[]
proc makeTmRadial(fx: Fixture, metric: BulletMetric, warmup, measure: int): CostResult =
let g = new(TmPatternGun)
g[] = initTmRadialGun()
randomize(1)
result = measureGun(fx,
proc(state: WorldState, bulletSpeed: float): GunPrediction = g[].predict(state, bulletSpeed),
proc(e: FeedbackEvent) = g[].onResult(e),
metric, warmup, measure)
echo fmt" TMPattern(radial): obs={g[].totalObs} labelMiss={g[].labelMisses} " &
fmt"traceMiss={g[].traceMisses} radAcc={g[].radCorrect}/{g[].radTotal}"
var lab = ""
var maj = 0
var tot = 0
for c in 0..<TM_CLASSES:
lab.add $g[].radLabelHist[c] & " "
maj = max(maj, g[].radLabelHist[c])
tot += g[].radLabelHist[c]
echo fmt" radial label hist [{lab}] majority={maj.float / max(1, tot).float * 100:.1f}% pendingDropped={g[].pendingDropped}"
proc makeTsetlin(fx: Fixture, metric: BulletMetric, warmup, measure: int): CostResult =
let g = new(TsetlinGun)
g[] = initTsetlinGun()
randomize(1)
result = measureGun(fx,
proc(state: WorldState, bulletSpeed: float): GunPrediction = g[].predict(state, bulletSpeed),
proc(e: FeedbackEvent) = g[].onResult(e),
metric, warmup, measure)
proc main() =
if not fileExists(FixturePath):
echo "fixture missing: ", FixturePath
quit(1)
let fx = loadFixture(FixturePath)
echo fmt"fixture: {fx.meta.adversary} ticks={fx.states.len} metric=point"
const Warmup = 150
const Measure = 800
const BudgetMs = 1000.0 / 76.0 # ~76 ticks/s measured bridge throughput
echo fmt"warmup={Warmup} measured={Measure} ticks; budget={BudgetMs:.2f} ms/tick"
echo ""
let tm = makeTmRadial(fx, bmPoint, Warmup, Measure)
let ts = makeTsetlin(fx, bmPoint, Warmup, Measure)
proc report(name: string, r: CostResult) =
let pMs = r.predictNs.float / 1.0e6 / max(1, r.ticks).float
let oMs = r.onResultNs.float / 1.0e6 / max(1, r.ticks).float
let tot = pMs + oMs
echo fmt"{name:<18} predict {pMs:6.2f} ms/tick onResult {oMs:6.2f} ms/tick " &
fmt"total {tot:6.2f} ms/tick ({tot / BudgetMs * 100:5.0f}% budget) " &
fmt"onCalls/tick={r.onCalls.float / max(1, r.ticks).float:.1f}"
echo "=================== PER-TICK COST (single gun, bmPoint) ==================="
report("TMPattern(radial)", tm)
report("Tsetlin (reference)", ts)
# ── pooled radial-head learning stats over the real fixture set ─────────────
const RealFixtures = ["drussgt_vs_crazy", "drussgt_vs_spinbot",
"drussgt_vs_drussgt", "tr_drussgt_vs_crazy", "tr_drussgt_vs_spinbot",
"tr_drussgt_vs_modularbot"]
echo ""
echo "=================== POOLED RADIAL HEAD (real fixtures, bmPoint) ==================="
var realHist: array[TM_CLASSES, int]
var realChosen: array[TM_CLASSES, int]
var realCorrect, realTotal = 0
var shufHist: array[TM_CLASSES, int]
var shufChosen: array[TM_CLASSES, int]
var shufCorrect, shufTotal = 0
for name in RealFixtures:
let p = RepoRoot / "tools" / "fixtures" / (name & ".jsonl")
if not fileExists(p): continue
let f = loadFixture(p)
block:
let g = new(TmPatternGun)
g[] = initTmRadialGun()
randomize(1)
discard measureGun(f,
proc(state: WorldState, bulletSpeed: float): GunPrediction = g[].predict(state, bulletSpeed),
proc(e: FeedbackEvent) = g[].onResult(e), bmPoint, 0, 0)
realCorrect += g[].radCorrect
realTotal += g[].radTotal
for c in 0..<TM_CLASSES: realHist[c] += g[].radLabelHist[c]
for c in 0..<TM_CLASSES: realChosen[c] += g[].radChosenHist[c]
block:
let g = new(TmPatternGun)
g[] = initTmRadialGun()
g[].shuffleLabels = true
randomize(1)
discard measureGun(f,
proc(state: WorldState, bulletSpeed: float): GunPrediction = g[].predict(state, bulletSpeed),
proc(e: FeedbackEvent) = g[].onResult(e), bmPoint, 0, 0)
shufCorrect += g[].radCorrect
shufTotal += g[].radTotal
for c in 0..<TM_CLASSES: shufHist[c] += g[].radLabelHist[c]
for c in 0..<TM_CLASSES: shufChosen[c] += g[].radChosenHist[c]
var realMaj, shufMaj, realN, shufN = 0
var rs, ss, rc, sc = ""
for c in 0..<TM_CLASSES:
realMaj = max(realMaj, realHist[c]); realN += realHist[c]
shufMaj = max(shufMaj, shufHist[c]); shufN += shufHist[c]
rs.add $realHist[c] & " "
ss.add $shufHist[c] & " "
rc.add $realChosen[c] & " "
sc.add $shufChosen[c] & " "
echo fmt" real: radAcc={realCorrect}/{realTotal} = {realCorrect.float/max(1,realTotal).float*100:.1f}% " &
fmt"majority={realMaj.float/max(1,realN).float*100:.1f}% labels=[{rs}] chosen=[{rc}]"
echo fmt" shuffled: radAcc={shufCorrect}/{shufTotal} = {shufCorrect.float/max(1,shufTotal).float*100:.1f}% " &
fmt"majority={shufMaj.float/max(1,shufN).float*100:.1f}% labels=[{ss}] chosen=[{sc}]"
when isMainModule:
main()
+10 -7
View File
@@ -139,15 +139,18 @@ proc testParseMembership() =
proc testDefaultsAllBoth() =
clearRackEnv()
# The shipped table is all-`both` EXCEPT the new TM pattern gun (id 14),
# which defaults to `off` so it cannot alter the default selection.
var allBoth = true
for m in DefaultRackMembership:
if m != rmBoth: allBoth = false
check "defaults: the shipped membership table is all-`both`", allBoth
for i in 0..<RackGunNames.len:
let want = if i == 14: rmOff else: rmBoth
if DefaultRackMembership[i] != want: allBoth = false
check "defaults: the shipped table is all-`both` except TMPATTERN=off", allBoth
let loaded = loadRackMembership()
check "defaults: with a clean environment loadRackMembership() == shipped table",
loaded == DefaultRackMembership
check "defaults: RackGunNames covers the shipped 14-gun rack",
RackGunNames.len == 14 and DefaultRackMembership.len == 14
check "defaults: RackGunNames covers the shipped 15-gun rack",
RackGunNames.len == 15 and DefaultRackMembership.len == 15
proc testEnvOverrides() =
clearRackEnv()
@@ -161,8 +164,8 @@ proc testEnvOverrides() =
m[9] == rmOnlyMelee
check "env: TR_RACK_HEADON=off removes only HeadOn (id 0)",
m[0] == rmOff
check "env: every untouched gun keeps the all-`both` default",
m[1] == rmBoth and m[3] == rmBoth and m[13] == rmBoth
check "env: every untouched gun keeps its shipped default",
m[1] == rmBoth and m[3] == rmBoth and m[13] == rmBoth and m[14] == rmOff
putEnv("TR_RACK_KNN", "banana")
check "env: unknown values are ignored, not fatal",
loadRackMembership()[12] == rmBoth
@@ -0,0 +1,109 @@
## MINIMAL LIVE CHECK for the rack-registered TM pattern gun (id 14).
##
## Proves the new gun is a first-class rack member that the LIVE ModularBot
## actually predicts, spawns and can be forced alone with env vars only:
##
## TR_RACK_HEADON=off ... TR_RACK_TMSELECT=off TR_RACK_TMPATTERN=both
##
## One 1-round battle (deliberately minimal — the full A/B runs in a later job).
## Reads /tmp/tm_pattern_rack_stats.jsonl (the bot's per-round gun dump) and
## asserts gun 14 was selected and spawned virtual bullets while every other gun
## was deselected.
##
## Run with:
## TR_SERVER_JAR=... TR_BATTLE_RUNNER=... \
## nim c -r common_libs/tests/test_tm_pattern_rack_live.nim
##
## Skips (exit 0) when the JARs are absent, matching the other live tests.
import std/[os, json, strutils, tables]
import test_framework/test_framework
const
repoRoot = currentSourcePath().parentDir.parentDir.parentDir
modularBotDir = repoRoot / "ModularBot_garage"
adversaryDir = repoRoot / "common_libs" / "test_framework" / "adversaries" / "SittingDuck"
statsPath = "/tmp/tm_pattern_rack_stats.jsonl"
const TmPatternId = 14
const RackGunNames = ["HEADON", "LINEAR", "TSETLIN", "CIRCULAR", "GUESSFACTOR",
"PATTERN", "WALLBOUNCE", "ACCEL", "STOPSHOT", "DISPLACE", "AVGLEAD",
"DECAYGF", "KNN", "TMSELECT", "TMPATTERN"]
var failures = 0
proc check(name: string, ok: bool) =
if ok: echo "PASS: ", name
else: echo "FAIL: ", name; inc failures
proc lastRound(path: string): JsonNode =
result = nil
for line in lines(path):
let s = line.strip()
if s.len == 0: continue
let node = parseJson(s)
if node.hasKey("guns"): result = node
proc main() =
let serverJar = getEnv("TR_SERVER_JAR", "")
let runnerJar = getEnv("TR_BATTLE_RUNNER", "")
if serverJar.len == 0 or runnerJar.len == 0:
echo "Skipping: TR_SERVER_JAR / TR_BATTLE_RUNNER not set"
quit(0)
if not fileExists(serverJar) or not fileExists(runnerJar):
echo "Skipping: TR JARs not found (server=", serverJar, ", runner=", runnerJar, ")"
quit(0)
if not fileExists(modularBotDir / "src" / "ModularBot.nim"):
echo "Skipping: ModularBot source not found"
quit(0)
if fileExists(statsPath): removeFile(statsPath)
# Force the new gun alone. Every other rack entry is removed; TMPATTERN is
# admitted in both racks. With no other candidate, the selector must pick it.
for i, name in RackGunNames:
putEnv("TR_RACK_" & name, if i == TmPatternId: "both" else: "off")
putEnv("GUN_VBULLET_METRIC", "point") # the metric the radial win lives on
putEnv("GUN_STATS_PATH", statsPath)
defer:
for name in RackGunNames: delEnv("TR_RACK_" & name)
delEnv("GUN_VBULLET_METRIC")
delEnv("GUN_STATS_PATH")
echo "=== live: ModularBot (only TMPATTERN racked) vs SittingDuck, 1 round ==="
let battle = runBattle(@[modularBotDir, adversaryDir], rounds = 1,
timeout = 180000, maxSpeed = true)
for res in battle.results:
echo " ", res.name, " rank=", res.rank, " score=", res.totalScore
if not fileExists(statsPath):
echo "FAIL: no gun_stats dump at ", statsPath
quit(1)
let round = lastRound(statsPath)
check "gun_stats.jsonl has a round record", round != nil
if round == nil: quit(1)
var tmShots = 0
var tmSelected = 0
var otherSelected = 0
for g in round["guns"]:
let id = g["id"].getInt()
if id == TmPatternId:
tmShots = g["vShots"].getInt()
tmSelected = g["selected"].getInt()
elif g["selected"].getInt() > 0:
otherSelected += g["selected"].getInt()
echo "gun 14 (TMPattern): vShots=", tmShots, " selected=", tmSelected,
" other-gun selections=", otherSelected
check "the live loop SPAWNED virtual bullets for gun 14 (vShots > 0)", tmShots > 0
check "the live selector SELECTED gun 14 (selected > 0)", tmSelected > 0
check "no other gun was selected while TMPATTERN was forced alone",
otherSelected == 0
if failures > 0:
echo "\n", failures, " check(s) FAILED"
quit(1)
echo "\nTM pattern rack live check passed."
when isMainModule:
main()
@@ -0,0 +1,197 @@
## Offline guard for the TM pattern gun rack registration (id 14) and the
## default-path parity proof.
##
## Covers:
## * RackGunNames / DefaultRackMembership carry the new gun, defaulting `off`;
## * `TR_RACK_TMPATTERN` parses both|1v1|melee|off like every other gun;
## * the DEFAULT membership table admits exactly the OLD 14-gun rack, so the
## selection sequence is identical to the pre-change all-`both` rack, RNG
## draw for RNG draw, through both `bestGun` and the live `selectGun`;
## * `initTmRadialGun()` selects the radial target mode;
## * the deferred-label fix (Task 2) resolves every fired virtual bullet:
## a radial-mode replay ends with `labelMisses == 0` and non-zero training.
##
## Run: nim c -r common_libs/tests/test_tm_pattern_registration.nim
import std/[random, tables, os]
import gun_harness/gun_interface
import gun_harness/virtual_bullets
import gun_harness/selector
import gun_harness/offline_range
import guns/tm_pattern
const TmPatternId = 14
proc toSeq(r: HSlice[int, int]): seq[int] =
for i in r.a..r.b: result.add i
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 seedWindow(t: var VirtualTracker, targetId, gunId, binIdx, hits, misses: int) =
if targetId notin t.fitness:
t.fitness[targetId] = newSeq[GunFitness](t.numGuns)
var fw = addr t.fitness[targetId][gunId].bins[binIdx]
for _ in 0..<hits: recordHit(fw[], true)
for _ in 0..<misses: recordHit(fw[], false)
proc seedOldRack(t: var VirtualTracker, targetId: int) =
## 14 deterministic rates for the old rack.
seedWindow(t, targetId, 0, 0, 60, 40)
seedWindow(t, targetId, 1, 0, 40, 60)
seedWindow(t, targetId, 2, 0, 80, 20)
seedWindow(t, targetId, 3, 0, 55, 45)
seedWindow(t, targetId, 4, 0, 20, 80)
seedWindow(t, targetId, 5, 0, 70, 30)
seedWindow(t, targetId, 6, 0, 45, 55)
seedWindow(t, targetId, 7, 0, 65, 35)
seedWindow(t, targetId, 8, 0, 30, 70)
seedWindow(t, targetId, 9, 0, 50, 50)
seedWindow(t, targetId, 10, 0, 35, 65)
seedWindow(t, targetId, 11, 0, 75, 25)
seedWindow(t, targetId, 12, 0, 25, 75)
seedWindow(t, targetId, 13, 0, 52, 48)
# ── registration table ────────────────────────────────────────────────────────
proc testTable() =
check "rack: RackGunNames has 15 entries", RackGunNames.len == 15
check "rack: the new gun is named TMPATTERN at id 14",
RackGunNames[TmPatternId] == "TMPATTERN"
check "rack: the new gun defaults to `off`",
DefaultRackMembership[TmPatternId] == rmOff
var oldAllBoth = true
for i in 0..<TmPatternId:
if DefaultRackMembership[i] != rmBoth: oldAllBoth = false
check "rack: every pre-existing gun still defaults to `both`", oldAllBoth
proc testDefaultAdmitsOldRack() =
check "default membership admits exactly the old 14-gun rack (1v1)",
admittedGuns(15, rm1v1, DefaultRackMembership) ==
toSeq(0..13)
check "default membership admits exactly the old 14-gun rack (melee)",
admittedGuns(15, rmMelee, DefaultRackMembership) ==
toSeq(0..13)
proc testEnvOverride() =
for name in RackGunNames: delEnv("TR_RACK_" & name)
putEnv("TR_RACK_TMPATTERN", "both")
check "env: TR_RACK_TMPATTERN=both admits gun 14",
loadRackMembership()[TmPatternId] == rmBoth
putEnv("TR_RACK_TMPATTERN", "1v1")
check "env: TR_RACK_TMPATTERN=1v1 parses",
loadRackMembership()[TmPatternId] == rmOnly1v1
putEnv("TR_RACK_TMPATTERN", "melee")
check "env: TR_RACK_TMPATTERN=melee parses",
loadRackMembership()[TmPatternId] == rmOnlyMelee
putEnv("TR_RACK_TMPATTERN", "off")
check "env: TR_RACK_TMPATTERN=off parses",
loadRackMembership()[TmPatternId] == rmOff
delEnv("TR_RACK_TMPATTERN")
# ── default-path parity (RNG draw for RNG draw) ───────────────────────────────
proc testDefaultSelectionParity() =
## The OLD rack: 14 guns, all-`both`, empty membership (pre-change call).
## The NEW rack: 15 guns, DEFAULT membership (gun 14 off). Selection must be
## identical draw for draw, because gun 14 is filtered out before any RNG use.
var oldT = initTracker(14)
seedOldRack(oldT, 7)
var newT = initTracker(15)
seedOldRack(newT, 7)
# Give gun 14 data too: it must still be excluded by the default membership.
seedWindow(newT, 7, TmPatternId, 0, 100, 0)
randomize(20250922)
var oldSeq: seq[int]
for _ in 0..<400: oldSeq.add oldT.bestGun(7)
randomize(20250922)
var newSeq: seq[int]
for _ in 0..<400:
newSeq.add newT.bestGun(7, rackMode = rm1v1, membership = DefaultRackMembership)
check "parity: default 15-gun bestGun == old 14-gun rack, RNG draw for draw",
oldSeq == newSeq
check "parity: gun 14 is never selected under the default membership",
TmPatternId notin newSeq
# Same through the live hysteresis path.
var oldH = initTracker(14); seedOldRack(oldH, 7)
var newH = initTracker(15); seedOldRack(newH, 7)
seedWindow(newH, 7, TmPatternId, 0, 100, 0)
randomize(4242)
var oldHSeq: seq[int]
for tick in 0..<400: oldHSeq.add oldH.selectGun(7, tick = tick)
randomize(4242)
var newHSeq: seq[int]
for tick in 0..<400:
newHSeq.add newH.selectGun(7, tick = tick, rackMode = rm1v1,
membership = DefaultRackMembership)
check "parity: default 15-gun selectGun == old 14-gun rack, RNG draw for draw",
oldHSeq == newHSeq
# Forcing TMPATTERN alone DOES change selection (it is forceable).
var forced = initTracker(15)
seedOldRack(forced, 7)
seedWindow(forced, 7, TmPatternId, 0, 100, 0)
var onlyTmp: array[15, RackMembership]
for i in 0..<15: onlyTmp[i] = rmOff
onlyTmp[TmPatternId] = rmBoth
check "force: with every other gun off, TMPATTERN is the only candidate",
admittedGuns(15, rm1v1, onlyTmp) == @[TmPatternId]
randomize(9)
var forcedSeq: seq[int]
for _ in 0..<50:
forcedSeq.add forced.bestGun(7, rackMode = rm1v1, membership = onlyTmp)
var allTmp = true
for g in forcedSeq:
if g != TmPatternId: allTmp = false
check "force: the forced-alone rack always returns TMPATTERN", allTmp
# ── radial mode + deferred-label fix ─────────────────────────────────────────
proc testRadialInit() =
let g = initTmRadialGun()
check "init: initTmRadialGun() selects the radial target mode",
g.targetMode == tmRadial
proc testDeferredLabel() =
## Drive a radial-mode gun over a synthetic fixture and prove the deferred
## label resolves every bullet: no label misses, and training happened.
let fx = synthesizeOscillator(ticks = 400)
var g2 = initTmRadialGun()
randomize(3)
var tracker = initTracker(1, bmPoint)
for state in fx.states:
var preds: array[len(PowerBins), GunPrediction]
for b in 0..<len(PowerBins):
preds[b] = g2.predict(state, bulletSpeed(PowerBins[b]))
tracker.spawnBullets(0, preds, state, fx.enemyId)
var et: Table[int, tuple[x, y: float, lastSeenTick: int, alive: bool]]
et[fx.enemyId] = (x: state.enemyX, y: state.enemyY,
lastSeenTick: state.tick, alive: true)
tracker.tickBullets(state, et,
proc(gunId: GunId, binIdx: int, e: FeedbackEvent) = g2.onResult(e))
check "deferred: a radial replay trains (totalObs > 0)", g2.totalObs > 0
check "deferred: no label misses remain (labelMisses == 0)", g2.labelMisses == 0
check "deferred: the pending queue never overflowed", g2.pendingDropped == 0
check "deferred: the radial head is scored above chance (radTotal > 0)",
g2.radTotal > 0 and g2.radCorrect > 0
when isMainModule:
testTable()
testDefaultAdmitsOldRack()
testEnvOverride()
testDefaultSelectionParity()
testRadialInit()
testDeferredLabel()
if failures > 0:
echo "\n", failures, " check(s) FAILED"
quit(1)
echo "\nAll TM pattern registration checks passed."