Files
SirRoboGarage/common_libs/tests/test_tm_pattern_registration.nim
T
SirStone 31c7c01d28 SHIPPED: the default rack is now Pattern-only (+49% hit rate, +66% damage on the boss)
`DefaultRackMembership` now admits Pattern (id 5) and marks all 14 other guns
`rmOff`. **The selector mechanism is untouched** - `chooseFromFit`, the floor/band
logic, the hysteresis and the virtual-fitness plumbing are all intact and
functional. Only the rack membership changed, so this is reverted by env alone.

Evidence (measured, replicated three times, 10 adversaries): Pattern alone gives
10.36% real hit rate / 264 damage per run vs the full rack's 6.93% / 159. Pattern
significantly wins on DrussGT, Corners, Crazy and PatternMover, ties on three, and
the full rack never significantly beats it on ANY adversary. Mechanism: the
virtual signal keeps ranking the wrong guns first (HeadOn 46% of ticks at 2.0%
real; Linear 57.7% at 6.0% real while Pattern sits at 11.1%).

**THIS CONTRADICTS THE USER'S STANDING DIRECTIVE** to keep virtual-fitness
selection. Recorded plainly in docs/selector_negative_value.md with a SHIPPED
DECISION banner rather than done quietly: the mechanism is retained and one env
var away, because the measurement says it is negative value on every rack size
tested and on 10/10 adversaries.

Revert one-liner (no rebuild):
  TR_RACK_PATTERN=both TR_RACK_HEADON=both TR_RACK_LINEAR=both TR_RACK_TSETLIN=both \
  TR_RACK_CIRCULAR=both TR_RACK_GUESSFACTOR=both TR_RACK_WALLBOUNCE=both \
  TR_RACK_ACCEL=both TR_RACK_STOPSHOT=both TR_RACK_DISPLACE=both TR_RACK_AVGLEAD=both \
  TR_RACK_DECAYGF=both TR_RACK_KNN=both TR_RACK_TMSELECT=both ./out/ModularBot
The unit test `testRevertOverrideRestoresFullRack` exercises exactly this table.

FLOOR PATH, verified not assumed: `chooseFromFit` already returns `admitted[0]` on
the floor path, so it respects admission by construction. Cold field + shipped
default -> floor returns Pattern (id 5), NOT HeadOn. With an explicit all-`both`
membership the same cold field returns gun 0 (HeadOn) - the old behaviour. Four
assertions in `testFloorRespectsAdmission`.

LIVENESS: one 1-round battle with NO overrides -> Pattern selected 105/105 = 100%,
every other gun 0 including TMPattern.

Honesty caveat retained in the doc: 4 of the 10 opponents were Tank Royale
sample-bot PORTS rather than the original classic jars (only DrussGT is a real
classic jar through the shim).

Guards: test_rack_membership 48 (was 38; new floor/revert/default checks),
test_tm_pattern_registration 20 (5 checks hard-coded the old default and were
updated to assert the new one, with the TMPATTERN parity proof moved onto an
explicit old-rack table), 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_selector_tiebreak 19,
test_tm_pattern_rack_live 4, test_tm_pattern_learning 3,
acceptance_offline_vs_online 12/12 VERDICT PASS. ModularBot compiles.

FOLLOW-ON THIS EXPOSED: membership filters SELECTION but not virtual-bullet
SPAWNING, so under `onlyPattern` the 13 unselected guns still predict and spawn
every tick. Tsetlin alone is ~5.3 ms/tick (~41% of the 13.16 ms per-tick budget),
so we are still paying for it while never using it. Gating spawn on admission
would reclaim that; it was deliberately NOT done here because it would alter the
measurement protocol mid-A/B.
2026-09-22 02:10:07 +02:00

207 lines
8.6 KiB
Nim

## Offline guard for the TM pattern gun rack registration (id 14) and the
## default-path parity proof.
##
## Covers:
## * RackGunNames / DefaultRackMembership carry the new gun, defaulting `off`;
## * `TR_RACK_TMPATTERN` parses both|1v1|melee|off like every other gun;
## * the DEFAULT membership table is the shipped `onlyPattern` rack (Pattern
## admitted, TMPATTERN and every other gun `off`);
## * an EXPLICIT old-rack membership (guns 0..13 both, gun 14 off) still
## reproduces the pre-change all-`both` 14-gun selection, RNG draw for RNG
## draw, through both `bestGun` and the live `selectGun` — so admitting
## TMPATTERN never perturbs the old rack when it is selected explicitly;
## * `initTmRadialGun()` selects the radial target mode;
## * the deferred-label fix (Task 2) resolves every fired virtual bullet:
## a radial-mode replay ends with `labelMisses == 0` and non-zero training.
##
## Run: nim c -r common_libs/tests/test_tm_pattern_registration.nim
import std/[random, tables, os]
import gun_harness/gun_interface
import gun_harness/virtual_bullets
import gun_harness/selector
import gun_harness/offline_range
import guns/tm_pattern
const TmPatternId = 14
var failures = 0
proc check(name: string, ok: bool) =
if ok: echo "PASS: ", name
else: echo "FAIL: ", name; inc failures
proc recordHit(fw: var FitnessWindow, hit: bool) =
fw.hits[fw.head] = hit
fw.head = (fw.head + 1) mod WindowSize
inc fw.count
proc seedWindow(t: var VirtualTracker, targetId, gunId, binIdx, hits, misses: int) =
if targetId notin t.fitness:
t.fitness[targetId] = newSeq[GunFitness](t.numGuns)
var fw = addr t.fitness[targetId][gunId].bins[binIdx]
for _ in 0..<hits: recordHit(fw[], true)
for _ in 0..<misses: recordHit(fw[], false)
proc seedOldRack(t: var VirtualTracker, targetId: int) =
## 14 deterministic rates for the old rack.
seedWindow(t, targetId, 0, 0, 60, 40)
seedWindow(t, targetId, 1, 0, 40, 60)
seedWindow(t, targetId, 2, 0, 80, 20)
seedWindow(t, targetId, 3, 0, 55, 45)
seedWindow(t, targetId, 4, 0, 20, 80)
seedWindow(t, targetId, 5, 0, 70, 30)
seedWindow(t, targetId, 6, 0, 45, 55)
seedWindow(t, targetId, 7, 0, 65, 35)
seedWindow(t, targetId, 8, 0, 30, 70)
seedWindow(t, targetId, 9, 0, 50, 50)
seedWindow(t, targetId, 10, 0, 35, 65)
seedWindow(t, targetId, 11, 0, 75, 25)
seedWindow(t, targetId, 12, 0, 25, 75)
seedWindow(t, targetId, 13, 0, 52, 48)
# ── registration table ────────────────────────────────────────────────────────
proc testTable() =
check "rack: RackGunNames has 15 entries", RackGunNames.len == 15
check "rack: the new gun is named TMPATTERN at id 14",
RackGunNames[TmPatternId] == "TMPATTERN"
check "rack: the new gun defaults to `off`",
DefaultRackMembership[TmPatternId] == rmOff
var onlyPattern = true
for i in 0..<RackGunNames.len:
let want = if i == 5: rmBoth else: rmOff
if DefaultRackMembership[i] != want: onlyPattern = false
check "rack: the shipped default is the onlyPattern rack", onlyPattern
const PatternId = 5
proc oldRackMembership(): array[15, RackMembership] =
## The pre-change rack as an explicit table: guns 0..13 `both`, TMPATTERN off.
for i in 0..<TmPatternId: result[i] = rmBoth
result[TmPatternId] = rmOff
proc testDefaultAdmitsOnlyPattern() =
check "default membership admits only PATTERN (1v1)",
admittedGuns(15, rm1v1, DefaultRackMembership) == @[PatternId]
check "default membership admits only PATTERN (melee)",
admittedGuns(15, rmMelee, DefaultRackMembership) == @[PatternId]
proc testEnvOverride() =
for name in RackGunNames: delEnv("TR_RACK_" & name)
putEnv("TR_RACK_TMPATTERN", "both")
check "env: TR_RACK_TMPATTERN=both admits gun 14",
loadRackMembership()[TmPatternId] == rmBoth
putEnv("TR_RACK_TMPATTERN", "1v1")
check "env: TR_RACK_TMPATTERN=1v1 parses",
loadRackMembership()[TmPatternId] == rmOnly1v1
putEnv("TR_RACK_TMPATTERN", "melee")
check "env: TR_RACK_TMPATTERN=melee parses",
loadRackMembership()[TmPatternId] == rmOnlyMelee
putEnv("TR_RACK_TMPATTERN", "off")
check "env: TR_RACK_TMPATTERN=off parses",
loadRackMembership()[TmPatternId] == rmOff
delEnv("TR_RACK_TMPATTERN")
# ── default-path parity (RNG draw for RNG draw) ───────────────────────────────
proc testDefaultSelectionParity() =
## The OLD rack: 14 guns, all-`both`, empty membership (pre-change call).
## The NEW rack: 15 guns, EXPLICIT old-rack membership (gun 14 off). Selection
## must be identical draw for draw, because gun 14 is filtered out before any
## RNG use. (The shipped DEFAULT is now `onlyPattern`; this explicit table
## preserves the TMPATTERN-addition parity proof independent of that default.)
let oldRack = oldRackMembership()
var oldT = initTracker(14)
seedOldRack(oldT, 7)
var newT = initTracker(15)
seedOldRack(newT, 7)
# Give gun 14 data too: it must still be excluded by the default membership.
seedWindow(newT, 7, TmPatternId, 0, 100, 0)
randomize(20250922)
var oldSeq: seq[int]
for _ in 0..<400: oldSeq.add oldT.bestGun(7)
randomize(20250922)
var newSeq: seq[int]
for _ in 0..<400:
newSeq.add newT.bestGun(7, rackMode = rm1v1, membership = oldRack)
check "parity: old-rack-membership 15-gun bestGun == 14-gun rack, RNG draw for draw",
oldSeq == newSeq
check "parity: gun 14 is never selected under the old-rack membership",
TmPatternId notin newSeq
# Same through the live hysteresis path.
var oldH = initTracker(14); seedOldRack(oldH, 7)
var newH = initTracker(15); seedOldRack(newH, 7)
seedWindow(newH, 7, TmPatternId, 0, 100, 0)
randomize(4242)
var oldHSeq: seq[int]
for tick in 0..<400: oldHSeq.add oldH.selectGun(7, tick = tick)
randomize(4242)
var newHSeq: seq[int]
for tick in 0..<400:
newHSeq.add newH.selectGun(7, tick = tick, rackMode = rm1v1,
membership = oldRack)
check "parity: old-rack-membership 15-gun selectGun == 14-gun rack, RNG draw for draw",
oldHSeq == newHSeq
# Forcing TMPATTERN alone DOES change selection (it is forceable).
var forced = initTracker(15)
seedOldRack(forced, 7)
seedWindow(forced, 7, TmPatternId, 0, 100, 0)
var onlyTmp: array[15, RackMembership]
for i in 0..<15: onlyTmp[i] = rmOff
onlyTmp[TmPatternId] = rmBoth
check "force: with every other gun off, TMPATTERN is the only candidate",
admittedGuns(15, rm1v1, onlyTmp) == @[TmPatternId]
randomize(9)
var forcedSeq: seq[int]
for _ in 0..<50:
forcedSeq.add forced.bestGun(7, rackMode = rm1v1, membership = onlyTmp)
var allTmp = true
for g in forcedSeq:
if g != TmPatternId: allTmp = false
check "force: the forced-alone rack always returns TMPATTERN", allTmp
# ── radial mode + deferred-label fix ─────────────────────────────────────────
proc testRadialInit() =
let g = initTmRadialGun()
check "init: initTmRadialGun() selects the radial target mode",
g.targetMode == tmRadial
proc testDeferredLabel() =
## Drive a radial-mode gun over a synthetic fixture and prove the deferred
## label resolves every bullet: no label misses, and training happened.
let fx = synthesizeOscillator(ticks = 400)
var g2 = initTmRadialGun()
randomize(3)
var tracker = initTracker(1, bmPoint)
for state in fx.states:
var preds: array[len(PowerBins), GunPrediction]
for b in 0..<len(PowerBins):
preds[b] = g2.predict(state, bulletSpeed(PowerBins[b]))
tracker.spawnBullets(0, preds, state, fx.enemyId)
var et: Table[int, tuple[x, y: float, lastSeenTick: int, alive: bool]]
et[fx.enemyId] = (x: state.enemyX, y: state.enemyY,
lastSeenTick: state.tick, alive: true)
tracker.tickBullets(state, et,
proc(gunId: GunId, binIdx: int, e: FeedbackEvent) = g2.onResult(e))
check "deferred: a radial replay trains (totalObs > 0)", g2.totalObs > 0
check "deferred: no label misses remain (labelMisses == 0)", g2.labelMisses == 0
check "deferred: the pending queue never overflowed", g2.pendingDropped == 0
check "deferred: the radial head is scored above chance (radTotal > 0)",
g2.radTotal > 0 and g2.radCorrect > 0
when isMainModule:
testTable()
testDefaultAdmitsOnlyPattern()
testEnvOverride()
testDefaultSelectionParity()
testRadialInit()
testDeferredLabel()
if failures > 0:
echo "\n", failures, " check(s) FAILED"
quit(1)
echo "\nAll TM pattern registration checks passed."