Files
SirRoboGarage/common_libs/tests/acceptance_offline_vs_online.nim
T
SirStone 57b2ac3849 feat(guns): scale-aware power selection (+52% damage); TM classifier gun built, measured, DISABLED
TASK 2 - power selection, a clear win. bestPower used an ABSOLUTE
MinHitRate = 0.40 bar. Measured per-bin virtual rates (rolling-100 fraction)
show no bin ever clears 40%, so 11 of 14 guns were stuck at bin 0 (power 1.0)
even where higher bins were comparable:
  Linear  p1.0 44% p1.5 39% p2.0 30% p3.0 29%   old bin 0 -> new bin 3
  Accel   p1.0 44% p1.5 40% p2.0 26% p3.0 29%   old bin 1 -> new bin 3
  Pattern p1.0 50% p1.5 40% p2.0 27% p3.0 12%   old bin 1 -> new bin 2
Replaced with a scale-aware PowerBarFrac = 0.50 (a dimensionless FRACTION of
the gun's own best bin rate). 13 of 14 selections now pick heavier bullets.
Real effect vs DrussGT (8 rounds x 3 runs): hit rate unchanged (7.56% ->
7.47%) but damage dealt +52% (157 -> 239 per run) and rounds end faster.
Same accuracy, half the shots, half again more damage.

TASK 1 - the TM pattern-classifier gun does NOT earn its slot. It was built as
a mixture of experts with a corrected-Granmo TM as a multi-class gate over
HeadOn/Linear/Circular/WallBounce/Accel, labelled by which expert's prediction
was closest to the actual enemy position (an exact, supervised, per-shot
label - no delayed credit). Offline it loses to the best of its OWN experts on
essentially every fixture, and against DrussGT it cost real performance:
  baseline (path+relative)  7.56% real hit rate, damage 157
  + power fix               7.47%,                 damage 239
  + power fix + TM gun      5.59%,                 damage 133
The gun was selected on 806 ticks and fired 24 real shots at 4.2%.
So the tree ships with EnableTmSelector = false: code and wiring kept intact
for re-enabling, but it is not in the active rack.

Worth recording from the clause dump: the gate DOES latch onto meaningful
structure. On energy-threshold-turner, HeadOn's clauses key on the energy bits
(the rule's own driving variable) while Circular keys on distance/velocity. So
the TM is learning something real and interpretable - it simply cannot beat
'always pick the best expert'. Root cause (INFERRED): the closest-expert label
is noisy because several experts are near-tied, and under the path metric the
winner varies by power bin while the gate sees one shared per-tick input, so a
one-vs-rest gate over a saturated 870-bit clause space has no margin to exploit.
(Zero-padding the 2-frame window was tried first and saturated every clause at
256-755 included literals; alternating the two real frames fixed that.)

Also factors the corrected feedback into an exported tmLearnDir and exports the
encoding/TM primitives; the Tsetlin tests still reproduce the documented
mean=13.8 included literals, so the refactor is behaviour-preserving.

Verified: 33/33 guard checks, tsetlin tests green, metric checks green, new
power-selection guard green (13/14 selections change; relative bar still picks
bin 1 and not bin 3 for a [30,25,12,5]% profile), 12/12 offline==online
acceptance under the shipped default.
2026-09-21 05:19:07 +02:00

127 lines
4.9 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 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"
serverJar = "/home/davide/Projects/tank-royale/server/build/libs/robocode-tankroyale-server-0.35.5-all.jar"
runnerJar = "/home/davide/Projects/tank-royale/runner/examples/lib/robocode-tankroyale-runner.jar"
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")
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(), 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()