59c5af0499
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>
680 lines
31 KiB
Nim
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
|