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:
@@ -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."
|
||||
Reference in New Issue
Block a user