From 589a2301068f485c7494ac674696a8e5012e015f Mon Sep 17 00:00:00 2001 From: Davide Cappellini Date: Tue, 22 Sep 2026 01:58:33 +0200 Subject: [PATCH] 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. --- ModularBot_garage/src/ModularBot.nim | 47 ++++- common_libs/gun_harness/selector.nim | 21 +- common_libs/guns/tm_pattern.nim | 198 ++++++++++++------ common_libs/tests/measure_tm_pattern_cost.nim | 175 ++++++++++++++++ common_libs/tests/test_rack_membership.nim | 17 +- .../tests/test_tm_pattern_rack_live.nim | 109 ++++++++++ .../tests/test_tm_pattern_registration.nim | 197 +++++++++++++++++ 7 files changed, 675 insertions(+), 89 deletions(-) create mode 100644 common_libs/tests/measure_tm_pattern_cost.nim create mode 100644 common_libs/tests/test_tm_pattern_rack_live.nim create mode 100644 common_libs/tests/test_tm_pattern_registration.nim diff --git a/ModularBot_garage/src/ModularBot.nim b/ModularBot_garage/src/ModularBot.nim index d5353c9..1bb66f2 100644 --- a/ModularBot_garage/src/ModularBot.nim +++ b/ModularBot_garage/src/ModularBot.nim @@ -1,5 +1,5 @@ ## ModularBot — plugin gun architecture tracer bullet. -## Guns: HeadOnGun (0), LinearGun (1), TsetlinGun (2), CircularGun (3), GFGun (4), PatternMatcherGun (5), WallBounceGun (6), AccelGun (7), StopShotGun (8), DisplacementGun (9), AveragedLeadGun (10), DecayGFGun (11), KNNGun (12), TmSelectorGun (13) via GunHarness. +## Guns: HeadOnGun (0), LinearGun (1), TsetlinGun (2), CircularGun (3), GFGun (4), PatternMatcherGun (5), WallBounceGun (6), AccelGun (7), StopShotGun (8), DisplacementGun (9), AveragedLeadGun (10), DecayGFGun (11), KNNGun (12), TmSelectorGun (13), TmPatternGun (14) via GunHarness. ## Radar: RadarLockModule (1v1) / AdaptiveMeleeRadarModule (2+ enemies), auto-switched per tick. ## Movement: OscillatorModule (perpendicular strafing). @@ -25,6 +25,7 @@ import guns/averaged_lead import guns/decay_gf import guns/knn_gun import guns/tm_selector +import guns/tm_pattern import movements/phantom_meteor import movements/rammer import movements/ram_decision @@ -106,7 +107,14 @@ let ShotLogPath = getEnv("GUN_SHOTLOG_PATH", "/tmp/shot_log.jsonl") ## shows why the cap moved. The policy itself lives in the shared gun harness ## (`applyPowerPolicy`), so both live and offline paths see the same rule. let PowerLog = existsEnv("TR_POWER_LOG") -const GunNames = ["HeadOn", "Linear", "Tsetlin", "Circular", "GuessFactor", "Pattern", "WallBounce", "Accel", "StopShot", "Displace", "AvgLead", "DecayGF", "KNN", "TMSelect"] +const GunNames = ["HeadOn", "Linear", "Tsetlin", "Circular", "GuessFactor", "Pattern", "WallBounce", "Accel", "StopShot", "Displace", "AvgLead", "DecayGF", "KNN", "TMSelect", "TMPattern"] + +## Rack id of the new TM pattern gun. It defaults to `TR_RACK_TMPATTERN=off`; +## unlike the other guns, its virtual-bullet spawn is gated on rack admission +## (see the aim block in `run`), so the shipped default never spawns it and the +## shared VirtualTracker ring head — hence every other gun's learning order and +## the default selection sequence — is byte-for-byte unchanged. +const TmPatternId = 14 const CLR_GUN = "\e[33m" # yellow @@ -153,6 +161,7 @@ type decayGF: DecayGFGun knnGun: KNNGun tmSelector: TmSelectorGun + tmPattern: TmPatternGun mover: TFILModule ringMover: TFILRingModule rammer: RammerModule @@ -179,18 +188,18 @@ type roundNumber: int realShotsFired: int realHits: int - gunRealShots: array[14, int] - gunRealHits: array[14, int] + gunRealShots: array[15, int] + gunRealHits: array[15, int] # Same real-shot accounting split by the rack in force at fire time, so a # later data-driven pass can rank guns per mode (1v1 vs melee). - gunRealShotsByMode: array[vb.RackMode, array[14, int]] - gunRealHitsByMode: array[vb.RackMode, array[14, int]] + gunRealShotsByMode: array[vb.RackMode, array[15, int]] + gunRealHitsByMode: array[vb.RackMode, array[15, int]] pendingFires: seq[PendingShot] ## FIFO of fired shots awaiting onBulletFired bulletId stamp bulletGun: Table[int, int] ## bulletId -> gun id, filled on onBulletFired, drained on resolution bulletMode: Table[int, vb.RackMode] ## bulletId -> rack at fire time bulletShot: Table[int, PendingShot] ## bulletId -> shot metadata (Task A shot log) pendingHitBullets: HashSet[int] ## hit bulletIds seen before their onBulletFired stamp - gunSelectionCount: array[14, int] + gunSelectionCount: array[15, int] lastPowerLogKey: string ## change detector for the TR_POWER_LOG line lastKnownTargetId: int ## persists through death, used for round-end stats # Radar measurement instrumentation (only touched when RadarScanLog is set). @@ -554,7 +563,7 @@ method onRoundEnded*(bot: ModularBot, e: RoundEndedEventForBot) = let fit = bot.tracker.fitnessFor(targetId) var gunsArr = newJArray() - for gid in 0..<14: + for gid in 0..<15: var totalShots = 0 var totalHits = 0 for binIdx in 0.. 1v1 rack only # TR_RACK_DISPLACE=melee -> melee rack only # TR_RACK_KNN=off -> removed from both racks +# TR_RACK_TMPATTERN=off (shipped default for the new TM pattern gun) # # The mode itself is derived from SERVER truth (`getEnemyCount()`), never from # the tracker's known-enemy count, by `rackMode` in virtual_bullets — the same -# transition the radar uses. Defaults are all-`both`, so an unset environment -# preserves the pre-change single-rack selection byte-for-byte. +# transition the radar uses. Every gun except TMPATTERN defaults to `both`, so an +# unset environment preserves the pre-change single-rack selection byte-for-byte; +# TMPATTERN defaults to `off` so it cannot alter that selection. const - RackGunNames*: array[14, string] = [ + RackGunNames*: array[15, string] = [ "HEADON", "LINEAR", "TSETLIN", "CIRCULAR", "GUESSFACTOR", "PATTERN", "WALLBOUNCE", "ACCEL", "STOPSHOT", "DISPLACE", "AVGLEAD", "DECAYGF", - "KNN", "TMSELECT"] + "KNN", "TMSELECT", "TMPATTERN"] RackEnvPrefix* = "TR_RACK_" - DefaultRackMembership*: array[14, RackMembership] = [ + ## Defaults are all-`both` EXCEPT the new TM pattern gun (id 14), which ships + ## `off`: it is registered and forceable (`TR_RACK_TMPATTERN=both|1v1|melee`) + ## but never spawns a virtual bullet unless explicitly enabled, so the shared + ## VirtualTracker ring head — and therefore every other gun's learning order + ## and the default selection sequence — is byte-for-byte unchanged. Defaulting + ## it to `both` would let it compete for selection and change the default rack. + DefaultRackMembership*: array[15, RackMembership] = [ rmBoth, rmBoth, rmBoth, rmBoth, rmBoth, rmBoth, rmBoth, - rmBoth, rmBoth, rmBoth, rmBoth, rmBoth, rmBoth, rmBoth] + rmBoth, rmBoth, rmBoth, rmBoth, rmBoth, rmBoth, rmBoth, + rmOff] proc parseRackMembership*(value: string): RackMembership = ## Parse a `TR_RACK_` value. Empty / unknown values fall back to the diff --git a/common_libs/guns/tm_pattern.nim b/common_libs/guns/tm_pattern.nim index 64bb008..090f5f7 100644 --- a/common_libs/guns/tm_pattern.nim +++ b/common_libs/guns/tm_pattern.nim @@ -70,6 +70,13 @@ const TM_SOFT_BETA* = parseFloat(TM_SOFT_BETA_DEF) TM_TRACE_SLOTS = 1024 POS_RING = 512 + ## Deferred-label queue (Task 2): a virtual bullet whose radial correction + ## aimed SHORT resolves BEFORE its BASE arrival tick, when the arrival-tick + ## position is not yet in `posRing`. Instead of dropping the sample + ## (`labelMisses`), the trace is copied here and resolved on the first later + ## `predict` tick at which the base arrival tick's position exists, so every + ## fired virtual bullet contributes an unbiased training sample. + TM_PENDING_SLOTS = 1024 DebugTMPattern* = false ## ── radial head (Task 2) ──────────────────────────────────────────────── ## Radial label = (enemy radius at the BASE arrival tick) - (base fire @@ -125,12 +132,26 @@ type heading: float valid: bool + PendingResolve = object + ## A fired virtual bullet whose label was not yet resolvable at resolution + ## time. `trace` is a COPY of the fire-time trace (features + clause + ## caches), so the deferred training update is identical to an immediate + ## one, just later. + arrivalTick: int + powerBin: int + power: float + trace: TmPatternTrace + TmPatternGun* = object teams: array[TM_CLASSES, seq[int16]] radTeams: array[TM_CLASSES, seq[int16]] revTeams: array[2, seq[int16]] targetMode*: TmTargetMode traces: array[TM_TRACE_SLOTS, TmPatternTrace] + # ── deferred labels (Task 2) ── + pending: array[TM_PENDING_SLOTS, PendingResolve] + pendingCount: int + pendingDropped*: int # ── history ── posRing: array[POS_RING, PosSample] lastTick: int @@ -268,6 +289,14 @@ proc initTmPatternGun*(): TmPatternGun = randomize() result.debugGraphics = false +proc initTmRadialGun*(): TmPatternGun = + ## The RACK-REGISTERED instance: the RADIAL target mode, which is the + ## control-validated winner under `bmPoint` (see tm_pattern_sweep_results.md, + ## Round 2 Task 2). The gun type carries all three heads; the live rack only + ## ever selects this radial-mode instance. + result = initTmPatternGun() + result.targetMode = tmRadial + proc isWarmedUp*(g: TmPatternGun): bool {.inline.} = true proc resetLearning*(g: var TmPatternGun) = @@ -284,6 +313,7 @@ proc resetLearning*(g: var TmPatternGun) = g.sinceReversal = 0 g.radialFracSm = 0.0 g.latPersist = 0 + g.pendingCount = 0 proc tmUpdateHistory(g: var TmPatternGun, state: WorldState) = if state.tick == g.lastTick: return @@ -442,6 +472,90 @@ proc tmChooseAt(votes: openArray[float], centre: int, margin: float, proc tmChooseClass(g: var TmPatternGun, votes: array[TM_CLASSES, float]): int = tmChooseAt(votes, (TM_CLASSES - 1) div 2, TM_CONF_MARGIN, g.totalObs) +proc tmResolveTrace(g: var TmPatternGun, t: TmPatternTrace, power: float) = + ## One label + one TM update for a fired virtual bullet, using the enemy + ## position recorded at the BASE arrival tick. `t` is a value copy of the + ## fire-time trace, so this is safe to call either from `onResult` (the label + ## is already resolvable) or from `tmFlushPending` (the label was deferred + ## because the bullet resolved before its base arrival tick). + let s = ((t.arrivalTick mod POS_RING) + POS_RING) mod POS_RING + let speed = bulletSpeed(power) + let mea = arcsin(clamp(8.0 / speed, -1.0, 1.0)) + let actualBearing = arctan2(g.posRing[s].y - t.fireY, g.posRing[s].x - t.fireX) + var delta = actualBearing - t.baseBearing + while delta > PI: delta -= 2.0 * PI + while delta < -PI: delta += 2.0 * PI + let gf = if mea > 1e-10: clamp(delta / mea, -1.0, 1.0) else: 0.0 + + # The shuffled control randomises ONLY the head the current mode is claiming. + let shuffleGF = g.shuffleLabels and g.targetMode == tmGF + let shuffleRad = g.shuffleLabels and g.targetMode == tmRadial + let shuffleRev = g.shuffleLabels and g.targetMode == tmReversal + + let winner = if shuffleGF: rand(TM_CLASSES - 1) else: gfToBucket(gf) + inc g.labelHist[winner] + if t.warm: + inc g.classTotal + if winner == t.chosen: inc g.classCorrect + + # Radial label: enemy radius at the base arrival tick minus the base fire + # distance. Independent of our own aim, so it is a clean target. + let actualRadius = hypot(g.posRing[s].x - t.fireX, g.posRing[s].y - t.fireY) + let radDelta = actualRadius - t.fireDist + let radWinner = if shuffleRad: rand(TM_CLASSES - 1) else: radToBucket(radDelta) + inc g.radLabelHist[radWinner] + if t.warm: + inc g.radTotal + if radWinner == t.radChosen: inc g.radCorrect + + # Reversal label: net heading turn over the flight, opposite to the direction + # the enemy was turning at fire time. + let dh = normDeg(g.posRing[s].heading - t.fireHeading) + let netTurn = if dh > TM_REV_TURN_DEG: 1 elif dh < -TM_REV_TURN_DEG: -1 else: 0 + let revWinner = + if shuffleRev: rand(1) + elif t.fireTurn != 0 and netTurn != 0 and netTurn != t.fireTurn: 1 + else: 0 + inc g.revLabelHist[revWinner] + if t.warm: + inc g.revTotal + if revWinner == t.revChosen: inc g.revCorrect + + case g.targetMode + of tmGF: + for c in 0.. PI: delta -= 2.0 * PI - while delta < -PI: delta += 2.0 * PI - let gf = if mea > 1e-10: clamp(delta / mea, -1.0, 1.0) else: 0.0 - - # The shuffled control randomises ONLY the head the current mode is claiming. - let shuffleGF = g.shuffleLabels and g.targetMode == tmGF - let shuffleRad = g.shuffleLabels and g.targetMode == tmRadial - let shuffleRev = g.shuffleLabels and g.targetMode == tmReversal - - let winner = if shuffleGF: rand(TM_CLASSES - 1) else: gfToBucket(gf) - inc g.labelHist[winner] - if t.warm: - inc g.classTotal - if winner == t.chosen: inc g.classCorrect - - # Radial label: enemy radius at the base arrival tick minus the base fire - # distance. Independent of our own aim, so it is a clean target. - let actualRadius = hypot(g.posRing[s].x - t.fireX, g.posRing[s].y - t.fireY) - let radDelta = actualRadius - t.fireDist - let radWinner = if shuffleRad: rand(TM_CLASSES - 1) else: radToBucket(radDelta) - inc g.radLabelHist[radWinner] - if t.warm: - inc g.radTotal - if radWinner == t.radChosen: inc g.radCorrect - - # Reversal label: net heading turn over the flight, opposite to the direction - # the enemy was turning at fire time. - let dh = normDeg(g.posRing[s].heading - t.fireHeading) - let netTurn = if dh > TM_REV_TURN_DEG: 1 elif dh < -TM_REV_TURN_DEG: -1 else: 0 - let revWinner = - if shuffleRev: rand(1) - elif t.fireTurn != 0 and netTurn != 0 and netTurn != t.fireTurn: 1 - else: 0 - inc g.revLabelHist[revWinner] - if t.warm: - inc g.revTotal - if revWinner == t.revChosen: inc g.revCorrect - - case g.targetMode - of tmGF: - for c in 0..= 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.. 0: + otherSelected += g["selected"].getInt() + + echo "gun 14 (TMPattern): vShots=", tmShots, " selected=", tmSelected, + " other-gun selections=", otherSelected + check "the live loop SPAWNED virtual bullets for gun 14 (vShots > 0)", tmShots > 0 + check "the live selector SELECTED gun 14 (selected > 0)", tmSelected > 0 + check "no other gun was selected while TMPATTERN was forced alone", + otherSelected == 0 + + if failures > 0: + echo "\n", failures, " check(s) FAILED" + quit(1) + echo "\nTM pattern rack live check passed." + +when isMainModule: + main() diff --git a/common_libs/tests/test_tm_pattern_registration.nim b/common_libs/tests/test_tm_pattern_registration.nim new file mode 100644 index 0000000..2a7cdf0 --- /dev/null +++ b/common_libs/tests/test_tm_pattern_registration.nim @@ -0,0 +1,197 @@ +## 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 admits exactly the OLD 14-gun rack, so the +## selection sequence is identical to the pre-change all-`both` rack, RNG +## draw for RNG draw, through both `bestGun` and the live `selectGun`; +## * `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 + +proc toSeq(r: HSlice[int, int]): seq[int] = + for i in r.a..r.b: result.add i + +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.. 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() + testDefaultAdmitsOldRack() + testEnvOverride() + testDefaultSelectionParity() + testRadialInit() + testDeferredLabel() + if failures > 0: + echo "\n", failures, " check(s) FAILED" + quit(1) + echo "\nAll TM pattern registration checks passed."