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:
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -44,10 +44,10 @@ import virtual_bullets
|
||||
# transition the radar uses.
|
||||
|
||||
const
|
||||
RackGunNames*: array[16, string] = [
|
||||
RackGunNames*: array[17, string] = [
|
||||
"HEADON", "LINEAR", "TSETLIN", "CIRCULAR", "GUESSFACTOR", "PATTERN",
|
||||
"WALLBOUNCE", "ACCEL", "STOPSHOT", "DISPLACE", "AVGLEAD", "DECAYGF",
|
||||
"KNN", "TMSELECT", "TMPATTERN", "TMHORIZON"]
|
||||
"KNN", "TMSELECT", "TMPATTERN", "TMHORIZON", "BITBRAIN"]
|
||||
RackEnvPrefix* = "TR_RACK_"
|
||||
## SHIPPED DEFAULT: `onlyPattern`. Pattern (id 5) is admitted in both racks;
|
||||
## every other gun is `off`. The selection mechanism is untouched and remains
|
||||
@@ -57,7 +57,7 @@ const
|
||||
## it never spawns a virtual bullet unless explicitly enabled, so the shared
|
||||
## VirtualTracker ring head — and every other gun's learning order — is
|
||||
## unchanged.
|
||||
DefaultRackMembership*: array[16, RackMembership] = [
|
||||
DefaultRackMembership*: array[17, RackMembership] = [
|
||||
rmOff, # 0 HEADON — off (measured: worst over-selected gun)
|
||||
rmOff, # 1 LINEAR — off
|
||||
rmOff, # 2 TSETLIN — off
|
||||
@@ -73,7 +73,11 @@ const
|
||||
rmOff, # 12 KNN — off
|
||||
rmOff, # 13 TMSELECT — off
|
||||
rmOff, # 14 TMPATTERN — off (already shipped off; TM pattern gun)
|
||||
rmOff] # 15 TMHORIZON — off (horizon-based TM corrector; expected to lose)
|
||||
rmOff, # 15 TMHORIZON — off (horizon-based TM corrector; expected to lose)
|
||||
rmOff] # 16 BITBRAIN — off (fine-grained ADE+SBC corrector)
|
||||
## NOTE: the table is registered in the SAME commit as the gun id (16) and the
|
||||
## live wiring, so `TR_RACK_BITBRAIN=both` is the ONLY thing that admits it and
|
||||
## an unset environment is byte-for-byte the shipped Pattern-only rack.
|
||||
|
||||
proc parseRackMembership*(value: string): RackMembership =
|
||||
## Parse a `TR_RACK_<GUN>` value. Empty / unknown values fall back to the
|
||||
|
||||
@@ -0,0 +1,536 @@
|
||||
## bitbrain_gun.nim — BitBrain (ADE + SBC) FINE-GRAINED AIM CORRECTOR.
|
||||
##
|
||||
## THE BRIEF THIS IMPLEMENTS (from the offline gate test, docs/bitbrain_gate_test.md):
|
||||
## * BASE — the shipped Pattern gun's prediction (`guns/pattern_matcher`).
|
||||
## BitBrain supplies only a small ANGULAR CORRECTION on top of it,
|
||||
## exactly the shape the gate test measured and the shape TMHorizon
|
||||
## uses. That keeps the comparison against Pattern/TMHorizon
|
||||
## apples-to-apples.
|
||||
## * INPUT — the SAME 53 bits TMHorizon uses: the 49-bit draft spec
|
||||
## (`tmhBaseBits`) PLUS a 4-bit horizon one-hot (`tmhLits`). These are
|
||||
## reused from `guns/tm_horizon.nim`, not re-derived.
|
||||
## * OUTPUT — a fine-grained angular-correction CLASS over ±`TR_BITBRAIN_RANGE`
|
||||
## degrees (`TR_BITBRAIN_N` bins, default 32). The readout is the
|
||||
## ARGMAX class centre (the gate test MEASURED that argmax is the
|
||||
## winning readout; the count-weighted mean is a shrinkage predictor
|
||||
## that lowers the hit rate). Zero correction when there is no
|
||||
## evidence.
|
||||
## * LABEL — the +h-tick FACT from our OWN observation ring (the ring the
|
||||
## embedded TmHorizonGun maintains): `h = round(dist/speed)`,
|
||||
## `speed = 20 - 3*power`, clamped to [10, 50]. Never crosses a round
|
||||
## boundary (pending samples are dropped on a round reset).
|
||||
## * TRAIN — ONLINE / PREQUENTIAL: predict, then learn the resolved fact when
|
||||
## it becomes due `h` ticks later.
|
||||
## * AD LAYER — synthesised for OUR data. The MNIST weights are useless.
|
||||
## center = 0 (the inputs are BINARY; the reference 127 would collapse
|
||||
## the code to a polarity count). Thresholds start from a small
|
||||
## heuristic that fires ~1 % from the first ticks, are then calibrated
|
||||
## from a running score histogram to the paper's ~1 % operating point
|
||||
## (the gate test's percentile init, made online), and are nudged by
|
||||
## the library's deterministic `adaptThresholds` homeostasis.
|
||||
##
|
||||
## MEMORY MODES (`TR_BITBRAIN_MEM`):
|
||||
## perRound (DEFAULT) — wipe the SBCs every round. The gate test measured this
|
||||
## as the WINNING regime.
|
||||
## retained — accumulate across the whole battle/enemy and wipe only
|
||||
## on a target change / new battle. This is what the user
|
||||
## asked for, and the gate test measured it as the WEAKEST
|
||||
## regime: the idempotent SBC only ADDS, so it saturates.
|
||||
## decay — retained PLUS a periodic partial wipe of the SBC
|
||||
## tensors (TR_BITBRAIN_DECAY every N samples, a fraction
|
||||
## TR_BITBRAIN_DECAY_FRAC of words zeroed). This is the one
|
||||
## mechanism with a measured diagnosis behind it: the SBC
|
||||
## saturates and a bounded/decaying memory should help.
|
||||
##
|
||||
## DEFAULT OFF / PARITY: this gun is admitted ONLY when `TR_RACK_BITBRAIN` says so
|
||||
## (default `off`) AND it never runs its network until `predict` is first called
|
||||
## (`ensureInit`). With the shipped rack the live loop never calls `predict`, so
|
||||
## no network is built, no RNG is touched and the shipped bot is unchanged.
|
||||
|
||||
import std/[math, os, strutils, strformat, random]
|
||||
import gun_harness/gun_interface
|
||||
import guns/tm_horizon
|
||||
import guns/pattern_matcher
|
||||
import bitbrain/bitbrain
|
||||
|
||||
const
|
||||
## ── env knobs (all resolved once at gun construction) ─────────────────────
|
||||
BB_MEM_ENV* = "TR_BITBRAIN_MEM" ## perRound|retained|decay
|
||||
BB_N_ENV* = "TR_BITBRAIN_N" ## correction classes
|
||||
BB_NADE_ENV* = "TR_BITBRAIN_NADE" ## ADEs per address decoder
|
||||
BB_RANGE_ENV* = "TR_BITBRAIN_RANGE" ## class half-range, degrees
|
||||
BB_LOG_ENV* = "TR_BITBRAIN_LOG" ## 1 = per-change [bb] log
|
||||
BB_MIN_OBS_ENV* = "TR_BITBRAIN_MIN_OBS" ## resolved samples before correction
|
||||
BB_WARMUP_ENV* = "TR_BITBRAIN_WARMUP" ## samples before percentile init
|
||||
BB_ADAPT_ENV* = "TR_BITBRAIN_ADAPT" ## homeostasis interval (samples)
|
||||
BB_CALIB_ENV* = "TR_BITBRAIN_CALIB" ## percentile recalibration interval
|
||||
BB_DECAY_ENV* = "TR_BITBRAIN_DECAY" ## decay interval (samples)
|
||||
BB_DECAY_FRAC_ENV* = "TR_BITBRAIN_DECAY_FRAC" ## fraction of words zeroed per decay
|
||||
BB_SEED_ENV* = "TR_BITBRAIN_SEED" ## deterministic AD/decay seed
|
||||
BB_RESET_ON_TARGET_ENV* = "TR_BITBRAIN_RESET_ON_TARGET"
|
||||
## ── fixed geometry ────────────────────────────────────────────────────────
|
||||
BB_WIDTHS* = [6, 8, 10, 12] ## the paper's multi-width ADs
|
||||
BB_TARGET_RATE* = 0.01 ## the paper's ~1 % firing target
|
||||
BB_PENDING_CAP* = 512 ## deferred-label queue (>= 4 buckets x 50 ticks)
|
||||
## ── shipped defaults ──────────────────────────────────────────────────────
|
||||
BB_N_DEF = 32
|
||||
BB_NADE_DEF = 256
|
||||
BB_RANGE_DEF = 40.0
|
||||
BB_MIN_OBS_DEF = 8
|
||||
BB_WARMUP_DEF = 400
|
||||
BB_ADAPT_DEF = 32
|
||||
BB_CALIB_DEF = 512
|
||||
BB_DECAY_DEF = 250
|
||||
BB_DECAY_FRAC_DEF = 0.02
|
||||
BB_SEED_DEF = 20240921
|
||||
BB_RESET_ON_TARGET_DEF = true
|
||||
|
||||
type
|
||||
BitMemMode* = enum
|
||||
bmPerRound, bmRetained, bmDecay
|
||||
|
||||
BbPending = object
|
||||
## One deferred training sample. `lits` is the exact literal vector the ADs
|
||||
## saw at fire time; the label is resolved `horizon` ticks later.
|
||||
fireTick: int
|
||||
horizon: int
|
||||
selfX*, selfY: float
|
||||
baseBearing: float
|
||||
lits: array[TMH_NLITS, uint8]
|
||||
|
||||
BitBrainGun* = object
|
||||
tmh: TmHorizonGun
|
||||
bb: BitBrain
|
||||
initialized: bool
|
||||
# ── resolved config ──────────────────────────────────────────────────────
|
||||
nClasses*: int
|
||||
maxDeg*: float
|
||||
nAde*: int
|
||||
memMode*: BitMemMode
|
||||
logEnabled*: bool
|
||||
minObs*: int
|
||||
warmupN*: int
|
||||
adaptEvery*: int
|
||||
calibEvery*: int
|
||||
decayEvery*: int
|
||||
decayFrac*: float
|
||||
seed*: int64
|
||||
resetOnTarget*: bool
|
||||
# ── AD calibration state ─────────────────────────────────────────────────
|
||||
rng: Rand
|
||||
hist: seq[seq[int32]] ## per-AD raw-score histogram (bins 2w+1)
|
||||
histTotal: int
|
||||
sampleCount*: int
|
||||
sinceAdapt: int
|
||||
sinceCalib: int
|
||||
sinceDecay: int
|
||||
decays*: int
|
||||
# ── scratch (avoid per-sample allocation) ────────────────────────────────
|
||||
scratch: seq[seq[int32]]
|
||||
counts: seq[int]
|
||||
# ── deferred labels ──────────────────────────────────────────────────────
|
||||
pending: array[BB_PENDING_CAP, BbPending]
|
||||
pendingCount*: int
|
||||
pendingDropped*: int
|
||||
# ── per-tick caches ──────────────────────────────────────────────────────
|
||||
lastTick: int
|
||||
lastEnqTick: int
|
||||
lastEnqBucket: int
|
||||
cachedBits: array[TMH_N_BASE, uint8]
|
||||
cachedBitsTick: int
|
||||
bitsValid: bool
|
||||
# ── accounting / logging ─────────────────────────────────────────────────
|
||||
trained*: int
|
||||
lastBest: int
|
||||
lastShift*: float
|
||||
corrections*: int
|
||||
lastLogKey: string
|
||||
lastLogTick: int
|
||||
observedTargetId*: int
|
||||
|
||||
# ── small pure helpers ───────────────────────────────────────────────────────
|
||||
|
||||
proc wrapRadBB(r: float): float {.inline.} =
|
||||
result = r
|
||||
while result > PI: result -= 2.0 * PI
|
||||
while result < -PI: result += 2.0 * PI
|
||||
|
||||
proc memModeName*(m: BitMemMode): string =
|
||||
case m
|
||||
of bmPerRound: "perRound"
|
||||
of bmRetained: "retained"
|
||||
of bmDecay: "decay"
|
||||
|
||||
proc parseMemMode*(value: string): BitMemMode =
|
||||
## Empty / unknown values fall back to the shipped `perRound` (the measured
|
||||
## winning regime), so a typo cannot silently select another regime.
|
||||
case value.strip().toLowerAscii()
|
||||
of "retained", "retain", "accum", "accumulate": bmRetained
|
||||
of "decay", "forget", "age": bmDecay
|
||||
else: bmPerRound
|
||||
|
||||
proc envFloatBB(name: string, default: float): float =
|
||||
let v = getEnv(name, "")
|
||||
if v.len == 0: return default
|
||||
try: parseFloat(v.strip()) except ValueError: default
|
||||
|
||||
proc envIntBB(name: string, default: int): int =
|
||||
let v = getEnv(name, "")
|
||||
if v.len == 0: return default
|
||||
try: parseInt(v.strip()) except ValueError: default
|
||||
|
||||
proc envBoolBB(name: string, default: bool): bool =
|
||||
case getEnv(name, "").strip().toLowerAscii()
|
||||
of "1", "true", "yes", "on": true
|
||||
of "0", "false", "no", "off": false
|
||||
else: default
|
||||
|
||||
proc bbCenterDeg*(k, nClasses: int, maxDeg: float): float =
|
||||
## Centre (degrees) of correction class `k` over ±maxDeg.
|
||||
let w = 2.0 * maxDeg / float(nClasses)
|
||||
-maxDeg + (float(k) + 0.5) * w
|
||||
|
||||
proc bbClassOf*(errRad: float, nClasses: int, maxDeg: float): int =
|
||||
## Bin a signed angular error (radians) into one of `nClasses` bins over
|
||||
## [−maxDeg, +maxDeg] (the gate test's `binOf`).
|
||||
let x = radToDeg(errRad)
|
||||
var k = int((x + maxDeg) / (2.0 * maxDeg) * float(nClasses))
|
||||
if k < 0: k = 0
|
||||
if k >= nClasses: k = nClasses - 1
|
||||
k
|
||||
|
||||
# ── construction / lazy network build ────────────────────────────────────────
|
||||
|
||||
proc initBitBrainGun*(): BitBrainGun =
|
||||
result.nClasses = clamp(envIntBB(BB_N_ENV, BB_N_DEF), 2, 512)
|
||||
result.nAde = clamp(envIntBB(BB_NADE_ENV, BB_NADE_DEF), 8, 4096)
|
||||
result.maxDeg = clamp(envFloatBB(BB_RANGE_ENV, BB_RANGE_DEF), 1.0, 180.0)
|
||||
result.memMode = parseMemMode(getEnv(BB_MEM_ENV, ""))
|
||||
result.logEnabled = envBoolBB(BB_LOG_ENV, false)
|
||||
result.minObs = max(1, envIntBB(BB_MIN_OBS_ENV, BB_MIN_OBS_DEF))
|
||||
result.warmupN = max(0, envIntBB(BB_WARMUP_ENV, BB_WARMUP_DEF))
|
||||
result.adaptEvery = max(1, envIntBB(BB_ADAPT_ENV, BB_ADAPT_DEF))
|
||||
result.calibEvery = max(1, envIntBB(BB_CALIB_ENV, BB_CALIB_DEF))
|
||||
result.decayEvery = max(1, envIntBB(BB_DECAY_ENV, BB_DECAY_DEF))
|
||||
result.decayFrac = clamp(envFloatBB(BB_DECAY_FRAC_ENV, BB_DECAY_FRAC_DEF), 0.0, 1.0)
|
||||
result.seed = int64(envIntBB(BB_SEED_ENV, BB_SEED_DEF))
|
||||
result.resetOnTarget = envBoolBB(BB_RESET_ON_TARGET_ENV, BB_RESET_ON_TARGET_DEF)
|
||||
result.lastTick = -1
|
||||
result.lastEnqTick = -1
|
||||
result.lastEnqBucket = -1
|
||||
result.observedTargetId = -1
|
||||
result.rng = initRand(result.seed + 991)
|
||||
|
||||
proc resetThresholdsHeuristic(g: var BitBrainGun) =
|
||||
## Cold-start thresholds: a small multiple of the raw-score standard deviation
|
||||
## puts every ADE near the paper's ~1 % firing rate from the FIRST ticks, so
|
||||
## the SBCs see a useful (sparse) coincidence set immediately and inference
|
||||
## never degenerates into an O(nAde^2) dense scan. The running-histogram
|
||||
## percentile calibration replaces these once warmup has passed.
|
||||
for a in 0..<g.bb.ades.len:
|
||||
let w = float(g.bb.ades[a].width)
|
||||
let thr = int32(round(2.33 * sqrt(w * 0.28)))
|
||||
let scaled = int32(g.bb.ades[a].scale) * thr
|
||||
for e in 0..<g.bb.ades[a].nAde:
|
||||
g.bb.ades[a].thresholds[e] = scaled
|
||||
|
||||
proc ensureInit*(g: var BitBrainGun) =
|
||||
## Build the AD/SBC network on first use. Never runs on the shipped default
|
||||
## path (the rack does not admit BitBrain), so the default bot is untouched.
|
||||
if g.initialized: return
|
||||
g.initialized = true
|
||||
g.tmh = initTmHorizonGun()
|
||||
var rng = initRand(g.seed)
|
||||
var ades: seq[AddressDecoder]
|
||||
for w in BB_WIDTHS:
|
||||
ades.add initRandomAddressDecoder(g.nAde, w, TMH_N_BITS, rng,
|
||||
scale = DefaultScale, center = 0,
|
||||
threshold = 0'i32)
|
||||
g.bb = initBitBrain(ades, crossPairs(ades.len), g.nClasses)
|
||||
g.hist = newSeq[seq[int32]](ades.len)
|
||||
g.scratch = newSeq[seq[int32]](ades.len)
|
||||
for a in 0..<ades.len:
|
||||
g.hist[a] = newSeq[int32](2 * ades[a].width + 1)
|
||||
g.counts = newSeq[int](g.nClasses)
|
||||
g.resetThresholdsHeuristic()
|
||||
|
||||
# ── AD calibration (online percentile init + library homeostasis) ────────────
|
||||
|
||||
proc calibrate(g: var BitBrainGun) =
|
||||
## Set every ADE's threshold to the raw score whose `count >= t` is CLOSEST to
|
||||
## `BB_TARGET_RATE * total` — the gate test's percentile init, run online over
|
||||
## the running histogram. This is what pins the realised firing rate near 1 %.
|
||||
if g.histTotal <= 0: return
|
||||
let target = BB_TARGET_RATE * float(g.histTotal)
|
||||
for a in 0..<g.bb.ades.len:
|
||||
let w = g.bb.ades[a].width
|
||||
let sc = g.bb.ades[a].scale
|
||||
var cum = 0
|
||||
var bestRaw = w
|
||||
var bestDiff = Inf
|
||||
for raw in countdown(w, -w):
|
||||
cum += int(g.hist[a][raw + w])
|
||||
let d = abs(float(cum) - target)
|
||||
if d < bestDiff:
|
||||
bestDiff = d
|
||||
bestRaw = raw
|
||||
let t = int32(bestRaw) * int32(sc)
|
||||
for e in 0..<g.bb.ades[a].nAde:
|
||||
g.bb.ades[a].thresholds[e] = t
|
||||
|
||||
proc afterSample(g: var BitBrainGun) =
|
||||
## Post-sample calibration/homeostasis schedule.
|
||||
inc g.sampleCount
|
||||
if g.sampleCount == g.warmupN:
|
||||
g.calibrate()
|
||||
g.sinceAdapt = 0
|
||||
g.sinceCalib = 0
|
||||
elif g.sampleCount > g.warmupN:
|
||||
inc g.sinceAdapt
|
||||
inc g.sinceCalib
|
||||
if g.sinceAdapt >= g.adaptEvery:
|
||||
for a in 0..<g.bb.ades.len:
|
||||
g.bb.ades[a].adaptThresholds(g.adaptEvery, BB_TARGET_RATE, 1)
|
||||
g.sinceAdapt = 0
|
||||
if g.sinceCalib >= g.calibEvery:
|
||||
g.calibrate()
|
||||
g.sinceCalib = 0
|
||||
|
||||
# ── one AD pass: firing counts + histogram + inference ───────────────────────
|
||||
|
||||
proc bbObserve(g: var BitBrainGun, lits: array[TMH_NLITS, uint8]) =
|
||||
## Drive every ADE: update its firing accumulator and the score histogram,
|
||||
## collect the active list, then infer the class counts into `g.counts`.
|
||||
for a in 0..<g.bb.ades.len:
|
||||
let w = g.bb.ades[a].width
|
||||
let sc = g.bb.ades[a].scale
|
||||
g.scratch[a].setLen(0)
|
||||
for e in 0..<g.bb.ades[a].nAde:
|
||||
var raw = 0
|
||||
let off = e * w
|
||||
for j in 0..<w:
|
||||
let c = g.bb.ades[a].codes[off + j]
|
||||
let idx = if c > 0'i32: int(c) - 1 else: int(-c) - 1
|
||||
let pol = if c > 0'i32: 1 else: -1
|
||||
raw += pol * int(lits[idx])
|
||||
inc g.hist[a][raw + w]
|
||||
if raw * sc >= int(g.bb.ades[a].thresholds[e]):
|
||||
g.scratch[a].add int32(e)
|
||||
inc g.bb.ades[a].fireCounts[e]
|
||||
inc g.histTotal
|
||||
for k in 0..<g.counts.len: g.counts[k] = 0
|
||||
for sl in 0..<g.bb.sbcs.len:
|
||||
let spec = g.bb.specs[sl]
|
||||
g.bb.sbcs[sl].infer(g.scratch[spec.row], g.scratch[spec.col], g.counts)
|
||||
|
||||
proc bbLearn(g: var BitBrainGun, lits: array[TMH_NLITS, uint8], cls: int) =
|
||||
## Recompute the active lists for a resolved sample and set its class bits in
|
||||
## every SBC (idempotent, so a repeat is a no-op).
|
||||
for a in 0..<g.bb.ades.len:
|
||||
let w = g.bb.ades[a].width
|
||||
let sc = g.bb.ades[a].scale
|
||||
g.scratch[a].setLen(0)
|
||||
for e in 0..<g.bb.ades[a].nAde:
|
||||
var raw = 0
|
||||
let off = e * w
|
||||
for j in 0..<w:
|
||||
let c = g.bb.ades[a].codes[off + j]
|
||||
let idx = if c > 0'i32: int(c) - 1 else: int(-c) - 1
|
||||
let pol = if c > 0'i32: 1 else: -1
|
||||
raw += pol * int(lits[idx])
|
||||
if raw * sc >= int(g.bb.ades[a].thresholds[e]):
|
||||
g.scratch[a].add int32(e)
|
||||
for sl in 0..<g.bb.sbcs.len:
|
||||
let spec = g.bb.specs[sl]
|
||||
discard g.bb.sbcs[sl].learn(g.scratch[spec.row], g.scratch[spec.col], cls)
|
||||
|
||||
proc applyDecay(g: var BitBrainGun) =
|
||||
## Age the SBC tensors: zero a fraction of their 32-bit words. This is the
|
||||
## bounded-memory mechanism the gate test's diagnosis called for (the
|
||||
## idempotent SBC otherwise only ADDS and saturates with stale class bits).
|
||||
let cut = int(g.decayFrac * 1000.0)
|
||||
if cut <= 0: return
|
||||
for sl in 0..<g.bb.sbcs.len:
|
||||
for wi in 0..<g.bb.sbcs[sl].bits.len:
|
||||
if g.rng.rand(999) < cut:
|
||||
g.bb.sbcs[sl].bits[wi] = 0'u32
|
||||
inc g.decays
|
||||
|
||||
# ── deferred-label resolution (prequential learning) ─────────────────────────
|
||||
|
||||
proc resolvePending(g: var BitBrainGun, state: WorldState) =
|
||||
var w = 0
|
||||
for i in 0..<g.pendingCount:
|
||||
let p = g.pending[i]
|
||||
let due = p.fireTick + p.horizon
|
||||
if due > state.tick:
|
||||
g.pending[w] = p
|
||||
inc w
|
||||
elif due == state.tick:
|
||||
let obs = tmhObservedAt(g.tmh, state.tick, p.selfX, p.selfY)
|
||||
if obs.ok and (state.tick - obs.lastSeenTick) <= TMH_STALE_MAX:
|
||||
let err = wrapRadBB(obs.bearing - p.baseBearing)
|
||||
let cls = bbClassOf(err, g.nClasses, g.maxDeg)
|
||||
g.bbLearn(p.lits, cls)
|
||||
inc g.trained
|
||||
inc g.sinceDecay
|
||||
if g.memMode == bmDecay and g.sinceDecay >= g.decayEvery:
|
||||
g.applyDecay()
|
||||
g.sinceDecay = 0
|
||||
else:
|
||||
inc g.pendingDropped
|
||||
else:
|
||||
inc g.pendingDropped
|
||||
g.pendingCount = w
|
||||
|
||||
# ── logging ──────────────────────────────────────────────────────────────────
|
||||
|
||||
proc bbLog(g: var BitBrainGun, state: WorldState, h, bucket, total, best: int) =
|
||||
## ONE change-gated `[bb]` line (behind TR_BITBRAIN_LOG=1) so the user tailing
|
||||
## the GUI log sees what the corrector is thinking, not one line per tick.
|
||||
if not g.logEnabled: return
|
||||
let shift = bbCenterDeg(best, g.nClasses, g.maxDeg)
|
||||
let key = fmt"{best}|{shift:.1f}"
|
||||
if key == g.lastLogKey: return
|
||||
if state.tick == g.lastLogTick: return
|
||||
g.lastLogKey = key
|
||||
g.lastLogTick = state.tick
|
||||
var nz = 0
|
||||
for k in 0..<g.counts.len:
|
||||
if g.counts[k] > 0: inc nz
|
||||
echo fmt"[bb] t={state.tick} h={h} bucket={bucket} cls={best}/{g.nClasses} " &
|
||||
fmt"shift={shift:+.1f}deg cnt={g.counts[best]}/{total} nz={nz} " &
|
||||
fmt"trained={g.trained} samples={g.sampleCount} pend={g.pendingCount} " &
|
||||
fmt"mode={memModeName(g.memMode)} warm={(if g.trained >= g.minObs: 1 else: 0)}"
|
||||
|
||||
# ── reset hooks (mirroring TmHorizonGun) ─────────────────────────────────────
|
||||
|
||||
proc resetRound(g: var BitBrainGun) =
|
||||
## PER-ROUND wipe. Always clear the observation ring, deferred labels and
|
||||
## per-tick caches (the bots teleport between rounds). In `perRound` mode the
|
||||
## SBCs are wiped too; `retained`/`decay` keep them across the round.
|
||||
g.tmh.resetRoundState()
|
||||
g.pendingCount = 0
|
||||
g.lastTick = -1
|
||||
g.lastEnqTick = -1
|
||||
g.lastEnqBucket = -1
|
||||
g.bitsValid = false
|
||||
g.lastLogKey = ""
|
||||
g.lastLogTick = -1
|
||||
if g.memMode == bmPerRound:
|
||||
g.bb.resetLearning()
|
||||
g.trained = 0
|
||||
|
||||
proc resetRoundState*(g: var BitBrainGun) =
|
||||
if not g.initialized: return
|
||||
g.resetRound()
|
||||
|
||||
proc resetLearning*(g: var BitBrainGun, reason = "") =
|
||||
## PER-BATTLE / PER-ENEMY wipe: SBCs, AD thresholds, histograms and counters.
|
||||
if not g.initialized: return
|
||||
g.bb.resetLearning()
|
||||
g.resetThresholdsHeuristic()
|
||||
for a in 0..<g.hist.len:
|
||||
for i in 0..<g.hist[a].len: g.hist[a][i] = 0
|
||||
g.histTotal = 0
|
||||
g.sampleCount = 0
|
||||
g.sinceAdapt = 0
|
||||
g.sinceCalib = 0
|
||||
g.sinceDecay = 0
|
||||
g.decays = 0
|
||||
g.trained = 0
|
||||
g.observedTargetId = -1
|
||||
g.rng = initRand(g.seed + 991)
|
||||
g.resetRound()
|
||||
if reason.len > 0 and g.logEnabled:
|
||||
echo fmt"[bb-reset] reason={reason}"
|
||||
|
||||
proc targetChanged*(g: var BitBrainGun, enemyId: int): bool =
|
||||
## Per-ENEMY reset: wipe when the target changes to a different bot id. First
|
||||
## acquisition never wipes, so the round-start pick does not cold-start us.
|
||||
if not g.resetOnTarget: return false
|
||||
if enemyId < 0: return false
|
||||
if g.observedTargetId >= 0 and enemyId != g.observedTargetId:
|
||||
g.resetLearning("target_change")
|
||||
g.observedTargetId = enemyId
|
||||
return true
|
||||
g.observedTargetId = enemyId
|
||||
false
|
||||
|
||||
# ── Gun interface ────────────────────────────────────────────────────────────
|
||||
|
||||
proc isWarmedUp*(g: BitBrainGun): bool {.inline.} = true
|
||||
|
||||
proc networkBytes*(g: BitBrainGun): int =
|
||||
## Bytes held by the AD/SBC network (0 until the network is built).
|
||||
if g.initialized: g.bb.memoryBytes else: 0
|
||||
|
||||
proc predict*(g: var BitBrainGun, state: WorldState,
|
||||
bulletSpeed: float): GunPrediction =
|
||||
g.ensureInit()
|
||||
|
||||
# Round boundary: a tick regression means a new round.
|
||||
if state.tick < g.lastTick: g.resetRound()
|
||||
|
||||
# Once per tick: observe the world, then resolve any labels now due.
|
||||
if state.tick != g.lastTick:
|
||||
tmhUpdateHistory(g.tmh, state)
|
||||
g.resolvePending(state)
|
||||
g.lastTick = state.tick
|
||||
g.bitsValid = false
|
||||
|
||||
# The base prediction is Pattern; BitBrain only corrects its bearing.
|
||||
let base = g.tmh.pattern.predict(state, bulletSpeed)
|
||||
if bulletSpeed <= 0.0: return base
|
||||
|
||||
let dist = hypot(state.enemyX - state.selfX, state.enemyY - state.selfY)
|
||||
let h = tmhHorizonFor(dist, bulletSpeed)
|
||||
let bucket = tmhHorizonBucket(h)
|
||||
|
||||
if not g.bitsValid or g.cachedBitsTick != state.tick:
|
||||
g.cachedBits = tmhBaseBits(g.tmh, state)
|
||||
g.cachedBitsTick = state.tick
|
||||
g.bitsValid = true
|
||||
let lits = tmhLits(g.cachedBits, bucket)
|
||||
|
||||
# Observe this input (AD pass + inference) and advance the calibration clock.
|
||||
g.bbObserve(lits)
|
||||
g.afterSample()
|
||||
|
||||
# Enqueue one deferred sample per (tick, bucket): predict runs once per power
|
||||
# bin, so all four horizons contribute evidence.
|
||||
if g.lastEnqTick != state.tick or g.lastEnqBucket != bucket:
|
||||
if g.pendingCount < BB_PENDING_CAP:
|
||||
g.pending[g.pendingCount] = BbPending(
|
||||
fireTick: state.tick, horizon: h,
|
||||
selfX: state.selfX, selfY: state.selfY,
|
||||
baseBearing: arctan2(base.y - state.selfY, base.x - state.selfX),
|
||||
lits: lits)
|
||||
inc g.pendingCount
|
||||
else:
|
||||
inc g.pendingDropped
|
||||
g.lastEnqTick = state.tick
|
||||
g.lastEnqBucket = bucket
|
||||
|
||||
# Readout: argmax class centre, zero correction with no evidence / cold.
|
||||
var shiftDeg = 0.0
|
||||
if g.trained >= g.minObs:
|
||||
var total = 0
|
||||
for k in 0..<g.counts.len: total += g.counts[k]
|
||||
if total > 0:
|
||||
var best = 0
|
||||
for k in 1..<g.counts.len:
|
||||
if g.counts[k] > g.counts[best]: best = k
|
||||
shiftDeg = bbCenterDeg(best, g.nClasses, g.maxDeg)
|
||||
g.lastBest = best
|
||||
g.lastShift = shiftDeg
|
||||
inc g.corrections
|
||||
g.bbLog(state, h, bucket, total, best)
|
||||
|
||||
if shiftDeg == 0.0: return base
|
||||
tmhApplyShift(state.selfX, state.selfY, base.x, base.y, shiftDeg)
|
||||
|
||||
proc onResult*(g: var BitBrainGun, e: FeedbackEvent) =
|
||||
## Labels come from our own observation ring, not from virtual-bullet
|
||||
## feedback, so there is nothing to do here. The hook exists for the rack.
|
||||
discard
|
||||
@@ -546,6 +546,19 @@ proc ringValidCount*(g: TmHorizonGun): int =
|
||||
for i in 0..<TMH_POS_RING:
|
||||
if g.ringValid[i]: inc result
|
||||
|
||||
proc tmhObservedAt*(g: TmHorizonGun, tick: int, selfX, selfY: float
|
||||
): tuple[ok: bool, bearing, dist: float, lastSeenTick: int] =
|
||||
## Exported seam for the BitBrain corrector: the enemy's OBSERVED bearing and
|
||||
## distance (relative to `(selfX, selfY)`) at `tick`, if the observation ring
|
||||
## holds that exact tick. `lastSeenTick` lets the caller apply the same
|
||||
## staleness rule TMHorizon uses before trusting the label. `ok = false` when
|
||||
## the tick is missing (e.g. across a round boundary or before warm-up).
|
||||
let r = g.ringAt(tick)
|
||||
if not r.ok:
|
||||
return (false, 0.0, 0.0, 0)
|
||||
(true, arctan2(r.t.ey - selfY, r.t.ex - selfX),
|
||||
hypot(r.t.ex - selfX, r.t.ey - selfY), r.t.lastSeenTick)
|
||||
|
||||
proc ringHas*(g: TmHorizonGun, tick: int): bool =
|
||||
## Observability / test seam: is there a valid observation exactly at `tick`?
|
||||
## Used to prove a round boundary drops the old positions (so a label can never
|
||||
@@ -598,7 +611,7 @@ proc bulletFeatures(g: var TmHorizonGun, state: WorldState): tuple[tta: int, lat
|
||||
result.lat = b.ux * vy - b.uy * vx
|
||||
g.bulletCount = w
|
||||
|
||||
proc tmhUpdateHistory(g: var TmHorizonGun, state: WorldState) =
|
||||
proc tmhUpdateHistory*(g: var TmHorizonGun, state: WorldState) =
|
||||
## Once per tick: write the observation ring, advance the reversal counter and
|
||||
## the self-energy-drop bullet proxy.
|
||||
let slot = ((state.tick mod TMH_POS_RING) + TMH_POS_RING) mod TMH_POS_RING
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
## Default-parity + registration guard for the BitBrain gun (id 16).
|
||||
##
|
||||
## No Java, no battle, no network build. Covers:
|
||||
## * the rack table carries BITBRAIN at id 16 and it defaults to `off`;
|
||||
## * the shipped rack still admits exactly Pattern;
|
||||
## * TR_RACK_BITBRAIN=both is what admits it, and the spawn gate honours it;
|
||||
## * `initBitBrainGun()` is LAZY (no network) and does NOT touch the global
|
||||
## RNG, so the default path cannot perturb the selector's random draws;
|
||||
## * the mode parser defaults to `perRound`.
|
||||
##
|
||||
## Run: nim c -r common_libs/tests/test_bitbrain_registration.nim
|
||||
|
||||
import std/[random, os, math]
|
||||
import gun_harness/virtual_bullets
|
||||
import gun_harness/selector
|
||||
import guns/bitbrain_gun
|
||||
|
||||
const BitBrainId = 16
|
||||
const PatternId = 5
|
||||
|
||||
var failures = 0
|
||||
proc check(name: string, ok: bool) =
|
||||
if ok: echo "PASS: ", name
|
||||
else: echo "FAIL: ", name; inc failures
|
||||
|
||||
proc clearRackEnv() =
|
||||
for name in RackGunNames: delEnv("TR_RACK_" & name)
|
||||
|
||||
proc testTable() =
|
||||
check "rack: BITBRAIN is registered at id 16", RackGunNames[BitBrainId] == "BITBRAIN"
|
||||
check "rack: BITBRAIN defaults to `off`", DefaultRackMembership[BitBrainId] == rmOff
|
||||
var onlyPattern = true
|
||||
for i in 0..<RackGunNames.len:
|
||||
let want = if i == PatternId: rmBoth else: rmOff
|
||||
if DefaultRackMembership[i] != want: onlyPattern = false
|
||||
check "rack: the shipped default is still the onlyPattern rack", onlyPattern
|
||||
check "rack: the default rack admits only Pattern (1v1)",
|
||||
admittedGuns(17, rm1v1, DefaultRackMembership) == @[PatternId]
|
||||
check "gate: BitBrain is NOT spawned under the default rack",
|
||||
not vBulletAdmitted(BitBrainId, rm1v1, DefaultRackMembership, true)
|
||||
|
||||
proc testEnvOverride() =
|
||||
clearRackEnv()
|
||||
putEnv("TR_RACK_BITBRAIN", "both")
|
||||
let m = loadRackMembership()
|
||||
check "env: TR_RACK_BITBRAIN=both admits BitBrain",
|
||||
m[BitBrainId] == rmBoth and
|
||||
vBulletAdmitted(BitBrainId, rm1v1, m, true)
|
||||
check "env: admitting BitBrain leaves Pattern as the only other member",
|
||||
admittedGuns(17, rm1v1, m) == @[PatternId, BitBrainId]
|
||||
clearRackEnv()
|
||||
|
||||
proc testLazyAndRngClean() =
|
||||
delEnv("TR_BITBRAIN_MEM")
|
||||
var g = initBitBrainGun()
|
||||
check "lazy: constructing the gun does NOT build the network (0 bytes)",
|
||||
g.networkBytes == 0
|
||||
check "default: unset TR_BITBRAIN_MEM is perRound",
|
||||
g.memMode == bmPerRound
|
||||
check "parse: retained/decay/unknown",
|
||||
parseMemMode("retained") == bmRetained and
|
||||
parseMemMode("decay") == bmDecay and
|
||||
parseMemMode("banana") == bmPerRound
|
||||
# Global RNG parity: constructing the gun must not consume global randomness.
|
||||
randomize(1234)
|
||||
let a = rand(1_000_000)
|
||||
randomize(1234)
|
||||
var g2 = initBitBrainGun()
|
||||
discard g2
|
||||
let b = rand(1_000_000)
|
||||
check "parity: initBitBrainGun() does not perturb the global RNG", a == b
|
||||
|
||||
proc testGeometry() =
|
||||
check "geometry: class 0 centre is the low edge + half a bin",
|
||||
abs(bbCenterDeg(0, 32, 40.0) - (-40.0 + 0.5 * 80.0 / 32.0)) < 1e-9
|
||||
check "geometry: bbClassOf round-trips the centre",
|
||||
bbClassOf(degToRad(bbCenterDeg(17, 32, 40.0)), 32, 40.0) == 17
|
||||
|
||||
testTable()
|
||||
testEnvOverride()
|
||||
testLazyAndRngClean()
|
||||
testGeometry()
|
||||
|
||||
if failures > 0:
|
||||
echo "\n", failures, " check(s) FAILED"
|
||||
quit(1)
|
||||
echo "\nAll BitBrain registration checks passed."
|
||||
@@ -158,14 +158,14 @@ proc testDefaultsOnlyPattern() =
|
||||
DefaultRackMembership[12] == rmOff and DefaultRackMembership[13] == rmOff and
|
||||
DefaultRackMembership[14] == rmOff
|
||||
check "defaults: the default rack admits ONLY Pattern in 1v1",
|
||||
admittedGuns(15, rm1v1, DefaultRackMembership) == @[PatternId]
|
||||
admittedGuns(17, rm1v1, DefaultRackMembership) == @[PatternId]
|
||||
check "defaults: the default rack admits ONLY Pattern in melee",
|
||||
admittedGuns(15, rmMelee, DefaultRackMembership) == @[PatternId]
|
||||
admittedGuns(17, rmMelee, DefaultRackMembership) == @[PatternId]
|
||||
let loaded = loadRackMembership()
|
||||
check "defaults: with a clean environment loadRackMembership() == shipped table",
|
||||
loaded == DefaultRackMembership
|
||||
check "defaults: RackGunNames covers the shipped 16-gun rack",
|
||||
RackGunNames.len == 16 and DefaultRackMembership.len == 16
|
||||
check "defaults: RackGunNames covers the shipped 17-gun rack",
|
||||
RackGunNames.len == 17 and DefaultRackMembership.len == 17
|
||||
|
||||
proc testFloorRespectsAdmission() =
|
||||
## The FLOOR path (`bestRate <= 0` or below the floor rate) historically fell
|
||||
@@ -212,8 +212,8 @@ proc testRevertOverrideRestoresFullRack() =
|
||||
let want = if i == 14: rmOff else: rmBoth
|
||||
if m[i] != want: full = false
|
||||
check "revert: the documented one-liner restores the all-`both` full rack", full
|
||||
check "revert: 1v1 rack admits every gun again (all 14, TMPATTERN excluded)",
|
||||
admittedGuns(15, rm1v1, m) == @[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]
|
||||
check "revert: 1v1 rack admits every gun again (TMPATTERN excluded)",
|
||||
admittedGuns(17, rm1v1, m) == @[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 15, 16]
|
||||
clearRackEnv()
|
||||
|
||||
proc testEnvOverrides() =
|
||||
|
||||
@@ -62,7 +62,7 @@ proc seedOldRack(t: var VirtualTracker, targetId: int) =
|
||||
# ── registration table ────────────────────────────────────────────────────────
|
||||
|
||||
proc testTable() =
|
||||
check "rack: RackGunNames has 16 entries", RackGunNames.len == 16
|
||||
check "rack: RackGunNames has 17 entries", RackGunNames.len == 17
|
||||
check "rack: the new gun is named TMPATTERN at id 14",
|
||||
RackGunNames[TmPatternId] == "TMPATTERN"
|
||||
check "rack: the new gun defaults to `off`",
|
||||
@@ -82,9 +82,9 @@ proc oldRackMembership(): array[15, RackMembership] =
|
||||
|
||||
proc testDefaultAdmitsOnlyPattern() =
|
||||
check "default membership admits only PATTERN (1v1)",
|
||||
admittedGuns(15, rm1v1, DefaultRackMembership) == @[PatternId]
|
||||
admittedGuns(17, rm1v1, DefaultRackMembership) == @[PatternId]
|
||||
check "default membership admits only PATTERN (melee)",
|
||||
admittedGuns(15, rmMelee, DefaultRackMembership) == @[PatternId]
|
||||
admittedGuns(17, rmMelee, DefaultRackMembership) == @[PatternId]
|
||||
|
||||
proc testEnvOverride() =
|
||||
for name in RackGunNames: delEnv("TR_RACK_" & name)
|
||||
|
||||
Reference in New Issue
Block a user