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."