Files
SirRoboGarage/common_libs/tests/measure_tm_pattern_cost.nim
SirStone 589a230106 TM radial gun: registered (default OFF) + label-bias fix that removes the bias but
retracts its own earlier learning claim

=== TASK 1: REGISTERED AS GUN 14, DEFAULT `off` ===
The radial TM gun is now a first-class rack member (`TMPATTERN`, id 14), forceable
alone with `TR_RACK_TMPATTERN=both` plus every other `TR_RACK_*=off`.
DEFAULT IS `off`, and the justification matters: `both` would let it compete for
selection AND (because the shared VirtualTracker ring is order-sensitive) shift
every other gun's learning order, so it CANNOT leave the default path unchanged.
With `off` its predict and spawnBullets are additionally GATED on rack admission
(the only gun wired that way), so the shipped default never spawns it at all:
zero cost, zero ring perturbation.
Live proof: 1-round battle with only TMPATTERN racked ->
  `gun 14 (TMPattern): vShots=400 selected=104 other-gun selections=0`.
Default-path-unchanged proof: parity checks that the 15-gun default bestGun/
selectGun equals the old 14-gun rack RNG-draw-for-RNG-draw, that gun 14 is never
selected by default, and acceptance 12/12.
Cost: 0.36 ms/tick (predict 0.30 + onResult 0.05) ~= 3% of the 13.16 ms budget.
Tsetlin in the same harness is 1.62 ms/tick, so the new gun is ~4.5x cheaper.

=== TASK 2: THE LABEL-BIAS FIX - AND A RETRACTION ===
Root cause confirmed: under bmPoint a SHORT radial correction resolves the virtual
bullet BEFORE the base arrival tick, so the label was dropped (labelMisses).
Fix: defer the label in a pending queue and flush it once the arrival tick is
recorded; labels still come from the BASE arrival tick.
  labelMisses        4,281,695  ->  0
  training samples   1,071,824  ->  5,345,847  (x5)
  radial head acc         48.8% ->  57.0%   (shuffled control 20.0%)
  bmPoint hit rate     9.4/5.8% ->  9.1/5.7%  (unchanged, within noise)
So the fix IMPROVES LEARNING but NOT the metric.

**RETRACTION OF THE PREVIOUS JOB'S CLAIM.** It reported the radial head's 48.8%
against a 36.7% majority baseline and concluded "conditional learning, not a
constant bias". With the bias removed, the correctly-measured majority baseline is
**58.2%** - so the head at 57.0% is AT/BELOW majority. The earlier apparent
conditional learning was PARTLY AN ARTEFACT OF THE BIASED SAMPLE. The bmPoint
metric win is real (TMRadial > Linear early 16/2 p=0.0013, overall 18/0 p<0.0001;
> shuffled 18/0 p<0.0001) but it comes from a NET-POSITIVE AVERAGE RADIAL SHIFT,
not from beating a majority classifier. Recorded plainly rather than left standing.

Guards: test_tm_pattern_registration 20 (new), test_tm_pattern_rack_live 4 (new),
test_gun_harness 39, test_vbullet_metric 11, test_power_selection 3 (the SIGSEGV is
gone - the knn_gun rewrite is now committed), test_adaptive_radar 41,
test_tfil_ring_weights 24, test_power_policy 26, test_ram_decision 28,
test_rack_membership 38, test_selector_tiebreak 19, test_tm_pattern_learning 3,
acceptance_offline_vs_online 12/12. ModularBot compiles (release).

Note: `common_libs/tests/range_guns.nim` still builds 14 offline drivers (the
offline sweep constructs TmPatternGun directly and acceptance only inspects ids
0..13), so nothing breaks - but a future job wanting it in the offline rack must
add a 15th driver and mirror the live admission gating. gun_stats.jsonl now emits
15 rows; downstream tooling should ignore id 14.
2026-09-22 01:58:33 +02:00

176 lines
7.2 KiB
Nim

## MEASUREMENT ONLY — per-tick cost of the rack-registered TM pattern gun
## (id 14, radial mode) in isolation, following the `runPerGun` pattern of
## `measure_parallel_vbullet_cost.nim`.
##
## Drives ONE gun through a real DrussGT fixture with a one-gun VirtualTracker,
## timing `predict` (4 power bins/tick) and `onResult` (one call per resolved
## virtual bullet) separately, then reports ms/tick against the ~13.16 ms live
## budget (76 ticks/s). Tsetlin is measured the same way as the reference the
## brief quotes (~5.3 ms/tick).
##
## Usage:
## nim c -r -d:release common_libs/tests/measure_tm_pattern_cost.nim
import std/[os, strformat, tables, math, times, monotimes, random]
import gun_harness/offline_range
import gun_harness/gun_interface
import gun_harness/virtual_bullets as vb
import guns/tm_pattern
import guns/tsetlin
const
RepoRoot = currentSourcePath().parentDir.parentDir.parentDir
FixturePath = RepoRoot / "tools" / "fixtures" / "tr_drussgt_vs_crazy.jsonl"
type
CostResult = object
predictNs: int64
onResultNs: int64
ticks: int
predictCalls: int
onCalls: int
proc measureGun(fx: Fixture, predictCb: proc(state: WorldState, bulletSpeed: float): GunPrediction {.closure.},
resultCb: proc(e: FeedbackEvent) {.closure.},
metric: BulletMetric, warmup, measure: int): CostResult =
var tracker = vb.initTracker(1, metric)
let acc = new(CostResult)
let ticks = fx.states.len
for si in 0..<ticks:
let state = fx.states[si]
var preds: array[len(vb.PowerBins), GunPrediction]
let t0 = getMonoTime()
for b in 0..<len(vb.PowerBins):
preds[b] = predictCb(state, bulletSpeed(vb.PowerBins[b]))
let t1 = getMonoTime()
tracker.spawnBullets(0, preds, state, fx.enemyId)
var enemyPositions: Table[int, tuple[x, y: float, lastSeenTick: int, alive: bool]]
enemyPositions[fx.enemyId] = (x: state.enemyX, y: state.enemyY,
lastSeenTick: state.tick, alive: true)
tracker.tickBullets(state, enemyPositions,
proc(gunId: GunId, binIdx: int, e: FeedbackEvent) =
let s0 = getMonoTime()
resultCb(e)
let s1 = getMonoTime()
if si >= warmup and si < warmup + measure:
acc.onResultNs += (s1 - s0).inNanoseconds
inc acc.onCalls)
if si >= warmup and si < warmup + measure:
inc acc.ticks
acc.predictNs += (t1 - t0).inNanoseconds
inc acc.predictCalls, len(vb.PowerBins)
result = acc[]
proc makeTmRadial(fx: Fixture, metric: BulletMetric, warmup, measure: int): CostResult =
let g = new(TmPatternGun)
g[] = initTmRadialGun()
randomize(1)
result = measureGun(fx,
proc(state: WorldState, bulletSpeed: float): GunPrediction = g[].predict(state, bulletSpeed),
proc(e: FeedbackEvent) = g[].onResult(e),
metric, warmup, measure)
echo fmt" TMPattern(radial): obs={g[].totalObs} labelMiss={g[].labelMisses} " &
fmt"traceMiss={g[].traceMisses} radAcc={g[].radCorrect}/{g[].radTotal}"
var lab = ""
var maj = 0
var tot = 0
for c in 0..<TM_CLASSES:
lab.add $g[].radLabelHist[c] & " "
maj = max(maj, g[].radLabelHist[c])
tot += g[].radLabelHist[c]
echo fmt" radial label hist [{lab}] majority={maj.float / max(1, tot).float * 100:.1f}% pendingDropped={g[].pendingDropped}"
proc makeTsetlin(fx: Fixture, metric: BulletMetric, warmup, measure: int): CostResult =
let g = new(TsetlinGun)
g[] = initTsetlinGun()
randomize(1)
result = measureGun(fx,
proc(state: WorldState, bulletSpeed: float): GunPrediction = g[].predict(state, bulletSpeed),
proc(e: FeedbackEvent) = g[].onResult(e),
metric, warmup, measure)
proc main() =
if not fileExists(FixturePath):
echo "fixture missing: ", FixturePath
quit(1)
let fx = loadFixture(FixturePath)
echo fmt"fixture: {fx.meta.adversary} ticks={fx.states.len} metric=point"
const Warmup = 150
const Measure = 800
const BudgetMs = 1000.0 / 76.0 # ~76 ticks/s measured bridge throughput
echo fmt"warmup={Warmup} measured={Measure} ticks; budget={BudgetMs:.2f} ms/tick"
echo ""
let tm = makeTmRadial(fx, bmPoint, Warmup, Measure)
let ts = makeTsetlin(fx, bmPoint, Warmup, Measure)
proc report(name: string, r: CostResult) =
let pMs = r.predictNs.float / 1.0e6 / max(1, r.ticks).float
let oMs = r.onResultNs.float / 1.0e6 / max(1, r.ticks).float
let tot = pMs + oMs
echo fmt"{name:<18} predict {pMs:6.2f} ms/tick onResult {oMs:6.2f} ms/tick " &
fmt"total {tot:6.2f} ms/tick ({tot / BudgetMs * 100:5.0f}% budget) " &
fmt"onCalls/tick={r.onCalls.float / max(1, r.ticks).float:.1f}"
echo "=================== PER-TICK COST (single gun, bmPoint) ==================="
report("TMPattern(radial)", tm)
report("Tsetlin (reference)", ts)
# ── pooled radial-head learning stats over the real fixture set ─────────────
const RealFixtures = ["drussgt_vs_crazy", "drussgt_vs_spinbot",
"drussgt_vs_drussgt", "tr_drussgt_vs_crazy", "tr_drussgt_vs_spinbot",
"tr_drussgt_vs_modularbot"]
echo ""
echo "=================== POOLED RADIAL HEAD (real fixtures, bmPoint) ==================="
var realHist: array[TM_CLASSES, int]
var realChosen: array[TM_CLASSES, int]
var realCorrect, realTotal = 0
var shufHist: array[TM_CLASSES, int]
var shufChosen: array[TM_CLASSES, int]
var shufCorrect, shufTotal = 0
for name in RealFixtures:
let p = RepoRoot / "tools" / "fixtures" / (name & ".jsonl")
if not fileExists(p): continue
let f = loadFixture(p)
block:
let g = new(TmPatternGun)
g[] = initTmRadialGun()
randomize(1)
discard measureGun(f,
proc(state: WorldState, bulletSpeed: float): GunPrediction = g[].predict(state, bulletSpeed),
proc(e: FeedbackEvent) = g[].onResult(e), bmPoint, 0, 0)
realCorrect += g[].radCorrect
realTotal += g[].radTotal
for c in 0..<TM_CLASSES: realHist[c] += g[].radLabelHist[c]
for c in 0..<TM_CLASSES: realChosen[c] += g[].radChosenHist[c]
block:
let g = new(TmPatternGun)
g[] = initTmRadialGun()
g[].shuffleLabels = true
randomize(1)
discard measureGun(f,
proc(state: WorldState, bulletSpeed: float): GunPrediction = g[].predict(state, bulletSpeed),
proc(e: FeedbackEvent) = g[].onResult(e), bmPoint, 0, 0)
shufCorrect += g[].radCorrect
shufTotal += g[].radTotal
for c in 0..<TM_CLASSES: shufHist[c] += g[].radLabelHist[c]
for c in 0..<TM_CLASSES: shufChosen[c] += g[].radChosenHist[c]
var realMaj, shufMaj, realN, shufN = 0
var rs, ss, rc, sc = ""
for c in 0..<TM_CLASSES:
realMaj = max(realMaj, realHist[c]); realN += realHist[c]
shufMaj = max(shufMaj, shufHist[c]); shufN += shufHist[c]
rs.add $realHist[c] & " "
ss.add $shufHist[c] & " "
rc.add $realChosen[c] & " "
sc.add $shufChosen[c] & " "
echo fmt" real: radAcc={realCorrect}/{realTotal} = {realCorrect.float/max(1,realTotal).float*100:.1f}% " &
fmt"majority={realMaj.float/max(1,realN).float*100:.1f}% labels=[{rs}] chosen=[{rc}]"
echo fmt" shuffled: radAcc={shufCorrect}/{shufTotal} = {shufCorrect.float/max(1,shufTotal).float*100:.1f}% " &
fmt"majority={shufMaj.float/max(1,shufN).float*100:.1f}% labels=[{ss}] chosen=[{sc}]"
when isMainModule:
main()