j142 retire the ADE+SBC gun (rack id 17): the owner watched it, it does not learn, throw it away
Remove guns/bitbrain_net.nim (+README), test_bitbrain_net.nim,
measure_bitbrain_scaling.nim, rack id 17 and all of its plumbing in
selector.nim / ModularBot.nim / env_report.nim, the TR_BITBRAIN_NET switch
and the NEW-NETWORK TR_BITBRAIN_* knobs, and the BitBrainNet arm of
run_prediction_quality.nim.
With id 17 gone there is nothing to disambiguate, so the legacy namespace
becomes the ONLY one: TR_RACK_BITBRAIN always selects id 16 LEADGAIN and
every TR_BITBRAIN_<X> in the frozen 14-suffix alias set always means
TR_LEADGAIN_<X>. The alias layer and its [depr] line stay.
KEPT: the common_libs/bitbrain/ SBC library (learned_surfer imports
bitbrain/sbc), lead_gain at id 16 with env TR_LEADGAIN_* and log tag [lg],
and the c9b6753 crash fix (NumRackGuns widths + test_rack_stat_width).
Tombstone: docs/bitbrain_campaign.md ## RETIRED and one cross-reference line
in docs/gun_campaign.md. Shipped defaults unchanged: clean env -> rack
active 1v1 = PATTERN, movement default strafe.
This commit is contained in:
@@ -1,193 +0,0 @@
|
||||
# bitbrain_net — quick recap (inputs / outputs)
|
||||
|
||||
Recap card. Everything below is read off `common_libs/guns/bitbrain_net.nim`.
|
||||
Companion to the library's own README (`common_libs/bitbrain/README.md`) and to
|
||||
`common_libs/guns/lead_gain.README.md` (the *other* gun, the per-range-band
|
||||
lead-gain corrector at rack id 16).
|
||||
|
||||
## What it is
|
||||
|
||||
The **real ADE+SBC gun**: 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`).
|
||||
|
||||
It took the `BITBRAIN` rack name (id **17**) and the `TR_BITBRAIN_*` knob prefix
|
||||
that the renamed corrector gave up. **Default OFF.** It needs
|
||||
`TR_RACK_BITBRAIN=both` in the rack table **and** `TR_BITBRAIN_NET=1` — the one
|
||||
switch that decides whether the `TR_BITBRAIN_*` namespace is legacy
|
||||
(`LEADGAIN`'s) or the new gun's, and the new gun's master on/off at the same
|
||||
time. The two are gated by the SAME switch, so no env configuration can admit
|
||||
the gun while leaving it disabled
|
||||
(`test_bitbrain_net.nim` pins that over a 6-setting truth table) — which
|
||||
matters, because a pre-rename `.env` still carries `TR_RACK_BITBRAIN=both` and a
|
||||
disabled gun's placeholder predictions in the shared `VirtualTracker` ring would
|
||||
shift every other gun's learning order. The network is built lazily, so an unset
|
||||
environment never allocates a byte.
|
||||
|
||||
## OUTPUT SHAPE — a fine-grained correction ON TOP of Pattern
|
||||
|
||||
**Not** a direct aim point from the argmax class. The class-resolved angular
|
||||
correction added to Pattern's bearing is the shape `docs/bitbrain_gate.md`
|
||||
actually measured, and Pattern is already a strong predictor, so the net's job
|
||||
is the small signed *residual*, not the whole aim.
|
||||
|
||||
| Output | Formula / meaning |
|
||||
|---|---|
|
||||
| Correction | `shift = Σ_k P(k)·centre_k / Σ_k P(k)` — the **probability-weighted mean of the `nClasses` class centres** under `inferProb`, in degrees, over ±`TR_BITBRAIN_SPAN` |
|
||||
| Aim point | `tmhApplyShift(self, Pattern prediction, shift)`. Below `TR_BITBRAIN_MINOBS` resolved samples, or when the posterior has no mass, `shift == 0.0` and **Pattern's prediction is returned unchanged** |
|
||||
| `[bbn]` log (only `TR_BITBRAIN_NETLOG=1`, change-gated) | `t`, `shift` (deg), `class` (argmax), `in`, `ncl`, `nAde`, `mode`, `trained`, `adapts`, `pend`, `dropped` |
|
||||
| Does **NOT** output | an aim point of its own; it never discards Pattern |
|
||||
|
||||
## INPUT — 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 `W`-slot
|
||||
thermometer code, so **width == resolution**. Slots are `0`/`255` uint8, which the
|
||||
ADE scorer centres at 127 (`DefaultCenter`): a synapse *matches* when its
|
||||
polarity agrees with the slot, so a random ADE fires iff its `w` synapses all
|
||||
match — a thresholded random projection with firing rate `2^-w`, which online
|
||||
homeostasis then drives toward `TR_BITBRAIN_TARGET`.
|
||||
|
||||
**The default block set is 52 slots:**
|
||||
|
||||
| block | quantities | width | slots | quantity |
|
||||
|---|---:|---:|---:|---|
|
||||
| `epos` | 2 | 4 | 8 | enemy offset from us, x and y, over the arena span |
|
||||
| `evel` | 2 | 3 | 6 | enemy speed; enemy heading minus the bearing to us |
|
||||
| `eturn` | 2 | 2 | 4 | turn direction this tick; turn consistency over the ring |
|
||||
| `eself` | 2 | 2 | 4 | our speed; our heading minus the bearing to the enemy |
|
||||
| `dist` | 2 | 5 | 10 | range; signed range rate over the last 10 ticks |
|
||||
| `bear` | 1 | 4 | 4 | relative bearing (enemy bearing minus our heading) |
|
||||
| `walls` | 4 | 2 | 8 | distance to each of the four arena walls |
|
||||
| `bull` | 2 | 2 | 4 | live-bullet count; nearest bullet's signed lateral offset |
|
||||
| `hzn` | 1 | 4 | 4 | bullet flight time to the current range |
|
||||
|
||||
`docs/state_window_gate.md` measured that a long **temporal window** of states
|
||||
destroys recurrence, so there is deliberately **no window block**: the only
|
||||
history-derived inputs are the three rate/turn quantities above (a 12-tick
|
||||
ring), i.e. the same causal information Pattern itself uses.
|
||||
|
||||
## KNOB TABLE
|
||||
|
||||
| Env | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `TR_RACK_BITBRAIN` | `off` | rack admission for id 17 (the *current* name of the rack key) |
|
||||
| `TR_BITBRAIN_NET` | `0` | **master switch + namespace disambiguator.** `0` = the gun is off AND the `TR_BITBRAIN_*` names are LEGACY aliases of `LEADGAIN` (and `TR_RACK_BITBRAIN` selects id 16); `1` = the gun is on and the names below are this gun's (and `TR_RACK_BITBRAIN` selects id 17) |
|
||||
| `TR_BITBRAIN_INPUT` | 52 | total input slots; pads or truncates the block layout so the ADE codes can never index out of range |
|
||||
| `TR_BITBRAIN_FEATURES` | all blocks at their shipped width | `name:W` list, comma separated. `name:0` switches a block OFF; an unlisted block keeps its shipped width; an unknown name warns and is ignored |
|
||||
| `TR_BITBRAIN_NCLASSES` | 8 | output resolution |
|
||||
| `TR_BITBRAIN_NADES` | 256 | ADEs per address decoder (RAM is **quadratic** in this) |
|
||||
| `TR_BITBRAIN_WIDTHS` | `4,5,6` | ADE clause widths; one AD per width, one cross-AD SBC per pair (3 widths → 3 SBCs) |
|
||||
| `TR_BITBRAIN_SPAN` | `40.0` | class half-range, degrees |
|
||||
| `TR_BITBRAIN_MODE` | `counted` | `bitset` \| `counted` (saturating counters + decay) |
|
||||
| `TR_BITBRAIN_DECAY_EVERY` | 64 | counted mode: learns between global decay passes |
|
||||
| `TR_BITBRAIN_DECAY_SHIFT` | 3 | counted mode: `c -= c shr shift` per pass (`0` disables) |
|
||||
| `TR_BITBRAIN_MINOBS` | 32 | resolved samples before the correction is applied at all |
|
||||
| `TR_BITBRAIN_ADAPT_EVERY` | 200 | inputs between ADE threshold-adaptation passes |
|
||||
| `TR_BITBRAIN_TARGET` | 0.01 | the paper's target ADE firing rate |
|
||||
| `TR_BITBRAIN_NETSEED` | 20240921 | network seed (a **private** RNG, never the global one) |
|
||||
| `TR_BITBRAIN_NETLOG` | `0` | `1` = emit the `[bbn]` line |
|
||||
| `TR_BITBRAIN_CALIB_EVERY` | — | compat alias for `TR_BITBRAIN_ADAPT_EVERY` |
|
||||
| `TR_BITBRAIN_NET_RESET_ON_TARGET` | `1` | wipe the SBC counters when the enemy id changes |
|
||||
|
||||
## HOW TO TURN IT ON
|
||||
|
||||
```
|
||||
TR_BITBRAIN_NET=1 # the master switch — the legacy aliases go quiet
|
||||
TR_RACK_BITBRAIN=both
|
||||
TR_RACK_PATTERN=off
|
||||
TR_BITBRAIN_NETLOG=1
|
||||
```
|
||||
|
||||
## MEASURED SCALING (RAM / ms-per-tick / quality)
|
||||
|
||||
`common_libs/tests/measure_bitbrain_scaling.nim`, replaying 3 recorded live runs
|
||||
(37 412 recorded ticks, 149 650 tick × power-bin samples), `-d:release`,
|
||||
single-threaded, one `predict` per power bin per tick — the same call pattern
|
||||
as the live loop. The timed region contains **only** the gun's `predict` calls:
|
||||
the interception solve is done once, up front, so the ruler's own cost cannot
|
||||
contaminate the timing.
|
||||
|
||||
| arm | in | nCl | nAde | mode | RAM B | SBC B | AD B | ms/tick | % of 13.16 | mean\|err\|° | hit% | Pattern\|err\|° |
|
||||
|---|---:|---:|---:|---|---:|---:|---:|---:|---:|---:|---:|---:|
|
||||
| input-small | 26 | 8 | 256 | counted | 1 594 368 | 1 572 864 | 21 504 | 2.60 | 20 % | 17.329 | 8.00 | 16.964 |
|
||||
| input-medium | 52 | 8 | 256 | counted | 1 594 368 | 1 572 864 | 21 504 | 2.70 | 21 % | 17.270 | 8.04 | 16.964 |
|
||||
| input-large | 84 | 8 | 256 | counted | 1 594 368 | 1 572 864 | 21 504 | 2.54 | 19 % | 17.259 | 8.06 | 16.964 |
|
||||
| classes-small | 52 | 2 | 256 | counted | 414 720 | 393 216 | 21 504 | 1.07 | 8 % | 17.201 | 8.12 | 16.964 |
|
||||
| classes-medium| 52 | 8 | 256 | counted | 1 594 368 | 1 572 864 | 21 504 | 2.70 | 21 % | 17.270 | 8.04 | 16.964 |
|
||||
| classes-large | 52 | 64 | 256 | counted |12 604 416 |12 582 912 | 21 504 | 19.56 | 149 % | 17.115 | 8.27 | 16.964 |
|
||||
| nAde-small | 52 | 8 | 64 | counted | 103 680 | 98 304 | 5 376 | 0.30 | 2 % | 17.254 | 8.08 | 16.964 |
|
||||
| nAde-medium | 52 | 8 | 256 | counted | 1 594 368 | 1 572 864 | 21 504 | 2.72 | 21 % | 17.270 | 8.04 | 16.964 |
|
||||
| nAde-large | 52 | 8 | 512 | counted | 6 334 464 | 6 291 456 | 43 008 | 9.76 | 74 % | 17.264 | 8.03 | 16.964 |
|
||||
| mode-bitset | 52 | 8 | 256 | bitset | 218 112 | 196 608 | 21 504 | 2.72 | 21 % | 16.975 | 8.53 | 16.964 |
|
||||
| mode-counted | 52 | 8 | 256 | counted | 1 594 368 | 1 572 864 | 21 504 | 2.71 | 21 % | 17.270 | 8.04 | 16.964 |
|
||||
|
||||
Reproduce:
|
||||
```bash
|
||||
nim c -r -d:release --path:common_libs common_libs/tests/measure_bitbrain_scaling.nim --limit 3
|
||||
```
|
||||
|
||||
### The scaling laws, as MEASURED
|
||||
|
||||
* **RAM is dominated by the SBC tensors (98.6 % at the default).** One SBC is
|
||||
`nAde² × nClasses` cells. Bitset: 1 bit/cell. Counted: 1 byte/cell. The
|
||||
measured counted/bitset factor is **7.30×** on the default geometry
|
||||
(1 594 368 / 218 112), which is the bitset tensor's 1/8-of-a-`uint32`-slot
|
||||
overhead — the tensors are `uint32`-slot-packed, so 8 classes share slots.
|
||||
* **RAM vs `nClasses`: exactly linear.** 2 → 8 → 64 classes: 0.41 → 1.59 → 12.60 MB
|
||||
(4× and 32× for 4× and 8× the classes). RAM vs `nAde`: **quadratic** —
|
||||
64 → 256 → 512 gives 0.10 → 1.59 → 6.33 MB (4× then 16× for 4× then 2×).
|
||||
* **RAM vs input width: FLAT.** 26 → 52 → 84 slots: 1 594 368 B in all three
|
||||
cases. The input width only sets how many of the `2^inputWidth` synapses the
|
||||
ADE codes may draw from; the codes are still `nAde × w` per AD. The only
|
||||
width-dependent term is the ADE scoring cost and the tiny `AD B` column
|
||||
(unchanged here because it is dominated by `nAde × w`, not by input width).
|
||||
* **ms/tick vs input width: flat** (2.60 / 2.70 / 2.54 ms). vs `nClasses`:
|
||||
**linear** (1.07 / 2.70 / 19.56 ms — the 64-class arm busts the 13.16 ms
|
||||
budget at 149 %). vs `nAde`: **quadratic-ish** (0.30 / 2.72 / 9.76 ms), because
|
||||
the ADE pass is `O(nAde × w)` and the SBC read is `O(|row| × |col|)`.
|
||||
* **counted vs bitset: same time, 7.3× the RAM** (2.71 vs 2.72 ms/tick).
|
||||
|
||||
### Capacity vs accuracy — the direct answer
|
||||
|
||||
**More capacity buys essentially nothing here; it costs RAM and, past a point,
|
||||
the tick budget.** Across a 100× range of RAM (0.10 MB → 12.60 MB) the offline
|
||||
mean |angular error| moves from 17.254° to 17.115° — a 0.14° spread around
|
||||
Pattern's own 16.964°, and the sign of the effect flips across the `nClasses`
|
||||
axis (17.201° at 2 classes, 17.115° at 64), so it is not a trend, it is noise.
|
||||
The only arm that *helps* is `mode-bitset` (16.975° vs 17.270° counted), and
|
||||
even that costs nothing in accuracy terms until you switch to counted for
|
||||
forgetting — at which point you pay 7.3× the RAM and lose the 0.3°.
|
||||
|
||||
The honest reading is that the information ceiling here is the **state**, not
|
||||
the classifier: `docs/bitbrain_gate.md` measured ~1 bit of information in a
|
||||
53-bit input, and this sweep reproduces that at 26, 52 and 84 slots alike. The
|
||||
corrector is consistently **slightly WORSE than Pattern** offline (17.2° vs
|
||||
17.0°), which is the same verdict the campaign already reached for every
|
||||
additive-shift design. Capacity is not the binding constraint and buying more of
|
||||
it is not the fix.
|
||||
|
||||
**VETO-CAPABLE CHECK ONLY.** Per `docs/offline_harness_trust.md` this is the
|
||||
single trustworthy use of the offline harness — per-gun single-tick prediction
|
||||
quality on a fixed trajectory. It is **not** a live result: nothing here says
|
||||
anything about damage, survival or round wins, and it is never presented as one.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
# 44 checks: the gun really engages the network (not just that it links)
|
||||
nim c -r -d:release --path:common_libs common_libs/tests/test_bitbrain_net.nim
|
||||
# the library itself (56 checks, unchanged)
|
||||
nim c -r -d:release --path:common_libs common_libs/tests/test_bitbrain.nim
|
||||
```
|
||||
|
||||
`test_bitbrain_net.nim` proves engagement by observation, not by linkage: the
|
||||
network is 0 bytes before first use, a **trained** head returns different
|
||||
classes for different states and separates two taught populations, a learn
|
||||
visibly raises SBC occupancy, a bitset learn is idempotent while a counted
|
||||
learn's evidence is monotone, ADE threshold adaptation runs, no env setting can
|
||||
admit a disabled gun, and construction + first predict leave the global RNG
|
||||
untouched.
|
||||
@@ -1,679 +0,0 @@
|
||||
## 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
|
||||
@@ -7,19 +7,22 @@ Recap card. Everything below is read off `common_libs/guns/lead_gain.nim`.
|
||||
This gun is **LEADGAIN** (rack id 16, `TR_LEADGAIN_*`). It used to be called
|
||||
`BITBRAIN` and to live in `guns/bitbrain_gun.nim`, but the ADE+SBC network was
|
||||
removed when it was rebuilt into what it actually is: **it learns a multiplier
|
||||
for Pattern's lead, separately per range band.** The rack id is unchanged. The
|
||||
real ADE+SBC gun is `guns/bitbrain_net.nim` (rack id 17).
|
||||
for Pattern's lead, separately per range band.** The rack id is unchanged, and
|
||||
it OWNS the `BITBRAIN` name and the `TR_BITBRAIN_*` prefix unconditionally:
|
||||
the ADE+SBC gun that briefly took both (rack id 17, `guns/bitbrain_net.nim`) was
|
||||
RETIRED and removed — see `docs/bitbrain_campaign.md` §RETIRED.
|
||||
|
||||
### Backward compatibility (read this before editing your `.env`)
|
||||
|
||||
The `TR_BITBRAIN_*` names your `.env` already contains still work, and still
|
||||
select **this** gun. The disambiguation is one switch, `TR_BITBRAIN_NET`
|
||||
(default `0`):
|
||||
select **this** gun. There is nothing to disambiguate any more, so the alias
|
||||
layer is unconditional:
|
||||
|
||||
| `TR_BITBRAIN_NET` | who owns `TR_BITBRAIN_*` |
|
||||
| name | meaning |
|
||||
|---|---|
|
||||
| unset / `0` | LEGACY — these are aliases for `TR_LEADGAIN_*`; the new ADE+SBC gun is off |
|
||||
| `1` | the new ADE+SBC gun (rack id 17) |
|
||||
| `TR_BITBRAIN_<X>` (14 frozen suffixes) | always a legacy alias for `TR_LEADGAIN_<X>` |
|
||||
| `TR_RACK_BITBRAIN` | always selects LEADGAIN (rack id 16) |
|
||||
| `TR_BITBRAIN_NET` | **RETIRED** — no longer read; a stale value in a `.env` is an unrecognised variable and the boot report warns about it |
|
||||
|
||||
So the owner's existing `TR_RACK_LEADGAIN=both TR_BITBRAIN_GAINS=… TR_BITBRAIN_MEM=decay
|
||||
TR_BITBRAIN_LOG=1` keeps behaving exactly as before, and one `[depr]` line on
|
||||
@@ -32,7 +35,7 @@ Migrated names are `TR_LEADGAIN_GAINS`, `_MEM`, `_MIN_OBS`, `_DECAY`,
|
||||
## What it is today
|
||||
|
||||
- A **lead-gain corrector on top of Pattern's prediction**. It scales Pattern's lead over the line of sight by a learned gain.
|
||||
- The **ADE/SBC neural network is REMOVED from this gun** (it lives in `guns/bitbrain_net.nim` now). The generic `common_libs/bitbrain/` library is intact and tested separately (`test_bitbrain.nim`, 56 checks).
|
||||
- The **ADE/SBC neural network is REMOVED from this gun and the project**. The generic `common_libs/bitbrain/` library is intact and tested separately (`test_bitbrain.nim`, 56 checks) — `movements/learned_surfer.nim` still imports `bitbrain/sbc`, so the LIBRARY is not the gun.
|
||||
|
||||
## INPUTS
|
||||
|
||||
@@ -91,8 +94,7 @@ sets are disjoint, so nothing is claimed twice.
|
||||
| `TR_LEADGAIN_DECAY_FRAC` / `TR_BITBRAIN_DECAY_FRAC` | per-decay shrink of the hit counts (`lgApplyDecay`) |
|
||||
| `TR_LEADGAIN_LOG` / `TR_BITBRAIN_LOG` | `1` = emit the `[lg]` line |
|
||||
| `TR_LEADGAIN_RESET_ON_TARGET` / `TR_BITBRAIN_RESET_ON_TARGET` | wipe learning when the enemy id changes (`targetChanged`) |
|
||||
| `TR_RACK_LEADGAIN` / `TR_RACK_BITBRAIN` | the rack admission switch |
|
||||
| `TR_BITBRAIN_NET` | `0` (default) keeps `TR_BITBRAIN_*` legacy; `1` hands the namespace to the new ADE+SBC gun |
|
||||
| `TR_RACK_LEADGAIN` / `TR_RACK_BITBRAIN` | the rack admission switch (the legacy name is unconditional) |
|
||||
|
||||
**INERT** — kept only so old configs and the boot report don't warn; never touch the gain learner:
|
||||
|
||||
|
||||
@@ -5,8 +5,10 @@
|
||||
## but its ADE+SBC network was removed when it was rebuilt into what it actually
|
||||
## is: **it learns a multiplier for Pattern's lead, separately per range band.**
|
||||
## `LEADGAIN` says that; `BITBRAIN` (a neural network) did not. The rack id 16
|
||||
## is UNCHANGED (many tests assert the id literals) and the real ADE+SBC gun is
|
||||
## the separate `guns/bitbrain_net.nim` at rack id 17.
|
||||
## is UNCHANGED (many tests assert the id literals) and the `BITBRAIN` name is
|
||||
## the gun's to keep: the ADE+SBC gun that briefly held it (rack id 17,
|
||||
## `guns/bitbrain_net.nim`) was RETIRED and removed — see
|
||||
## `docs/bitbrain_campaign.md` §RETIRED.
|
||||
##
|
||||
## ── WHY THE FILE WAS REBUILT (Phase 0/1 evidence) ────────────────────────────
|
||||
## The previous design was an ADDITIVE angular shift: an ADE+SBC network
|
||||
@@ -66,30 +68,22 @@
|
||||
## report (`env_report.nim`) and the registration guard tests keep working
|
||||
## unchanged; they no longer affect the gain learner. The generic
|
||||
## `common_libs/bitbrain/` library is untouched and still tested by
|
||||
## `test_bitbrain.nim`, and the new ADE+SBC gun that actually uses it is
|
||||
## `guns/bitbrain_net.nim` (rack id 17).
|
||||
## `test_bitbrain.nim` — `movements/learned_surfer.nim` still imports
|
||||
## `bitbrain/sbc`, so the library is NOT the gun.
|
||||
##
|
||||
## ── BACKWARD COMPATIBILITY: the `TR_BITBRAIN_*` legacy aliases ───────────────
|
||||
## The owner's live `.env` predates the rename and contains `TR_RACK_BITBRAIN`,
|
||||
## `TR_BITBRAIN_GAINS`, `TR_BITBRAIN_MEM`, `TR_BITBRAIN_LOG`, … Those names are
|
||||
## the OLD corrector's knobs and MUST keep working unchanged. The
|
||||
## `TR_BITBRAIN_*` prefix, however, now belongs to the NEW ADE+SBC gun
|
||||
## (`guns/bitbrain_net.nim`). The two uses are separated by ONE deterministic
|
||||
## switch, `TR_BITBRAIN_NET` (the new gun's master switch, default 0 = off):
|
||||
## the OLD corrector's knobs and MUST keep working unchanged.
|
||||
##
|
||||
## * `TR_BITBRAIN_NET` UNSET / 0 → LEGACY MODE. Every `TR_BITBRAIN_<X>` name
|
||||
## listed in `LegacyKnobEnvNames` is a legacy alias for this gun's
|
||||
## `TR_LEADGAIN_<X>`, and the new ADE+SBC gun is OFF. This is the owner's
|
||||
## current configuration, so its behaviour is unchanged.
|
||||
## * `TR_BITBRAIN_NET=1` → NEW-NETWORK MODE. `TR_BITBRAIN_<X>` names
|
||||
## the NEW gun's knobs (see `bitbrain_net.nim`) and this gun reads ONLY
|
||||
## `TR_LEADGAIN_<X>`.
|
||||
##
|
||||
## The legacy sets are DISJOINT (see `legacyKnobEnvNames` / the new gun's
|
||||
## `netKnobEnvNames`), so no name is ever claimed by both. `TR_RACK_BITBRAIN` is
|
||||
## the one genuinely ambiguous name (the rack is keyed by gun name and the new
|
||||
## gun is now the one called `BITBRAIN`); it is resolved by the SAME switch —
|
||||
## see `selector.nim`'s `RackLegacyAliases`.
|
||||
## The `TR_BITBRAIN_*` prefix was briefly shared with the ADE+SBC gun, which
|
||||
## had a two-mode disambiguator, `TR_BITBRAIN_NET`. That gun was RETIRED and
|
||||
## removed (it did not learn; the owner watched it live and threw it away), so
|
||||
## the legacy namespace is now the ONLY namespace and the mapping is
|
||||
## UNCONDITIONAL: every `TR_BITBRAIN_<X>` in the frozen `LegacyKnobEnvNames`
|
||||
## set is an alias for `TR_LEADGAIN_<X>`, and `TR_RACK_BITBRAIN` always selects
|
||||
## this gun (rack id 16). A stale `TR_BITBRAIN_NET=1` left in a `.env` is now an
|
||||
## unrecognised variable: the boot report warns about it and ignores it.
|
||||
##
|
||||
## ── TR_LEADGAIN_GAINS (the candidate set as an env knob) ─────────────────────
|
||||
## `TR_LEADGAIN_GAINS` is a comma-separated candidate list, e.g.
|
||||
@@ -121,11 +115,10 @@ const
|
||||
LG_NADE_ENV* = "TR_LEADGAIN_NADE" ## (legacy ADE count; inert)
|
||||
LG_RANGE_ENV* = "TR_LEADGAIN_RANGE" ## (legacy class half-range; inert)
|
||||
LG_LOG_ENV* = "TR_LEADGAIN_LOG" ## 1 = per-change [lg] log
|
||||
## ── the one switch that disambiguates the legacy `TR_BITBRAIN_*` names ────
|
||||
## Read by BOTH guns (see `bitbrain_net.nim`). Unset/0 => the `TR_BITBRAIN_*`
|
||||
## names are LEGACY aliases for this gun; 1 => they belong to the new ADE+SBC
|
||||
## gun. It is also the new gun's master on/off switch.
|
||||
LG_NET_SWITCH_ENV* = "TR_BITBRAIN_NET"
|
||||
## ── the legacy `TR_BITBRAIN_*` namespace ──────────────────────────────────
|
||||
## This gun OWNS the `TR_BITBRAIN_*` prefix unconditionally (the ADE+SBC gun
|
||||
## that briefly shared it was retired), so the frozen suffix set below maps
|
||||
## every legacy name onto this gun's `TR_LEADGAIN_<X>` with no switch.
|
||||
LG_MIN_OBS_ENV* = "TR_LEADGAIN_MIN_OBS" ## samples before a band is trusted
|
||||
LG_WARMUP_ENV* = "TR_LEADGAIN_WARMUP" ## (legacy; inert)
|
||||
LG_ADAPT_ENV* = "TR_LEADGAIN_ADAPT" ## (legacy; inert)
|
||||
@@ -288,10 +281,9 @@ const
|
||||
LegacyPrefix* = "TR_BITBRAIN_"
|
||||
NewPrefix* = "TR_LEADGAIN_"
|
||||
## The COMPLETE, FROZEN set of the old corrector's knob suffixes. A
|
||||
## `TR_BITBRAIN_<X>` in this set is a legacy alias for `TR_LEADGAIN_<X>`; any
|
||||
## other `TR_BITBRAIN_*` name belongs to the new ADE+SBC gun
|
||||
## (`bitbrain_net.nim`). The two sets are DISJOINT by construction, so the
|
||||
## mapping is total and deterministic — no name is claimed twice.
|
||||
## `TR_BITBRAIN_<X>` in this set is a legacy alias for `TR_LEADGAIN_<X>`.
|
||||
## This gun is the sole owner of the prefix (the ADE+SBC gun was retired), so
|
||||
## the mapping is total and unconditional — no name is claimed twice.
|
||||
LegacyKnobEnvNames* = [
|
||||
"GAINS", "MEM", "MIN_OBS", "DECAY", "DECAY_FRAC", "LOG", "RESET_ON_TARGET",
|
||||
"N", "NADE", "RANGE", "WARMUP", "ADAPT", "CALIB", "SEED"]
|
||||
@@ -299,26 +291,16 @@ const
|
||||
## for the boot report). A deprecation line is only worth printing for these
|
||||
## plus the inert ones, because a stale inert name is still a stale name.
|
||||
## The legacy rack knob, from `gun_harness/selector`'s alias table so the
|
||||
## two can never drift apart. It is the CURRENT name of rack id 17, which is
|
||||
## why it is not a distinct string.
|
||||
## two can never drift apart. It is NOT a current rack name any more (it was
|
||||
## `RackGunNames[RackNetGunId]` while the retired ADE+SBC gun existed).
|
||||
LegacyRackEnvName* = RackLegacyAlias[0][0]
|
||||
|
||||
proc netSwitchOn*(): bool =
|
||||
## `TR_BITBRAIN_NET` unset/0 => the `TR_BITBRAIN_*` names are LEGACY aliases
|
||||
## for this gun. 1 => they belong to the new ADE+SBC gun. The same predicate
|
||||
## is defined in `gun_harness/selector` (`netSwitchOwnsBitbrainName`), which
|
||||
## cannot import a concrete gun module.
|
||||
case getEnv(LG_NET_SWITCH_ENV, "").strip().toLowerAscii()
|
||||
of "1", "true", "yes", "on": true
|
||||
else: false
|
||||
|
||||
var deprecationShown = false
|
||||
|
||||
proc lgDeprecationLine*(): string =
|
||||
## The single clear deprecation line the owner sees. Names every legacy
|
||||
## `TR_BITBRAIN_*` knob that is actually set in the environment and the new
|
||||
## name that now owns it. Empty when there is nothing to migrate.
|
||||
if netSwitchOn(): return ""
|
||||
var parts: seq[string]
|
||||
for suffix in LegacyKnobEnvNames:
|
||||
let old = LegacyPrefix & suffix
|
||||
@@ -329,17 +311,14 @@ proc lgDeprecationLine*(): string =
|
||||
if parts.len == 0: return ""
|
||||
result = "[depr] " & LegacyPrefix & "* is the OLD lead-gain corrector's namespace; " &
|
||||
"it was renamed to " & NewPrefix & "* (gun LEADGAIN, rack id 16). " &
|
||||
"Still honoured: " & parts.join("; ") &
|
||||
". The new ADE+SBC gun owns the " & LegacyPrefix &
|
||||
"* names once " & LG_NET_SWITCH_ENV & "=1."
|
||||
"Still honoured: " & parts.join("; ") & "."
|
||||
|
||||
proc lgEnv(name: string): string =
|
||||
## Read a `TR_LEADGAIN_<X>` knob, falling back to the legacy
|
||||
## `TR_BITBRAIN_<X>` alias while `TR_BITBRAIN_NET` is off. The NEW name always
|
||||
## wins when both are set, so a migrated config is authoritative.
|
||||
## `TR_BITBRAIN_<X>` alias. The NEW name always wins when both are set, so a
|
||||
## migrated config is authoritative.
|
||||
var v = getEnv(name, "")
|
||||
if v.len > 0: return v
|
||||
if netSwitchOn(): return ""
|
||||
let suffix = if name.startsWith(NewPrefix): name[NewPrefix.len .. ^1] else: ""
|
||||
if suffix.len == 0: return ""
|
||||
for s in LegacyKnobEnvNames:
|
||||
|
||||
Reference in New Issue
Block a user