## 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..= 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.warmupN: inc g.sinceAdapt inc g.sinceCalib if g.sinceAdapt >= g.adaptEvery: for a in 0..= 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.. 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.. 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.. 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.. 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.. 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.. 0: var best = 0 for k in 1.. 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