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:
@@ -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