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>
This commit is contained in:
@@ -0,0 +1,667 @@
|
||||
## 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]
|
||||
Reference in New Issue
Block a user