j140 rebuild a real BitBrain gun: ADE+SBC at rack id 17, default off, and measure its scaling

The BITBRAIN name was sitting on a gun with no network in it. This is the gun
that actually runs the algorithm: an ADE layer (thresholded random projections
with ONLINE threshold adaptation) feeding the SBC head from
common_libs/bitbrain/, with the counted+decay mode available.

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-26 16:50:21 +02:00
parent 7a6237ec20
commit e9302bc9f6
14 changed files with 1520 additions and 39 deletions
+185
View File
@@ -0,0 +1,185 @@
# 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 BOTH
`TR_RACK_BITBRAIN` (rack table) and `TR_BITBRAIN_NET=1` (the gun's own master
switch, the same switch that disowns the legacy `TR_BITBRAIN_*` aliases of
`LEADGAIN`). 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.** `0` = off and the `TR_BITBRAIN_*` names are LEGACY aliases of `LEADGAIN`; `1` = on and the names below are the new gun's |
| `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
# 42 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, two different inputs give different class
outputs, a learn visibly raises SBC occupancy, a bitset learn is idempotent
while a counted learn is monotone, ADE threshold adaptation runs, and
construction + first predict leave the global RNG untouched.
+667
View File
@@ -0,0 +1,667 @@
## bitbrain_net.nim — BITBRAIN (rack id 17): the REAL ADE+SBC gun.
##
## The gun at rack id 16 used to be called `BITBRAIN` while containing no network
## at all; it is now `LEADGAIN` (`guns/lead_gain.nim`, a per-range-band lead-gain
## corrector). This module is the gun that actually runs the algorithm: an ADE
## layer (thresholded random projections with ONLINE threshold adaptation)
## feeding the SBC head from `common_libs/bitbrain/`, with the COUNTED + DECAY
## mode available — the mode that delivers forgetting and true per-class
## probabilities (`docs/bitbrain_counted_sbc.md`).
##
## ── OUTPUT SHAPE: a fine-grained aim correction ON TOP OF Pattern ────────────
## Not a direct aim point from the argmax class. Two reasons:
## 1. the gate test that motivated this (`docs/bitbrain_gate.md`) measured
## exactly this shape — a class-resolved correction added to Pattern's
## bearing — so this is the shape with a published measurement behind it;
## 2. Pattern's prediction is already a strong, fully-learned predictor. The
## network's job is the RESIDUAL angular error, which is a small, signed,
## zero-centred quantity; replacing the aim point outright would throw away
## Pattern entirely on the (measured) bet that the net beats it.
## The correction is the PROBABILITY-WEIGHTED MEAN of the class centres under
## `inferProb`, i.e. a continuous readout over `nClasses` discrete cells rather
## than a step function of the argmax. Below `TR_BITBRAIN_MINOBS` resolved
## samples — or when the readout has no evidence — the correction is exactly 0
## and the gun returns Pattern's prediction unchanged.
##
## ── THE INPUT IS A CONFIGURED SET OF FEATURE BLOCKS ─────────────────────────
## The input vector is a concatenation of FEATURE BLOCKS, each independently
## selectable (`TR_BITBRAIN_FEATURES`) and with a settable width. A block is a
## list of scalar QUANTITIES; a block of width `W` lays each quantity out as a
## thermometer (unary) code of `W` slots, so width == resolution: a wider block
## distinguishes more states of that quantity. Slots are 0/255 uint8, which the
## ADE scorer centres at 127 (`DefaultCenter`), so a synapse "matches" when its
## polarity agrees with the slot and a random ADE fires iff its `w` synapses all
## match — a clean thresholded random projection with firing rate 2^-w.
##
## The DEFAULT block set (52 slots) and what each slot means:
##
## block quant. slots quantity
## epos 2 8 enemy offset from us, x and y, over the arena span
## evel 2 6 enemy speed; enemy heading minus the bearing to us
## eturn 2 4 turn direction this tick; turn consistency over 10
## eself 2 4 our speed; our heading minus the bearing to the enemy
## dist 2 10 range; range rate over the last 10 ticks
## bear 1 4 relative bearing (enemy bearing minus our heading)
## walls 4 8 distance to each of the four arena walls
## bull 2 4 live bullet count; nearest bullet's signed lateral offset
## hzn 1 4 bullet flight time to the current range, h = dist / speed
##
## `docs/state_window_gate.md` measured that a long TEMPORAL WINDOW of states
## destroys recurrence, so there is deliberately NO window block here: the only
## history-derived inputs are the 3 rate/turn quantities above (10 ticks), which
## is the same causal information Pattern itself uses.
##
## ── DEFAULT OFF / PARITY ────────────────────────────────────────────────────
## Admitted ONLY when `TR_RACK_BITBRAIN=both` AND `TR_BITBRAIN_NET=1`. Both
## default off, so the shipped rack never calls `predict`, the network is never
## built (`ensureInit` is lazy), and the shipped bot is byte-for-byte unchanged.
## The default target RNG is a PRIVATE `initRand(seed)`, so construction cannot
## perturb the global selector RNG either.
import std/[math, os, strutils, strformat, algorithm, random]
import gun_harness/gun_interface
import guns/tm_horizon
import guns/pattern_matcher
import bitbrain/bitbrain
export ade, sbc
const
## ── env knobs (all resolved once at gun construction) ─────────────────────
BBN_INPUT_ENV* = "TR_BITBRAIN_INPUT" ## total input slots
BBN_CLASSES_ENV* = "TR_BITBRAIN_NCLASSES" ## output resolution
BBN_NADES_ENV* = "TR_BITBRAIN_NADES" ## ADEs per AD
BBN_WIDTHS_ENV* = "TR_BITBRAIN_WIDTHS" ## clause widths, one AD per width
BBN_FEATURES_ENV* = "TR_BITBRAIN_FEATURES" ## block:name:width,...
BBN_SPAN_ENV* = "TR_BITBRAIN_SPAN" ## class half-range, degrees
BBN_MINOBS_ENV* = "TR_BITBRAIN_MINOBS" ## resolved samples before trusting
BBN_LOG_ENV* = "TR_BITBRAIN_NETLOG" ## 1 = per-change [bbn] log
BBN_ADAPT_ENV* = "TR_BITBRAIN_ADAPT_EVERY"## ADE threshold adaptation interval
BBN_CALIB_ENV* = "TR_BITBRAIN_CALIB_EVERY"## (compat alias; same interval)
BBN_SEED_ENV* = "TR_BITBRAIN_NETSEED" ## network seed
BBN_TARGET_ENV* = "TR_BITBRAIN_TARGET" ## ADE target firing rate
BBN_RESET_ON_TARGET_ENV* = "TR_BITBRAIN_NET_RESET_ON_TARGET"
BBN_NET_ENV* = "TR_BITBRAIN_NET" ## master switch (legacy disambiguator)
## The library's own knobs (read inside `common_libs/bitbrain`), re-declared
## here so `knownEnvNames()` and the boot report cover the whole gun.
BBN_MODE_ENV* = "TR_BITBRAIN_MODE"
BBN_DECAY_EVERY_ENV* = "TR_BITBRAIN_DECAY_EVERY"
BBN_DECAY_SHIFT_ENV* = "TR_BITBRAIN_DECAY_SHIFT"
## Every env name this gun reads, for the tree-scan guard's known set.
BitbrainNetEnvNames* = [
BBN_INPUT_ENV, BBN_CLASSES_ENV, BBN_NADES_ENV, BBN_WIDTHS_ENV,
BBN_FEATURES_ENV, BBN_SPAN_ENV, BBN_MINOBS_ENV, BBN_LOG_ENV,
BBN_ADAPT_ENV, BBN_CALIB_ENV, BBN_SEED_ENV, BBN_TARGET_ENV,
BBN_RESET_ON_TARGET_ENV, BBN_MODE_ENV, BBN_DECAY_EVERY_ENV,
BBN_DECAY_SHIFT_ENV]
## (`BBN_NET_ENV` == `TR_BITBRAIN_NET` is deliberately absent: it is already
## registered as `LG_NET_SWITCH_ENV`, the one switch both guns share.)
## ── feature blocks ────────────────────────────────────────────────────────
BB_BLOCKS* = [
("epos", 2, 4), ("evel", 2, 3), ("eturn", 2, 2), ("eself", 2, 2),
("dist", 2, 5), ("bear", 1, 4), ("walls", 4, 2), ("bull", 2, 2),
("hzn", 1, 4)]
## Their shipped widths sum to BB_DEFAULT_SLOTS (8+6+4+4+10+4+8+4+4 = 52).
BB_DEFAULT_SLOTS* = 52
BB_MAX_SLOTS* = 4096
## ── defaults ──────────────────────────────────────────────────────────────
BBN_NCLASSES_DEF = 8 ## output resolution
BBN_NADES_DEF = 256 ## ADEs per AD
BBN_WIDTHS_DEF* = @[4, 5, 6] ## clause widths: one AD per width, 3 cross SBCs
BBN_SPAN_DEF = 40.0
BBN_MINOBS_DEF = 32 ## resolved samples before the net is trusted
BBN_ADAPT_DEF = 200 ## inputs between threshold-adaptation passes
BBN_TARGET_DEF = 0.01 ## paper's target ADE firing rate
BBN_SEED_DEF = 20240921
BBN_PENDING_CAP* = 512
BBN_HIST* = 12 ## the short causal history ring (see the note)
BBN_SBC_VALUE* = 255'u8 ## a "set" thermometer slot
BBN_SLOT_CENTRE = 127 ## DefaultCenter; a synapse matches when 0 or 255
type
Block = tuple[name: string, nQuant: int, width: int]
Pending = object
fireTick: int
horizon: int
selfX, selfY: float
baseBearing: float
input: seq[uint8]
klass: int
HistSample = object
x, y, heading: float
BitbrainNetGun* = object
tmh: TmHorizonGun
initialized: bool
## ── resolved config (boot report) ────────────────────────────────────────
enabled*: bool
inputWidth*: int
nClasses*: int
nAde*: int
widths*: seq[int]
blockWidths*: seq[int] ## per BB_BLOCKS entry; 0 = block disabled
features*: string ## the resolved block spec, for the boot report
maxDeg*: float
minObs*: int
adaptEvery*: int
targetRate*: float
seed*: int64
logEnabled*: bool
resetOnTarget*: bool
mode*: SbcMode
decayEvery*: int
decayShift*: int
## ── the network ─────────────────────────────────────────────────────────
net*: BitBrain
built: bool
## ── learner state ───────────────────────────────────────────────────────
trained*: int
sinceAdapt: int
adapts*: int
sinceEnqTick: int
sinceEnqBucket: int
pending: array[BBN_PENDING_CAP, Pending]
pendingCount*: int
pendingDropped*: int
observedTargetId*: int
lastTick: int
## ── readout ─────────────────────────────────────────────────────────────
lastShiftDeg*: float
lastClass*: int
corrections*: int
lastLogKey: string
## ── the short causal history the rate/turn blocks need ─────────────────
## DELIBERATELY SHORT (12 ticks, and only 3 derived quantities use it):
## `docs/state_window_gate.md` measured that feeding a long TEMPORAL WINDOW
## of states destroys the recurrence this network depends on. This is not
## a window block — it is the same 10-tick information Pattern uses.
hist: seq[HistSample]
# ── config helpers ───────────────────────────────────────────────────────────
proc envStrBbn(name: string): string {.inline.} =
let v = getEnv(name, "")
if v.len > 0: v.strip() else: ""
proc envIntBbn(name: string, default: int): int =
let v = envStrBbn(name)
if v.len == 0: return default
try: parseInt(v) except ValueError: default
proc envFloatBbn(name: string, default: float): float =
let v = envStrBbn(name)
if v.len == 0: return default
try: parseFloat(v) except ValueError: default
proc envBoolBbn(name: string, default: bool): bool =
case envStrBbn(name).toLowerAscii()
of "1", "true", "yes", "on": true
of "0", "false", "no", "off": false
else: default
proc netOn*(): bool =
## The master switch. Default OFF: this gun is never admitted by an unset
## environment, and while it is off the `TR_BITBRAIN_*` names are the LEGACY
## aliases of the LEADGAIN corrector (`guns/lead_gain.nim`).
envBoolBbn(BBN_NET_ENV, false)
proc blockNames*(): string =
## The known block names, comma separated (used in the unknown-block warning).
for i in 0 ..< BB_BLOCKS.len:
if i > 0: result.add ","
result.add BB_BLOCKS[i][0]
proc parseBlockWidths*(value: string): seq[int] =
## Parse `TR_BITBRAIN_FEATURES` — a comma-separated `name[:W]` list. A block
## that is NOT listed keeps its shipped width; an explicitly listed block may
## be switched off with width 0. Unknown names are ignored (with a stderr
## warning) so a typo cannot silently change the input size. Unset/empty ->
## the shipped widths, byte-identical.
result = newSeq[int](BB_BLOCKS.len)
for i, b in BB_BLOCKS: result[i] = b[2]
if value.len == 0: return
for part in value.split(','):
let p = part.strip()
if p.len == 0: continue
let ci = p.find(':')
let nm = (if ci < 0: p else: p[0..<ci]).strip().toLowerAscii()
var w = -1
if ci >= 0:
try: w = parseInt(p[ci+1..^1].strip())
except ValueError: w = -1
var found = false
for i, b in BB_BLOCKS:
if b[0] == nm:
found = true
result[i] = (if w < 0: b[2] else: w)
if not found:
stderr.writeLine("[bbn] unknown feature block '" & nm & "' in " &
BBN_FEATURES_ENV & "; ignored (known: " &
blockNames() & ")")
for w in result.mitems: w = max(0, min(w, 64))
proc parseWidths*(value: string): seq[int] =
## Parse `TR_BITBRAIN_WIDTHS` — the ADE clause widths, one AD per width, one
## cross-AD SBC per unordered pair. Unset -> `BBN_WIDTHS_DEF`.
if value.len == 0: return BBN_WIDTHS_DEF
for tok in value.split(','):
let t = tok.strip()
if t.len == 0: continue
var w: int
try: w = parseInt(t) except ValueError: continue
if w >= 1 and w <= 64: result.add w
if result.len == 0: return BBN_WIDTHS_DEF
proc blockWidthsString*(widths: seq[int]): string =
## The resolved block spec as the env's own form (boot report).
for i, w in widths:
if i > 0: result.add ","
if w == 0: result.add BB_BLOCKS[i][0] & ":0"
elif w == BB_BLOCKS[i][2]: result.add BB_BLOCKS[i][0]
else: result.add BB_BLOCKS[i][0] & ":" & $w
proc derivedSlots*(widths: seq[int]): int =
for i, b in BB_BLOCKS: result += b[1] * widths[i]
proc blockName*(i: int): string {.inline.} = BB_BLOCKS[i][0]
proc blockQuantities*(i: int): int {.inline.} = BB_BLOCKS[i][1]
# ── construction ─────────────────────────────────────────────────────────────
proc initBitbrainNetGun*(): BitbrainNetGun =
result.enabled = netOn()
result.blockWidths = parseBlockWidths(envStrBbn(BBN_FEATURES_ENV))
result.features = blockWidthsString(result.blockWidths)
let derived = derivedSlots(result.blockWidths)
result.inputWidth = clamp(envIntBbn(BBN_INPUT_ENV, derived), 1, BB_MAX_SLOTS)
result.nClasses = clamp(envIntBbn(BBN_CLASSES_ENV, BBN_NCLASSES_DEF), 2, 4096)
result.nAde = clamp(envIntBbn(BBN_NADES_ENV, BBN_NADES_DEF), 8, 8192)
result.widths = parseWidths(envStrBbn(BBN_WIDTHS_ENV))
result.maxDeg = clamp(envFloatBbn(BBN_SPAN_ENV, BBN_SPAN_DEF), 1.0, 180.0)
result.minObs = max(1, envIntBbn(BBN_MINOBS_ENV, BBN_MINOBS_DEF))
result.adaptEvery = max(1, envIntBbn(BBN_ADAPT_ENV, envIntBbn(BBN_CALIB_ENV, BBN_ADAPT_DEF)))
result.targetRate = clamp(envFloatBbn(BBN_TARGET_ENV, BBN_TARGET_DEF), 0.0001, 0.5)
result.seed = int64(envIntBbn(BBN_SEED_ENV, BBN_SEED_DEF))
result.logEnabled = envBoolBbn(BBN_LOG_ENV, false)
result.resetOnTarget = envBoolBbn(BBN_RESET_ON_TARGET_ENV, true)
# The SBC storage mode + its decay knobs are the LIBRARY's env names, read
# here so the gun's resolved config (and the boot report) is the real one.
result.mode = envSbcMode(smCounted)
result.decayEvery = envDecayEvery(64)
result.decayShift = envDecayShift(3)
result.lastTick = -1
result.sinceEnqTick = -1
result.sinceEnqBucket = -1
result.observedTargetId = -1
proc buildNet*(g: var BitbrainNetGun) =
## Build the ADE layers + SBC head. LAZY: the shipped rack never calls it.
## Deterministic given the seed, and it uses a PRIVATE `initRand`, so it can
## never perturb the global selector RNG.
if g.built: return
g.built = true
var rng = initRand(g.seed)
# The clause width must not exceed the input width or `initRandomAddressDecoder`
# cannot draw `w` distinct indices; clamp the widths to the input width.
var widths = g.widths
for i, w in widths: widths[i] = min(w, max(2, g.inputWidth))
g.net = buildRandomBitBrain(widths = widths, nAde = g.nAde,
inputWidth = g.inputWidth, nClasses = g.nClasses,
seed = g.seed, mode = g.mode,
decayEvery = g.decayEvery,
decayShift = g.decayShift)
# The initial threshold is 0, which for 0/255 slots centred at 127 fires every
# ADE whose `w` synapses all match — rate 2^-w. Homeostatic adaptation then
# drives each ADE toward the paper's ~1% target rate, online, unsupervised.
for ad in g.net.ades.mitems:
for i in 0 ..< ad.nAde: ad.thresholds[i] = int32(127 * ad.width - 1)
g.tmh = initTmHorizonGun()
proc ensureInit*(g: var BitbrainNetGun) =
if g.initialized: return
g.initialized = true
if not g.enabled: return
g.buildNet()
# ── the input vector ─────────────────────────────────────────────────────────
proc pushTherm(dst: var seq[uint8], v: float, w: int) =
## Lay one quantity out as a `w`-slot thermometer code over [0, 1]. `w` == 0
## disables the quantity.
if w <= 0: return
var level = int(clamp(v, 0.0, 0.999999) * float(w))
if level < 0: level = 0
if level > w - 1: level = w - 1
for j in 0 ..< w:
dst.add(if j < level: BBN_SBC_VALUE else: 0'u8)
proc wrapRadNet(r: float): float {.inline.} =
result = r
while result > PI: result -= 2.0 * PI
while result < -PI: result += 2.0 * PI
proc pushHist(g: var BitbrainNetGun, state: WorldState) =
## Ring of the last `BBN_HIST` states. One push per tick, in `predict`.
if g.hist.len == 0:
for _ in 0 ..< BBN_HIST:
g.hist.add HistSample(x: state.enemyX, y: state.enemyY,
heading: state.enemyHeading)
g.hist.insert(HistSample(x: state.enemyX, y: state.enemyY,
heading: state.enemyHeading), 0)
g.hist.setLen(BBN_HIST)
proc turnSignal(g: BitbrainNetGun, state: WorldState): (float, float) =
## (turn direction this tick, turn consistency over the ring). 0.5 = straight,
## 0/1 = a full left/right turn; consistency = fraction of the ring's
## consecutive steps that turn the SAME way.
if g.hist.len < 3:
return (0.5, 0.5)
let d0 = wrapRadNet(degToRad(state.enemyHeading - g.hist[0].heading))
let dir = 0.5 + 0.5 * (if d0 > 0.0: 1.0 elif d0 < 0.0: -1.0 else: 0.0)
var same = 0
var total = 0
for i in 0 ..< g.hist.len - 1:
let d = wrapRadNet(degToRad(g.hist[i].heading - g.hist[i+1].heading))
if abs(d) < 1e-9: continue
inc total
if (d > 0) == (d0 > 0.0): inc same
(dir, if total == 0: 0.5 else: float(same) / float(total))
proc rangeSignal(g: BitbrainNetGun, state: WorldState,
dist: float): float =
## Signed range rate over the ring, in [-1, 1] (approaching / opening), scaled
## to +-400 px per tick and clamped. One quantity, ten ticks of history.
if g.hist.len < 2: return 0.0
let dPrev = hypot(g.hist[0].x - state.selfX, g.hist[0].y - state.selfY)
let step = (dist - dPrev) / float(max(1, g.hist.len - 1))
clamp(step / 400.0, -1.0, 1.0)
proc bulletSignal(g: BitbrainNetGun, state: WorldState): (float, float) =
## (live-bullet count / 4, nearest bullet's signed lateral offset in [-1,1]).
## The nearest known bullet is the one whose own last-seen tick is the most
## recent; with no bullet knowledge both are neutral.
var best = -1
var bestAge = high(int)
for e in state.enemies:
if e.lastSeenTick < 0: continue
let age = state.tick - e.lastSeenTick
if age < bestAge: bestAge = age; best = e.id
if best < 0: return (0.0, 0.0)
for e in state.enemies:
if e.id != best: continue
let toE = arctan2(e.y - state.selfY, e.x - state.selfX)
let toB = arctan2(state.enemyY - state.selfY, state.enemyX - state.selfX)
let lat = wrapRadNet(toB - toE)
return (0.25, clamp(lat / 0.5, -1.0, 1.0))
(0.0, 0.0)
proc buildInput*(g: var BitbrainNetGun, state: WorldState): seq[uint8] =
## (see the header for the block table)
## The configured feature-block vector for this state. Block order is fixed
## (`BB_BLOCKS`); each block's slot count is `g.blockWidths[i]`. The result is
## padded with zero slots or truncated to exactly `g.inputWidth`, so the
## ADE codes (drawn once at build time over `inputWidth`) can never index out
## of range no matter how the two knobs are combined.
result = newSeqOfCap[uint8](g.inputWidth)
let bw = g.blockWidths
let ex = state.enemyX
let ey = state.enemyY
let sx = state.selfX
let sy = state.selfY
let wAll = max(1.0, state.arenaWidth)
let hAll = max(1.0, state.arenaHeight)
let dx = ex - sx
let dy = ey - sy
let dist = max(1e-6, hypot(dx, dy))
let bearing = arctan2(dy, dx)
let selfHdg = degToRad(state.selfHeading)
let enemyHdg = degToRad(state.enemyHeading)
for bi in 0 ..< BB_BLOCKS.len:
let w = bw[bi]
if w <= 0: continue
case bi
of 0: # epos — enemy offset over the arena
pushTherm(result, 0.5 + dx / wAll, w div 2)
pushTherm(result, 0.5 + dy / hAll, w - w div 2)
of 1: # evel — speed, heading vs the lane
pushTherm(result, state.enemySpeed / 16.0, w div 2)
pushTherm(result, 0.5 + 0.5 * sin(enemyHdg - bearing), w - w div 2)
of 2: # eturn — turn now, consistency over 10
let (dir, cons) = turnSignal(g, state)
pushTherm(result, dir, w div 2)
pushTherm(result, cons, w - w div 2)
of 3: # eself — our speed, our heading error
pushTherm(result, state.selfSpeed / 16.0, w div 2)
pushTherm(result, 0.5 + 0.5 * sin(selfHdg - bearing), w - w div 2)
of 4: # dist — range, range rate over 10
pushTherm(result, dist / 1000.0, w div 2)
pushTherm(result, 0.5 + 0.5 * rangeSignal(g, state, dist), w - w div 2)
of 5: # bear — relative bearing
pushTherm(result, (bearing - selfHdg + PI) / (2.0 * PI), w)
of 6: # walls — distance to each wall
pushTherm(result, ex / wAll, w div 4)
pushTherm(result, (wAll - ex) / wAll, w div 4)
pushTherm(result, ey / hAll, w div 4)
pushTherm(result, (hAll - ey) / hAll, w - 3 * (w div 4))
of 7: # bull — live bullets, lateral offset
let (n, lat) = bulletSignal(g, state)
pushTherm(result, n / 4.0, w div 2)
pushTherm(result, 0.5 + 0.5 * lat, w - w div 2)
of 8: # hzn — bullet flight time
pushTherm(result, float(tmhHorizonFor(dist, 11.0)) / 50.0, w)
else: discard
if result.len < g.inputWidth:
for _ in result.len ..< g.inputWidth: result.add 0'u8
elif result.len > g.inputWidth:
result.setLen(g.inputWidth)
# ── class geometry ───────────────────────────────────────────────────────────
proc classCenterDeg*(k, nClasses: int, maxDeg: float): float {.inline.} =
## Centre (degrees) of correction class `k` over ±maxDeg.
-maxDeg + (float(k) + 0.5) * (2.0 * maxDeg / float(nClasses))
proc classOf*(errRad, maxDeg: float, nClasses: int): int {.inline.} =
## Bin a signed angular error (radians) into one of `nClasses` bins.
let x = radToDeg(errRad)
var k = int((x + maxDeg) / (2.0 * maxDeg) * float(nClasses))
if k < 0: k = 0
if k >= nClasses: k = nClasses - 1
k
# ── readout ──────────────────────────────────────────────────────────────────
proc netShiftDeg*(g: var BitbrainNetGun, input: openArray[uint8]): float =
## The fine-grained correction: the PROBABILITY-WEIGHTED MEAN of the class
## centres over the SBC posterior. Returns 0.0 when there is no evidence.
let (label, scores) = g.net.inferProb(input)
var sum = 0.0
var tot = 0.0
for k in 0 ..< scores.len:
sum += scores[k] * classCenterDeg(k, g.nClasses, g.maxDeg)
tot += scores[k]
g.lastClass = label
if tot <= 0.0: 0.0 else: sum / tot
proc adaptThresholds*(g: var BitbrainNetGun, input: openArray[uint8]) =
## Online homeostasis (unsupervised): accumulate this input's ADE firings and
## every `adaptEvery` inputs nudge each threshold toward `targetRate`.
for ad in g.net.ades.mitems: ad.accumulateFiring(input)
inc g.sinceAdapt
if g.sinceAdapt >= g.adaptEvery:
for ad in g.net.ades.mitems:
ad.adaptThresholds(interval = g.sinceAdapt, targetRate = g.targetRate)
g.sinceAdapt = 0
inc g.adapts
proc resolvePending(g: var BitbrainNetGun, state: WorldState) =
## Prequential label resolution: `h` ticks after the fire, the enemy's OBSERVED
## bearing from the firing position is the FACT; the required correction is
## `observedBearing - baseBearing`, binned into a class, and learned.
var w = 0
for i in 0 ..< g.pendingCount:
let p = g.pending[i]
let due = p.fireTick + p.horizon
if due > state.tick:
g.pending[w] = p
inc w
elif due == state.tick:
let obs = tmhObservedAt(g.tmh, state.tick, p.selfX, p.selfY)
if obs.ok and (state.tick - obs.lastSeenTick) <= TMH_STALE_MAX:
let err = wrapRadNet(obs.bearing - p.baseBearing)
g.net.learn(p.input, classOf(err, g.maxDeg, g.nClasses))
inc g.trained
else:
inc g.pendingDropped
else:
inc g.pendingDropped
g.pendingCount = w
proc bbnLog(g: var BitbrainNetGun, state: WorldState, shiftDeg: float) =
if not g.logEnabled: return
let key = fmt"{shiftDeg:.2f}"
if key == g.lastLogKey: return
g.lastLogKey = key
echo fmt"[bbn] t={state.tick} shift={shiftDeg:+.2f}deg class={g.lastClass} " &
fmt"in={g.inputWidth} ncl={g.nClasses} nAde={g.nAde} " &
fmt"mode={($g.mode)[7..^1]} trained={g.trained} adapts={g.adapts} " &
fmt"pend={g.pendingCount} dropped={g.pendingDropped}"
# ── reset hooks ──────────────────────────────────────────────────────────────
proc resetRoundState*(g: var BitbrainNetGun) =
if not g.initialized: return
g.tmh.resetRoundState()
g.pendingCount = 0
g.hist.setLen(0)
g.lastTick = -1
g.sinceEnqTick = -1
g.sinceEnqBucket = -1
g.lastLogKey = ""
proc resetLearning*(g: var BitbrainNetGun, reason = "") =
## Per-battle / per-enemy wipe of the SBC counters. The ADEs (codes and
## adapted thresholds) survive: they are unsupervised structure, not labels.
if not g.initialized: return
g.net.resetLearning()
g.trained = 0
g.sinceAdapt = 0
g.adapts = 0
g.corrections = 0
g.lastShiftDeg = 0.0
g.observedTargetId = -1
g.resetRoundState()
if reason.len > 0 and g.logEnabled: echo fmt"[bbn-reset] reason={reason}"
proc targetChanged*(g: var BitbrainNetGun, enemyId: int): bool =
if not g.resetOnTarget: return false
if enemyId < 0: return false
if g.observedTargetId >= 0 and enemyId != g.observedTargetId:
g.resetLearning("target_change")
g.observedTargetId = enemyId
return true
g.observedTargetId = enemyId
false
# ── Gun interface ────────────────────────────────────────────────────────────
proc isWarmedUp*(g: BitbrainNetGun): bool {.inline.} =
## Ready as soon as it has enough resolved samples to have a readout; before
## that it is the identity on Pattern, which is always a valid prediction.
g.trained >= g.minObs
proc networkBytes*(g: BitbrainNetGun): int =
## RAM held by the network (ADs + SBC tensors). 0 before the lazy build.
if not g.built: 0 else: g.net.memoryBytes
proc sbcBytes*(g: BitbrainNetGun): int =
if not g.built: 0 else: g.net.sbcMemoryBytes
proc predict*(g: var BitbrainNetGun, state: WorldState,
bulletSpeed: float): GunPrediction =
g.ensureInit()
if not g.enabled: return GunPrediction(x: state.enemyX, y: state.enemyY)
if state.tick < g.lastTick: g.resetRoundState()
if state.tick != g.lastTick:
tmhUpdateHistory(g.tmh, state)
g.resolvePending(state)
g.pushHist(state)
g.lastTick = state.tick
# The base is Pattern; BitBrain only adds a fine-grained correction to it.
let base = g.tmh.pattern.predict(state, bulletSpeed)
if bulletSpeed <= 0.0: return base
let dist = hypot(state.enemyX - state.selfX, state.enemyY - state.selfY)
let h = tmhHorizonFor(dist, bulletSpeed)
let hb = tmhHorizonBucket(h)
var input = buildInput(g, state)
g.adaptThresholds(input)
# One deferred training sample per (tick, horizon bucket): `predict` runs once
# per power bin, so all four horizons contribute evidence.
if g.sinceEnqTick != state.tick or g.sinceEnqBucket != hb:
if g.pendingCount < BBN_PENDING_CAP:
let los = arctan2(state.enemyY - state.selfY, state.enemyX - state.selfX)
let baseBearing = arctan2(base.y - state.selfY, base.x - state.selfX)
g.pending[g.pendingCount] = Pending(
fireTick: state.tick, horizon: h, selfX: state.selfX, selfY: state.selfY,
baseBearing: baseBearing, input: input,
klass: classOf(wrapRadNet(baseBearing - los), g.maxDeg, g.nClasses))
inc g.pendingCount
else:
inc g.pendingDropped
g.sinceEnqTick = state.tick
g.sinceEnqBucket = hb
g.lastShiftDeg = 0.0
if g.trained >= g.minObs:
g.lastShiftDeg = g.netShiftDeg(input)
g.bbnLog(state, g.lastShiftDeg)
if abs(g.lastShiftDeg) < 1e-9: return base
inc g.corrections
tmhApplyShift(state.selfX, state.selfY, base.x, base.y, g.lastShiftDeg)
proc onResult*(g: var BitbrainNetGun, e: FeedbackEvent) =
## Labels come from our own observation ring, not from virtual-bullet
## feedback. The hook exists for the rack.
discard
proc blockNameIndex*(name: string): int =
## Index of the named block in `BB_BLOCKS`, or -1. Used by the tests and the
## boot report so a block is addressed by its NAME, never by a bare index.
for i in 0 ..< BB_BLOCKS.len:
if BB_BLOCKS[i][0] == name: return i
-1
proc inferClass*(g: var BitbrainNetGun, input: openArray[uint8]): int =
## The argmax class of the SBC posterior. Exposed (rather than letting callers
## reach into `g.net`) so the gun's network stays an implementation detail and
## so this name cannot be shadowed by the SBC-level `inferProb` export.
g.net.inferProb(input).label
proc sbcBytesOf*(g: BitbrainNetGun): int =
let net = g.net
net.sbcMemoryBytes()
proc sbcsOf*(g: BitbrainNetGun): seq[Sbc] = g.net.sbcs
proc learnSample*(g: var BitbrainNetGun, input: openArray[uint8], klass: int) =
## One supervised online step: drive the ADE layer and set/increment the class
## in every observed coincidence. Exposed so tests can drive the learner
## without replaying a whole deferred-label stream.
g.net.learn(input, klass)
proc evidenceFor*(g: var BitbrainNetGun, input: openArray[uint8], klass: int): int =
## Total SBC evidence the network currently holds for `klass` on `input` (the
## summed counters, or the set-bit count in bitset mode). Unlike
## `occupancy` — which saturates once a cell is non-zero — this GROWS with
## every counted learn, so it is the observable that proves the counted mode
## is accumulating rather than just flipping bits.
g.net.infer(input).counts[klass]
+5 -1
View File
@@ -111,6 +111,7 @@ import std/[math, os, strutils, strformat, algorithm]
import gun_harness/gun_interface
import guns/tm_horizon
import guns/pattern_matcher
import gun_harness/selector
const
## ── env knobs (all resolved once at gun construction) ─────────────────────
@@ -297,7 +298,10 @@ const
## Knobs that actually change behaviour (the rest are inert configuration kept
## 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.
LegacyRackEnvName* = "TR_RACK_BITBRAIN"
## 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.
LegacyRackEnvName* = RackLegacyAlias[0][0]
proc netSwitchOn*(): bool =
## `TR_BITBRAIN_NET` unset/0 => the `TR_BITBRAIN_*` names are LEGACY aliases