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
+58 -33
View File
@@ -90,6 +90,18 @@ let DisabledGuns =
s
proc gunDisabled(id: int): bool {.inline.} = id in DisabledGuns
## ── virtual-bullet spawn gate (TR_VBULLET_ADMIT_ONLY) ──────────────────────
## Rack membership filters SELECTION; this knob extends it to SPAWNING. With it
## set (default 1) a gun the current rack does not admit is skipped ENTIRELY in
## the live aim block — no `predict()`, no `spawnBullets()` — so 13 unselected
## guns stop computing a fitness table nobody reads (~40-46% of the per-tick
## budget in the shipped onlyPattern rack). Set to 0 to restore the pre-change
## behaviour (every non-TMPATTERN gun spawns) on the SAME frozen binary.
## `onResult` feedback is NEVER gated (see the gated aim block in `run`).
let VBulletAdmitOnly* =
block:
let v = getEnv("TR_VBULLET_ADMIT_ONLY", "1").strip().toLowerAscii()
not (v == "0" or v == "false" or v == "no" or v == "off")
## ── Movement rack (runtime switch) ─────────────────────────────────────────
## `TR_MOVEMENT` selects the movement engine, mirroring the gun rack's
## env-driven construction. Both engines are always constructed, so the switch
@@ -995,45 +1007,58 @@ method run*(bot: ModularBot) =
var knnPreds: array[len(PowerBins), GunPrediction]
var tmselPreds: array[len(PowerBins), GunPrediction]
var tmpPreds: array[len(PowerBins), GunPrediction]
# ── TR_VBULLET_ADMIT_ONLY gate ──────────────────────────────────────
# Rack membership used to filter only SELECTION, so every unselected gun
# still ran predict()+spawnBullets() each tick to feed a fitness table the
# selector would never read (~40-46% of the per-tick budget). `admit`
# skips BOTH calls for a gun the current rack does not admit. It is
# recomputed each tick because the rack mode can change mid-round (a melee
# thinning to 1v1). Bullets already in flight are NOT cancelled: they are
# resolved by `tickBullets` and the feedback `case` below still calls the
# owning gun's onResult, so attribution survives a mid-round rack change.
# TMPATTERN keeps its own admission gate even when the knob is 0, so
# TR_VBULLET_ADMIT_ONLY=0 reproduces the exact pre-change rack.
var admit: array[15, bool]
for gi in 0..<15:
admit[gi] = vBulletAdmitted(gi, bot.rackMode, ActiveRackMembership,
VBulletAdmitOnly or gi == TmPatternId)
for i in 0..<len(PowerBins):
headsUp[i] = bot.headOn.predict(bot.lastState, bulletSpeed(PowerBins[i]))
linPreds[i] = bot.linear.predict(bot.lastState, bulletSpeed(PowerBins[i]))
tmPreds[i] = bot.tsetlin.predict(bot.lastState, bulletSpeed(PowerBins[i]))
circPreds[i] = bot.circular.predict(bot.lastState, bulletSpeed(PowerBins[i]))
gfPreds[i] = bot.guessFactor.predict(bot.lastState, bulletSpeed(PowerBins[i]))
pmPreds[i] = bot.patternMatcher.predict(bot.lastState, bulletSpeed(PowerBins[i]))
wbPreds[i] = bot.wallBounce.predict(bot.lastState, bulletSpeed(PowerBins[i]))
acPreds[i] = bot.accelGun.predict(bot.lastState, bulletSpeed(PowerBins[i]))
ssPreds[i] = bot.stopShot.predict(bot.lastState, bulletSpeed(PowerBins[i]))
dsPreds[i] = bot.displace.predict(bot.lastState, bulletSpeed(PowerBins[i]))
alPreds[i] = bot.avgLead.predict(bot.lastState, bulletSpeed(PowerBins[i]))
dgPreds[i] = bot.decayGF.predict(bot.lastState, bulletSpeed(PowerBins[i]))
knnPreds[i] = bot.knnGun.predict(bot.lastState, bulletSpeed(PowerBins[i]))
if EnableTmSelector:
if admit[0]: headsUp[i] = bot.headOn.predict(bot.lastState, bulletSpeed(PowerBins[i]))
if admit[1]: linPreds[i] = bot.linear.predict(bot.lastState, bulletSpeed(PowerBins[i]))
if admit[2]: tmPreds[i] = bot.tsetlin.predict(bot.lastState, bulletSpeed(PowerBins[i]))
if admit[3]: circPreds[i] = bot.circular.predict(bot.lastState, bulletSpeed(PowerBins[i]))
if admit[4]: gfPreds[i] = bot.guessFactor.predict(bot.lastState, bulletSpeed(PowerBins[i]))
if admit[5]: pmPreds[i] = bot.patternMatcher.predict(bot.lastState, bulletSpeed(PowerBins[i]))
if admit[6]: wbPreds[i] = bot.wallBounce.predict(bot.lastState, bulletSpeed(PowerBins[i]))
if admit[7]: acPreds[i] = bot.accelGun.predict(bot.lastState, bulletSpeed(PowerBins[i]))
if admit[8]: ssPreds[i] = bot.stopShot.predict(bot.lastState, bulletSpeed(PowerBins[i]))
if admit[9]: dsPreds[i] = bot.displace.predict(bot.lastState, bulletSpeed(PowerBins[i]))
if admit[10]: alPreds[i] = bot.avgLead.predict(bot.lastState, bulletSpeed(PowerBins[i]))
if admit[11]: dgPreds[i] = bot.decayGF.predict(bot.lastState, bulletSpeed(PowerBins[i]))
if admit[12]: knnPreds[i] = bot.knnGun.predict(bot.lastState, bulletSpeed(PowerBins[i]))
if EnableTmSelector and admit[13]:
tmselPreds[i] = bot.tmSelector.predict(bot.lastState, bulletSpeed(PowerBins[i]))
# TMPATTERN predict is gated on rack admission, not just spawn: with the
# shipped `off` default it costs nothing and touches no shared state.
if rackAdmitted(TmPatternId, bot.rackMode, ActiveRackMembership):
# TMPATTERN keeps its own rack-admission gate (see `admit` above).
if admit[TmPatternId]:
tmpPreds[i] = bot.tmPattern.predict(bot.lastState, bulletSpeed(PowerBins[i]))
bot.tracker.spawnBullets(0, headsUp, bot.lastState, tid)
if not gunDisabled(1): bot.tracker.spawnBullets(1, linPreds, bot.lastState, tid)
if not gunDisabled(2) and bot.tsetlin.isWarmedUp():
if admit[0]: bot.tracker.spawnBullets(0, headsUp, bot.lastState, tid)
if admit[1] and not gunDisabled(1): bot.tracker.spawnBullets(1, linPreds, bot.lastState, tid)
if admit[2] and not gunDisabled(2) and bot.tsetlin.isWarmedUp():
bot.tracker.spawnBullets(2, tmPreds, bot.lastState, tid)
if not gunDisabled(3): bot.tracker.spawnBullets(3, circPreds, bot.lastState, tid)
if not gunDisabled(4): bot.tracker.spawnBullets(4, gfPreds, bot.lastState, tid)
if not gunDisabled(5): bot.tracker.spawnBullets(5, pmPreds, bot.lastState, tid)
if not gunDisabled(6): bot.tracker.spawnBullets(6, wbPreds, bot.lastState, tid)
if not gunDisabled(7): bot.tracker.spawnBullets(7, acPreds, bot.lastState, tid)
if not gunDisabled(8): bot.tracker.spawnBullets(8, ssPreds, bot.lastState, tid)
if not gunDisabled(9): bot.tracker.spawnBullets(9, dsPreds, bot.lastState, tid)
if not gunDisabled(10): bot.tracker.spawnBullets(10, alPreds, bot.lastState, tid)
if not gunDisabled(11): bot.tracker.spawnBullets(11, dgPreds, bot.lastState, tid)
if not gunDisabled(12): bot.tracker.spawnBullets(12, knnPreds, bot.lastState, tid)
if EnableTmSelector and not gunDisabled(13) and bot.tmSelector.isWarmedUp():
if admit[3] and not gunDisabled(3): bot.tracker.spawnBullets(3, circPreds, bot.lastState, tid)
if admit[4] and not gunDisabled(4): bot.tracker.spawnBullets(4, gfPreds, bot.lastState, tid)
if admit[5] and not gunDisabled(5): bot.tracker.spawnBullets(5, pmPreds, bot.lastState, tid)
if admit[6] and not gunDisabled(6): bot.tracker.spawnBullets(6, wbPreds, bot.lastState, tid)
if admit[7] and not gunDisabled(7): bot.tracker.spawnBullets(7, acPreds, bot.lastState, tid)
if admit[8] and not gunDisabled(8): bot.tracker.spawnBullets(8, ssPreds, bot.lastState, tid)
if admit[9] and not gunDisabled(9): bot.tracker.spawnBullets(9, dsPreds, bot.lastState, tid)
if admit[10] and not gunDisabled(10): bot.tracker.spawnBullets(10, alPreds, bot.lastState, tid)
if admit[11] and not gunDisabled(11): bot.tracker.spawnBullets(11, dgPreds, bot.lastState, tid)
if admit[12] and not gunDisabled(12): bot.tracker.spawnBullets(12, knnPreds, bot.lastState, tid)
if EnableTmSelector and admit[13] and not gunDisabled(13) and bot.tmSelector.isWarmedUp():
bot.tracker.spawnBullets(13, tmselPreds, bot.lastState, tid)
if not gunDisabled(TmPatternId) and
rackAdmitted(TmPatternId, bot.rackMode, ActiveRackMembership):
if admit[TmPatternId] and not gunDisabled(TmPatternId):
bot.tracker.spawnBullets(TmPatternId, tmpPreds, bot.lastState, tid)
# Build slim enemy table for tickBullets