Files
SirRoboGarage/common_libs/guns/bitbrain_gun.nim
T
SirStone 795a0e59fe 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.
2026-09-24 22:25:04 +02:00

537 lines
23 KiB
Nim
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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