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.
This commit is contained in:
2026-09-22 02:24:18 +02:00
parent 185a32e9eb
commit 3142b70aa5
5 changed files with 392 additions and 33 deletions
@@ -230,6 +230,24 @@ proc admittedGuns*(fitLen: int, mode: RackMode,
if result.len == 0:
for gunId in 0..<fitLen: result.add gunId
proc vBulletAdmitted*(gunId: int, mode: RackMode,
membership: openArray[RackMembership],
admitOnly: bool = true): bool =
## `TR_VBULLET_ADMIT_ONLY` gate for the LIVE predict+spawn block.
##
## Rack membership historically filtered only SELECTION, so every unselected
## gun still ran `predict`/`spawnBullets` each tick to feed a fitness table the
## selector would never read. This predicate lets the live loop skip BOTH for a
## gun the current rack does not admit.
##
## admitOnly = true (SHIPPED default) — a gun spawns only while admitted.
## admitOnly = false — every gun spawns (the pre-change behaviour).
##
## `onResult` feedback is deliberately NOT covered here: a bullet spawned
## while admitted must still resolve and be attributed to its gun even if the
## rack changes mid-round.
(not admitOnly) or rackAdmitted(gunId, mode, membership)
# ── runtime ranking knobs (A/B without rebuilding) ────────────────────────────
#
# Every knob below defaults to the SHIPPED constant, so an unset environment
@@ -69,6 +69,15 @@ proc main() =
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)
@@ -0,0 +1,208 @@
## MEASUREMENT ONLY — per-tick cost of the virtual-bullet SPAWN gate
## (`TR_VBULLET_ADMIT_ONLY`), on the REAL 14-gun rack over recorded DrussGT
## movement.
##
## Gating SKIPS both `predict()` and `spawnBullets()` for a gun the current rack
## does not admit (shipped default rack = onlyPattern). This tool drives the same
## real guns + real `VirtualTracker` as `measure_parallel_vbullet_cost.nim`, but
## reports the ONE comparison the live bot cares about:
##
## OFF = pre-change: every non-TM gun predicts+spawns (ids 0..12)
## ON = shipped: only the rack-admitted guns predict+spawn (Pattern, id 5)
##
## Both arms run the SAME 13-gun rack (ids 0..12; TMSelect is disabled in the
## live bot via `EnableTmSelector=false`, TMPATTERN id 14 is separately gated).
## The gate predicate is the SHIPPED `vb.vBulletAdmitted` against the SHIPPED
## `DefaultRackMembership`, so this measures the real rule.
##
## Measured directly (not derived): predict+spawn wall time and the full
## `tickBullets` (resolve + onResult) wall time, per tick, for each arm. A
## per-gun breakdown names which costs disappear.
##
## Usage:
## nim c -r common_libs/tests/measure_vbullet_admit_gate.nim
import std/[os, strformat, times, tables, math, monotimes, algorithm, random]
import gun_harness/offline_range
import gun_harness/gun_interface
import gun_harness/virtual_bullets as vb
import gun_harness/selector
import range_guns
const
NGun = 14
NBins = len(vb.PowerBins)
TmGun = 13 ## TMSelect — disabled in the live bot
RepoRoot = currentSourcePath().parentDir.parentDir.parentDir
FixturesDir = RepoRoot / "tools" / "fixtures"
FixtureNames = [
"tr_drussgt_vs_corners.jsonl",
"tr_drussgt_vs_crazy.jsonl",
"tr_drussgt_vs_modularbot.jsonl",
"tr_drussgt_vs_modularbot_shield.jsonl",
"tr_drussgt_vs_spinbot.jsonl",
"drussgt_vs_ramfire.jsonl",
"drussgt_vs_drussgt.jsonl",
]
MaxTicks = 2000
Warmup = 150
Measure = 400
const GunNames = ["HeadOn", "Linear", "Tsetlin", "Circular", "GuessFactor",
"Pattern", "WallBounce", "Accel", "StopShot", "Displace", "AvgLead",
"DecayGF", "KNN", "TMSelect"]
proc loadFixtures(): seq[Fixture] =
for n in FixtureNames:
let p = FixturesDir / n
if fileExists(p): result.add loadFixture(p)
proc buildStates(fx: seq[Fixture], maxTicks: int): seq[seq[WorldState]] =
var ticks = maxTicks
for f in fx: ticks = min(ticks, f.states.len)
result = newSeq[seq[WorldState]](fx.len)
for k in 0..<fx.len:
result[k] = newSeq[WorldState](ticks)
for si in 0..<ticks:
var ws = fx[k].states[si]
let s0 = fx[0].states[si]
ws.selfX = s0.selfX; ws.selfY = s0.selfY
ws.selfHeading = s0.selfHeading; ws.selfSpeed = s0.selfSpeed
ws.selfRadarHeading = s0.selfRadarHeading; ws.selfEnergy = s0.selfEnergy
ws.tick = si
result[k][si] = ws
proc enemyTable(states: seq[seq[WorldState]], si: int):
Table[int, tuple[x, y: float, lastSeenTick: int, alive: bool]] =
for k in 0..<states.len:
let e = states[k][si]
result[k + 1] = (x: e.enemyX, y: e.enemyY, lastSeenTick: si, alive: true)
type
ArmResult = object
ticks: int
psNs: int64 ## predict+spawn wall time
resNs: int64 ## full tickBullets (resolve + onResult) wall time
spawns: int
proc runArm(states: seq[seq[WorldState]], gateOn: bool): ArmResult =
## Drive the real 13-gun live rack (ids 0..12) with the shipped gate.
let rack = buildAllGunDrivers(seed = 1, enableTmSelector = false)
var tracker = vb.initTracker(NGun, bmPath)
let ticks = states[0].len
for si in 0..<ticks:
let ws = states[0][si]
let measuring = si >= Warmup and si < Warmup + Measure
let t0 = getMonoTime()
for gi in 0..<NGun:
if gi == TmGun: continue # disabled in the live bot
# The SHIPPED gate predicate against the SHIPPED rack.
if not vb.vBulletAdmitted(gi, rm1v1, DefaultRackMembership, gateOn):
continue
var preds: array[NBins, GunPrediction]
for b in 0..<NBins:
preds[b] = rack[gi].predictCb(ws, bulletSpeed(vb.PowerBins[b]))
tracker.spawnBullets(gi, preds, ws, 1)
if measuring: inc result.spawns, NBins
let t1 = getMonoTime()
let et = enemyTable(states, si)
tracker.tickBullets(ws, et,
proc(gunId: GunId, binIdx: int, e: FeedbackEvent) =
rack[gunId].resultCb(e))
let t2 = getMonoTime()
if measuring:
inc result.ticks
result.psNs += (t1 - t0).inNanoseconds
result.resNs += (t2 - t1).inNanoseconds
proc perGunCosts(states: seq[seq[WorldState]]):
tuple[pred: array[NGun, float], onRes: array[NGun, float]] =
## Per-gun predict+spawn ns/tick and onResult ns/tick (always FULL rack).
let rack = buildAllGunDrivers(seed = 1, enableTmSelector = false)
var tracker = vb.initTracker(NGun, bmPath)
var counts: array[NGun, int]
var onCalls: array[NGun, int]
var acc: tuple[pred: array[NGun, float], onRes: array[NGun, float]]
let ticks = states[0].len
for si in 0..<ticks:
let ws = states[0][si]
let measuring = si >= Warmup and si < Warmup + Measure
for gi in 0..<NGun:
if gi == TmGun: continue
let t0 = getMonoTime()
var preds: array[NBins, GunPrediction]
for b in 0..<NBins:
preds[b] = rack[gi].predictCb(ws, bulletSpeed(vb.PowerBins[b]))
tracker.spawnBullets(gi, preds, ws, 1)
let dt = (getMonoTime() - t0).inNanoseconds
if measuring:
acc.pred[gi] += dt.float
inc counts[gi]
let et = enemyTable(states, si)
tracker.tickBullets(ws, et,
proc(gunId: GunId, binIdx: int, e: FeedbackEvent) =
let t0 = getMonoTime()
rack[gunId].resultCb(e)
let dt = (getMonoTime() - t0).inNanoseconds
if measuring:
acc.onRes[gunId] += dt.float
inc onCalls[gunId])
for gi in 0..<NGun:
if counts[gi] > 0: acc.pred[gi] /= counts[gi].float
if onCalls[gi] > 0: acc.onRes[gi] /= onCalls[gi].float
result = acc
proc main() =
randomize(1)
let fx = loadFixtures()
let states = buildStates(fx, MaxTicks)
echo fmt"fixtures={fx.len} ticks={states[0].len} warmup={Warmup} measured={Measure}"
echo "shipped rack: onlyPattern (DefaultRackMembership); predicate vb.vBulletAdmitted"
# Two reps, take the min (least noise).
var offPs, offRes, onPs, onRes: int64
var offSpawns, onSpawns, ticksRun: int
for rep in 0..<2:
let off = runArm(states, gateOn = false)
let on = runArm(states, gateOn = true)
if rep == 0:
offPs = off.psNs; offRes = off.resNs; offSpawns = off.spawns
onPs = on.psNs; onRes = on.resNs; onSpawns = on.spawns
ticksRun = off.ticks
else:
offPs = min(offPs, off.psNs); offRes = min(offRes, off.resNs)
onPs = min(onPs, on.psNs); onRes = min(onRes, on.resNs)
offSpawns = min(offSpawns, off.spawns); onSpawns = min(onSpawns, on.spawns)
let n = max(1, ticksRun).float
let offPSms = offPs.float / 1.0e6 / n # ms/tick
let offRSms = offRes.float / 1.0e6 / n
let onPSms = onPs.float / 1.0e6 / n
let onRSms = onRes.float / 1.0e6 / n
let offTot = offPSms + offRSms
let onTot = onPSms + onRSms
echo ""
echo "=================== PER-TICK COST: gate ON vs OFF (13-gun live rack) ==================="
echo "arm predict+spawn ms/t resolve+onResult ms/t total ms/t ticks/s spawns/t"
echo fmt"OFF {offPSms:>20.2f} {offRSms:>22.2f} {offTot:>12.2f} {1000.0/offTot:>7.0f} {offSpawns.float/n:>10.1f}"
echo fmt"ON {onPSms:>20.2f} {onRSms:>22.2f} {onTot:>12.2f} {1000.0/onTot:>7.0f} {onSpawns.float/n:>10.1f}"
echo fmt"reclaimed = {offTot - onTot:.2f} ms/tick ({100.0*(offTot-onTot)/offTot:.0f}% of the gate-OFF total)"
echo fmt"implied ticks/s: OFF {1000.0/offTot:.0f} -> ON {1000.0/onTot:.0f} (x{offTot/onTot:.2f})"
echo "(note: tickBullets also scans the fixed 8192-slot ring each tick, present in BOTH arms)"
let pg = perGunCosts(states)
echo ""
echo "=================== PER-GUN COST (full rack, names what disappears) ==================="
echo "gun predict+spawn ms/t onResult ms/t gateON"
var sumPred, sumOn: float
for gi in 0..<NGun:
if gi == TmGun: continue
sumPred += pg.pred[gi]; sumOn += pg.onRes[gi]
let admitted = vb.vBulletAdmitted(gi, rm1v1, DefaultRackMembership, true)
echo fmt"{GunNames[gi]:<12}{pg.pred[gi]/1.0e6:>24.3f}{pg.onRes[gi]/1.0e6:>18.3f}{($admitted):>10}"
echo fmt"TOTAL {sumPred/1.0e6:>24.3f}{sumOn/1.0e6:>18.3f}"
echo ""
when isMainModule:
main()
@@ -0,0 +1,99 @@
## Pure unit guard for the `TR_VBULLET_ADMIT_ONLY` spawn-gate predicate.
##
## No Java, no battle, no virtual bullets: `vBulletAdmitted` is a pure function of
## (gunId, rack mode, membership table, admitOnly) over the SHIPPED
## `DefaultRackMembership`, so the live gate can be pinned without running the
## bot. Covers:
## * the shipped default rack admits Pattern (5) and nothing else;
## * `admitOnly=false` (TR_VBULLET_ADMIT_ONLY=0) restores "spawn for every gun";
## * mode-restricted membership (`rmOnly1v1` / `rmOnlyMelee`) flips with mode;
## * an empty membership table filters nothing (pre-change callers);
## * out-of-range ids are admitted, negative ids are not;
## * the TMPATTERN special case: forcing `admitOnly=true` keeps gun 14 gated
## even when the knob is off — the exact composition the live loop uses.
##
## Run with plain:
## nim c -r common_libs/tests/test_vbullet_admit_gate.nim
import gun_harness/virtual_bullets
import gun_harness/selector
var failures = 0
proc check(name: string, ok: bool) =
if ok: echo "PASS: ", name
else: echo "FAIL: ", name; inc failures
proc testShippedDefaultRack() =
## The shipped onlyPattern table: Pattern (5) admitted in both modes, every
## other registered gun (0..14) off.
check "default: Pattern admitted in 1v1",
vBulletAdmitted(5, rm1v1, DefaultRackMembership, true)
check "default: Pattern admitted in melee",
vBulletAdmitted(5, rmMelee, DefaultRackMembership, true)
var allOthersOff = true
for gi in 0..<len(DefaultRackMembership):
if gi == 5: continue
if vBulletAdmitted(gi, rm1v1, DefaultRackMembership, true): allOthersOff = false
if vBulletAdmitted(gi, rmMelee, DefaultRackMembership, true): allOthersOff = false
check "default: every other gun (incl. TMPATTERN 14) is NOT admitted",
allOthersOff
proc testKnobOffRestoresFullRack() =
## TR_VBULLET_ADMIT_ONLY=0: the gate is a no-op for the general guns, i.e.
## every id spawns regardless of rack membership.
var allAdmitted = true
for gi in 0..<len(DefaultRackMembership):
if not vBulletAdmitted(gi, rm1v1, DefaultRackMembership, false): allAdmitted = false
if not vBulletAdmitted(gi, rmMelee, DefaultRackMembership, false): allAdmitted = false
check "knob=0: vBulletAdmitted is true for EVERY gun in both modes", allAdmitted
proc testModeRestrictedMembership() =
# 0: both, 1: only1v1, 2: onlyMelee, 3: off
let mem = [rmBoth, rmOnly1v1, rmOnlyMelee, rmOff]
check "1v1: both + only1v1 admitted, onlyMelee/off not",
vBulletAdmitted(0, rm1v1, mem, true) and
vBulletAdmitted(1, rm1v1, mem, true) and
(not vBulletAdmitted(2, rm1v1, mem, true)) and
(not vBulletAdmitted(3, rm1v1, mem, true))
check "melee: both + onlyMelee admitted, only1v1/off not",
vBulletAdmitted(0, rmMelee, mem, true) and
vBulletAdmitted(2, rmMelee, mem, true) and
(not vBulletAdmitted(1, rmMelee, mem, true)) and
(not vBulletAdmitted(3, rmMelee, mem, true))
check "knob=0 overrides even rmOff / mode restrictions",
vBulletAdmitted(3, rm1v1, mem, false) and
vBulletAdmitted(1, rmMelee, mem, false)
proc testEmptyAndOutOfRange() =
let empty: seq[RackMembership] = @[]
check "empty membership filters nothing (pre-change callers)",
vBulletAdmitted(0, rm1v1, empty, true) and
vBulletAdmitted(13, rmMelee, empty, true)
let mem = [rmOff, rmOff]
check "id past the membership table is admitted",
vBulletAdmitted(7, rm1v1, mem, true)
check "negative id is not admitted when the gate is on",
(not vBulletAdmitted(-1, rm1v1, mem, true))
proc testTmPatternStaysGatedWhenKnobOff() =
## The live loop composes the TMPATTERN gate as
## vBulletAdmitted(14, mode, mem, VBulletAdmitOnly or id == TmPatternId)
## so gun 14 stays gated even when TR_VBULLET_ADMIT_ONLY=0.
let knobOff = false
for mode in [rm1v1, rmMelee]:
let composed = vBulletAdmitted(14, mode, DefaultRackMembership,
knobOff or 14 == 14)
let alwaysGated = vBulletAdmitted(14, mode, DefaultRackMembership, true)
check "knob=0 keeps TMPATTERN (14) gated exactly as when gated",
composed == alwaysGated and composed == false
testShippedDefaultRack()
testKnobOffRestoresFullRack()
testModeRestrictedMembership()
testEmptyAndOutOfRange()
testTmPatternStaysGatedWhenKnobOff()
if failures > 0:
echo "\n", failures, " check(s) FAILED"
quit(1)
echo "\nAll vbullet-admit-gate checks passed."