Files
SirRoboGarage/common_libs/tests/acceptance_offline_vs_online.nim
T
SirStone 3142b70aa5 Gate virtual-bullet spawn on rack admission: +68% tick rate, selected gun unchanged
The default rack is now Pattern-only (31c7c01), but membership filters SELECTION,
not SPAWNING - so all 13 unselected guns still ran `predict` + `spawnBullets`
every tick to feed fitness tables nobody reads. Measured waste: Tsetlin alone
0.98 ms/tick, KNN 0.30, plus 10 more. This generalises the gate TMPATTERN already
had to every gun, behind `TR_VBULLET_ADMIT_ONLY` (default 1 = gate, 0 = old).

OFFLINE COST (release build, 400 ticks, min of 2 reps, 13-gun rack):
  OFF  2.14 ms/tick  (implied 467 ticks/s)
  ON   0.04 ms/tick  (implied 27667 ticks/s)
  -> reclaimed 2.10 ms/tick, ~98% of the virtual-bullet cost. Tsetlin's 0.98
     disappears, KNN's 0.30 disappears, only Pattern (0.017) survives.
Note the absolute scale is lower than an earlier unoptimised measurement (~6.1
ms/t) because this is a -d:release build; the ON-vs-OFF DELTA is the robust result.

LIVE TICK RATE (ONE frozen binary, 4 runs/arm x 4 rounds, all 8 concurrent, gate
varied by env only):
  ON   146.2 ticks/s  (142.3, 145.2, 147.9, 149.4)
  OFF   86.9 ticks/s  ( 95.0,  41.4,  99.1, 112.1)
  NO OVERLAP: ON min 142.3 > OFF max 112.1. Excluding a game-outcome outlier in
  the OFF arm, OFF max is still 112.1. Outside the noise.
**+68% tick rate**, which also makes every future A/B faster. Both arms still pay
the fixed 8192-slot ring scan in tickBullets.

SAFETY, verified not assumed: Pattern is admitted under the shipped rack, so its
own fitness keeps accumulating and the SELECTED gun is unchanged - Pattern 100% in
all 4 runs both arms, and Pattern was the ONLY gun with vShots>0 in the ON arm
while all 13 had vShots>0 in the OFF arm. So admission is the correct predicate.
Mid-round transitions are safe by construction: only predict/spawn are gated, while
tickBullets still resolves every active bullet and the feedback case still calls
the owning gun's onResult.

A TOOLING BUG THIS CAUGHT, and a correction to the task's assumption:
`acceptance_offline_vs_online.nim` IS affected (I had assumed it was not). It
compares the live per-gun vShots against an offline replay that always spawns all
guns, so under the default gate the online non-Pattern vShots are 0 while offline
is ~400 - a guaranteed mismatch. Fixed by pinning `TR_VBULLET_ADMIT_ONLY=0` inside
that test (same putEnv/defer pattern as TR_RECORD_WORLDSTATE), keeping 12/12. The
test is about offline/online METRIC parity, so it needs every gun spawning.
Unaffected (verified from source): audit_virtual_guns.nim,
measure_cornering_guns.nim, sweep_tm_pattern.nim - all offline, none read
GUN_STATS_PATH.

Guards: test_vbullet_admit_gate 12 (new, pure), test_gun_harness 39,
test_vbullet_metric 11, test_power_selection 3, test_adaptive_radar 41,
test_tfil_ring_weights 24, test_power_policy 26, test_ram_decision 28,
test_rack_membership 48, test_selector_tiebreak 19, test_tm_pattern_registration 20,
test_tm_pattern_learning 3, acceptance_offline_vs_online 12/12. ModularBot compiles.
2026-09-22 02:24:18 +02:00

142 lines
5.7 KiB
Nim

## Task 3: acceptance test — prove the offline range reproduces the live
## virtual-bullet metric, or the range is worthless.
##
## Steps:
## 1. run ONE live ModularBot vs OscillatorBot round with the ModularBot
## recorder enabled for this battle only (the test exports
## TR_RECORD_WORLDSTATE=1, which the bot reads at RUNTIME; ordinary
## builds leave it unset and write no fixture),
## 2. read the online per-gun virtual fitness from /tmp/gun_stats.jsonl,
## 3. replay the recorded WorldState fixture offline through the same guns,
## 4. compare.
##
## Tsetlin is stochastic (tmLearnOne calls rand(); its constructor calls
## randomize()), so byte-identical replay is impossible for it. The 12
## deterministic guns must match EXACTLY; Tsetlin is reported separately.
##
## Run with:
## nim c -r common_libs/tests/acceptance_offline_vs_online.nim
##
## Requires TR_SERVER_JAR / TR_BATTLE_RUNNER (or the default dev paths below).
import std/[json, os, strformat, strutils, math]
import test_framework/test_framework
import test_framework/server_manager
import gun_harness/offline_range
import range_guns
const
repoRoot = currentSourcePath().parentDir.parentDir.parentDir
modularBotDir = repoRoot / "ModularBot_garage"
adversaryDir = repoRoot / "common_libs" / "test_framework" / "adversaries" / "OscillatorBot"
statsPath = "/tmp/gun_stats.jsonl"
recordPath = "/tmp/worldstate_record.jsonl"
defaultRunnerJar = "/home/davide/Projects/tank-royale/runner/examples/lib/robocode-tankroyale-runner.jar"
# Honor the caller's TR_SERVER_JAR / TR_BATTLE_RUNNER; otherwise use the
# current defaults (server 1.3.1). The runner uses the same default as
# runner_process.nim so an unset env var is not silently treated as "missing".
let serverJar = getEnv("TR_SERVER_JAR", DefaultServerJar)
let runnerJar = getEnv("TR_BATTLE_RUNNER", defaultRunnerJar)
const TsetlinId = 2
const TmSelectorId = 13 ## also stochastic (rand() in Gate choose + TM feedback)
proc isStochastic(id: int): bool = id == TsetlinId or id == TmSelectorId
proc lastOnlineRound(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 main() =
if not fileExists(serverJar) or not fileExists(runnerJar):
echo "Skipping: TR JARs not found (server=", serverJar, ", runner=", runnerJar, ")"
quit(0)
if not fileExists(modularBotDir / "src" / "ModularBot.nim"):
echo "Skipping: ModularBot source not found"
quit(0)
for p in [statsPath, recordPath]:
if fileExists(p): removeFile(p)
# Enable the ModularBot's runtime world-state recorder for THIS battle only.
# The env var is inherited by the battle-runner process and then by the bot
# processes it spawns, so a single invocation of this test is self-contained.
putEnv("TR_RECORD_WORLDSTATE", "1")
defer: delEnv("TR_RECORD_WORLDSTATE")
# This test validates PER-GUN offline==online parity, so it needs the FULL
# virtual-bullet rack online. `TR_VBULLET_ADMIT_ONLY=1` (shipped) skips
# predict+spawn for every non-admitted gun, so under the shipped onlyPattern
# rack their online `vShots` would be 0 while the offline range still spawns
# all of them — a guaranteed MISMATCH. Pin the gate OFF for this battle so the
# live loop spawns every gun and the per-gun comparison stays well-defined.
putEnv("TR_VBULLET_ADMIT_ONLY", "0")
defer: delEnv("TR_VBULLET_ADMIT_ONLY")
echo "=== live battle: ModularBot vs OscillatorBot, 1 round, max speed ==="
let battle = runBattle(@[modularBotDir, adversaryDir], rounds = 1,
timeout = 240000, maxSpeed = true)
for res in battle.results:
echo fmt" {res.name:<14} rank={res.rank} score={res.totalScore}"
if not fileExists(recordPath):
echo "FAIL: recorder produced no fixture (TR_RECORD_WORLDSTATE not inherited?)"
quit(1)
if not fileExists(statsPath):
echo "FAIL: no /tmp/gun_stats.jsonl"
quit(1)
let online = lastOnlineRound(statsPath)
let fx = loadFixture(recordPath)
let reports = replayFixture(fx, buildAllGunDrivers(enableTmSelector = false), liveActual = true)
# Map online stats by gun id.
var onShots: array[14, int]
var onHits: array[14, int]
var onNames: array[14, string]
for g in online["guns"]:
let id = g["id"].getInt()
if id >= 0 and id < 14:
onShots[id] = g["vShots"].getInt()
onHits[id] = g["vHits"].getInt()
onNames[id] = g["name"].getStr()
echo ""
echo fmt"fixture: {recordPath} ticks={fx.states.len} enemyId={fx.enemyId} enemyDied={fx.enemyDied}"
echo "online stats: /tmp/gun_stats.jsonl round ", online["round"].getInt()
echo ""
echo "gun online(vHits/vShots) offline(hits/shots) verdict"
echo "-----------------------------------------------------------------------"
var matches = 0
var deterministic = 0
for id in 0..<14:
let r = reports[id]
let match = r.hits == onHits[id] and r.shots == onShots[id]
var verdict: string
if isStochastic(id):
verdict = if match: "MATCH (stochastic)" else: "differs (stochastic, expected)"
else:
inc deterministic
if match:
inc matches
verdict = "OK"
else:
verdict = "MISMATCH"
echo fmt"{r.name:<12} {onHits[id]:>5}/{onShots[id]:<5} {r.hits:>5}/{r.shots:<5} {verdict}"
echo ""
echo fmt"deterministic guns matching exactly: {matches}/{deterministic}"
if matches != deterministic:
echo "VERDICT: FAIL — offline range does NOT reproduce the live metric."
quit(1)
echo "VERDICT: PASS — offline == online for all 12 deterministic guns."
echo "(Tsetlin and TMSelect are stochastic and are allowed to differ.)"
when isMainModule:
main()