BitBrain gun (id 16): Pattern-relative ADE+SBC aim corrector, default off

Wire the verified common_libs/bitbrain ADE+SBC library into ModularBot as a
fine-grained angular corrector on top of Pattern's prediction, the shape the
offline gate test measured (argmax readout over N correction classes).

- common_libs/guns/bitbrain_gun.nim: new gun. Input = the existing TMHorizon
  53 bits (tmhBaseBits + tmhLits); output = argmax class centre over
  +-TR_BITBRAIN_RANGE, applied by rotating the Pattern point around the shooter
  exactly as tmhApplyShift does. Label = the +h-tick fact from TmHorizonGun's
  own observation ring (never across a round). Prequential (defer + resolve).
  AD layer synthesised online for our binary inputs (center=0): heuristic
  cold-start thresholds + running-histogram ~1% percentile init + the library's
  adaptThresholds. Memory modes perRound (default, measured best) / retained /
  decay (periodic partial SBC wipe). Lazy network build + local RNG, so the
  default path builds nothing and consumes no global randomness.
- tm_horizon.nim: export tmhUpdateHistory and add tmhObservedAt (label seam).
- selector.nim: register BITBRAIN at rack id 16, default rmOff, in the SAME
  commit as the id and the wiring (the aed579b admission bug is not repeated).
- ModularBot.nim: id 16 wired through predict/spawn/onResult/resets/colors,
  arrays grown 16->17, spawn gated on rack admission, per-round/per-battle/
  target reset hooks.
- env_report.nim: report every TR_BITBRAIN_* knob + add names to the known set.
- tests: update the rack length literals; new test_bitbrain_registration
  (default-parity: off, lazy, global-RNG clean).

Guard counts unchanged: rack 48, tm_pattern_registration 20, vbullet_admit 12,
env_report 25, and the rest of the suite green.
This commit is contained in:
2026-09-24 22:25:04 +02:00
parent 1fec87537d
commit 795a0e59fe
8 changed files with 721 additions and 28 deletions
+40 -14
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), TmPatternGun (14), TmHorizonGun (15) 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), TmHorizonGun (15), BitBrainGun (16) via GunHarness.
## Radar: RadarLockModule (1v1) / AdaptiveMeleeRadarModule (2+ enemies), auto-switched per tick.
## Movement: OscillatorModule (perpendicular strafing).
@@ -27,6 +27,7 @@ import guns/knn_gun
import guns/tm_selector
import guns/tm_pattern
import guns/tm_horizon
import guns/bitbrain_gun
import movements/phantom_meteor
import movements/rammer
import movements/ram_decision
@@ -121,7 +122,7 @@ 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", "TMPattern", "TMHorizon"]
const GunNames = ["HeadOn", "Linear", "Tsetlin", "Circular", "GuessFactor", "Pattern", "WallBounce", "Accel", "StopShot", "Displace", "AvgLead", "DecayGF", "KNN", "TMSelect", "TMPattern", "TMHorizon", "BitBrain"]
## 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
@@ -136,6 +137,12 @@ const TmPatternId = 14
## head — hence every other gun's learning order — is byte-for-byte unchanged.
const TmHorizonId = 15
## Rack id of the BitBrain ADE+SBC fine-grained aim corrector. It defaults to
## `TR_RACK_BITBRAIN=off` and its virtual-bullet spawn is gated on rack
## admission, so the shipped default never spawns it and the shared tracker ring
## head — hence every other gun's learning order — is byte-for-byte unchanged.
const BitBrainId = 16
const
CLR_GUN = "\e[33m" # yellow
CLR_MOVE = "\e[36m" # cyan
@@ -183,6 +190,7 @@ type
tmSelector: TmSelectorGun
tmPattern: TmPatternGun
tmHorizon: TmHorizonGun
bitbrain: BitBrainGun
mover: TFILModule
ringMover: TFILRingModule
rammer: RammerModule
@@ -208,18 +216,18 @@ type
roundNumber: int
realShotsFired: int
realHits: int
gunRealShots: array[16, int]
gunRealHits: array[16, int]
gunRealShots: array[17, int]
gunRealHits: array[17, 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[16, int]]
gunRealHitsByMode: array[vb.RackMode, array[16, int]]
gunRealShotsByMode: array[vb.RackMode, array[17, int]]
gunRealHitsByMode: array[vb.RackMode, array[17, 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[16, int]
gunSelectionCount: array[17, 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).
@@ -589,7 +597,7 @@ method onRoundEnded*(bot: ModularBot, e: RoundEndedEventForBot) =
let fit = bot.tracker.fitnessFor(targetId)
var gunsArr = newJArray()
for gid in 0..<16:
for gid in 0..<17:
var totalShots = 0
var totalHits = 0
for binIdx in 0..<len(vb.PowerBins):
@@ -702,12 +710,12 @@ method onRoundStarted*(bot: ModularBot, e: RoundStartedEvent) =
bot.radarAcquireTicks = 0
bot.radarTrackTicks = 0
bot.radarMeleeActive = false
for i in 0..<16:
for i in 0..<17:
bot.gunSelectionCount[i] = 0
bot.gunRealShots[i] = 0
bot.gunRealHits[i] = 0
for m in vb.RackMode:
for i in 0..<16:
for i in 0..<17:
bot.gunRealShotsByMode[m][i] = 0
bot.gunRealHitsByMode[m][i] = 0
# Reset per-round integrity counters so each /tmp/gun_stats.jsonl line reports
@@ -740,6 +748,11 @@ method onRoundStarted*(bot: ModularBot, e: RoundStartedEvent) =
bot.tmHorizon.resetRoundState()
if e.roundNumber <= 1:
bot.tmHorizon.resetLearning("round1")
# BitBrain: per-round wipe always clears observations/labels; `perRound` mode
# also wipes its SBCs (the gate test's winning regime).
bot.bitbrain.resetRoundState()
if e.roundNumber <= 1:
bot.bitbrain.resetLearning("round1")
bot.isRamming = false
bot.ramDurationTicks = 0
bot.ramCooldownTicks = 0
@@ -798,6 +811,7 @@ method onGameStarted*(bot: ModularBot, e: GameStartedEventForBot) =
# state, so this is the only place the learning is dropped at a battle
# boundary. The round-1 fallback in `onRoundStarted` covers a missed callback.
bot.tmHorizon.resetLearning("game_start")
bot.bitbrain.resetLearning("game_start")
# minNumberOfParticipants == maxNumberOfParticipants for fixed battles; self is -1
bot.initialEnemyCount = e.gameSetup.minNumberOfParticipants - 1
# The custom event must be registered AFTER `start()` ran `initGlobals()`,
@@ -892,6 +906,7 @@ method run*(bot: ModularBot) =
# target changes to a different bot id (gated by
# TR_TMHORIZON_RESET_ON_TARGET, default on; no-op in 1v1).
discard bot.tmHorizon.targetChanged(candidateId)
discard bot.bitbrain.targetChanged(candidateId)
if bot.currentTargetId >= 0:
bot.lastKnownTargetId = bot.currentTargetId
@@ -1028,6 +1043,7 @@ method run*(bot: ModularBot) =
var tmselPreds: array[len(PowerBins), GunPrediction]
var tmpPreds: array[len(PowerBins), GunPrediction]
var tmhPreds: array[len(PowerBins), GunPrediction]
var bbPreds: array[len(PowerBins), GunPrediction]
# ── TR_VBULLET_ADMIT_ONLY gate ──────────────────────────────────────
# Rack membership used to filter only SELECTION, so every unselected gun
# still ran predict()+spawnBullets() each tick to feed a fitness table the
@@ -1039,11 +1055,11 @@ method run*(bot: ModularBot) =
# owning gun's onResult, so attribution survives a mid-round rack change.
# TMPATTERN and TMHORIZON keep their own admission gate even when the knob
# is 0, so TR_VBULLET_ADMIT_ONLY=0 reproduces the exact pre-change rack.
var admit: array[16, bool]
for gi in 0..<16:
var admit: array[17, bool]
for gi in 0..<17:
admit[gi] = vBulletAdmitted(gi, bot.rackMode, ActiveRackMembership,
VBulletAdmitOnly or gi == TmPatternId or
gi == TmHorizonId)
gi == TmHorizonId or gi == BitBrainId)
for i in 0..<len(PowerBins):
if admit[0]: headsUp[i] = bot.headOn.predict(bot.lastState, bulletSpeed(PowerBins[i]))
if admit[1]: linPreds[i] = bot.linear.predict(bot.lastState, bulletSpeed(PowerBins[i]))
@@ -1066,6 +1082,9 @@ method run*(bot: ModularBot) =
# TMHORIZON likewise: Pattern base + horizon TM correction.
if admit[TmHorizonId]:
tmhPreds[i] = bot.tmHorizon.predict(bot.lastState, bulletSpeed(PowerBins[i]))
# BITBRAIN: Pattern base + ADE/SBC fine-grained angular correction.
if admit[BitBrainId]:
bbPreds[i] = bot.bitbrain.predict(bot.lastState, bulletSpeed(PowerBins[i]))
if admit[0]: bot.tracker.spawnBullets(0, headsUp, bot.lastState, tid)
if admit[1] and not gunDisabled(1): bot.tracker.spawnBullets(1, linPreds, bot.lastState, tid)
@@ -1087,6 +1106,8 @@ method run*(bot: ModularBot) =
bot.tracker.spawnBullets(TmPatternId, tmpPreds, bot.lastState, tid)
if admit[TmHorizonId] and not gunDisabled(TmHorizonId):
bot.tracker.spawnBullets(TmHorizonId, tmhPreds, bot.lastState, tid)
if admit[BitBrainId] and not gunDisabled(BitBrainId):
bot.tracker.spawnBullets(BitBrainId, bbPreds, bot.lastState, tid)
# Build slim enemy table for tickBullets
var enemyPositions: Table[int, tuple[x, y: float, lastSeenTick: int, alive: bool]]
@@ -1117,6 +1138,7 @@ method run*(bot: ModularBot) =
of 13: bot.tmSelector.onResult(fe)
of TmPatternId: bot.tmPattern.onResult(fe)
of TmHorizonId: bot.tmHorizon.onResult(fe)
of BitBrainId: bot.bitbrain.onResult(fe)
else: discard
if fe.hit: inc bot.virtualHits else: inc bot.virtualMiss
when DebugVBullets:
@@ -1162,6 +1184,7 @@ method run*(bot: ModularBot) =
of 13: setTurretColor("#00FFCC"); setBulletColor("#66FFDD")
of TmPatternId: setTurretColor("#AAFF00"); setBulletColor("#CCFF66")
of TmHorizonId: setTurretColor("#00AAFF"); setBulletColor("#66CCFF")
of BitBrainId: setTurretColor("#FF1493"); setBulletColor("#FF69B4")
else: discard
let pred = case selectedGun
@@ -1180,6 +1203,7 @@ method run*(bot: ModularBot) =
of 13: bot.tmSelector.predict(bot.lastState, bulletSpeed(power))
of TmPatternId: bot.tmPattern.predict(bot.lastState, bulletSpeed(power))
of TmHorizonId: bot.tmHorizon.predict(bot.lastState, bulletSpeed(power))
of BitBrainId: bot.bitbrain.predict(bot.lastState, bulletSpeed(power))
else: bot.headOn.predict(bot.lastState, bulletSpeed(power))
let aimTarget = aimAngle(getX(), getY(), pred.x, pred.y)
@@ -1234,7 +1258,7 @@ proc seedSelectorRng() =
when isMainModule:
var bot = ModularBot(
tracker: vb.initTracker(16), # 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, 15: TMHorizon
tracker: vb.initTracker(17), # 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, 15: TMHorizon, 16: BitBrain
headOn: HeadOnGun(),
linear: LinearGun(),
circular: CircularGun(),
@@ -1251,6 +1275,7 @@ when isMainModule:
tmSelector: initTmSelectorGun(),
tmPattern: initTmRadialGun(),
tmHorizon: initTmHorizonGun(),
bitbrain: initBitBrainGun(),
radar: RadarLockModule(),
meleeRadar: initAdaptiveMeleeRadar(),
mover: TFILModule(debugGraphics: true),
@@ -1280,5 +1305,6 @@ when isMainModule:
disabledGuns: DisabledGuns,
tmHorizon: bot.tmHorizon,
patternMatcher: bot.patternMatcher,
bitbrain: bot.bitbrain,
))
start(bot, botJsonPath)
+27
View File
@@ -26,6 +26,7 @@ import movements/ram_decision
import movements/the_floor_is_lava
import movements/the_floor_is_lava_ring
import guns/tm_horizon
import guns/bitbrain_gun
import guns/pattern_matcher
const EnvReportEnableEnvVar* = "TR_ENV_REPORT"
@@ -50,6 +51,7 @@ type
shotLogPath*: string
disabledGuns*: HashSet[int]
tmHorizon*: TmHorizonGun
bitbrain*: BitBrainGun
patternMatcher*: PatternMatcherGun
# ── helpers ──────────────────────────────────────────────────────────────────
@@ -281,6 +283,28 @@ proc printEffectiveValues(ctx: EnvReportContext) =
sourceOf("TR_TMHORIZON_RETRAIN_EVERY"))
emit("TR_TMHORIZON_EPOCHS", $tmh.retrainEpochs, sourceOf("TR_TMHORIZON_EPOCHS"))
# ── the BitBrain ADE+SBC corrector ────────────────────────────────────────
# All knobs are resolved at gun construction (`initBitBrainGun`), so the
# context holds the real values. The network itself is only built on the first
# `predict`, which is why `[rack]` membership is the actual enable switch.
emit("TR_BITBRAIN_MEM", memModeName(ctx.bitbrain.memMode),
sourceOf(BB_MEM_ENV))
emit("TR_BITBRAIN_N", $ctx.bitbrain.nClasses, sourceOf(BB_N_ENV))
emit("TR_BITBRAIN_NADE", $ctx.bitbrain.nAde, sourceOf(BB_NADE_ENV))
emit("TR_BITBRAIN_RANGE", $ctx.bitbrain.maxDeg, sourceOf(BB_RANGE_ENV))
emit("TR_BITBRAIN_LOG", onOff(ctx.bitbrain.logEnabled),
sourceOfPresence(BB_LOG_ENV))
emit("TR_BITBRAIN_MIN_OBS", $ctx.bitbrain.minObs, sourceOf(BB_MIN_OBS_ENV))
emit("TR_BITBRAIN_WARMUP", $ctx.bitbrain.warmupN, sourceOf(BB_WARMUP_ENV))
emit("TR_BITBRAIN_ADAPT", $ctx.bitbrain.adaptEvery, sourceOf(BB_ADAPT_ENV))
emit("TR_BITBRAIN_CALIB", $ctx.bitbrain.calibEvery, sourceOf(BB_CALIB_ENV))
emit("TR_BITBRAIN_DECAY", $ctx.bitbrain.decayEvery, sourceOf(BB_DECAY_ENV))
emit("TR_BITBRAIN_DECAY_FRAC", $ctx.bitbrain.decayFrac,
sourceOf(BB_DECAY_FRAC_ENV))
emit("TR_BITBRAIN_SEED", $ctx.bitbrain.seed, sourceOf(BB_SEED_ENV))
emit("TR_BITBRAIN_RESET_ON_TARGET", onOff(ctx.bitbrain.resetOnTarget),
sourceOf(BB_RESET_ON_TARGET_ENV))
# ── Pattern radial knobs ──────────────────────────────────────────────────
# These are resolved lazily inside `predict` (which has not run at boot), so
# unless something already forced them we report the raw env value and say so.
@@ -347,6 +371,9 @@ proc knownEnvNames*(): seq[string] =
TMH_SHIFT_ENV, TMH_BIG_MULT_ENV, TMH_LOG_ENV, TMH_RESET_ON_TARGET_ENV,
TMH_WINDOW_ENV, TMH_RESET_DROP_ENV, TMH_NSTATES_ENV, TMH_ACCURVE_ENV,
TMH_RETRAIN_EVERY_ENV, TMH_EPOCHS_ENV,
BB_MEM_ENV, BB_N_ENV, BB_NADE_ENV, BB_RANGE_ENV, BB_LOG_ENV,
BB_MIN_OBS_ENV, BB_WARMUP_ENV, BB_ADAPT_ENV, BB_CALIB_ENV, BB_DECAY_ENV,
BB_DECAY_FRAC_ENV, BB_SEED_ENV, BB_RESET_ON_TARGET_ENV,
]
# rack names are constructed from the prefix + gun table, not spelled out
for g in RackGunNames: