## 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..= 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]