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
+32 -6
View File
@@ -1,5 +1,5 @@
## ModularBot — plugin gun architecture tracer bullet.
## Guns: HeadOnGun (0), LinearGun (1), TsetlinGun (2), CircularGun (3), GFGun (4), PatternMatcherGun (5), WallBounceGun (6), AccelGun (7), StopShotGun (8), DisplacementGun (9), AveragedLeadGun (10), DecayGFGun (11), KNNGun (12), TmSelectorGun (13), TmPatternGun (14), TmHorizonGun (15), LeadGainGun (16) via GunHarness.
## Guns: HeadOnGun (0), LinearGun (1), TsetlinGun (2), CircularGun (3), GFGun (4), PatternMatcherGun (5), WallBounceGun (6), AccelGun (7), StopShotGun (8), DisplacementGun (9), AveragedLeadGun (10), DecayGFGun (11), KNNGun (12), TmSelectorGun (13), TmPatternGun (14), TmHorizonGun (15), LeadGainGun (16), BitbrainNetGun (17) via GunHarness.
## Radar: RadarLockModule (1v1) / AdaptiveMeleeRadarModule (2+ enemies), auto-switched per tick.
## Movement: OscillatorModule (perpendicular strafing).
@@ -31,6 +31,7 @@ import guns/tm_selector
import guns/tm_pattern
import guns/tm_horizon
import guns/lead_gain
import guns/bitbrain_net
import movements/phantom_meteor
import movements/rammer
import movements/ram_decision
@@ -150,7 +151,7 @@ let ResultLog* =
block:
let v = getEnv("TR_RESULT_LOG", "1").strip().toLowerAscii()
not (v == "0" or v == "false" or v == "no" or v == "off")
const GunNames = ["HeadOn", "Linear", "Tsetlin", "Circular", "GuessFactor", "Pattern", "WallBounce", "Accel", "StopShot", "Displace", "AvgLead", "DecayGF", "KNN", "TMSelect", "TMPattern", "TMHorizon", "LeadGain"]
const GunNames = ["HeadOn", "Linear", "Tsetlin", "Circular", "GuessFactor", "Pattern", "WallBounce", "Accel", "StopShot", "Displace", "AvgLead", "DecayGF", "KNN", "TMSelect", "TMPattern", "TMHorizon", "LeadGain", "BitBrainNet"]
## Rack id of the new TM pattern gun. It defaults to `TR_RACK_TMPATTERN=off`;
## unlike the other guns, its virtual-bullet spawn is gated on rack admission
@@ -174,6 +175,16 @@ const TmHorizonId = 15
## `TR_BITBRAIN_NET` is off (backward compatibility; see `gun_harness/selector`).
const LeadGainId = 16
## Rack id of the ADE+SBC gun — the gun that actually runs the algorithm
## (`guns/bitbrain_net.nim`). It takes the `BITBRAIN` name and the
## `TR_BITBRAIN_*` knob prefix that the renamed corrector gave up. TWO switches
## admit it, and both default off: `TR_RACK_BITBRAIN=both` in the rack table and
## `TR_BITBRAIN_NET=1` in the gun itself (the switch that also disowns the
## legacy `TR_BITBRAIN_*` aliases — see `gun_harness/selector`). Its
## virtual-bullet spawn is gated on rack admission, so the shipped default never
## spawns it and the shared tracker ring head is byte-for-byte unchanged.
const BitbrainNetId = 17
## ── virtual-bullet debug overlay (TR_VBULLET_DEBUG*) ───────────────────────
## OFF by default: the shipped bot draws nothing. When on, the tracker's
## virtual bullets are overlaid so the training signal the selector learns
@@ -246,6 +257,7 @@ type
tmPattern: TmPatternGun
tmHorizon: TmHorizonGun
leadGain: LeadGainGun
bbn: BitbrainNetGun
mover: TFILModule
ringMover: TFILRingModule
strafeMover: StrafeModule
@@ -943,6 +955,7 @@ method onRoundStarted*(bot: ModularBot, e: RoundStartedEvent) =
# LEADGAIN: per-round wipe always clears observations/labels; `perRound` mode
# also wipes its SBCs (the gate test's winning regime).
bot.leadGain.resetRoundState()
bot.bbn.resetRoundState()
if e.roundNumber <= 1:
bot.leadGain.resetLearning("round1")
bot.isRamming = false
@@ -1025,6 +1038,7 @@ method onGameStarted*(bot: ModularBot, e: GameStartedEventForBot) =
# boundary. The round-1 fallback in `onRoundStarted` covers a missed callback.
bot.tmHorizon.resetLearning("game_start")
bot.leadGain.resetLearning("game_start")
bot.bbn.resetLearning("game_start")
# minNumberOfParticipants == maxNumberOfParticipants for fixed battles; self is -1
bot.initialEnemyCount = e.gameSetup.minNumberOfParticipants - 1
# New battle: reset the cumulative outcome counters and remember how many
@@ -1133,6 +1147,7 @@ method run*(bot: ModularBot) =
# TR_TMHORIZON_RESET_ON_TARGET, default on; no-op in 1v1).
discard bot.tmHorizon.targetChanged(candidateId)
discard bot.leadGain.targetChanged(candidateId)
discard bot.bbn.targetChanged(candidateId)
if bot.currentTargetId >= 0:
bot.lastKnownTargetId = bot.currentTargetId
@@ -1277,6 +1292,7 @@ method run*(bot: ModularBot) =
var tmpPreds: array[len(PowerBins), GunPrediction]
var tmhPreds: array[len(PowerBins), GunPrediction]
var lgPreds: array[len(PowerBins), GunPrediction]
var bbnPreds: array[len(PowerBins), GunPrediction]
# ── TR_VBULLET_ADMIT_ONLY gate ──────────────────────────────────────
# Rack membership used to filter only SELECTION, so every unselected gun
# still ran predict()+spawnBullets() each tick to feed a fitness table the
@@ -1288,12 +1304,13 @@ method run*(bot: ModularBot) =
# owning gun's onResult, so attribution survives a mid-round rack change.
# TMPATTERN and TMHORIZON keep their own admission gate even when the knob
# is 0, so TR_VBULLET_ADMIT_ONLY=0 reproduces the exact pre-change rack.
var admit: array[17, bool]
for gi in 0..<17:
var admit: array[18, bool]
for gi in 0..<18:
admit[gi] = ModuleVBullets and
vBulletAdmitted(gi, bot.rackMode, ActiveRackMembership,
VBulletAdmitOnly or gi == TmPatternId or
gi == TmHorizonId or gi == LeadGainId)
gi == TmHorizonId or gi == LeadGainId or
gi == BitbrainNetId)
for i in 0..<len(PowerBins):
if admit[0]: headsUp[i] = bot.headOn.predict(bot.lastState, bulletSpeed(PowerBins[i]))
if admit[1]: linPreds[i] = bot.linear.predict(bot.lastState, bulletSpeed(PowerBins[i]))
@@ -1319,6 +1336,9 @@ method run*(bot: ModularBot) =
# LEADGAIN: Pattern base, lead scaled by a per-band learned gain.
if admit[LeadGainId]:
lgPreds[i] = bot.leadGain.predict(bot.lastState, bulletSpeed(PowerBins[i]))
# BITBRAIN: Pattern base + a fine-grained ADE/SBC angular correction.
if admit[BitbrainNetId]:
bbnPreds[i] = bot.bbn.predict(bot.lastState, bulletSpeed(PowerBins[i]))
if admit[0]: bot.tracker.spawnBullets(0, headsUp, bot.lastState, tid)
if admit[1] and not gunDisabled(1): bot.tracker.spawnBullets(1, linPreds, bot.lastState, tid)
@@ -1342,6 +1362,8 @@ method run*(bot: ModularBot) =
bot.tracker.spawnBullets(TmHorizonId, tmhPreds, bot.lastState, tid)
if admit[LeadGainId] and not gunDisabled(LeadGainId):
bot.tracker.spawnBullets(LeadGainId, lgPreds, bot.lastState, tid)
if admit[BitbrainNetId] and not gunDisabled(BitbrainNetId):
bot.tracker.spawnBullets(BitbrainNetId, bbnPreds, bot.lastState, tid)
# Build slim enemy table for tickBullets
var enemyPositions: Table[int, tuple[x, y: float, lastSeenTick: int, alive: bool]]
@@ -1378,6 +1400,7 @@ method run*(bot: ModularBot) =
of TmPatternId: bot.tmPattern.onResult(fe)
of TmHorizonId: bot.tmHorizon.onResult(fe)
of LeadGainId: bot.leadGain.onResult(fe)
of BitbrainNetId: bot.bbn.onResult(fe)
else: discard
if fe.hit: inc bot.virtualHits else: inc bot.virtualMiss
if DebugDrawOn and VBulletDebugOn:
@@ -1433,6 +1456,7 @@ method run*(bot: ModularBot) =
of TmPatternId: bot.tmPattern.predict(bot.lastState, bulletSpeed(power))
of TmHorizonId: bot.tmHorizon.predict(bot.lastState, bulletSpeed(power))
of LeadGainId: bot.leadGain.predict(bot.lastState, bulletSpeed(power))
of BitbrainNetId: bot.bbn.predict(bot.lastState, bulletSpeed(power))
else: bot.headOn.predict(bot.lastState, bulletSpeed(power))
let aimTarget = aimAngle(getX(), getY(), pred.x, pred.y)
@@ -1500,7 +1524,7 @@ proc seedSelectorRng() =
when isMainModule:
var bot = ModularBot(
tracker: vb.initTracker(17), # 0: HeadOn, 1: Linear, 2: Tsetlin, 3: Circular, 4: GuessFactor, 5: Pattern, 6: WallBounce, 7: Accel, 8: StopShot, 9: Displace, 10: AvgLead, 11: DecayGF, 12: KNN, 13: TMSelect, 14: TMPattern, 15: TMHorizon, 16: LeadGain
tracker: vb.initTracker(18), # 0: HeadOn, 1: Linear, 2: Tsetlin, 3: Circular, 4: GuessFactor, 5: Pattern, 6: WallBounce, 7: Accel, 8: StopShot, 9: Displace, 10: AvgLead, 11: DecayGF, 12: KNN, 13: TMSelect, 14: TMPattern, 15: TMHorizon, 16: LeadGain, 17: BitBrainNet
headOn: HeadOnGun(),
linear: LinearGun(),
circular: CircularGun(),
@@ -1518,6 +1542,7 @@ when isMainModule:
tmPattern: initTmRadialGun(),
tmHorizon: initTmHorizonGun(),
leadGain: initLeadGainGun(),
bbn: initBitbrainNetGun(),
radar: RadarLockModule(),
meleeRadar: initAdaptiveMeleeRadar(),
mover: TFILModule(debugGraphics: DebugDrawOn),
@@ -1557,5 +1582,6 @@ when isMainModule:
tmHorizon: bot.tmHorizon,
patternMatcher: bot.patternMatcher,
leadGain: bot.leadGain,
bitbrainNet: bot.bbn,
))
start(bot, botJsonPath)
+34 -4
View File
@@ -32,6 +32,7 @@ import movements/wave_surfer
import movements/learned_surfer
import guns/tm_horizon
import guns/lead_gain
import guns/bitbrain_net
import guns/pattern_matcher
import vbullet_draw
import geo_overlay
@@ -65,6 +66,7 @@ type
disabledGuns*: HashSet[int]
tmHorizon*: TmHorizonGun
leadGain*: LeadGainGun
bitbrainNet*: BitbrainNetGun
patternMatcher*: PatternMatcherGun
# ── helpers ──────────────────────────────────────────────────────────────────
@@ -139,6 +141,9 @@ proc sourceOfLgPresence(name: string): string =
else:
sourceOfPresence(name)
proc sbcModeName(m: SbcMode): string {.inline.} =
(if m == smCounted: "counted" else: "bitset")
proc disabledGunsString(guns: HashSet[int]): string =
if guns.len == 0: return "none"
var ids: seq[int]
@@ -428,6 +433,29 @@ proc printEffectiveValues(ctx: EnvReportContext) =
if dep.len > 0: emit("legacy TR_BITBRAIN_* aliases honoured", "yes", "legacy")
else: emit("legacy TR_BITBRAIN_* aliases honoured", "no", "default")
# ── the ADE+SBC gun (rack id 17) ─────────────────────────────────────────
# Two switches admit it, both default off: `TR_RACK_BITBRAIN` (rack table) and
# `TR_BITBRAIN_NET` (the gun's own master switch, which is also the switch
# that disowns the legacy `TR_BITBRAIN_*` aliases above).
let bbn = ctx.bitbrainNet
emit("TR_BITBRAIN_NET", onOff(bbn.enabled), sourceOfPresence(BBN_NET_ENV))
emit("TR_BITBRAIN_INPUT", $bbn.inputWidth, sourceOf(BBN_INPUT_ENV))
emit("TR_BITBRAIN_FEATURES", bbn.features, sourceOf(BBN_FEATURES_ENV))
emit("TR_BITBRAIN_NCLASSES", $bbn.nClasses, sourceOf(BBN_CLASSES_ENV))
emit("TR_BITBRAIN_NADES", $bbn.nAde, sourceOf(BBN_NADES_ENV))
emit("TR_BITBRAIN_WIDTHS", bbn.widths.join(","), sourceOf(BBN_WIDTHS_ENV))
emit("TR_BITBRAIN_SPAN", $bbn.maxDeg, sourceOf(BBN_SPAN_ENV))
emit("TR_BITBRAIN_MODE", sbcModeName(bbn.mode), sourceOf(BBN_MODE_ENV))
emit("TR_BITBRAIN_DECAY_EVERY", $bbn.decayEvery, sourceOf(BBN_DECAY_EVERY_ENV))
emit("TR_BITBRAIN_DECAY_SHIFT", $bbn.decayShift, sourceOf(BBN_DECAY_SHIFT_ENV))
emit("TR_BITBRAIN_MINOBS", $bbn.minObs, sourceOf(BBN_MINOBS_ENV))
emit("TR_BITBRAIN_ADAPT_EVERY", $bbn.adaptEvery, sourceOf(BBN_ADAPT_ENV))
emit("TR_BITBRAIN_TARGET", $bbn.targetRate, sourceOf(BBN_TARGET_ENV))
emit("TR_BITBRAIN_NETSEED", $bbn.seed, sourceOf(BBN_SEED_ENV))
emit("TR_BITBRAIN_NETLOG", onOff(bbn.logEnabled),
sourceOfPresence(BBN_LOG_ENV))
emit("bitbrain_net.ram", $bbn.networkBytes, "default") ## 0 until the lazy build
# ── Pattern match-shape + radial knobs ────────────────────────────────────
# These are resolved lazily inside `predict` (which has not run at boot), so
# unless something already forced them we report the raw env value and say so.
@@ -600,14 +628,16 @@ proc knownEnvNames*(): seq[string] =
"TR_BITBRAIN_RANGE", "TR_BITBRAIN_WARMUP", "TR_BITBRAIN_ADAPT",
"TR_BITBRAIN_CALIB", "TR_BITBRAIN_SEED",
# common_libs/bitbrain (counted-SBC library) reads these inline; job j102
"TR_BITBRAIN_MODE", "TR_BITBRAIN_DECAY_EVERY", "TR_BITBRAIN_DECAY_SHIFT",
]
# the ADE+SBC gun (rack id 17) owns the rest of the TR_BITBRAIN_* namespace
for n in BitbrainNetEnvNames: result.add n
# rack names are constructed from the prefix + gun table, not spelled out
for g in RackGunNames:
result.add RackEnvPrefix & g
# the LEGACY rack name of the renamed corrector (still honoured while
# LG_NET_SWITCH_ENV is off — see gun_harness/selector's RackLegacyAlias)
for (key, _) in RackLegacyAlias: result.add key
# NOTE: the legacy rack alias `TR_RACK_BITBRAIN` needs no extra entry — it is
# the CURRENT name of rack id 17, so the RackGunNames loop above already
# registers it. See gun_harness/selector's RackLegacyAlias for how it is
# routed to LEADGAIN while LG_NET_SWITCH_ENV is off.
# inline reads with no exported constant (the guard test scans for these)
for n in [
"GUN_SELECTOR_WINDOW", "GUN_SELECTOR_MINOBS", "GUN_SELECTOR_TIE",