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
+36 -11
View File
@@ -1,5 +1,5 @@
## ModularBot — plugin gun architecture tracer bullet.
## Guns: HeadOnGun (0), LinearGun (1), TsetlinGun (2), CircularGun (3), GFGun (4), PatternMatcherGun (5), WallBounceGun (6), AccelGun (7), StopShotGun (8), DisplacementGun (9), AveragedLeadGun (10), DecayGFGun (11), KNNGun (12), TmSelectorGun (13) via GunHarness.
## Guns: HeadOnGun (0), LinearGun (1), TsetlinGun (2), CircularGun (3), GFGun (4), PatternMatcherGun (5), WallBounceGun (6), AccelGun (7), StopShotGun (8), DisplacementGun (9), AveragedLeadGun (10), DecayGFGun (11), KNNGun (12), TmSelectorGun (13), TmPatternGun (14) via GunHarness.
## Radar: RadarLockModule (1v1) / AdaptiveMeleeRadarModule (2+ enemies), auto-switched per tick.
## Movement: OscillatorModule (perpendicular strafing).
@@ -25,6 +25,7 @@ import guns/averaged_lead
import guns/decay_gf
import guns/knn_gun
import guns/tm_selector
import guns/tm_pattern
import movements/phantom_meteor
import movements/rammer
import movements/ram_decision
@@ -106,7 +107,14 @@ let ShotLogPath = getEnv("GUN_SHOTLOG_PATH", "/tmp/shot_log.jsonl")
## shows why the cap moved. The policy itself lives in the shared gun harness
## (`applyPowerPolicy`), so both live and offline paths see the same rule.
let PowerLog = existsEnv("TR_POWER_LOG")
const GunNames = ["HeadOn", "Linear", "Tsetlin", "Circular", "GuessFactor", "Pattern", "WallBounce", "Accel", "StopShot", "Displace", "AvgLead", "DecayGF", "KNN", "TMSelect"]
const GunNames = ["HeadOn", "Linear", "Tsetlin", "Circular", "GuessFactor", "Pattern", "WallBounce", "Accel", "StopShot", "Displace", "AvgLead", "DecayGF", "KNN", "TMSelect", "TMPattern"]
## Rack id of the new TM pattern gun. It defaults to `TR_RACK_TMPATTERN=off`;
## unlike the other guns, its virtual-bullet spawn is gated on rack admission
## (see the aim block in `run`), so the shipped default never spawns it and the
## shared VirtualTracker ring head — hence every other gun's learning order and
## the default selection sequence — is byte-for-byte unchanged.
const TmPatternId = 14
const
CLR_GUN = "\e[33m" # yellow
@@ -153,6 +161,7 @@ type
decayGF: DecayGFGun
knnGun: KNNGun
tmSelector: TmSelectorGun
tmPattern: TmPatternGun
mover: TFILModule
ringMover: TFILRingModule
rammer: RammerModule
@@ -179,18 +188,18 @@ type
roundNumber: int
realShotsFired: int
realHits: int
gunRealShots: array[14, int]
gunRealHits: array[14, int]
gunRealShots: array[15, int]
gunRealHits: array[15, int]
# Same real-shot accounting split by the rack in force at fire time, so a
# later data-driven pass can rank guns per mode (1v1 vs melee).
gunRealShotsByMode: array[vb.RackMode, array[14, int]]
gunRealHitsByMode: array[vb.RackMode, array[14, int]]
gunRealShotsByMode: array[vb.RackMode, array[15, int]]
gunRealHitsByMode: array[vb.RackMode, array[15, int]]
pendingFires: seq[PendingShot] ## FIFO of fired shots awaiting onBulletFired bulletId stamp
bulletGun: Table[int, int] ## bulletId -> gun id, filled on onBulletFired, drained on resolution
bulletMode: Table[int, vb.RackMode] ## bulletId -> rack at fire time
bulletShot: Table[int, PendingShot] ## bulletId -> shot metadata (Task A shot log)
pendingHitBullets: HashSet[int] ## hit bulletIds seen before their onBulletFired stamp
gunSelectionCount: array[14, int]
gunSelectionCount: array[15, int]
lastPowerLogKey: string ## change detector for the TR_POWER_LOG line
lastKnownTargetId: int ## persists through death, used for round-end stats
# Radar measurement instrumentation (only touched when RadarScanLog is set).
@@ -554,7 +563,7 @@ method onRoundEnded*(bot: ModularBot, e: RoundEndedEventForBot) =
let fit = bot.tracker.fitnessFor(targetId)
var gunsArr = newJArray()
for gid in 0..<14:
for gid in 0..<15:
var totalShots = 0
var totalHits = 0
for binIdx in 0..<len(vb.PowerBins):
@@ -667,12 +676,12 @@ method onRoundStarted*(bot: ModularBot, e: RoundStartedEvent) =
bot.radarAcquireTicks = 0
bot.radarTrackTicks = 0
bot.radarMeleeActive = false
for i in 0..<14:
for i in 0..<15:
bot.gunSelectionCount[i] = 0
bot.gunRealShots[i] = 0
bot.gunRealHits[i] = 0
for m in vb.RackMode:
for i in 0..<14:
for i in 0..<15:
bot.gunRealShotsByMode[m][i] = 0
bot.gunRealHitsByMode[m][i] = 0
# Reset per-round integrity counters so each /tmp/gun_stats.jsonl line reports
@@ -693,6 +702,10 @@ method onRoundStarted*(bot: ModularBot, e: RoundStartedEvent) =
bot.mover.resetRound()
bot.ringMover.resetRound()
bot.enemyTracker.resetRound()
# The TM pattern gun is cold every round (no cross-battle persistence): wipe
# its clause teams and motion history explicitly, so a same-id opponent in the
# next round cannot inherit the previous round's overfit net.
bot.tmPattern.resetLearning()
bot.isRamming = false
bot.ramStuckTicks = 0
bot.ramDurationTicks = 0
@@ -981,6 +994,7 @@ method run*(bot: ModularBot) =
var dgPreds: array[len(PowerBins), GunPrediction]
var knnPreds: array[len(PowerBins), GunPrediction]
var tmselPreds: array[len(PowerBins), GunPrediction]
var tmpPreds: array[len(PowerBins), GunPrediction]
for i in 0..<len(PowerBins):
headsUp[i] = bot.headOn.predict(bot.lastState, bulletSpeed(PowerBins[i]))
linPreds[i] = bot.linear.predict(bot.lastState, bulletSpeed(PowerBins[i]))
@@ -997,6 +1011,10 @@ method run*(bot: ModularBot) =
knnPreds[i] = bot.knnGun.predict(bot.lastState, bulletSpeed(PowerBins[i]))
if EnableTmSelector:
tmselPreds[i] = bot.tmSelector.predict(bot.lastState, bulletSpeed(PowerBins[i]))
# TMPATTERN predict is gated on rack admission, not just spawn: with the
# shipped `off` default it costs nothing and touches no shared state.
if rackAdmitted(TmPatternId, bot.rackMode, ActiveRackMembership):
tmpPreds[i] = bot.tmPattern.predict(bot.lastState, bulletSpeed(PowerBins[i]))
bot.tracker.spawnBullets(0, headsUp, bot.lastState, tid)
if not gunDisabled(1): bot.tracker.spawnBullets(1, linPreds, bot.lastState, tid)
@@ -1014,6 +1032,9 @@ method run*(bot: ModularBot) =
if not gunDisabled(12): bot.tracker.spawnBullets(12, knnPreds, bot.lastState, tid)
if EnableTmSelector and not gunDisabled(13) and bot.tmSelector.isWarmedUp():
bot.tracker.spawnBullets(13, tmselPreds, bot.lastState, tid)
if not gunDisabled(TmPatternId) and
rackAdmitted(TmPatternId, bot.rackMode, ActiveRackMembership):
bot.tracker.spawnBullets(TmPatternId, tmpPreds, bot.lastState, tid)
# Build slim enemy table for tickBullets
var enemyPositions: Table[int, tuple[x, y: float, lastSeenTick: int, alive: bool]]
@@ -1042,6 +1063,7 @@ method run*(bot: ModularBot) =
of 11: bot.decayGF.onResult(fe)
of 12: bot.knnGun.onResult(fe)
of 13: bot.tmSelector.onResult(fe)
of TmPatternId: bot.tmPattern.onResult(fe)
else: discard
if fe.hit: inc bot.virtualHits else: inc bot.virtualMiss
when DebugVBullets:
@@ -1083,6 +1105,7 @@ method run*(bot: ModularBot) =
of 11: setTurretColor("#008888"); setBulletColor("#00AAAA")
of 12: setTurretColor("#CC00CC"); setBulletColor("#FF44FF")
of 13: setTurretColor("#00FFCC"); setBulletColor("#66FFDD")
of TmPatternId: setTurretColor("#AAFF00"); setBulletColor("#CCFF66")
else: discard
let pred = case selectedGun
@@ -1099,6 +1122,7 @@ method run*(bot: ModularBot) =
of 11: bot.decayGF.predict(bot.lastState, bulletSpeed(power))
of 12: bot.knnGun.predict(bot.lastState, bulletSpeed(power))
of 13: bot.tmSelector.predict(bot.lastState, bulletSpeed(power))
of TmPatternId: bot.tmPattern.predict(bot.lastState, bulletSpeed(power))
else: bot.headOn.predict(bot.lastState, bulletSpeed(power))
let aimTarget = aimAngle(getX(), getY(), pred.x, pred.y)
@@ -1153,7 +1177,7 @@ proc seedSelectorRng() =
when isMainModule:
var bot = ModularBot(
tracker: vb.initTracker(14), # 0: HeadOn, 1: Linear, 2: Tsetlin, 3: Circular, 4: GuessFactor, 5: Pattern, 6: WallBounce, 7: Accel, 8: StopShot, 9: Displace, 10: AvgLead, 11: DecayGF, 12: KNN, 13: TMSelect
tracker: vb.initTracker(15), # 0: HeadOn, 1: Linear, 2: Tsetlin, 3: Circular, 4: GuessFactor, 5: Pattern, 6: WallBounce, 7: Accel, 8: StopShot, 9: Displace, 10: AvgLead, 11: DecayGF, 12: KNN, 13: TMSelect, 14: TMPattern
headOn: HeadOnGun(),
linear: LinearGun(),
circular: CircularGun(),
@@ -1168,6 +1192,7 @@ when isMainModule:
decayGF: initDecayGFGun(),
knnGun: initKNNGun(),
tmSelector: initTmSelectorGun(),
tmPattern: initTmRadialGun(),
radar: RadarLockModule(),
meleeRadar: initAdaptiveMeleeRadar(),
mover: TFILModule(debugGraphics: true),