5e32ec16df
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.
266 lines
12 KiB
Nim
266 lines
12 KiB
Nim
## Regression guard for the j141 "bot does not fire" regression.
|
|
##
|
|
## ROOT CAUSE (2026-09-26, j141): commit e9302bc added rack id 17 (the ADE+SBC
|
|
## BITBRAIN gun) but the LIVE per-gun real-shot accounting in
|
|
## `ModularBot_garage/src/ModularBot.nim` was still hard-wired to
|
|
## `array[17, int]`. The instant the selector picked gun 17, the accounting
|
|
## indexed one past the end:
|
|
##
|
|
## * debug build -> IndexDefect out of `run()`: the bot stops dead, 0 shots;
|
|
## * `-d:release` -> a silent out-of-bounds write that lands on the adjacent
|
|
## `lastPowerLogKey: string` header, so the bot kept moving
|
|
## but never fired and never reported a shot.
|
|
##
|
|
## The fix derives every gun-indexed width from `NumRackGuns` (== `len(RackGunNames)`).
|
|
## This test is the guard that would have caught it:
|
|
##
|
|
## A. rack table — `NumRackGuns` is 17, 17 DISTINCT names, and the
|
|
## membership table and the legacy alias target are in range;
|
|
## B. source shape — `ModularBot.nim` contains NO literal width/loop bound
|
|
## narrower than `NumRackGuns` for the gun-indexed
|
|
## accounting, and sizes them with `NumRackGuns`;
|
|
## C. accounting — an in-process replay of the real shape (`array[NumRackGuns]`
|
|
## indexed by every admitted id) never goes out of range,
|
|
## and the newest id IS accounted, even when the rack
|
|
## grows by one more gun;
|
|
## D. live (opt-in) — with a rack that admits ONLY the newest id, the bot
|
|
## selects it, fires and lands hits (skipped without JARs).
|
|
##
|
|
## The ADE+SBC gun (rack id 17) was later RETIRED, so the rack is back to 17 and
|
|
## the newest id is LEADGAIN (16). The guard is unchanged in shape and in
|
|
## strength: C3 now proves the fix survives a rack that grows again, which is
|
|
## the general form of the bug that shipped.
|
|
##
|
|
## Run: nim c -r common_libs/tests/test_rack_stat_width.nim
|
|
## Live: TR_SERVER_JAR=... TR_BATTLE_RUNNER=... nim c -r common_libs/tests/test_rack_stat_width.nim
|
|
|
|
import std/[os, strutils, json, tables]
|
|
import gun_harness/selector
|
|
import gun_harness/virtual_bullets
|
|
import test_framework/bot_compiler
|
|
import test_framework/server_manager
|
|
import test_framework/runner_process
|
|
|
|
const
|
|
repoRoot = currentSourcePath().parentDir.parentDir.parentDir
|
|
botSource = repoRoot / "ModularBot_garage" / "src" / "ModularBot.nim"
|
|
adversaryDir = repoRoot / "common_libs" / "test_framework" / "adversaries" / "SittingDuck"
|
|
statsPath = "/tmp/rack_stat_width_stats.jsonl"
|
|
PatternId = 5
|
|
NewestId = NumRackGuns - 1 ## 16 (LEADGAIN), the newest rack id
|
|
LegacyLeadGainId = 16
|
|
|
|
var failures = 0
|
|
proc check(name: string, ok: bool) =
|
|
if ok: echo "PASS: ", name
|
|
else: echo "FAIL: ", name; inc failures
|
|
|
|
# ── A. rack table ────────────────────────────────────────────────────────────
|
|
|
|
proc testRackTable() =
|
|
check "A1: NumRackGuns is derived from RackGunNames",
|
|
NumRackGuns == RackGunNames.len and NumRackGuns == 17
|
|
check "A2: rack membership table has one entry per gun",
|
|
DefaultRackMembership.len == NumRackGuns
|
|
var seen = initTable[string, bool]()
|
|
var allDistinct = true
|
|
for n in RackGunNames:
|
|
if n in seen: allDistinct = false
|
|
seen[n] = true
|
|
check "A3: all rack gun names are distinct", allDistinct
|
|
check "A4: the newest id is in range for every per-gun table",
|
|
NewestId < NumRackGuns and LegacyLeadGainId < NumRackGuns
|
|
check "A5: the legacy alias target is a real gun id",
|
|
RackLegacyAlias[0][1] < NumRackGuns
|
|
|
|
# ── B. the live bot's source is not width-locked to a literal ───────────────
|
|
|
|
proc testSourceShape() =
|
|
check "B0: ModularBot.nim exists", fileExists(botSource)
|
|
if not fileExists(botSource): return
|
|
let src = readFile(botSource)
|
|
var lines = src.splitLines()
|
|
|
|
# Every array declared as a per-gun accounting array, with its literal width.
|
|
let gunArrays = ["gunRealShots", "gunRealHits", "gunRealShotsByMode",
|
|
"gunRealHitsByMode", "gunSelectionCount"]
|
|
for field in gunArrays:
|
|
var found = false
|
|
for line in lines:
|
|
let st = line.strip()
|
|
if not (st.startsWith(field & ":")): continue
|
|
found = true
|
|
# outer width for a per-mode array, else the single array width
|
|
var inner = st[(st.find("array[") + "array[".len) .. ^1]
|
|
if st.find("array[") != st.rfind("array["): inner = inner[(inner.find("array[") + "array[".len) .. ^1]
|
|
let width = inner[0 ..< inner.find(',')].strip()
|
|
check "B1: " & field & " is sized with NumRackGuns (got '" & width & "')",
|
|
width == "NumRackGuns"
|
|
break
|
|
check "B2: " & field & " is still declared", found
|
|
|
|
# No hard-coded per-gun width/loop bound narrower than the rack may survive.
|
|
# Scoped to the gun-indexed accounting only: other arrays in the file (e.g. the
|
|
# 12-bucket covering-arc histogram) are legitimately narrower.
|
|
var offenders: seq[string]
|
|
for i, line in lines:
|
|
let code = line.split('#')[0]
|
|
var guarded = false
|
|
for field in gunArrays:
|
|
if field in code: guarded = true
|
|
if not guarded: continue
|
|
for token in ["array[", "0..<", "..<", "initTracker("]:
|
|
let at = code.find(token)
|
|
if at < 0: continue
|
|
var rest = code[at + token.len .. ^1].strip(chars = {' ', '('})
|
|
var digits = newStringOfCap(4)
|
|
for ch in rest:
|
|
if ch in {'0' .. '9'}: digits.add ch
|
|
else: break
|
|
if digits.len == 0: continue
|
|
if digits.len < rest.len and rest[digits.len] in {',', ')', '.'}: continue
|
|
if parseInt(digits) < NumRackGuns:
|
|
offenders.add "line " & $(i + 1) & ": " & line.strip()
|
|
check "B3: no per-gun width or loop bound is hard-coded below the rack size",
|
|
offenders.len == 0
|
|
for o in offenders: echo " offender: ", o
|
|
var literal17: seq[string] = @[]
|
|
for i, line in lines:
|
|
if line.split('#')[0].find("array[17,") >= 0 or line.split('#')[0].find("0..<17") >= 0:
|
|
literal17.add "line " & $(i + 1) & ": " & line.strip()
|
|
check "B4: the old literal 17 is gone from the accounting (comments exempt)",
|
|
literal17.len == 0
|
|
for o in literal17: echo " offender: ", o
|
|
check "B5: the accounting arrays are adjacent to a documented width note",
|
|
"NumRackGuns" in src
|
|
|
|
# ── C. the accounting shape, in process ──────────────────────────────────────
|
|
|
|
proc testAccounting() =
|
|
# Exactly the declaration the live bot now uses.
|
|
var gunSelectionCount: array[NumRackGuns, int]
|
|
var gunRealShots: array[NumRackGuns, int]
|
|
var gunRealHits: array[NumRackGuns, int]
|
|
|
|
# The rack that regressed: ONLY the newest gun admitted, in both modes.
|
|
var membership = DefaultRackMembership
|
|
for i in 0..<NumRackGuns: membership[i] = rmOff
|
|
membership[NewestId] = rmBoth
|
|
let admitted = admittedGuns(NumRackGuns, rm1v1, membership)
|
|
check "C1: a rack admitting only the newest id yields exactly that id",
|
|
admitted == @[NewestId]
|
|
|
|
# Drive the accounting the way `run` does, for every admitted id.
|
|
var inRange = true
|
|
for gid in admitted:
|
|
for _ in 0..<50:
|
|
inc gunSelectionCount[gid]
|
|
inc gunRealShots[gid]
|
|
inc gunRealHits[gid]
|
|
if gunSelectionCount[NewestId] != 50: inRange = false
|
|
if gunRealShots[NewestId] != 50: inRange = false
|
|
if gunRealHits[NewestId] != 50: inRange = false
|
|
check "C2: accounting stays in range and records the newest gun", inRange
|
|
|
|
# The bug in one line, in its GENERAL form: an accounting array hard-wired to
|
|
# a literal is out of range the moment the rack grows. Prove the fix
|
|
# (`NumRackGuns`) tracks a rack that grows by one more gun, where the id that
|
|
# regressed in j141 (17) would be a legal, accounted index.
|
|
var grown: array[NumRackGuns + 1, int]
|
|
var grownOk = true
|
|
for gid in 0 .. NumRackGuns:
|
|
try:
|
|
inc grown[gid]
|
|
except IndexDefect:
|
|
grownOk = false
|
|
check "C3: a rack one gun WIDER is still fully covered by the NumRackGuns width",
|
|
grownOk and grown[NumRackGuns] == 1
|
|
|
|
# The legacy namespace: TR_RACK_BITBRAIN is now unambiguously LEADGAIN's old
|
|
# name (the ADE+SBC gun that briefly owned it was retired), so it always
|
|
# admits LEADGAIN (16) — the newest id — and nothing else.
|
|
for n in RackGunNames: delEnv("TR_RACK_" & n)
|
|
delEnv("TR_BITBRAIN_NET")
|
|
putEnv("TR_RACK_BITBRAIN", "both")
|
|
let legacy = loadRackMembership()
|
|
check "C4: legacy TR_RACK_BITBRAIN admits LEADGAIN (16)",
|
|
legacy[LegacyLeadGainId] == rmBoth
|
|
check "C5: legacy TR_RACK_BITBRAIN alone adds LEADGAIN to the shipped Pattern",
|
|
admittedGuns(NumRackGuns, rm1v1, legacy) == @[PatternId, LegacyLeadGainId]
|
|
delEnv("TR_RACK_BITBRAIN")
|
|
|
|
# ── D. live proof, only with the JARs ────────────────────────────────────────
|
|
|
|
proc lastRound(path: string): JsonNode =
|
|
result = nil
|
|
for line in lines(path):
|
|
let s = line.strip()
|
|
if s.len == 0: continue
|
|
let node = parseJson(s)
|
|
if node.hasKey("guns"): result = node
|
|
|
|
proc live() =
|
|
if getEnv("TR_SERVER_JAR", "").len == 0 or getEnv("TR_BATTLE_RUNNER", "").len == 0:
|
|
echo "SKIP: live section (TR_SERVER_JAR / TR_BATTLE_RUNNER not set)"
|
|
return
|
|
# Rebuild the bot with a rack that admits ONLY the newest id — the exact
|
|
# configuration that used to index one past the end.
|
|
# The bot reads `<bot dir>/out/.env`, so the live check has to write there.
|
|
# It backs the owner's file up to `<out>/.env.j141-backup` FIRST and restores
|
|
# it in a `finally`, so even a hard crash leaves a recoverable copy.
|
|
let dotenv = repoRoot / "ModularBot_garage" / "out" / ".env"
|
|
let backup = repoRoot / "ModularBot_garage" / "out" / ".env.j141-backup"
|
|
let hadDotenv = fileExists(dotenv)
|
|
if hadDotenv: copyFile(dotenv, backup)
|
|
defer:
|
|
if hadDotenv: copyFile(backup, dotenv)
|
|
else: removeFile(dotenv)
|
|
removeFile(backup)
|
|
var linesOut: seq[string]
|
|
for l in readFile(backup).splitLines():
|
|
if l.strip().startsWith("GUN_STATS_PATH"): linesOut.add "GUN_STATS_PATH=" & statsPath
|
|
elif l.strip().startsWith("TR_RACK_BITBRAIN"): linesOut.add "TR_RACK_LEADGAIN=both"
|
|
else: linesOut.add l
|
|
writeFile(dotenv, linesOut.join("\n"))
|
|
echo "NOTE: live section wrote a temporary out/.env (backup at out/.env.j141-backup)."
|
|
|
|
echo "Running one live round (rack: only rack id ", NewestId, ")..."
|
|
let compiled = compileBots(@[repoRoot / "ModularBot_garage", adversaryDir])
|
|
echo "compiled: ", compiled
|
|
ensureServer()
|
|
discard runBattleRunner(getServerUrl(),
|
|
@[repoRoot / "ModularBot_garage", adversaryDir], 1, 300_000, true)
|
|
removeFile(statsPath)
|
|
if not fileExists(statsPath):
|
|
check "D1: the bot wrote its per-round gun stats", false
|
|
return
|
|
let r = lastRound(statsPath)
|
|
check "D1: the bot wrote its per-round gun stats", r != nil
|
|
if r == nil: return
|
|
let guns = r["guns"]
|
|
check "D2: the stats dump has one row per rack gun",
|
|
guns.len == NumRackGuns
|
|
var selected = 0
|
|
var shots = 0
|
|
var hits = 0
|
|
for g in guns:
|
|
selected += g["selected"].getInt()
|
|
shots += g["realShots"].getInt()
|
|
hits += g["realHits"].getInt()
|
|
echo " selected=", selected, " realShots=", shots, " realHits=", hits
|
|
check "D3: a gun IS selected (the selector reaches the newest id)", selected > 0
|
|
check "D4: shots ARE fired", shots > 0
|
|
check "D5: hits ARE landed", hits > 0
|
|
|
|
when isMainModule:
|
|
testRackTable()
|
|
testSourceShape()
|
|
testAccounting()
|
|
if paramCount() > 0 and paramStr(1) == "--live": live()
|
|
else: echo "SKIP: live section (pass --live with the JAR env vars)"
|
|
echo ""
|
|
if failures == 0: echo "ALL CHECKS PASSED"
|
|
else:
|
|
echo failures, " CHECK(S) FAILED"
|
|
quit(1)
|