Files
SirRoboGarage/common_libs/guns/bitbrain_net.nim
T
SirStone e9302bc9f6 j140 rebuild a real BitBrain gun: ADE+SBC at rack id 17, default off, and measure its scaling
The BITBRAIN name was sitting on a gun with no network in it. This 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.

  common_libs/guns/bitbrain_net.nim     the gun
  rack name BITBRAIN, rack id 17 (rack 17 -> 18 guns), both new guns default OFF
  admitted by TR_RACK_BITBRAIN=both AND TR_BITBRAIN_NET=1 (the switch that also
  disowns LEADGAIN's legacy TR_BITBRAIN_* aliases)

OUTPUT: a fine-grained aim CORRECTION on top of Pattern - the probability-
weighted mean of the nClasses class centres under inferProb - not a direct aim
point from the argmax. That is the shape docs/bitbrain_gate.md measured, and
Pattern is already a strong predictor, so the net's job is the signed residual.
Below TR_BITBRAIN_MINOBS the shift is exactly 0 and Pattern is returned
unchanged.

INPUT: a CONFIGURED set of FEATURE BLOCKS (TR_BITBRAIN_FEATURES=name:W), each
block's width == its resolution, laid out as a thermometer code over 0/255 slots
(so an ADE synapse 'matches' when its polarity agrees with the slot and a random
ADE fires iff its w synapses all match, rate 2^-w). Default is 52 slots over 9
blocks. NO long temporal window, per docs/state_window_gate.md: the only history
is a 12-tick ring feeding three rate/turn quantities.

Every knob env-configurable: _INPUT (width), _NCLASSES, _NADES, _WIDTHS
(clause widths), _FEATURES, _SPAN, _MODE, _DECAY_EVERY, _DECAY_SHIFT,
_MINOBS, _ADAPT_EVERY, _TARGET, _NETSEED, _NETLOG, _NET_RESET_ON_TARGET.

MEASURED SCALING (measure_bitbrain_scaling.nim, 3 recorded runs, 37412 ticks,
-d:release, one predict per power bin per tick, timed region = predicts only):
  RAM 1.59 MB default (98.6% SBC tensors); linear in nClasses, QUADRATIC in
      nAde, FLAT in input width; counted/bitset = 7.30x on RAM, ~1x on time.
  ms/tick 2.70 default = 21% of the 13.16 ms budget; 64 classes busts it (149%),
      nAde 512 uses 74%, nAde 64 uses 2%.
  CAPACITY vs ACCURACY: over a 100x RAM range the offline mean |err| moves
      17.254 -> 17.115 deg around Pattern's 16.964, and the sign flips along the
      nClasses axis, so it is noise, not a trend. The corrector is consistently
      slightly WORSE than Pattern. The ceiling is the STATE, not the classifier.
      VETO-CAPABLE OFFLINE CHECK ONLY (docs/offline_harness_trust.md), never
      presented as a live win.

ENGAGEMENT is proven, not assumed: test_bitbrain_net.nim (42 checks) shows 0
bytes before first use, different inputs -> different class outputs, a learn
raises SBC occupancy, bitset learn idempotent while counted learn is monotone,
threshold adaptation runs, and the global RNG is untouched.

Parity: shipped rack still onlyPattern, shipped movement still strafe. Guards:
test_env_report 25, test_rack_membership 48, test_tm_pattern_registration 20,
test_lead_gain_registration 13, test_lead_gain_legacy 24, test_bitbrain 56,
test_gun_harness 39, test_tfil_commit_env 30, test_bitbrain_net 42.
Clean archive build: [SuccessX].

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

668 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()
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]