Files
SirRoboGarage/common_libs/guns/bitbrain_net.nim
T
SirStone 59c5af0499 j140 fix: a pre-rename .env must not admit the disabled BitBrain net gun
TR_RACK_BITBRAIN=both is what the owner's live .env carries, and with the new
ADE+SBC gun registered at id 17 under the SAME rack name that value was also
landing on id 17 - so a gun that was not enabled (TR_BITBRAIN_NET unset) was
admitted into the rack and its placeholder predictions were pushed into the
shared VirtualTracker ring, which shifts every other gun's learning order.

While the namespace is LEGACY, loadRackMembership now skips id 17's
TR_RACK_BITBRAIN entirely, so that value addresses ONLY the gun it always
addressed (LEADGAIN, id 16). ModularBot additionally gates admission on
BitbrainNetGun.gunAdmitted(), and test_bitbrain_net pins the truth table: over
6 (rack, switch) settings there is NO configuration that admits the gun while
leaving it disabled.

Guards: test_env_report 25, test_rack_membership 49 (was 48; the revert
one-liner now sets TR_BITBRAIN_NET=1 and one truth-table check was added),
test_tm_pattern_registration 20, test_bitbrain 56, test_gun_harness 39,
test_tfil_commit_env 30, test_lead_gain_registration 13,
test_lead_gain_legacy 24, test_bitbrain_net 44.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-26 16:54:43 +02:00

680 lines
31 KiB
Nim

## bitbrain_net.nim — BITBRAIN (rack id 17): the REAL ADE+SBC gun.
##
## The gun at rack id 16 used to be called `BITBRAIN` while containing no network
## at all; it is now `LEADGAIN` (`guns/lead_gain.nim`, a per-range-band lead-gain
## corrector). This module is the gun that actually runs the algorithm: an ADE
## layer (thresholded random projections with ONLINE threshold adaptation)
## feeding the SBC head from `common_libs/bitbrain/`, with the COUNTED + DECAY
## mode available — the mode that delivers forgetting and true per-class
## probabilities (`docs/bitbrain_counted_sbc.md`).
##
## ── OUTPUT SHAPE: a fine-grained aim correction ON TOP OF Pattern ────────────
## Not a direct aim point from the argmax class. Two reasons:
## 1. the gate test that motivated this (`docs/bitbrain_gate.md`) measured
## exactly this shape — a class-resolved correction added to Pattern's
## bearing — so this is the shape with a published measurement behind it;
## 2. Pattern's prediction is already a strong, fully-learned predictor. The
## network's job is the RESIDUAL angular error, which is a small, signed,
## zero-centred quantity; replacing the aim point outright would throw away
## Pattern entirely on the (measured) bet that the net beats it.
## The correction is the PROBABILITY-WEIGHTED MEAN of the class centres under
## `inferProb`, i.e. a continuous readout over `nClasses` discrete cells rather
## than a step function of the argmax. Below `TR_BITBRAIN_MINOBS` resolved
## samples — or when the readout has no evidence — the correction is exactly 0
## and the gun returns Pattern's prediction unchanged.
##
## ── THE INPUT IS A CONFIGURED SET OF FEATURE BLOCKS ─────────────────────────
## The input vector is a concatenation of FEATURE BLOCKS, each independently
## selectable (`TR_BITBRAIN_FEATURES`) and with a settable width. A block is a
## list of scalar QUANTITIES; a block of width `W` lays each quantity out as a
## thermometer (unary) code of `W` slots, so width == resolution: a wider block
## distinguishes more states of that quantity. Slots are 0/255 uint8, which the
## ADE scorer centres at 127 (`DefaultCenter`), so a synapse "matches" when its
## polarity agrees with the slot and a random ADE fires iff its `w` synapses all
## match — a clean thresholded random projection with firing rate 2^-w.
##
## The DEFAULT block set (52 slots) and what each slot means:
##
## block quant. slots quantity
## epos 2 8 enemy offset from us, x and y, over the arena span
## evel 2 6 enemy speed; enemy heading minus the bearing to us
## eturn 2 4 turn direction this tick; turn consistency over 10
## eself 2 4 our speed; our heading minus the bearing to the enemy
## dist 2 10 range; range rate over the last 10 ticks
## bear 1 4 relative bearing (enemy bearing minus our heading)
## walls 4 8 distance to each of the four arena walls
## bull 2 4 live bullet count; nearest bullet's signed lateral offset
## hzn 1 4 bullet flight time to the current range, h = dist / speed
##
## `docs/state_window_gate.md` measured that a long TEMPORAL WINDOW of states
## destroys recurrence, so there is deliberately NO window block here: the only
## history-derived inputs are the 3 rate/turn quantities above (10 ticks), which
## is the same causal information Pattern itself uses.
##
## ── DEFAULT OFF / PARITY ────────────────────────────────────────────────────
## Admitted ONLY when `TR_RACK_BITBRAIN=both` AND `TR_BITBRAIN_NET=1`. Both
## default off, so the shipped rack never calls `predict`, the network is never
## built (`ensureInit` is lazy), and the shipped bot is byte-for-byte unchanged.
## The default target RNG is a PRIVATE `initRand(seed)`, so construction cannot
## perturb the global selector RNG either.
import std/[math, os, strutils, strformat, algorithm, random]
import gun_harness/gun_interface
import guns/tm_horizon
import guns/pattern_matcher
import bitbrain/bitbrain
export ade, sbc
const
## ── env knobs (all resolved once at gun construction) ─────────────────────
BBN_INPUT_ENV* = "TR_BITBRAIN_INPUT" ## total input slots
BBN_CLASSES_ENV* = "TR_BITBRAIN_NCLASSES" ## output resolution
BBN_NADES_ENV* = "TR_BITBRAIN_NADES" ## ADEs per AD
BBN_WIDTHS_ENV* = "TR_BITBRAIN_WIDTHS" ## clause widths, one AD per width
BBN_FEATURES_ENV* = "TR_BITBRAIN_FEATURES" ## block:name:width,...
BBN_SPAN_ENV* = "TR_BITBRAIN_SPAN" ## class half-range, degrees
BBN_MINOBS_ENV* = "TR_BITBRAIN_MINOBS" ## resolved samples before trusting
BBN_LOG_ENV* = "TR_BITBRAIN_NETLOG" ## 1 = per-change [bbn] log
BBN_ADAPT_ENV* = "TR_BITBRAIN_ADAPT_EVERY"## ADE threshold adaptation interval
BBN_CALIB_ENV* = "TR_BITBRAIN_CALIB_EVERY"## (compat alias; same interval)
BBN_SEED_ENV* = "TR_BITBRAIN_NETSEED" ## network seed
BBN_TARGET_ENV* = "TR_BITBRAIN_TARGET" ## ADE target firing rate
BBN_RESET_ON_TARGET_ENV* = "TR_BITBRAIN_NET_RESET_ON_TARGET"
BBN_NET_ENV* = "TR_BITBRAIN_NET" ## master switch (legacy disambiguator)
## The library's own knobs (read inside `common_libs/bitbrain`), re-declared
## here so `knownEnvNames()` and the boot report cover the whole gun.
BBN_MODE_ENV* = "TR_BITBRAIN_MODE"
BBN_DECAY_EVERY_ENV* = "TR_BITBRAIN_DECAY_EVERY"
BBN_DECAY_SHIFT_ENV* = "TR_BITBRAIN_DECAY_SHIFT"
## Every env name this gun reads, for the tree-scan guard's known set.
BitbrainNetEnvNames* = [
BBN_INPUT_ENV, BBN_CLASSES_ENV, BBN_NADES_ENV, BBN_WIDTHS_ENV,
BBN_FEATURES_ENV, BBN_SPAN_ENV, BBN_MINOBS_ENV, BBN_LOG_ENV,
BBN_ADAPT_ENV, BBN_CALIB_ENV, BBN_SEED_ENV, BBN_TARGET_ENV,
BBN_RESET_ON_TARGET_ENV, BBN_MODE_ENV, BBN_DECAY_EVERY_ENV,
BBN_DECAY_SHIFT_ENV]
## (`BBN_NET_ENV` == `TR_BITBRAIN_NET` is deliberately absent: it is already
## registered as `LG_NET_SWITCH_ENV`, the one switch both guns share.)
## ── feature blocks ────────────────────────────────────────────────────────
BB_BLOCKS* = [
("epos", 2, 4), ("evel", 2, 3), ("eturn", 2, 2), ("eself", 2, 2),
("dist", 2, 5), ("bear", 1, 4), ("walls", 4, 2), ("bull", 2, 2),
("hzn", 1, 4)]
## Their shipped widths sum to BB_DEFAULT_SLOTS (8+6+4+4+10+4+8+4+4 = 52).
BB_DEFAULT_SLOTS* = 52
BB_MAX_SLOTS* = 4096
## ── defaults ──────────────────────────────────────────────────────────────
BBN_NCLASSES_DEF = 8 ## output resolution
BBN_NADES_DEF = 256 ## ADEs per AD
BBN_WIDTHS_DEF* = @[4, 5, 6] ## clause widths: one AD per width, 3 cross SBCs
BBN_SPAN_DEF = 40.0
BBN_MINOBS_DEF = 32 ## resolved samples before the net is trusted
BBN_ADAPT_DEF = 200 ## inputs between threshold-adaptation passes
BBN_TARGET_DEF = 0.01 ## paper's target ADE firing rate
BBN_SEED_DEF = 20240921
BBN_PENDING_CAP* = 512
BBN_HIST* = 12 ## the short causal history ring (see the note)
BBN_SBC_VALUE* = 255'u8 ## a "set" thermometer slot
BBN_SLOT_CENTRE = 127 ## DefaultCenter; a synapse matches when 0 or 255
type
Block = tuple[name: string, nQuant: int, width: int]
Pending = object
fireTick: int
horizon: int
selfX, selfY: float
baseBearing: float
input: seq[uint8]
klass: int
HistSample = object
x, y, heading: float
BitbrainNetGun* = object
tmh: TmHorizonGun
initialized: bool
## ── resolved config (boot report) ────────────────────────────────────────
enabled*: bool
inputWidth*: int
nClasses*: int
nAde*: int
widths*: seq[int]
blockWidths*: seq[int] ## per BB_BLOCKS entry; 0 = block disabled
features*: string ## the resolved block spec, for the boot report
maxDeg*: float
minObs*: int
adaptEvery*: int
targetRate*: float
seed*: int64
logEnabled*: bool
resetOnTarget*: bool
mode*: SbcMode
decayEvery*: int
decayShift*: int
## ── the network ─────────────────────────────────────────────────────────
net*: BitBrain
built: bool
## ── learner state ───────────────────────────────────────────────────────
trained*: int
sinceAdapt: int
adapts*: int
sinceEnqTick: int
sinceEnqBucket: int
pending: array[BBN_PENDING_CAP, Pending]
pendingCount*: int
pendingDropped*: int
observedTargetId*: int
lastTick: int
## ── readout ─────────────────────────────────────────────────────────────
lastShiftDeg*: float
lastClass*: int
corrections*: int
lastLogKey: string
## ── the short causal history the rate/turn blocks need ─────────────────
## DELIBERATELY SHORT (12 ticks, and only 3 derived quantities use it):
## `docs/state_window_gate.md` measured that feeding a long TEMPORAL WINDOW
## of states destroys the recurrence this network depends on. This is not
## a window block — it is the same 10-tick information Pattern uses.
hist: seq[HistSample]
# ── config helpers ───────────────────────────────────────────────────────────
proc envStrBbn(name: string): string {.inline.} =
let v = getEnv(name, "")
if v.len > 0: v.strip() else: ""
proc envIntBbn(name: string, default: int): int =
let v = envStrBbn(name)
if v.len == 0: return default
try: parseInt(v) except ValueError: default
proc envFloatBbn(name: string, default: float): float =
let v = envStrBbn(name)
if v.len == 0: return default
try: parseFloat(v) except ValueError: default
proc envBoolBbn(name: string, default: bool): bool =
case envStrBbn(name).toLowerAscii()
of "1", "true", "yes", "on": true
of "0", "false", "no", "off": false
else: default
proc netOn*(): bool =
## The master switch. Default OFF: this gun is never admitted by an unset
## environment, and while it is off the `TR_BITBRAIN_*` names are the LEGACY
## aliases of the LEADGAIN corrector (`guns/lead_gain.nim`).
envBoolBbn(BBN_NET_ENV, false)
proc blockNames*(): string =
## The known block names, comma separated (used in the unknown-block warning).
for i in 0 ..< BB_BLOCKS.len:
if i > 0: result.add ","
result.add BB_BLOCKS[i][0]
proc parseBlockWidths*(value: string): seq[int] =
## Parse `TR_BITBRAIN_FEATURES` — a comma-separated `name[:W]` list. A block
## that is NOT listed keeps its shipped width; an explicitly listed block may
## be switched off with width 0. Unknown names are ignored (with a stderr
## warning) so a typo cannot silently change the input size. Unset/empty ->
## the shipped widths, byte-identical.
result = newSeq[int](BB_BLOCKS.len)
for i, b in BB_BLOCKS: result[i] = b[2]
if value.len == 0: return
for part in value.split(','):
let p = part.strip()
if p.len == 0: continue
let ci = p.find(':')
let nm = (if ci < 0: p else: p[0..<ci]).strip().toLowerAscii()
var w = -1
if ci >= 0:
try: w = parseInt(p[ci+1..^1].strip())
except ValueError: w = -1
var found = false
for i, b in BB_BLOCKS:
if b[0] == nm:
found = true
result[i] = (if w < 0: b[2] else: w)
if not found:
stderr.writeLine("[bbn] unknown feature block '" & nm & "' in " &
BBN_FEATURES_ENV & "; ignored (known: " &
blockNames() & ")")
for w in result.mitems: w = max(0, min(w, 64))
proc parseWidths*(value: string): seq[int] =
## Parse `TR_BITBRAIN_WIDTHS` — the ADE clause widths, one AD per width, one
## cross-AD SBC per unordered pair. Unset -> `BBN_WIDTHS_DEF`.
if value.len == 0: return BBN_WIDTHS_DEF
for tok in value.split(','):
let t = tok.strip()
if t.len == 0: continue
var w: int
try: w = parseInt(t) except ValueError: continue
if w >= 1 and w <= 64: result.add w
if result.len == 0: return BBN_WIDTHS_DEF
proc blockWidthsString*(widths: seq[int]): string =
## The resolved block spec as the env's own form (boot report).
for i, w in widths:
if i > 0: result.add ","
if w == 0: result.add BB_BLOCKS[i][0] & ":0"
elif w == BB_BLOCKS[i][2]: result.add BB_BLOCKS[i][0]
else: result.add BB_BLOCKS[i][0] & ":" & $w
proc derivedSlots*(widths: seq[int]): int =
for i, b in BB_BLOCKS: result += b[1] * widths[i]
proc blockName*(i: int): string {.inline.} = BB_BLOCKS[i][0]
proc blockQuantities*(i: int): int {.inline.} = BB_BLOCKS[i][1]
# ── construction ─────────────────────────────────────────────────────────────
proc initBitbrainNetGun*(): BitbrainNetGun =
result.enabled = netOn()
result.blockWidths = parseBlockWidths(envStrBbn(BBN_FEATURES_ENV))
result.features = blockWidthsString(result.blockWidths)
let derived = derivedSlots(result.blockWidths)
result.inputWidth = clamp(envIntBbn(BBN_INPUT_ENV, derived), 1, BB_MAX_SLOTS)
result.nClasses = clamp(envIntBbn(BBN_CLASSES_ENV, BBN_NCLASSES_DEF), 2, 4096)
result.nAde = clamp(envIntBbn(BBN_NADES_ENV, BBN_NADES_DEF), 8, 8192)
result.widths = parseWidths(envStrBbn(BBN_WIDTHS_ENV))
result.maxDeg = clamp(envFloatBbn(BBN_SPAN_ENV, BBN_SPAN_DEF), 1.0, 180.0)
result.minObs = max(1, envIntBbn(BBN_MINOBS_ENV, BBN_MINOBS_DEF))
result.adaptEvery = max(1, envIntBbn(BBN_ADAPT_ENV, envIntBbn(BBN_CALIB_ENV, BBN_ADAPT_DEF)))
result.targetRate = clamp(envFloatBbn(BBN_TARGET_ENV, BBN_TARGET_DEF), 0.0001, 0.5)
result.seed = int64(envIntBbn(BBN_SEED_ENV, BBN_SEED_DEF))
result.logEnabled = envBoolBbn(BBN_LOG_ENV, false)
result.resetOnTarget = envBoolBbn(BBN_RESET_ON_TARGET_ENV, true)
# The SBC storage mode + its decay knobs are the LIBRARY's env names, read
# here so the gun's resolved config (and the boot report) is the real one.
result.mode = envSbcMode(smCounted)
result.decayEvery = envDecayEvery(64)
result.decayShift = envDecayShift(3)
result.lastTick = -1
result.sinceEnqTick = -1
result.sinceEnqBucket = -1
result.observedTargetId = -1
proc buildNet*(g: var BitbrainNetGun) =
## Build the ADE layers + SBC head. LAZY: the shipped rack never calls it.
## Deterministic given the seed, and it uses a PRIVATE `initRand`, so it can
## never perturb the global selector RNG.
if g.built: return
g.built = true
var rng = initRand(g.seed)
# The clause width must not exceed the input width or `initRandomAddressDecoder`
# cannot draw `w` distinct indices; clamp the widths to the input width.
var widths = g.widths
for i, w in widths: widths[i] = min(w, max(2, g.inputWidth))
g.net = buildRandomBitBrain(widths = widths, nAde = g.nAde,
inputWidth = g.inputWidth, nClasses = g.nClasses,
seed = g.seed, mode = g.mode,
decayEvery = g.decayEvery,
decayShift = g.decayShift)
# The initial threshold is 0, which for 0/255 slots centred at 127 fires every
# ADE whose `w` synapses all match — rate 2^-w. Homeostatic adaptation then
# drives each ADE toward the paper's ~1% target rate, online, unsupervised.
for ad in g.net.ades.mitems:
for i in 0 ..< ad.nAde: ad.thresholds[i] = int32(127 * ad.width - 1)
g.tmh = initTmHorizonGun()
proc ensureInit*(g: var BitbrainNetGun) =
if g.initialized: return
g.initialized = true
if not g.enabled: return
g.buildNet()
# ── the input vector ─────────────────────────────────────────────────────────
proc pushTherm(dst: var seq[uint8], v: float, w: int) =
## Lay one quantity out as a `w`-slot thermometer code over [0, 1]. `w` == 0
## disables the quantity.
if w <= 0: return
var level = int(clamp(v, 0.0, 0.999999) * float(w))
if level < 0: level = 0
if level > w - 1: level = w - 1
for j in 0 ..< w:
dst.add(if j < level: BBN_SBC_VALUE else: 0'u8)
proc wrapRadNet(r: float): float {.inline.} =
result = r
while result > PI: result -= 2.0 * PI
while result < -PI: result += 2.0 * PI
proc pushHist(g: var BitbrainNetGun, state: WorldState) =
## Ring of the last `BBN_HIST` states. One push per tick, in `predict`.
if g.hist.len == 0:
for _ in 0 ..< BBN_HIST:
g.hist.add HistSample(x: state.enemyX, y: state.enemyY,
heading: state.enemyHeading)
g.hist.insert(HistSample(x: state.enemyX, y: state.enemyY,
heading: state.enemyHeading), 0)
g.hist.setLen(BBN_HIST)
proc turnSignal(g: BitbrainNetGun, state: WorldState): (float, float) =
## (turn direction this tick, turn consistency over the ring). 0.5 = straight,
## 0/1 = a full left/right turn; consistency = fraction of the ring's
## consecutive steps that turn the SAME way.
if g.hist.len < 3:
return (0.5, 0.5)
let d0 = wrapRadNet(degToRad(state.enemyHeading - g.hist[0].heading))
let dir = 0.5 + 0.5 * (if d0 > 0.0: 1.0 elif d0 < 0.0: -1.0 else: 0.0)
var same = 0
var total = 0
for i in 0 ..< g.hist.len - 1:
let d = wrapRadNet(degToRad(g.hist[i].heading - g.hist[i+1].heading))
if abs(d) < 1e-9: continue
inc total
if (d > 0) == (d0 > 0.0): inc same
(dir, if total == 0: 0.5 else: float(same) / float(total))
proc rangeSignal(g: BitbrainNetGun, state: WorldState,
dist: float): float =
## Signed range rate over the ring, in [-1, 1] (approaching / opening), scaled
## to +-400 px per tick and clamped. One quantity, ten ticks of history.
if g.hist.len < 2: return 0.0
let dPrev = hypot(g.hist[0].x - state.selfX, g.hist[0].y - state.selfY)
let step = (dist - dPrev) / float(max(1, g.hist.len - 1))
clamp(step / 400.0, -1.0, 1.0)
proc bulletSignal(g: BitbrainNetGun, state: WorldState): (float, float) =
## (live-bullet count / 4, nearest bullet's signed lateral offset in [-1,1]).
## The nearest known bullet is the one whose own last-seen tick is the most
## recent; with no bullet knowledge both are neutral.
var best = -1
var bestAge = high(int)
for e in state.enemies:
if e.lastSeenTick < 0: continue
let age = state.tick - e.lastSeenTick
if age < bestAge: bestAge = age; best = e.id
if best < 0: return (0.0, 0.0)
for e in state.enemies:
if e.id != best: continue
let toE = arctan2(e.y - state.selfY, e.x - state.selfX)
let toB = arctan2(state.enemyY - state.selfY, state.enemyX - state.selfX)
let lat = wrapRadNet(toB - toE)
return (0.25, clamp(lat / 0.5, -1.0, 1.0))
(0.0, 0.0)
proc buildInput*(g: var BitbrainNetGun, state: WorldState): seq[uint8] =
## (see the header for the block table)
## The configured feature-block vector for this state. Block order is fixed
## (`BB_BLOCKS`); each block's slot count is `g.blockWidths[i]`. The result is
## padded with zero slots or truncated to exactly `g.inputWidth`, so the
## ADE codes (drawn once at build time over `inputWidth`) can never index out
## of range no matter how the two knobs are combined.
result = newSeqOfCap[uint8](g.inputWidth)
let bw = g.blockWidths
let ex = state.enemyX
let ey = state.enemyY
let sx = state.selfX
let sy = state.selfY
let wAll = max(1.0, state.arenaWidth)
let hAll = max(1.0, state.arenaHeight)
let dx = ex - sx
let dy = ey - sy
let dist = max(1e-6, hypot(dx, dy))
let bearing = arctan2(dy, dx)
let selfHdg = degToRad(state.selfHeading)
let enemyHdg = degToRad(state.enemyHeading)
for bi in 0 ..< BB_BLOCKS.len:
let w = bw[bi]
if w <= 0: continue
case bi
of 0: # epos — enemy offset over the arena
pushTherm(result, 0.5 + dx / wAll, w div 2)
pushTherm(result, 0.5 + dy / hAll, w - w div 2)
of 1: # evel — speed, heading vs the lane
pushTherm(result, state.enemySpeed / 16.0, w div 2)
pushTherm(result, 0.5 + 0.5 * sin(enemyHdg - bearing), w - w div 2)
of 2: # eturn — turn now, consistency over 10
let (dir, cons) = turnSignal(g, state)
pushTherm(result, dir, w div 2)
pushTherm(result, cons, w - w div 2)
of 3: # eself — our speed, our heading error
pushTherm(result, state.selfSpeed / 16.0, w div 2)
pushTherm(result, 0.5 + 0.5 * sin(selfHdg - bearing), w - w div 2)
of 4: # dist — range, range rate over 10
pushTherm(result, dist / 1000.0, w div 2)
pushTherm(result, 0.5 + 0.5 * rangeSignal(g, state, dist), w - w div 2)
of 5: # bear — relative bearing
pushTherm(result, (bearing - selfHdg + PI) / (2.0 * PI), w)
of 6: # walls — distance to each wall
pushTherm(result, ex / wAll, w div 4)
pushTherm(result, (wAll - ex) / wAll, w div 4)
pushTherm(result, ey / hAll, w div 4)
pushTherm(result, (hAll - ey) / hAll, w - 3 * (w div 4))
of 7: # bull — live bullets, lateral offset
let (n, lat) = bulletSignal(g, state)
pushTherm(result, n / 4.0, w div 2)
pushTherm(result, 0.5 + 0.5 * lat, w - w div 2)
of 8: # hzn — bullet flight time
pushTherm(result, float(tmhHorizonFor(dist, 11.0)) / 50.0, w)
else: discard
if result.len < g.inputWidth:
for _ in result.len ..< g.inputWidth: result.add 0'u8
elif result.len > g.inputWidth:
result.setLen(g.inputWidth)
# ── class geometry ───────────────────────────────────────────────────────────
proc classCenterDeg*(k, nClasses: int, maxDeg: float): float {.inline.} =
## Centre (degrees) of correction class `k` over ±maxDeg.
-maxDeg + (float(k) + 0.5) * (2.0 * maxDeg / float(nClasses))
proc classOf*(errRad, maxDeg: float, nClasses: int): int {.inline.} =
## Bin a signed angular error (radians) into one of `nClasses` bins.
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
# ── readout ──────────────────────────────────────────────────────────────────
proc netShiftDeg*(g: var BitbrainNetGun, input: openArray[uint8]): float =
## The fine-grained correction: the PROBABILITY-WEIGHTED MEAN of the class
## centres over the SBC posterior. Returns 0.0 when there is no evidence.
let (label, scores) = g.net.inferProb(input)
var sum = 0.0
var tot = 0.0
for k in 0 ..< scores.len:
sum += scores[k] * classCenterDeg(k, g.nClasses, g.maxDeg)
tot += scores[k]
g.lastClass = label
if tot <= 0.0: 0.0 else: sum / tot
proc adaptThresholds*(g: var BitbrainNetGun, input: openArray[uint8]) =
## Online homeostasis (unsupervised): accumulate this input's ADE firings and
## every `adaptEvery` inputs nudge each threshold toward `targetRate`.
for ad in g.net.ades.mitems: ad.accumulateFiring(input)
inc g.sinceAdapt
if g.sinceAdapt >= g.adaptEvery:
for ad in g.net.ades.mitems:
ad.adaptThresholds(interval = g.sinceAdapt, targetRate = g.targetRate)
g.sinceAdapt = 0
inc g.adapts
proc resolvePending(g: var BitbrainNetGun, state: WorldState) =
## Prequential label resolution: `h` ticks after the fire, the enemy's OBSERVED
## bearing from the firing position is the FACT; the required correction is
## `observedBearing - baseBearing`, binned into a class, and learned.
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 = wrapRadNet(obs.bearing - p.baseBearing)
g.net.learn(p.input, classOf(err, g.maxDeg, g.nClasses))
inc g.trained
else:
inc g.pendingDropped
else:
inc g.pendingDropped
g.pendingCount = w
proc bbnLog(g: var BitbrainNetGun, state: WorldState, shiftDeg: float) =
if not g.logEnabled: return
let key = fmt"{shiftDeg:.2f}"
if key == g.lastLogKey: return
g.lastLogKey = key
echo fmt"[bbn] t={state.tick} shift={shiftDeg:+.2f}deg class={g.lastClass} " &
fmt"in={g.inputWidth} ncl={g.nClasses} nAde={g.nAde} " &
fmt"mode={($g.mode)[7..^1]} trained={g.trained} adapts={g.adapts} " &
fmt"pend={g.pendingCount} dropped={g.pendingDropped}"
# ── reset hooks ──────────────────────────────────────────────────────────────
proc resetRoundState*(g: var BitbrainNetGun) =
if not g.initialized: return
g.tmh.resetRoundState()
g.pendingCount = 0
g.hist.setLen(0)
g.lastTick = -1
g.sinceEnqTick = -1
g.sinceEnqBucket = -1
g.lastLogKey = ""
proc resetLearning*(g: var BitbrainNetGun, reason = "") =
## Per-battle / per-enemy wipe of the SBC counters. The ADEs (codes and
## adapted thresholds) survive: they are unsupervised structure, not labels.
if not g.initialized: return
g.net.resetLearning()
g.trained = 0
g.sinceAdapt = 0
g.adapts = 0
g.corrections = 0
g.lastShiftDeg = 0.0
g.observedTargetId = -1
g.resetRoundState()
if reason.len > 0 and g.logEnabled: echo fmt"[bbn-reset] reason={reason}"
proc targetChanged*(g: var BitbrainNetGun, enemyId: int): bool =
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: BitbrainNetGun): bool {.inline.} =
## Ready as soon as it has enough resolved samples to have a readout; before
## that it is the identity on Pattern, which is always a valid prediction.
g.trained >= g.minObs
proc networkBytes*(g: BitbrainNetGun): int =
## RAM held by the network (ADs + SBC tensors). 0 before the lazy build.
if not g.built: 0 else: g.net.memoryBytes
proc sbcBytes*(g: BitbrainNetGun): int =
if not g.built: 0 else: g.net.sbcMemoryBytes
proc predict*(g: var BitbrainNetGun, state: WorldState,
bulletSpeed: float): GunPrediction =
g.ensureInit()
## A disabled gun must never be called: ModularBot gates admission on
## `g.enabled`, and this branch is only a belt-and-braces guard. It returns
## the CURRENT position (a valid, if useless, prediction) rather than anything
## that could poison a shared tracker.
if not g.enabled: return GunPrediction(x: state.enemyX, y: state.enemyY)
if state.tick < g.lastTick: g.resetRoundState()
if state.tick != g.lastTick:
tmhUpdateHistory(g.tmh, state)
g.resolvePending(state)
g.pushHist(state)
g.lastTick = state.tick
# The base is Pattern; BitBrain only adds a fine-grained correction to it.
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 hb = tmhHorizonBucket(h)
var input = buildInput(g, state)
g.adaptThresholds(input)
# One deferred training sample per (tick, horizon bucket): `predict` runs once
# per power bin, so all four horizons contribute evidence.
if g.sinceEnqTick != state.tick or g.sinceEnqBucket != hb:
if g.pendingCount < BBN_PENDING_CAP:
let los = arctan2(state.enemyY - state.selfY, state.enemyX - state.selfX)
let baseBearing = arctan2(base.y - state.selfY, base.x - state.selfX)
g.pending[g.pendingCount] = Pending(
fireTick: state.tick, horizon: h, selfX: state.selfX, selfY: state.selfY,
baseBearing: baseBearing, input: input,
klass: classOf(wrapRadNet(baseBearing - los), g.maxDeg, g.nClasses))
inc g.pendingCount
else:
inc g.pendingDropped
g.sinceEnqTick = state.tick
g.sinceEnqBucket = hb
g.lastShiftDeg = 0.0
if g.trained >= g.minObs:
g.lastShiftDeg = g.netShiftDeg(input)
g.bbnLog(state, g.lastShiftDeg)
if abs(g.lastShiftDeg) < 1e-9: return base
inc g.corrections
tmhApplyShift(state.selfX, state.selfY, base.x, base.y, g.lastShiftDeg)
proc onResult*(g: var BitbrainNetGun, e: FeedbackEvent) =
## Labels come from our own observation ring, not from virtual-bullet
## feedback. The hook exists for the rack.
discard
proc blockNameIndex*(name: string): int =
## Index of the named block in `BB_BLOCKS`, or -1. Used by the tests and the
## boot report so a block is addressed by its NAME, never by a bare index.
for i in 0 ..< BB_BLOCKS.len:
if BB_BLOCKS[i][0] == name: return i
-1
proc inferClass*(g: var BitbrainNetGun, input: openArray[uint8]): int =
## The argmax class of the SBC posterior. Exposed (rather than letting callers
## reach into `g.net`) so the gun's network stays an implementation detail and
## so this name cannot be shadowed by the SBC-level `inferProb` export.
g.net.inferProb(input).label
proc sbcBytesOf*(g: BitbrainNetGun): int =
let net = g.net
net.sbcMemoryBytes()
proc sbcsOf*(g: BitbrainNetGun): seq[Sbc] = g.net.sbcs
proc learnSample*(g: var BitbrainNetGun, input: openArray[uint8], klass: int) =
## One supervised online step: drive the ADE layer and set/increment the class
## in every observed coincidence. Exposed so tests can drive the learner
## without replaying a whole deferred-label stream.
g.net.learn(input, klass)
proc evidenceFor*(g: var BitbrainNetGun, input: openArray[uint8], klass: int): int =
## Total SBC evidence the network currently holds for `klass` on `input` (the
## summed counters, or the set-bit count in bitset mode). Unlike
## `occupancy` — which saturates once a cell is non-zero — this GROWS with
## every counted learn, so it is the observable that proves the counted mode
## is accumulating rather than just flipping bits.
g.net.infer(input).counts[klass]
proc gunAdmitted*(g: BitbrainNetGun, rackAdmitted: bool): bool {.inline.} =
## The FULL admission predicate for rack id 17: the rack table AND this gun's
## own master switch. Both default off, so an incomplete configuration is
## exactly as inert as an unset one — which matters, because a pre-rename
## `.env` still carries `TR_RACK_BITBRAIN=both` and must not be able to push a
## disabled gun's placeholder predictions into the shared VirtualTracker ring.
rackAdmitted and g.enabled