Files
SirRoboGarage/common_libs/tests/test_tm_pattern_registration.nim
T
SirStone e9302bc9f6 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>
2026-09-26 16:50:21 +02:00

207 lines
8.6 KiB
Nim

## Offline guard for the TM pattern gun rack registration (id 14) and the
## default-path parity proof.
##
## Covers:
## * RackGunNames / DefaultRackMembership carry the new gun, defaulting `off`;
## * `TR_RACK_TMPATTERN` parses both|1v1|melee|off like every other gun;
## * the DEFAULT membership table is the shipped `onlyPattern` rack (Pattern
## admitted, TMPATTERN and every other gun `off`);
## * an EXPLICIT old-rack membership (guns 0..13 both, gun 14 off) still
## reproduces the pre-change all-`both` 14-gun selection, RNG draw for RNG
## draw, through both `bestGun` and the live `selectGun` — so admitting
## TMPATTERN never perturbs the old rack when it is selected explicitly;
## * `initTmRadialGun()` selects the radial target mode;
## * the deferred-label fix (Task 2) resolves every fired virtual bullet:
## a radial-mode replay ends with `labelMisses == 0` and non-zero training.
##
## Run: nim c -r common_libs/tests/test_tm_pattern_registration.nim
import std/[random, tables, os]
import gun_harness/gun_interface
import gun_harness/virtual_bullets
import gun_harness/selector
import gun_harness/offline_range
import guns/tm_pattern
const TmPatternId = 14
var failures = 0
proc check(name: string, ok: bool) =
if ok: echo "PASS: ", name
else: echo "FAIL: ", name; inc failures
proc recordHit(fw: var FitnessWindow, hit: bool) =
fw.hits[fw.head] = hit
fw.head = (fw.head + 1) mod WindowSize
inc fw.count
proc seedWindow(t: var VirtualTracker, targetId, gunId, binIdx, hits, misses: int) =
if targetId notin t.fitness:
t.fitness[targetId] = newSeq[GunFitness](t.numGuns)
var fw = addr t.fitness[targetId][gunId].bins[binIdx]
for _ in 0..<hits: recordHit(fw[], true)
for _ in 0..<misses: recordHit(fw[], false)
proc seedOldRack(t: var VirtualTracker, targetId: int) =
## 14 deterministic rates for the old rack.
seedWindow(t, targetId, 0, 0, 60, 40)
seedWindow(t, targetId, 1, 0, 40, 60)
seedWindow(t, targetId, 2, 0, 80, 20)
seedWindow(t, targetId, 3, 0, 55, 45)
seedWindow(t, targetId, 4, 0, 20, 80)
seedWindow(t, targetId, 5, 0, 70, 30)
seedWindow(t, targetId, 6, 0, 45, 55)
seedWindow(t, targetId, 7, 0, 65, 35)
seedWindow(t, targetId, 8, 0, 30, 70)
seedWindow(t, targetId, 9, 0, 50, 50)
seedWindow(t, targetId, 10, 0, 35, 65)
seedWindow(t, targetId, 11, 0, 75, 25)
seedWindow(t, targetId, 12, 0, 25, 75)
seedWindow(t, targetId, 13, 0, 52, 48)
# ── registration table ────────────────────────────────────────────────────────
proc testTable() =
check "rack: RackGunNames has 18 entries", RackGunNames.len == 18
check "rack: the new gun is named TMPATTERN at id 14",
RackGunNames[TmPatternId] == "TMPATTERN"
check "rack: the new gun defaults to `off`",
DefaultRackMembership[TmPatternId] == rmOff
var onlyPattern = true
for i in 0..<RackGunNames.len:
let want = if i == 5: rmBoth else: rmOff
if DefaultRackMembership[i] != want: onlyPattern = false
check "rack: the shipped default is the onlyPattern rack", onlyPattern
const PatternId = 5
proc oldRackMembership(): array[15, RackMembership] =
## The pre-change rack as an explicit table: guns 0..13 `both`, TMPATTERN off.
for i in 0..<TmPatternId: result[i] = rmBoth
result[TmPatternId] = rmOff
proc testDefaultAdmitsOnlyPattern() =
check "default membership admits only PATTERN (1v1)",
admittedGuns(18, rm1v1, DefaultRackMembership) == @[PatternId]
check "default membership admits only PATTERN (melee)",
admittedGuns(18, rmMelee, DefaultRackMembership) == @[PatternId]
proc testEnvOverride() =
for name in RackGunNames: delEnv("TR_RACK_" & name)
putEnv("TR_RACK_TMPATTERN", "both")
check "env: TR_RACK_TMPATTERN=both admits gun 14",
loadRackMembership()[TmPatternId] == rmBoth
putEnv("TR_RACK_TMPATTERN", "1v1")
check "env: TR_RACK_TMPATTERN=1v1 parses",
loadRackMembership()[TmPatternId] == rmOnly1v1
putEnv("TR_RACK_TMPATTERN", "melee")
check "env: TR_RACK_TMPATTERN=melee parses",
loadRackMembership()[TmPatternId] == rmOnlyMelee
putEnv("TR_RACK_TMPATTERN", "off")
check "env: TR_RACK_TMPATTERN=off parses",
loadRackMembership()[TmPatternId] == rmOff
delEnv("TR_RACK_TMPATTERN")
# ── default-path parity (RNG draw for RNG draw) ───────────────────────────────
proc testDefaultSelectionParity() =
## The OLD rack: 14 guns, all-`both`, empty membership (pre-change call).
## The NEW rack: 15 guns, EXPLICIT old-rack membership (gun 14 off). Selection
## must be identical draw for draw, because gun 14 is filtered out before any
## RNG use. (The shipped DEFAULT is now `onlyPattern`; this explicit table
## preserves the TMPATTERN-addition parity proof independent of that default.)
let oldRack = oldRackMembership()
var oldT = initTracker(14)
seedOldRack(oldT, 7)
var newT = initTracker(15)
seedOldRack(newT, 7)
# Give gun 14 data too: it must still be excluded by the default membership.
seedWindow(newT, 7, TmPatternId, 0, 100, 0)
randomize(20250922)
var oldSeq: seq[int]
for _ in 0..<400: oldSeq.add oldT.bestGun(7)
randomize(20250922)
var newSeq: seq[int]
for _ in 0..<400:
newSeq.add newT.bestGun(7, rackMode = rm1v1, membership = oldRack)
check "parity: old-rack-membership 15-gun bestGun == 14-gun rack, RNG draw for draw",
oldSeq == newSeq
check "parity: gun 14 is never selected under the old-rack membership",
TmPatternId notin newSeq
# Same through the live hysteresis path.
var oldH = initTracker(14); seedOldRack(oldH, 7)
var newH = initTracker(15); seedOldRack(newH, 7)
seedWindow(newH, 7, TmPatternId, 0, 100, 0)
randomize(4242)
var oldHSeq: seq[int]
for tick in 0..<400: oldHSeq.add oldH.selectGun(7, tick = tick)
randomize(4242)
var newHSeq: seq[int]
for tick in 0..<400:
newHSeq.add newH.selectGun(7, tick = tick, rackMode = rm1v1,
membership = oldRack)
check "parity: old-rack-membership 15-gun selectGun == 14-gun rack, RNG draw for draw",
oldHSeq == newHSeq
# Forcing TMPATTERN alone DOES change selection (it is forceable).
var forced = initTracker(15)
seedOldRack(forced, 7)
seedWindow(forced, 7, TmPatternId, 0, 100, 0)
var onlyTmp: array[15, RackMembership]
for i in 0..<15: onlyTmp[i] = rmOff
onlyTmp[TmPatternId] = rmBoth
check "force: with every other gun off, TMPATTERN is the only candidate",
admittedGuns(15, rm1v1, onlyTmp) == @[TmPatternId]
randomize(9)
var forcedSeq: seq[int]
for _ in 0..<50:
forcedSeq.add forced.bestGun(7, rackMode = rm1v1, membership = onlyTmp)
var allTmp = true
for g in forcedSeq:
if g != TmPatternId: allTmp = false
check "force: the forced-alone rack always returns TMPATTERN", allTmp
# ── radial mode + deferred-label fix ─────────────────────────────────────────
proc testRadialInit() =
let g = initTmRadialGun()
check "init: initTmRadialGun() selects the radial target mode",
g.targetMode == tmRadial
proc testDeferredLabel() =
## Drive a radial-mode gun over a synthetic fixture and prove the deferred
## label resolves every bullet: no label misses, and training happened.
let fx = synthesizeOscillator(ticks = 400)
var g2 = initTmRadialGun()
randomize(3)
var tracker = initTracker(1, bmPoint)
for state in fx.states:
var preds: array[len(PowerBins), GunPrediction]
for b in 0..<len(PowerBins):
preds[b] = g2.predict(state, bulletSpeed(PowerBins[b]))
tracker.spawnBullets(0, preds, state, fx.enemyId)
var et: Table[int, tuple[x, y: float, lastSeenTick: int, alive: bool]]
et[fx.enemyId] = (x: state.enemyX, y: state.enemyY,
lastSeenTick: state.tick, alive: true)
tracker.tickBullets(state, et,
proc(gunId: GunId, binIdx: int, e: FeedbackEvent) = g2.onResult(e))
check "deferred: a radial replay trains (totalObs > 0)", g2.totalObs > 0
check "deferred: no label misses remain (labelMisses == 0)", g2.labelMisses == 0
check "deferred: the pending queue never overflowed", g2.pendingDropped == 0
check "deferred: the radial head is scored above chance (radTotal > 0)",
g2.radTotal > 0 and g2.radCorrect > 0
when isMainModule:
testTable()
testDefaultAdmitsOnlyPattern()
testEnvOverride()
testDefaultSelectionParity()
testRadialInit()
testDeferredLabel()
if failures > 0:
echo "\n", failures, " check(s) FAILED"
quit(1)
echo "\nAll TM pattern registration checks passed."