Files
SirRoboGarage/common_libs/tests/test_bitbrain_net.nim
T
SirStone 59c5af0499 j140 fix: a pre-rename .env must not admit the disabled BitBrain net gun
TR_RACK_BITBRAIN=both is what the owner's live .env carries, and with the new
ADE+SBC gun registered at id 17 under the SAME rack name that value was also
landing on id 17 - so a gun that was not enabled (TR_BITBRAIN_NET unset) was
admitted into the rack and its placeholder predictions were pushed into the
shared VirtualTracker ring, which shifts every other gun's learning order.

While the namespace is LEGACY, loadRackMembership now skips id 17's
TR_RACK_BITBRAIN entirely, so that value addresses ONLY the gun it always
addressed (LEADGAIN, id 16). ModularBot additionally gates admission on
BitbrainNetGun.gunAdmitted(), and test_bitbrain_net pins the truth table: over
6 (rack, switch) settings there is NO configuration that admits the gun while
leaving it disabled.

Guards: test_env_report 25, test_rack_membership 49 (was 48; the revert
one-liner now sets TR_BITBRAIN_NET=1 and one truth-table check was added),
test_tm_pattern_registration 20, test_bitbrain 56, test_gun_harness 39,
test_tfil_commit_env 30, test_lead_gain_registration 13,
test_lead_gain_legacy 24, test_bitbrain_net 44.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-26 16:54:43 +02:00

326 lines
13 KiB
Nim

## Does the BITBRAIN gun (rack id 17) actually ENGAGE the ADE+SBC network?
##
## Linking is not engagement. These checks drive the real gun over synthetic
## states and assert the observable consequences of a live network:
## * the network is BUILT on first predict and is 0 bytes before that (lazy);
## * two DIFFERENT inputs give DIFFERENT class outputs (the ADE layer is
## discriminating, not a constant);
## * learning a sample CHANGES the memory (the SBC head is writing);
## * `learn` is idempotent on a bitset SBC (setting the same class twice is a
## no-op) and monotone on a counted one;
## * the shipped knobs really move RAM and the derived geometry;
## * the input is the CONFIGURED block set: a disabled block shrinks it, a
## wider block grows it, and `TR_BITBRAIN_INPUT` pins it exactly;
## * construction is RNG-clean (the default path cannot perturb the selector).
##
## No Java, no battle, no fixtures.
##
## Run: nim c -r --path:common_libs common_libs/tests/test_bitbrain_net.nim
import std/[os, random, math, strutils, sequtils]
import gun_harness/gun_interface
import gun_harness/virtual_bullets
import gun_harness/selector
import guns/bitbrain_net
const BitbrainNetId = 17
const PatternId = 5
var failures = 0
proc check(name: string, ok: bool) =
if ok: echo "PASS: ", name
else: echo "FAIL: ", name; inc failures
proc clearEnv() =
for n in BitbrainNetEnvNames: delEnv(n)
# the master switch is shared with LEADGAIN, so it is NOT in
# BitbrainNetEnvNames and must be cleared explicitly here
delEnv("TR_BITBRAIN_NET")
for n in RackGunNames: delEnv("TR_RACK_" & n)
proc mkState(t: int, ex, ey, sx, sy, eh: float): WorldState =
WorldState(arenaWidth: 800, arenaHeight: 600, tick: t,
enemyX: ex, enemyY: ey, enemyHeading: eh, enemySpeed: 8,
selfX: sx, selfY: sy, selfHeading: 0, selfSpeed: 8,
selfEnergy: 100, enemyEnergy: 100, selfRadarHeading: 0)
proc armNet() =
## A small, fast, deterministic configuration: TR_BITBRAIN_NET must be on for
## the gun to engage at all.
putEnv(BBN_NET_ENV, "1")
putEnv(BBN_NADES_ENV, "64")
putEnv(BBN_CLASSES_ENV, "8")
putEnv(BBN_MINOBS_ENV, "1")
putEnv(BBN_INPUT_ENV, "24")
# ── registration / default-off ───────────────────────────────────────────────
proc testRegistration() =
clearEnv()
check "rack: BITBRAIN is registered at id 17 and defaults to off",
RackGunNames.len == 18 and
RackGunNames[BitbrainNetId] == "BITBRAIN" and
DefaultRackMembership[BitbrainNetId] == rmOff
check "rack: the shipped default still admits only Pattern",
admittedGuns(18, rm1v1, DefaultRackMembership) == @[PatternId] and
admittedGuns(18, rmMelee, DefaultRackMembership) == @[PatternId]
check "gate: the gun is NOT spawned under the default rack",
not vBulletAdmitted(BitbrainNetId, rm1v1, DefaultRackMembership, true)
var g = initBitbrainNetGun()
check "default OFF: TR_BITBRAIN_NET unset leaves the gun disabled",
not g.enabled and g.networkBytes() == 0
# With the switch on but the gun not built, predict must not spend anything.
armNet()
var g2 = initBitbrainNetGun()
check "lazy: an enabled but unused gun still holds 0 bytes",
g2.enabled and g2.networkBytes() == 0
proc testRackEnv() =
clearEnv()
putEnv("TR_RACK_BITBRAIN", "both")
putEnv(BBN_NET_ENV, "1")
let m = loadRackMembership()
check "env: TR_RACK_BITBRAIN=both + TR_BITBRAIN_NET=1 admits id 17",
m[BitbrainNetId] == rmBoth and
vBulletAdmitted(BitbrainNetId, rm1v1, m, true)
# A pre-rename .env carries TR_RACK_BITBRAIN=both WITHOUT the master switch.
# That must not admit the gun: a disabled gun's placeholder predictions would
# enter the shared VirtualTracker ring and shift every other gun's learning
# order, i.e. it would break the default path.
clearEnv()
putEnv("TR_RACK_BITBRAIN", "both") # exactly what a pre-rename .env carries
let mLegacy = loadRackMembership()
check "env: TR_RACK_BITBRAIN with the switch OFF still means LEADGAIN (id 16)",
mLegacy[BitbrainNetId] == rmOff and mLegacy[16] == rmBoth
# TR_RACK_BITBRAIN and TR_BITBRAIN_NET are gated by the SAME switch, so there
# is no env configuration that admits the gun without enabling it. Pin that:
# a disabled gun must never reach the shared VirtualTracker ring, because its
# placeholder predictions would shift every other gun's learning order.
var consistent = true
for (rackVal, netVal) in [("both", ""), ("both", "0"), ("both", "1"),
("off", "1"), ("1v1", "1"), ("1v1", "")]:
clearEnv()
putEnv("TR_RACK_BITBRAIN", rackVal)
if netVal.len > 0: putEnv(BBN_NET_ENV, netVal)
let mm = loadRackMembership()
let inRack = mm[BitbrainNetId] != rmOff
let admitted = initBitbrainNetGun().gunAdmitted(inRack)
if inRack != admitted: consistent = false
if admitted and not initBitbrainNetGun().enabled: consistent = false
check "env: TR_RACK_BITBRAIN alone can NEVER admit a disabled id 17 (6 settings)",
consistent
clearEnv()
clearEnv()
# ── the knobs really resolve ─────────────────────────────────────────────────
proc testKnobs() =
clearEnv()
armNet()
putEnv(BBN_INPUT_ENV, "33")
putEnv(BBN_CLASSES_ENV, "17")
putEnv(BBN_NADES_ENV, "128")
putEnv(BBN_WIDTHS_ENV, "3,5,7,9")
putEnv(BBN_SPAN_ENV, "25")
putEnv(BBN_MODE_ENV, "counted")
putEnv(BBN_DECAY_EVERY_ENV, "11")
putEnv(BBN_DECAY_SHIFT_ENV, "2")
putEnv(BBN_MINOBS_ENV, "7")
var g = initBitbrainNetGun()
check "knob: input width", g.inputWidth == 33
check "knob: nClasses (output resolution)", g.nClasses == 17
check "knob: nAde per AD", g.nAde == 128
check "knob: clause widths (one AD per width)", g.widths == @[3, 5, 7, 9]
check "knob: class half-range", g.maxDeg == 25.0
check "knob: SBC mode", g.mode == smCounted
check "knob: counted-mode decay knobs",
g.decayEvery == 11 and g.decayShift == 2
check "knob: minObs", g.minObs == 7
putEnv(BBN_MODE_ENV, "bitset")
check "knob: bitset mode", initBitbrainNetGun().mode == smBitset
clearEnv()
# ── the input is a CONFIGURED SET OF BLOCKS ──────────────────────────────────
proc testFeatureBlocks() =
clearEnv()
armNet()
delEnv(BBN_INPUT_ENV)
var g = initBitbrainNetGun()
check "features: the shipped block set is 47 slots",
derivedSlots(g.blockWidths) == BB_DEFAULT_SLOTS and
g.inputWidth == BB_DEFAULT_SLOTS
check "features: every shipped block is enabled by default",
g.blockWidths.allIt(it > 0)
# disable two blocks -> the input shrinks by exactly their widths
delEnv(BBN_INPUT_ENV)
putEnv(BBN_FEATURES_ENV, "epos:0,bull:0")
var g2 = initBitbrainNetGun()
check "features: a disabled block removes its slots",
g2.inputWidth == BB_DEFAULT_SLOTS - 8 - 4
# widen one block -> the input grows by exactly the extra slots
putEnv(BBN_FEATURES_ENV, "dist:9")
var g3 = initBitbrainNetGun()
check "features: a wider block adds its slots",
g3.inputWidth == BB_DEFAULT_SLOTS - 10 + 18
# TR_BITBRAIN_INPUT pins the width whatever the blocks say
putEnv(BBN_FEATURES_ENV, "dist:9")
putEnv(BBN_INPUT_ENV, "64")
var g4 = initBitbrainNetGun()
check "features: TR_BITBRAIN_INPUT pins the width",
g4.inputWidth == 64
putEnv(BBN_INPUT_ENV, "9")
var g5 = initBitbrainNetGun()
check "features: TR_BITBRAIN_INPUT can shrink below the block total",
g5.inputWidth == 9
# the unknown-block warning path must not change the widths
putEnv(BBN_FEATURES_ENV, "banana,hzn:3")
var g6 = initBitbrainNetGun()
check "features: an unknown block is ignored, the rest still apply",
g6.blockWidths[blockNameIndex("hzn")] == 3 and
g6.blockWidths[blockNameIndex("epos")] == 4
clearEnv()
# ── the network really engages ───────────────────────────────────────────────
proc testNetworkEngages() =
clearEnv()
armNet()
var g = initBitbrainNetGun()
let st = mkState(1, 600, 300, 100, 300, 0)
var input = buildInput(g, st)
check "engage: the input vector is exactly inputWidth long",
input.len == g.inputWidth
check "engage: the input is a 0/255 thermometer, not a dead constant",
input.allIt(it == 0'u8 or it == BBN_SBC_VALUE) and
input.anyIt(it == BBN_SBC_VALUE)
check "engage: no network is built before the gun is used",
g.networkBytes() == 0
discard predict(g, st, 11.0)
check "engage: the gun builds the network on first use",
g.networkBytes() > 0
# two different inputs -> different class outputs
g.buildNet()
let a = buildInput(g, mkState(1, 600, 300, 100, 300, 0))
let b = buildInput(g, mkState(1, 200, 500, 700, 100, 90))
check "engage: two different inputs give DIFFERENT bit vectors", a != b
# An untrained SBC has no evidence, so the argmax is trivially 0. The real
# engagement test is: teach it a rule, then check the readout separates.
for i in 0 ..< 300:
let half = (i mod 2 == 0)
let x = buildInput(g, mkState(i, if half: 200.0 else: 700.0,
150.0 + float(i mod 7), 100, 500, float(i mod 3)))
g.learnSample(x, if half: 0 else: 7)
var labels = newSeq[int](100)
for i in 0 ..< 100:
let half = (i mod 2 == 0)
let x = buildInput(g, mkState(i, if half: 200.0 else: 700.0,
150.0 + float(i mod 7), 100, 500, float(i mod 3)))
labels[i] = g.inferClass(x)
var differ = false
for i in 1 ..< labels.len:
if labels[i] != labels[0]: differ = true
check "engage: a TRAINED ADE+SBC head returns DIFFERENT classes for different states",
differ
var agree = 0
for i in 0 ..< labels.len:
if (labels[i] == 0) == (i mod 2 == 0): inc agree
check "engage: the readout separates the two taught populations",
agree >= 80
# learning changes the memory
g.resetLearning()
let before = g.sbcBytesOf()
let beforeOcc = g.sbcsOf()[0].occupancy()
g.learnSample(a, 3)
check "engage: learning one sample WRITES the SBC memory",
g.sbcsOf()[0].occupancy() > beforeOcc
check "engage: memoryBytes is unchanged by a learn (no realloc)",
g.sbcBytesOf() == before
# bitset learn is idempotent
clearEnv()
armNet()
putEnv(BBN_MODE_ENV, "bitset")
putEnv(BBN_WIDTHS_ENV, "2,3,4") # denser clauses so coincidences actually fire
var gb = initBitbrainNetGun()
gb.buildNet()
gb.learnSample(a, 2)
let occ1 = gb.net.sbcs[0].occupancy()
for _ in 0 ..< 49: gb.learnSample(a, 2)
check "engage: a bitset learn really writes the memory", occ1 > 0.0
check "engage: bitset learn is idempotent (50x learn == 1x)",
abs(gb.net.sbcs[0].occupancy() - occ1) < 1e-12
# counted learn is monotone: the same cell keeps gaining evidence
putEnv(BBN_MODE_ENV, "counted")
putEnv(BBN_WIDTHS_ENV, "2,3,4")
var gc = initBitbrainNetGun()
gc.buildNet()
gc.learnSample(a, 2)
let ev2 = gc.evidenceFor(a, 2)
for _ in 0 ..< 4: gc.learnSample(a, 2)
check "engage: counted learn is MONOTONE (5 learns beat 1)",
ev2 > 0 and gc.evidenceFor(a, 2) > ev2
check "engage: the counted mode really holds saturating counters",
gc.sbcsOf()[0].mode == smCounted
clearEnv()
# ── the readout is a fine-grained correction, and it is gated ────────────────
proc testReadout() =
clearEnv()
armNet()
putEnv(BBN_MINOBS_ENV, "1000")
var g = initBitbrainNetGun()
let st = mkState(1, 600, 300, 100, 300, 0)
discard predict(g, st, 11.0)
check "readout: below minObs the correction is exactly zero",
g.lastShiftDeg == 0.0 and not isWarmedUp(g)
putEnv(BBN_MINOBS_ENV, "1")
var g2 = initBitbrainNetGun()
# feed a stream so labels resolve and the learner engages
var t = 0
for _ in 0 ..< 400:
let ft = float(t)
let st = mkState(t, 100.0 + 2.0 * ft, 200.0 + 1.1 * ft, 400, 300,
20.0 * sin(ft * 0.2))
discard predict(g2, st, 11.0)
inc t
check "readout: a resolved stream trains the net", g2.trained > 100
check "readout: the net becomes warmed up past minObs", isWarmedUp(g2)
check "readout: the correction is a bounded, fine-grained angle",
g2.lastShiftDeg >= -g2.maxDeg - 1e-9 and
g2.lastShiftDeg <= g2.maxDeg + 1e-9
check "readout: the gun's output is a POINT (Pattern base + correction)",
predict(g2, mkState(400, 500, 300, 400, 300, 0), 11.0).x != 0.0
check "readout: ADE threshold adaptation ran (online calibration)",
g2.adapts > 0
clearEnv()
# ── RNG parity ───────────────────────────────────────────────────────────────
proc testRngClean() =
clearEnv()
randomize(1234)
let a = rand(1_000_000)
randomize(1234)
armNet()
var g = initBitbrainNetGun()
g.buildNet()
discard predict(g, mkState(1, 300, 300, 100, 100, 0), 11.0)
let b = rand(1_000_000)
check "parity: building + running the net does not perturb the global RNG",
a == b
clearEnv()
testRegistration()
testRackEnv()
testKnobs()
testFeatureBlocks()
testNetworkEngages()
testReadout()
testRngClean()
if failures > 0:
echo "\n", failures, " check(s) FAILED"
quit(1)
echo "\nAll BITBRAIN (ADE+SBC) engagement checks passed."