feat(guns): scale-aware power selection (+52% damage); TM classifier gun built, measured, DISABLED

TASK 2 - power selection, a clear win. bestPower used an ABSOLUTE
MinHitRate = 0.40 bar. Measured per-bin virtual rates (rolling-100 fraction)
show no bin ever clears 40%, so 11 of 14 guns were stuck at bin 0 (power 1.0)
even where higher bins were comparable:
  Linear  p1.0 44% p1.5 39% p2.0 30% p3.0 29%   old bin 0 -> new bin 3
  Accel   p1.0 44% p1.5 40% p2.0 26% p3.0 29%   old bin 1 -> new bin 3
  Pattern p1.0 50% p1.5 40% p2.0 27% p3.0 12%   old bin 1 -> new bin 2
Replaced with a scale-aware PowerBarFrac = 0.50 (a dimensionless FRACTION of
the gun's own best bin rate). 13 of 14 selections now pick heavier bullets.
Real effect vs DrussGT (8 rounds x 3 runs): hit rate unchanged (7.56% ->
7.47%) but damage dealt +52% (157 -> 239 per run) and rounds end faster.
Same accuracy, half the shots, half again more damage.

TASK 1 - the TM pattern-classifier gun does NOT earn its slot. It was built as
a mixture of experts with a corrected-Granmo TM as a multi-class gate over
HeadOn/Linear/Circular/WallBounce/Accel, labelled by which expert's prediction
was closest to the actual enemy position (an exact, supervised, per-shot
label - no delayed credit). Offline it loses to the best of its OWN experts on
essentially every fixture, and against DrussGT it cost real performance:
  baseline (path+relative)  7.56% real hit rate, damage 157
  + power fix               7.47%,                 damage 239
  + power fix + TM gun      5.59%,                 damage 133
The gun was selected on 806 ticks and fired 24 real shots at 4.2%.
So the tree ships with EnableTmSelector = false: code and wiring kept intact
for re-enabling, but it is not in the active rack.

Worth recording from the clause dump: the gate DOES latch onto meaningful
structure. On energy-threshold-turner, HeadOn's clauses key on the energy bits
(the rule's own driving variable) while Circular keys on distance/velocity. So
the TM is learning something real and interpretable - it simply cannot beat
'always pick the best expert'. Root cause (INFERRED): the closest-expert label
is noisy because several experts are near-tied, and under the path metric the
winner varies by power bin while the gate sees one shared per-tick input, so a
one-vs-rest gate over a saturated 870-bit clause space has no margin to exploit.
(Zero-padding the 2-frame window was tried first and saturated every clause at
256-755 included literals; alternating the two real frames fixed that.)

Also factors the corrected feedback into an exported tmLearnDir and exports the
encoding/TM primitives; the Tsetlin tests still reproduce the documented
mean=13.8 included literals, so the refactor is behaviour-preserving.

Verified: 33/33 guard checks, tsetlin tests green, metric checks green, new
power-selection guard green (13/14 selections change; relative bar still picks
bin 1 and not bin 3 for a [30,25,12,5]% profile), 12/12 offline==online
acceptance under the shipped default.
This commit is contained in:
2026-09-21 05:19:07 +02:00
parent dea4dcb574
commit 57b2ac3849
8 changed files with 601 additions and 60 deletions
+28 -8
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) 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) via GunHarness.
## Radar: RadarLockModule (1v1) / MeleeScanModule (2+ enemies), auto-switched per tick.
## Movement: OscillatorModule (perpendicular strafing).
@@ -24,6 +24,7 @@ import guns/displacement
import guns/averaged_lead
import guns/decay_gf
import guns/knn_gun
import guns/tm_selector
import movements/phantom_meteor
import movements/rammer
import movements/the_floor_is_lava
@@ -35,6 +36,15 @@ import targeting/target_selector
const botJsonPath = currentSourcePath().parentDir / "ModularBot.json"
const DebugVBullets = false
const DebugCircular = false
## Registration switch for the TM selector gun (id 13). Set false to build a
## rack without it (A/B runs); the tracker still reserves gun id 13 so the other
## ids and per-gun stats are unchanged.
##
## VERDICT: left FALSE. The TM gate underperforms its own experts on every
## offline fixture (energy-threshold 63% vs Circular 100%, random-walk 45% vs
## WallBounce 68%, Corners 25% vs Accel 34%) and the DrussGT A/B showed real hit
## rate 5.59% vs 7.47% for the power-fix-only rack. Flip to true to re-enable.
const EnableTmSelector = false
## Task A instrumentation: log every real shot + its eventual outcome to
## /tmp/shot_log.jsonl. Set to false to compile all shot-log machinery out.
const ShotLog = true
@@ -45,7 +55,7 @@ const ShotLog = true
## can enable recording for just the battle it spawns by exporting the env var.
let RecordWorldState* = existsEnv("TR_RECORD_WORLDSTATE")
const WorldStateRecordPath = "/tmp/worldstate_record.jsonl"
const GunNames = ["HeadOn", "Linear", "Tsetlin", "Circular", "GuessFactor", "Pattern", "WallBounce", "Accel", "StopShot", "Displace", "AvgLead", "DecayGF", "KNN"]
const GunNames = ["HeadOn", "Linear", "Tsetlin", "Circular", "GuessFactor", "Pattern", "WallBounce", "Accel", "StopShot", "Displace", "AvgLead", "DecayGF", "KNN", "TMSelect"]
const
CLR_GUN = "\e[33m" # yellow
@@ -90,6 +100,7 @@ type
avgLead: AveragedLeadGun
decayGF: DecayGFGun
knnGun: KNNGun
tmSelector: TmSelectorGun
mover: TFILModule
rammer: RammerModule
isRamming: bool
@@ -110,13 +121,13 @@ type
roundNumber: int
realShotsFired: int
realHits: int
gunRealShots: array[13, int]
gunRealHits: array[13, int]
gunRealShots: array[14, int]
gunRealHits: array[14, 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
bulletShot: Table[int, PendingShot] ## bulletId -> shot metadata (Task A shot log)
pendingHitBullets: HashSet[int] ## hit bulletIds seen before their onBulletFired stamp
gunSelectionCount: array[13, int]
gunSelectionCount: array[14, int]
lastKnownTargetId: int ## persists through death, used for round-end stats
proc writeShotLog(shot: PendingShot, hit: bool, unresolved: bool) =
@@ -322,7 +333,7 @@ method onRoundEnded*(bot: ModularBot, e: RoundEndedEventForBot) =
let fit = bot.tracker.fitnessFor(targetId)
var gunsArr = newJArray()
for gid in 0..<13:
for gid in 0..<14:
var totalShots = 0
var totalHits = 0
for binIdx in 0..<len(vb.PowerBins):
@@ -382,7 +393,7 @@ method onRoundStarted*(bot: ModularBot, e: RoundStartedEvent) =
bot.bulletGun.clear()
bot.bulletShot.clear()
bot.pendingHitBullets.clear()
for i in 0..<13:
for i in 0..<14:
bot.gunSelectionCount[i] = 0
bot.gunRealShots[i] = 0
bot.gunRealHits[i] = 0
@@ -589,6 +600,7 @@ method run*(bot: ModularBot) =
var alPreds: array[len(PowerBins), GunPrediction]
var dgPreds: array[len(PowerBins), GunPrediction]
var knnPreds: array[len(PowerBins), GunPrediction]
var tmselPreds: 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]))
@@ -603,6 +615,8 @@ method run*(bot: ModularBot) =
alPreds[i] = bot.avgLead.predict(bot.lastState, bulletSpeed(PowerBins[i]))
dgPreds[i] = bot.decayGF.predict(bot.lastState, bulletSpeed(PowerBins[i]))
knnPreds[i] = bot.knnGun.predict(bot.lastState, bulletSpeed(PowerBins[i]))
if EnableTmSelector:
tmselPreds[i] = bot.tmSelector.predict(bot.lastState, bulletSpeed(PowerBins[i]))
bot.tracker.spawnBullets(0, headsUp, bot.lastState, tid)
bot.tracker.spawnBullets(1, linPreds, bot.lastState, tid)
@@ -618,6 +632,8 @@ method run*(bot: ModularBot) =
bot.tracker.spawnBullets(10, alPreds, bot.lastState, tid)
bot.tracker.spawnBullets(11, dgPreds, bot.lastState, tid)
bot.tracker.spawnBullets(12, knnPreds, bot.lastState, tid)
if EnableTmSelector and bot.tmSelector.isWarmedUp():
bot.tracker.spawnBullets(13, tmselPreds, bot.lastState, tid)
# Build slim enemy table for tickBullets
var enemyPositions: Table[int, tuple[x, y: float, lastSeenTick: int, alive: bool]]
@@ -645,6 +661,7 @@ method run*(bot: ModularBot) =
of 10: bot.avgLead.onResult(fe)
of 11: bot.decayGF.onResult(fe)
of 12: bot.knnGun.onResult(fe)
of 13: bot.tmSelector.onResult(fe)
else: discard
if fe.hit: inc bot.virtualHits else: inc bot.virtualMiss
when DebugVBullets:
@@ -672,6 +689,7 @@ method run*(bot: ModularBot) =
of 10: setTurretColor("#996633"); setBulletColor("#CC9966")
of 11: setTurretColor("#008888"); setBulletColor("#00AAAA")
of 12: setTurretColor("#CC00CC"); setBulletColor("#FF44FF")
of 13: setTurretColor("#00FFCC"); setBulletColor("#66FFDD")
else: discard
let pred = case selectedGun
@@ -687,6 +705,7 @@ method run*(bot: ModularBot) =
of 10: bot.avgLead.predict(bot.lastState, bulletSpeed(power))
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))
else: bot.headOn.predict(bot.lastState, bulletSpeed(power))
let aimTarget = aimAngle(getX(), getY(), pred.x, pred.y)
@@ -718,7 +737,7 @@ method run*(bot: ModularBot) =
when isMainModule:
var bot = ModularBot(
tracker: vb.initTracker(13), # 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
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
headOn: HeadOnGun(),
linear: LinearGun(),
circular: CircularGun(),
@@ -732,6 +751,7 @@ when isMainModule:
avgLead: initAveragedLeadGun(),
decayGF: initDecayGFGun(),
knnGun: initKNNGun(),
tmSelector: initTmSelectorGun(),
radar: RadarLockModule(),
meleeScan: initMeleeScan(),
mover: TFILModule(debugGraphics: true),