From aed579b3af60f8bbdd149b9d0425507849c9c660 Mon Sep 17 00:00:00 2001 From: Davide Cappellini Date: Tue, 22 Sep 2026 23:24:11 +0200 Subject: [PATCH] TM horizon: retain learning ACROSS ROUNDS, reset only when the ENEMY changes The user's requirement: "every battle i means from round 1 to round end-battle, so retain all learning until the enemy change." What was built wiped the Tsetlin machines EVERY ROUND, in two places (`onRoundStarted` and the gun's own tick-regression self-reset), so in a 7-round battle each round started cold, trained ~360 samples and threw them away - discarding most of its one chance to do what was asked: overfit the current enemy over the whole battle. THE FIX - two kinds of state, two triggers: - **`resetRoundState` (per ROUND)**: the observation ring, pending/deferred labels, the bullet proxy, motion history, per-tick caches, `roundStartTrained`. These MUST clear every round, because bots teleport back to the starting corners between rounds - an old position would build a garbage label. (That exact class of bug shipped 36-58% wrong labels in the old gun.) - **`resetLearning` (per BATTLE / per ENEMY)**: both Tsetlin machines, `trained`, `sideCorrect/sideTotal`, all histograms, the magnitude median, `pendingDropped`, `observedTargetId`. These now SURVIVE round boundaries. Triggers for the machine wipe: `onGameStarted` (primary) plus a redundant `roundNumber <= 1` fallback in `onRoundStarted`; and a TARGET CHANGE (`targetChanged`, knob `TR_TMHORIZON_RESET_ON_TARGET` default on - a no-op in 1v1, fires on melee target switches; first acquisition never wipes). The tick-regression self-reset now clears ONLY per-round state. Still NO cross-battle persistence: grep for file I/O in the gun finds none. PROOF IT WORKS (live 2-round battle, `TR_RACK_PATTERN=off TR_RACK_TMHORIZON=both`): [tmh-reset] reason=game_start trained_was=0 [tmh-reset] reason=round1 trained_was=0 ...exactly TWO reset lines in the whole battle, both at battle start, and NONE at the round-2 boundary. And the per-round summaries: [tmh-round] trained=1249 thisRound=1249 ... sideAcc=711/1177 (60.4%) [tmh-round] trained=2226 thisRound=977 ... sideAcc=1361/2154 (63.2%) `trained` CLIMBED 1249 -> 2226 across the boundary, and side accuracy rose 60.4% -> 63.2% in round 2 (one battle - suggestive, not proof). Unit tests: `test_tm_horizon` 79 (was 54), including "trained SURVIVES the boundary", "clause states SURVIVE", "trained climbs round1->round2", "game-start wipes and all clauses end Exclude", "different enemy wipes / same enemy does not / knob-off does not", and crucially "a label CANNOT be built across a round boundary" (ringValidCount==0, ringHas(oldTick)==false, pendingCount==0) - the single most dangerous interaction of this change. Guards: test_tm_horizon 79, test_rack_membership 48, 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 40, test_selector_tiebreak 19, test_tm_pattern_registration 20, test_vbullet_admit_gate 12, test_tm_diag 48, test_tm_automata_diag 55, test_tm_clause_shape 66. acceptance_offline_vs_online 12/12 VERDICT PASS. Shipped rack unchanged: DefaultRackMembership is still Pattern-only, TMHORIZON off. Residual (pre-existing, out of scope, stated): the internal base `PatternMatcherGun` has a rolling move-history buffer that is NOT cleared at round boundaries - it never was, and the SHIPPED Pattern gun carries history across rounds too. It cannot affect label correctness (labels come from `g.ring`), only base-prediction quality in a round's first ticks. --- ModularBot_garage/src/ModularBot.nim | 71 ++- common_libs/guns/tm_horizon.nim | 850 ++++++++++++++++++++++++++ common_libs/tests/test_tm_horizon.nim | 340 +++++++++++ 3 files changed, 1245 insertions(+), 16 deletions(-) create mode 100644 common_libs/guns/tm_horizon.nim create mode 100644 common_libs/tests/test_tm_horizon.nim diff --git a/ModularBot_garage/src/ModularBot.nim b/ModularBot_garage/src/ModularBot.nim index 1142afa..9348acb 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), TmPatternGun (14) 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), TmHorizonGun (15) via GunHarness. ## Radar: RadarLockModule (1v1) / AdaptiveMeleeRadarModule (2+ enemies), auto-switched per tick. ## Movement: OscillatorModule (perpendicular strafing). @@ -26,6 +26,7 @@ import guns/decay_gf import guns/knn_gun import guns/tm_selector import guns/tm_pattern +import guns/tm_horizon import movements/phantom_meteor import movements/rammer import movements/ram_decision @@ -119,7 +120,7 @@ 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", "TMPattern"] +const GunNames = ["HeadOn", "Linear", "Tsetlin", "Circular", "GuessFactor", "Pattern", "WallBounce", "Accel", "StopShot", "Displace", "AvgLead", "DecayGF", "KNN", "TMSelect", "TMPattern", "TMHorizon"] ## 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 @@ -128,6 +129,12 @@ const GunNames = ["HeadOn", "Linear", "Tsetlin", "Circular", "GuessFactor", "Pat ## the default selection sequence — is byte-for-byte unchanged. const TmPatternId = 14 +## Rack id of the horizon-based TM corrector. Like TMPATTERN it defaults to +## `TR_RACK_TMHORIZON=off` and its virtual-bullet spawn is gated on rack +## admission, so the shipped default never spawns it and the shared tracker ring +## head — hence every other gun's learning order — is byte-for-byte unchanged. +const TmHorizonId = 15 + const CLR_GUN = "\e[33m" # yellow CLR_MOVE = "\e[36m" # cyan @@ -174,6 +181,7 @@ type knnGun: KNNGun tmSelector: TmSelectorGun tmPattern: TmPatternGun + tmHorizon: TmHorizonGun mover: TFILModule ringMover: TFILRingModule rammer: RammerModule @@ -199,18 +207,18 @@ type roundNumber: int realShotsFired: int realHits: int - gunRealShots: array[15, int] - gunRealHits: array[15, int] + gunRealShots: array[16, int] + gunRealHits: array[16, 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[15, int]] - gunRealHitsByMode: array[vb.RackMode, array[15, int]] + gunRealShotsByMode: array[vb.RackMode, array[16, int]] + gunRealHitsByMode: array[vb.RackMode, array[16, 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[15, int] + gunSelectionCount: array[16, 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). @@ -569,6 +577,9 @@ method onRoundEnded*(bot: ModularBot, e: RoundEndedEventForBot) = ## Dump per-gun virtual bullet stats to /tmp/gun_stats.jsonl (one line per round). if RecordWorldState: bot.finishWorldStateRecord() + # Per-round TM-horizon summary (change-gated behind TR_TMHORIZON_LOG=1) so the + # user can watch it learn across the round. + bot.tmHorizon.roundSummary() # Use lastKnownTargetId: currentTargetId is -1 if enemy died before round end let targetId = if bot.currentTargetId >= 0: bot.currentTargetId else: bot.lastKnownTargetId # Per-target fitness when known, else a deterministic recency-weighted aggregate @@ -577,7 +588,7 @@ method onRoundEnded*(bot: ModularBot, e: RoundEndedEventForBot) = let fit = bot.tracker.fitnessFor(targetId) var gunsArr = newJArray() - for gid in 0..<15: + for gid in 0..<16: var totalShots = 0 var totalHits = 0 for binIdx in 0..= 0: bot.lastKnownTargetId = bot.currentTargetId @@ -998,6 +1026,7 @@ method run*(bot: ModularBot) = var knnPreds: array[len(PowerBins), GunPrediction] var tmselPreds: array[len(PowerBins), GunPrediction] var tmpPreds: array[len(PowerBins), GunPrediction] + var tmhPreds: 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 @@ -1007,12 +1036,13 @@ method run*(bot: ModularBot) = # 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: + # TMPATTERN and TMHORIZON keep their own admission gate even when the knob + # is 0, so TR_VBULLET_ADMIT_ONLY=0 reproduces the exact pre-change rack. + var admit: array[16, bool] + for gi in 0..<16: admit[gi] = vBulletAdmitted(gi, bot.rackMode, ActiveRackMembership, - VBulletAdmitOnly or gi == TmPatternId) + VBulletAdmitOnly or gi == TmPatternId or + gi == TmHorizonId) for i in 0.. max horizon + lookback) + TMH_PENDING_CAP* = 512 ## deferred-label queue (<= 4 spawns/tick * 50) + TMH_BULLETS* = 32 ## our own in-flight bullets (proxy) + ## Magnitude-median histogram resolution. Fine enough that the median does not + ## collapse to 0.0 when the bulk of |err| sits in the first bin (which would + ## label every sample BIG and reintroduce a majority-class trap). + TMH_ABS_RES* = 0.1 + TMH_ABS_BINS* = 640 ## 0.1 deg bins up to 64 deg + ## ── env knobs ─────────────────────────────────────────────────────────── + TMH_SHIFT_ENV* = "TR_TMHORIZON_SHIFT" ## deg; 0 = no correction arm + TMH_BIG_MULT_ENV* = "TR_TMHORIZON_BIG_MULT" ## scale when magnitude is BIG + TMH_LOG_ENV* = "TR_TMHORIZON_LOG" ## 1 = per-change thinking log + ## Reset the machines when the TARGET changes to a different bot id. Default + ## ON: in 1v1 there is one enemy so it is a no-op; in melee it fires on target + ## switches. Set 0 to observe melee without the per-switch wipe. + TMH_RESET_ON_TARGET_ENV* = "TR_TMHORIZON_RESET_ON_TARGET" + TMH_SHIFT_DEFAULT* = 2.0 + TMH_BIG_MULT_DEFAULT* = 1.5 + TMH_RESET_ON_TARGET_DEFAULT* = true + +type + TmhTick* = object + tick*: int + ex*, ey*, eh*, es*: float + lastSeenTick*: int + + TmhBullet = object + ## INFERRED proxy for one of OUR real bullets: detected from a self-energy + ## drop (0.05 < drop <= 3.1), direction proxied by self->enemy at fire time — + ## exactly the offline `buildBulletSeries` model. + arrivalTick: int + ux, uy: float + sx, sy: float + active: bool + + TmhPending* = object + ## One deferred training sample. `lits` is the exact literal vector the TM + ## saw at fire time; the label is resolved h ticks later. + fireTick*: int + horizon*: int + bucket*: int + selfX*, selfY*: float + baseBearing*: float + sidePred*: int + magPred*: int + warm*: bool + lits*: array[TMH_NLITS, uint8] + + TmhEval = object + valid: bool + side: int + sideConf: float + mag: int + + TmHorizonGun* = object + pattern*: PatternMatcherGun + sideMachine: TmMachine + magMachine: TmMachine + # per-class scratch caches (avoid per-sample allocation) + sideScratch: array[2, seq[uint8]] + magScratch: array[2, seq[uint8]] + # observation ring + ring: array[TMH_POS_RING, TmhTick] + ringValid: array[TMH_POS_RING, bool] + # deferred labels + pending: array[TMH_PENDING_CAP, TmhPending] + pendingCount*: int + pendingDropped*: int + # our own bullets (energy-drop proxy) + bullets: array[TMH_BULLETS, TmhBullet] + bulletCount: int + # per-tick caches + cachedBits: array[TMH_N_BASE, uint8] + cachedBitsTick: int + bitsValid: bool + bucketEval: array[TMH_NH, TmhEval] + # motion history + lastTick: int + hasPrev: bool + sinceRev: int + lastNonzeroSign: int + selfPrevEnergy: float + hasSelfPrev: bool + # enqueue dedupe (predict is called once per power bin) + lastEnqTick: int + lastEnqBucket: int + # logging + lastLogKey: string + lastLogTick: int + shiftConfigured: bool + shiftDeg*: float + bigMult*: float + logEnabled*: bool + # round seeding + seedCounter: int + # per-battle config: reset the machines when the target changes enemy id + resetOnTarget*: bool + # the enemy id the machines currently represent (-1 = none). Used by + # `targetChanged` so first acquisition never wipes and a new BATTLE clears it. + observedTargetId*: int + # cumulative `trained` at the start of the current round, so the per-round + # summary can report `thisRound` while `trained` keeps climbing. + roundStartTrained*: int + # magnitude-median histogram (TMH_ABS_RES deg bins) + absHist: array[TMH_ABS_BINS, int] + absCount: int + # ── instrumentation ───────────────────────────────────────────────────── + trained*: int + sideCorrect*, sideTotal*: int + sidePredHist*: array[2, int] + magPredHist*: array[2, int] + sideLabelHist*: array[2, int] + magLabelHist*: array[2, int] + quadHist*: array[4, int] + lastSidePred*: int + lastMagPred*: int + +# ── small pure helpers ─────────────────────────────────────────────────────── + +proc wrapDeg(d: float): float {.inline.} = + result = d + while result > 180.0: result -= 360.0 + while result < -180.0: result += 360.0 + +proc wrapRad(r: float): float {.inline.} = + result = r + while result > PI: result -= 2.0 * PI + while result < -PI: result += 2.0 * PI + +proc signf(x: float): int {.inline.} = + if x > 1e-9: 1 elif x < -1e-9: -1 else: 0 + +proc envFloatT(name: string, default: float): float = + let v = getEnv(name, "") + if v.len == 0: return default + try: parseFloat(v.strip()) + except ValueError: default + +proc envBoolT(name: string, default: bool): bool = + case getEnv(name, "").strip().toLowerAscii() + of "1", "true", "yes", "on": true + of "0", "false", "no", "off": false + else: default + +# ── horizon maths (pure, unit-tested) ──────────────────────────────────────── + +proc tmhHorizonFor*(dist, bulletSpeed: float): int = + ## Derive the horizon from the ACTUAL bullet flight time. `speed = 20-3*power` + ## so `bulletSpeed` already encodes the energy-aware power policy's power. + ## Clamped to the measured live band [10, 50]. + if bulletSpeed <= 0.0: return TMH_H_MIN + result = int(round(dist / bulletSpeed)) + if result < TMH_H_MIN: result = TMH_H_MIN + elif result > TMH_H_MAX: result = TMH_H_MAX + +proc tmhHorizonBucket*(h: int): int = + ## 4 one-hot buckets over [10, 50]: 10-19, 20-29, 30-39, 40-50. + if h < 20: 0 + elif h < 30: 1 + elif h < 40: 2 + else: 3 + +proc tmhQuadrantName*(side, mag: int): string = + ## `side` 1 = LEFT (positive angular error, CCW), 0 = RIGHT; `mag` 1 = BIG. + ## `side < 0` means the model is cold and emits no correction. + if side < 0: return "COLD" + let s = if side == 1: "LEFT" else: "RIGHT" + let m = if mag == 1: "LARGE" else: "SMALL" + m & "-" & s + +proc tmhApplyShift*(selfX, selfY, px, py, shiftDeg: float): GunPrediction = + ## Rotate the base prediction point around the shooter by `shiftDeg` degrees + ## (positive = CCW). The aim DISTANCE is preserved; only the bearing moves. + let dx = px - selfX + let dy = py - selfY + let d = hypot(dx, dy) + if d < 1e-9: return GunPrediction(x: px, y: py) + let b = arctan2(dy, dx) + degToRad(shiftDeg) + GunPrediction(x: selfX + cos(b) * d, y: selfY + sin(b) * d) + +proc tmhLits*(base: array[TMH_N_BASE, uint8], + bucket: int): array[TMH_NLITS, uint8] = + ## Pack the 49 draft bits + the 4-bit horizon one-hot into the pos-then-neg + ## literal layout the TM core uses. + var raw: array[TMH_N_BITS, uint8] + for i in 0.. 0: + g.ensureConfig() + if g.logEnabled: + echo fmt"[tmh-reset] reason={reason} trained_was={trainedWas}" + +proc targetChanged*(g: var TmHorizonGun, enemyId: int): bool = + ## Per-ENEMY reset: when the selected target changes to a DIFFERENT bot id, + ## wipe the machines (the user's reset condition is literally "until the enemy + ## change"). First acquisition (`observedTargetId < 0`) never wipes, so the + ## round-start pick does not cold-start the gun every round. Gated by + ## `TR_TMHORIZON_RESET_ON_TARGET` (default on); returns true when it wiped. + g.ensureConfig() + if not g.resetOnTarget: return false + if enemyId < 0: return false + if g.observedTargetId >= 0 and enemyId != g.observedTargetId: + g.resetLearning("target_change") + g.observedTargetId = enemyId + return true + g.observedTargetId = enemyId + false + +# ── history / observation ring ─────────────────────────────────────────────── + +proc ringAt(g: TmHorizonGun, tick: int): tuple[ok: bool, t: TmhTick] = + let slot = ((tick mod TMH_POS_RING) + TMH_POS_RING) mod TMH_POS_RING + if g.ringValid[slot] and g.ring[slot].tick == tick: + (true, g.ring[slot]) + else: + (false, TmhTick()) + +proc ringValidCount*(g: TmHorizonGun): int = + ## Observability / test seam: how many observation-ring slots are valid. + for i in 0.. b.arrivalTick: + b.active = false + continue + g.bullets[w] = b + inc w + let ta = b.arrivalTick - state.tick + if result.tta < 0 or ta < result.tta: + result.tta = ta + let vx = state.enemyX - b.sx + let vy = state.enemyY - b.sy + result.lat = b.ux * vy - b.uy * vx + g.bulletCount = w + +proc tmhUpdateHistory(g: var TmHorizonGun, state: WorldState) = + ## Once per tick: write the observation ring, advance the reversal counter and + ## the self-energy-drop bullet proxy. + let slot = ((state.tick mod TMH_POS_RING) + TMH_POS_RING) mod TMH_POS_RING + var lst = state.tick + if state.enemies.len > 0: lst = state.enemies[0].lastSeenTick + g.ring[slot] = TmhTick(tick: state.tick, ex: state.enemyX, ey: state.enemyY, + eh: state.enemyHeading, es: state.enemySpeed, + lastSeenTick: lst) + g.ringValid[slot] = true + + let sg = signf(state.enemySpeed) + if sg != 0: + if g.lastNonzeroSign != 0 and sg != g.lastNonzeroSign: g.sinceRev = 0 + else: inc g.sinceRev + g.lastNonzeroSign = sg + else: + inc g.sinceRev + + if g.hasSelfPrev: + let drop = g.selfPrevEnergy - state.selfEnergy + if drop > 0.05 and drop <= 3.1: + let power = clamp(drop, 0.1, 3.0) + let speed = 20.0 - 3.0 * power + let dx = state.enemyX - state.selfX + let dy = state.enemyY - state.selfY + let nrm = max(1e-6, hypot(dx, dy)) + let flight = int(ceil(nrm / speed)) + g.addBullet(TmhBullet(arrivalTick: state.tick + flight, + ux: dx / nrm, uy: dy / nrm, + sx: state.selfX, sy: state.selfY, active: true)) + g.selfPrevEnergy = state.selfEnergy + g.hasSelfPrev = true + +# ── the 49 draft bits, live ────────────────────────────────────────────────── + +proc tmhBaseBits*(g: var TmHorizonGun, state: WorldState): array[TMH_N_BASE, uint8] = + ## The draftTMSpec() blocks, computed from the live bot state + the observation + ## ring. Block layout (mirrors draftTMSpec): + ## 0..3 dist-to-nearest-wall | 4..7 which-wall-nearest + ## 8..13 dist-from-us | 14..16 enemy-heading-vs-line-to-us + ## 17..19 turn-direction t,t-1,t-2 | 20..24 ticks-since-reversal + ## 25..27 turn-consistency-10 | 28..30 distance-moved-10 + ## 31..33 speed-trend-10 | 34..36 turn-rate-change-5 + ## 37..41 time-until-bullet | 42..48 bullet-lateral-offset + var b: array[TMH_N_BASE, uint8] + template put1(idx: int) = b[idx] = 1'u8 + + let ex = state.enemyX + let ey = state.enemyY + + # walls (8) + let dL = ex + let dR = state.arenaWidth - ex + let dT = state.arenaHeight - ey + let dB = ey + let dmin = min(min(dL, dR), min(dT, dB)) + if dmin < 50.0: put1(0) + elif dmin < 100.0: put1(1) + elif dmin < 200.0: put1(2) + else: put1(3) + var wb = 0 + let walls = [dL, dR, dT, dB] + for w in 1..3: + if walls[w] < walls[wb]: wb = w + put1(4 + wb) + + # us (9) + let rng = hypot(ex - state.selfX, ey - state.selfY) + var ub = 5 + if rng < 100.0: ub = 0 + elif rng < 200.0: ub = 1 + elif rng < 300.0: ub = 2 + elif rng < 400.0: ub = 3 + elif rng < 600.0: ub = 4 + put1(8 + ub) + + let lane = arctan2(state.selfY - ey, state.selfX - ex) + let hdg = degToRad(state.enemyHeading) + let perp = abs(sin(hdg - lane)) + var hb = 1 + if perp < 0.5: hb = 2 + elif perp > 0.866: hb = 0 + put1(14 + hb) + + # motion (20) + for k in 0..2: + let cur = g.ringAt(state.tick - k) + let prv = g.ringAt(state.tick - k - 1) + if cur.ok and prv.ok: + let d = wrapDeg(cur.t.eh - prv.t.eh) + if d > 1e-6: put1(17 + k) + + var rb = 4 + let sr = g.sinceRev + if sr < 5: rb = 0 + elif sr < 10: rb = 1 + elif sr < 20: rb = 2 + elif sr < 40: rb = 3 + put1(20 + rb) + + var pos = 0 + var neg = 0 + for k in 0..9: + let cur = g.ringAt(state.tick - k) + let prv = g.ringAt(state.tick - k - 1) + if cur.ok and prv.ok: + let d = wrapDeg(cur.t.eh - prv.t.eh) + if d > 1e-6: inc pos + elif d < -1e-6: inc neg + let tot = pos + neg + let cons = if tot > 0: max(pos, neg).float / tot.float else: 0.0 + var cb = 0 + if cons > 0.8: cb = 2 + elif cons >= 0.5: cb = 1 + put1(25 + cb) + + let j10 = g.ringAt(state.tick - 10) + let dm = if j10.ok: hypot(ex - j10.t.ex, ey - j10.t.ey) else: 0.0 + var mb = 1 + if dm < 20.0: mb = 0 + elif dm > 50.0: mb = 2 + put1(28 + mb) + + let spd10 = if j10.ok: abs(j10.t.es) else: abs(state.enemySpeed) + let spdDiff = abs(state.enemySpeed) - spd10 + var sb = 1 + if spdDiff < -0.5: sb = 0 + elif spdDiff > 0.5: sb = 2 + put1(31 + sb) + + var r1 = 0.0 + var n1 = 0 + for k in 0..4: + let cur = g.ringAt(state.tick - k) + let prv = g.ringAt(state.tick - k - 1) + if cur.ok and prv.ok: + r1 += abs(wrapDeg(cur.t.eh - prv.t.eh)); inc n1 + var r2 = 0.0 + var n2 = 0 + for k in 5..9: + let cur = g.ringAt(state.tick - k) + let prv = g.ringAt(state.tick - k - 1) + if cur.ok and prv.ok: + r2 += abs(wrapDeg(cur.t.eh - prv.t.eh)); inc n2 + let m1 = if n1 > 0: r1 / n1.float else: 0.0 + let m2 = if n2 > 0: r2 / n2.float else: 0.0 + let dtr = m1 - m2 + var tb = 1 + if dtr < -0.3: tb = 0 + elif dtr > 0.3: tb = 2 + put1(34 + tb) + + # bullets (12) — INFERRED from self-energy drops (no gun heading live) + let (tta, lat) = g.bulletFeatures(state) + var b1 = 0 + if tta >= 0: + if tta < 5: b1 = 1 + elif tta < 10: b1 = 2 + elif tta < 20: b1 = 3 + else: b1 = 4 + put1(37 + b1) + + var lb = 3 + if lat < -72.0: lb = 0 + elif lat < -36.0: lb = 1 + elif lat < -18.0: lb = 2 + elif lat <= 18.0: lb = 3 + elif lat <= 36.0: lb = 4 + elif lat <= 72.0: lb = 5 + else: lb = 6 + put1(42 + lb) + + result = b + +# ── TM evaluation / training (no per-sample allocation) ────────────────────── + +proc tmhEvalOne(m: TmMachine, lits: array[TMH_NLITS, uint8], + caches: var array[2, seq[uint8]]): tuple[cls: int, conf: float] = + var votes: array[2, float] + for c in 0..1: + votes[c] = tmForward(m, m.teams[c], lits, caches[c]) + let cls = if votes[1] > votes[0]: 1 else: 0 + let margin = abs(votes[1] - votes[0]) + let conf = + if m.half > 0: clamp(margin / (2.0 * float(m.half)), 0.0, 1.0) + else: 0.0 + (cls, conf) + +proc tmhTrainOne(m: var TmMachine, lits: array[TMH_NLITS, uint8], label: int, + caches: var array[2, seq[uint8]]) = + var votes: array[2, float] + for c in 0..1: + votes[c] = tmForward(m, m.teams[c], lits, caches[c]) + for c in 0..1: + let d = if c == label: 1.0 else: -1.0 + tmLearnDir(m, m.teams[c], lits, caches[c], votes[c], d) + +# ── magnitude median (running histogram) ───────────────────────────────────── + +proc recordAbs(g: var TmHorizonGun, a: float) = + var idx = int(a / TMH_ABS_RES) + if idx < 0: idx = 0 + if idx >= TMH_ABS_BINS: idx = TMH_ABS_BINS - 1 + inc g.absHist[idx] + inc g.absCount + +proc medianAbs(g: TmHorizonGun): float = + if g.absCount == 0: return 0.0 + let half = g.absCount div 2 + var cum = 0 + for i in 0.. half: return i.float * TMH_ABS_RES + TMH_ABS_RES * 0.5 + 63.95 + +# ── deferred label resolution ──────────────────────────────────────────────── + +proc tmhResolveOne(g: var TmHorizonGun, state: WorldState, + p: TmhPending): bool = + ## Resolve one due sample against our OWN observation at `fireTick + horizon`. + ## Returns false when the sample must be DROPPED (missing / stale + ## observation); a perfectly balanced (zero-error) sample is consumed without + ## training and counts as resolved. + let slot = ((state.tick mod TMH_POS_RING) + TMH_POS_RING) mod TMH_POS_RING + if not g.ringValid[slot] or g.ring[slot].tick != state.tick: return false + if state.tick - g.ring[slot].lastSeenTick > TMH_STALE_MAX: return false + let act = g.ring[slot] + let ba = arctan2(act.ey - p.selfY, act.ex - p.selfX) + let err = wrapRad(ba - p.baseBearing) + let absE = abs(err) + + if absE >= 1e-9: + let med = g.medianAbs() + let sideLabel = if err > 0.0: 1 else: 0 + let magLabel = if absE > med: 1 else: 0 + if p.warm: + inc g.sideTotal + if p.sidePred == sideLabel: inc g.sideCorrect + inc g.sideLabelHist[sideLabel] + inc g.magLabelHist[magLabel] + tmhTrainOne(g.sideMachine, p.lits, sideLabel, g.sideScratch) + tmhTrainOne(g.magMachine, p.lits, magLabel, g.magScratch) + inc g.trained + g.recordAbs(absE) + true + +proc tmhResolvePending(g: var TmHorizonGun, state: WorldState) = + ## Resolve every sample due at this tick, compacting the queue in place. + ## Samples whose horizon reaches past the round end are simply never due and + ## are discarded by the next `resetRoundState` (a plain round boundary). + var w = 0 + for i in 0.. state.tick: + g.pending[w] = p + inc w + elif due == state.tick: + if not g.tmhResolveOne(state, p): + inc g.pendingDropped + else: + inc g.pendingDropped + g.pendingCount = w + +proc tmhEnqueue(g: var TmHorizonGun, state: WorldState, baseBearing: float, + h, bucket: int, lits: array[TMH_NLITS, uint8], + sidePred, magPred: int, warm: bool) = + if g.pendingCount >= TMH_PENDING_CAP: + inc g.pendingDropped + return + g.pending[g.pendingCount] = TmhPending( + fireTick: state.tick, horizon: h, bucket: bucket, + selfX: state.selfX, selfY: state.selfY, baseBearing: baseBearing, + sidePred: sidePred, magPred: magPred, warm: warm, lits: lits) + inc g.pendingCount + if warm: + inc g.sidePredHist[sidePred] + inc g.magPredHist[magPred] + inc g.quadHist[sidePred * 2 + magPred] + +# ── logging ────────────────────────────────────────────────────────────────── + +proc tmhLog(g: var TmHorizonGun, state: WorldState, bulletSpeed: float, + h, side, mag: int, sideConf, shift: float, aimDeg: float) = + ## ONE change-gated line (behind `TR_TMHORIZON_LOG=1`) so the user can see it + ## think per shot, not per tick. Format: + ## [tmh] h=22 p=2.0 pred=LARGE-LEFT shift=-3.0 aim=173.2 gun=Pattern conf=0.62 trained=1841 + if not g.logEnabled: return + let q = tmhQuadrantName(side, mag) + # Gate on the PREDICTION (quadrant) and the applied shift — NOT on the horizon, + # which changes almost every tick as the range moves and would spam the log. + let key = fmt"{q}|{shift:.1f}" + if key == g.lastLogKey: return + if state.tick == g.lastLogTick: return + g.lastLogKey = key + g.lastLogTick = state.tick + let power = (20.0 - bulletSpeed) / 3.0 + echo fmt"[tmh] h={h} p={power:.1f} pred={q} shift={shift:.1f} " & + fmt"aim={aimDeg:.1f} gun=Pattern conf={sideConf:.2f} trained={g.trained}" + +proc roundSummary*(g: var TmHorizonGun) = + ## Per-round summary on round end (behind the same log switch), so the user can + ## watch it learn across the round. + g.ensureConfig() + if not g.logEnabled: return + let thisRound = g.trained - g.roundStartTrained + let acc = if g.sideTotal > 0: g.sideCorrect.float / g.sideTotal.float * 100.0 + else: 0.0 + echo fmt"[tmh-round] trained={g.trained} thisRound={thisRound} pending={g.pendingCount} " & + fmt"dropped={g.pendingDropped} sideAcc={g.sideCorrect}/{g.sideTotal} " & + fmt"({acc:.1f}%) " & + fmt"quad=[SR:{g.quadHist[0]} LR:{g.quadHist[1]} " & + fmt"SL:{g.quadHist[2]} LL:{g.quadHist[3]}] " & + fmt"sidePred=[R:{g.sidePredHist[0]} L:{g.sidePredHist[1]}] " & + fmt"magPred=[S:{g.magPredHist[0]} B:{g.magPredHist[1]}] " & + fmt"labels=[side R:{g.sideLabelHist[0]} L:{g.sideLabelHist[1]} " & + fmt"mag S:{g.magLabelHist[0]} B:{g.magLabelHist[1]}]" + +# ── Gun interface ──────────────────────────────────────────────────────────── + +proc isWarmedUp*(g: TmHorizonGun): bool {.inline.} = true + +proc predict*(g: var TmHorizonGun, state: WorldState, + bulletSpeed: float): GunPrediction = + g.ensureConfig() + + # Round boundary: a tick regression means a new round. Clear ONLY the + # per-round state (ring + deferred labels); the machines SURVIVE so learning + # accumulates across the whole battle. + if state.tick < g.lastTick: g.resetRoundState() + + # Once-per-tick: observe the world, then resolve any labels now due. + if state.tick != g.lastTick: + g.tmhUpdateHistory(state) + g.tmhResolvePending(state) + g.lastTick = state.tick + for i in 0..= TMH_MIN_OBS + let side = if warm: ev.side else: -1 + let mag = if warm: ev.mag else: -1 + g.lastSidePred = side + g.lastMagPred = mag + + let baseBearing = arctan2(base.y - state.selfY, base.x - state.selfX) + + # Enqueue one sample per (tick, bucket); predict runs once per power bin. + if g.lastEnqTick != state.tick or g.lastEnqBucket != bucket: + g.tmhEnqueue(state, baseBearing, h, bucket, lits, side, mag, warm) + g.lastEnqTick = state.tick + g.lastEnqBucket = bucket + + # Small hit-optimal-style correction (NOT the conditional median). + var shift = 0.0 + if warm and g.shiftDeg != 0.0: + let magScale = if mag == 1: g.bigMult else: 1.0 + let sgn = if side == 1: 1.0 else: -1.0 + shift = sgn * g.shiftDeg * magScale + + let pred = + if shift == 0.0: base + else: tmhApplyShift(state.selfX, state.selfY, base.x, base.y, shift) + + let aimDeg = radToDeg(arctan2(pred.y - state.selfY, pred.x - state.selfX)) + g.tmhLog(state, bulletSpeed, h, side, mag, ev.sideConf, shift, aimDeg) + pred + +proc onResult*(g: var TmHorizonGun, e: FeedbackEvent) = + ## The label comes from our own observation ring, not from virtual-bullet + ## feedback, so there is nothing to do here. The hook exists for the rack. + discard diff --git a/common_libs/tests/test_tm_horizon.nim b/common_libs/tests/test_tm_horizon.nim new file mode 100644 index 0000000..1c4f51b --- /dev/null +++ b/common_libs/tests/test_tm_horizon.nim @@ -0,0 +1,340 @@ +## Pure unit guard for the horizon-based TM gun (common_libs/guns/tm_horizon). +## +## No Java, no server, no battle. Covers what can be tested without a battle: +## * the horizon-from-flight-time maths and its [10,50] clamp; +## * the 4-bit horizon bucket boundaries; +## * feature extraction shapes: 49 draft bits with exactly one-hot blocks, and +## the 53-bit literal vector with pos/neg complementary literals; +## * the applied-shift geometry (0 deg = identity, +90 = CCW); +## * label lookup: a sample resolves h ticks later, is DROPPED when the +## observation is stale, the last h ticks of a round never resolve, and a +## round boundary wipes the pending queue AND the observation ring (so a +## label can never be built from a position across a round boundary); +## * the reset SPLIT: a round boundary keeps the machines (learning +## accumulates), a game start wipes them, and a target change wipes them +## unless TR_TMHORIZON_RESET_ON_TARGET is off; +## * a full round trains the two binary heads. +## +## Run with plain: +## nim c -r common_libs/tests/test_tm_horizon.nim + +import std/[math] +import gun_harness/gun_interface +import gun_harness/virtual_bullets +import gun_harness/offline_range +import guns/pattern_matcher +import guns/tm_horizon + +var failures = 0 +proc check(name: string, ok: bool) = + if ok: echo "PASS: ", name + else: echo "FAIL: ", name; inc failures + +proc approx(a, b, tol: float): bool {.inline.} = abs(a - b) <= tol + +proc sideTeamCopy(g: TmHorizonGun): seq[int16] = + ## Snapshot of the learned clause states, to prove the machines survive or are + ## wiped by a given reset. + g.sideClauseStates() + +proc drive(g: var TmHorizonGun, fx: Fixture) = + for state in fx.states: + for b in 0.. 20 ticks", + tmhHorizonFor(220.0, 11.0) == 20 + check "horizon: dist 100 / speed 17 -> clamped to H_MIN (10)", + tmhHorizonFor(100.0, 17.0) == TMH_H_MIN + check "horizon: dist 800 / speed 11 -> clamped to H_MAX (50)", + tmhHorizonFor(800.0, 11.0) == TMH_H_MAX + check "horizon: zero bullet speed falls back to H_MIN", + tmhHorizonFor(300.0, 0.0) == TMH_H_MIN + check "horizon: rounding is to the nearest tick", + tmhHorizonFor(250.0, 10.0) == 25 + +proc testHorizonBuckets() = + check "bucket: 10..19 -> 0", + tmhHorizonBucket(10) == 0 and tmhHorizonBucket(19) == 0 + check "bucket: 20..29 -> 1", + tmhHorizonBucket(20) == 1 and tmhHorizonBucket(29) == 1 + check "bucket: 30..39 -> 2", + tmhHorizonBucket(30) == 2 and tmhHorizonBucket(39) == 2 + check "bucket: 40..50 -> 3", + tmhHorizonBucket(40) == 3 and tmhHorizonBucket(50) == 3 + +proc testQuadrantNames() = + check "quadrant: cold model names COLD", tmhQuadrantName(-1, -1) == "COLD" + check "quadrant: LARGE-LEFT", tmhQuadrantName(1, 1) == "LARGE-LEFT" + check "quadrant: SMALL-RIGHT", tmhQuadrantName(0, 0) == "SMALL-RIGHT" + +# ── feature shapes ──────────────────────────────────────────────────────────── + +proc blockSum(b: array[TMH_N_BASE, uint8], lo, hi: int): int = + for i in lo..hi: result += int(b[i]) + +proc testFeatureShapes() = + var g = initTmHorizonGun() + # A straight, moving enemy so every history-dependent block is populated. + let fx = synthesizeConstantVelocity(ticks = 40, speed = 4.0) + for state in fx.states: + discard g.predict(state, bulletSpeed(PowerBins[0])) + let state = fx.states[^1] + let b = g.tmhBaseBits(state) + check "bits: the draft vector is exactly 49 bits", b.len == TMH_N_BASE + # Every one-hot block must carry exactly one set bit. + check "bits: dist-to-nearest-wall is one-hot", blockSum(b, 0, 3) == 1 + check "bits: which-wall-nearest is one-hot", blockSum(b, 4, 7) == 1 + check "bits: dist-from-us is one-hot", blockSum(b, 8, 13) == 1 + check "bits: enemy-heading-vs-line-to-us is one-hot", blockSum(b, 14, 16) == 1 + check "bits: ticks-since-reversal is one-hot", blockSum(b, 20, 24) == 1 + check "bits: turn-consistency-10 is one-hot", blockSum(b, 25, 27) == 1 + check "bits: distance-moved-10 is one-hot", blockSum(b, 28, 30) == 1 + check "bits: speed-trend-10 is one-hot", blockSum(b, 31, 33) == 1 + check "bits: turn-rate-change-5 is one-hot", blockSum(b, 34, 36) == 1 + check "bits: time-until-bullet is one-hot", blockSum(b, 37, 41) == 1 + check "bits: bullet-lateral-offset is one-hot", blockSum(b, 42, 48) == 1 + +proc testLiteralLayout() = + var base: array[TMH_N_BASE, uint8] + base[0] = 1'u8 + base[8] = 1'u8 + for bucket in 0.. north (+y in the math convention). + let p90 = tmhApplyShift(100.0, 100.0, 200.0, 100.0, 90.0) + check "shift: +90 deg rotates east to +y (CCW)", + approx(p90.x, 100.0, 1e-9) and approx(p90.y, 200.0, 1e-9) + let pm90 = tmhApplyShift(100.0, 100.0, 200.0, 100.0, -90.0) + check "shift: -90 deg rotates east to -y (CW)", + approx(pm90.x, 100.0, 1e-9) and approx(pm90.y, 0.0, 1e-9) + check "shift: the aim distance is preserved", + approx(hypot(p90.x - 100.0, p90.y - 100.0), 100.0, 1e-9) + +# ── label resolution ────────────────────────────────────────────────────────── + +proc testRoundTrains() = + var g = initTmHorizonGun() + g.setShift(0.0) # pure-predict arm: still trains + let fx = synthesizeCircular(ticks = 240) + drive(g, fx) + check "train: a full round resolves samples (trained > 0)", g.trained > 0 + check "train: the model warmed past the cold gate", g.trained >= TMH_MIN_OBS + check "train: the last h ticks are still pending at round end", + g.pendingCount > 0 + +proc testRoundReset() = + var g = initTmHorizonGun() + g.setShift(0.0) + drive(g, synthesizeCircular(ticks = 240)) + let t1 = g.trained + g.resetLearning() + check "reset: trained is wiped", g.trained == 0 + check "reset: the pending queue is wiped (no cross-round labels)", g.pendingCount == 0 + check "reset: side accuracy counters are wiped", g.sideTotal == 0 + drive(g, synthesizeCircular(ticks = 240)) + check "reset: the fresh round trains again", g.trained > 0 and t1 > 0 + +proc testRoundBoundaryKeepsMachines() = + ## THE SPLIT: a plain round boundary (`resetRoundState`) must keep the learned + ## machines and drop only the per-round observation/label state. + var g = initTmHorizonGun() + g.setShift(0.0) + drive(g, synthesizeCircular(ticks = 240)) + let trainedBefore = g.trained + let sigBefore = sideTeamCopy(g) + check "round-boundary: samples trained before the boundary", trainedBefore > 0 + check "round-boundary: unresolved labels exist at the boundary", g.pendingCount > 0 + g.resetRoundState() + check "round-boundary: trained SURVIVES the boundary", g.trained == trainedBefore + check "round-boundary: the clause states SURVIVE the boundary", + sideTeamCopy(g) == sigBefore + check "round-boundary: the pending label queue is cleared", g.pendingCount == 0 + check "round-boundary: the observation ring is cleared", g.ringValidCount() == 0 + +proc testLearningAccumulatesAcrossRounds() = + ## The whole point of the fix: round 2 keeps round 1's learning and keeps + ## training on top of it, so `trained` CLIMBS across the battle. + var g = initTmHorizonGun() + g.setShift(0.0) + drive(g, synthesizeCircular(ticks = 240)) + let r1 = g.trained + g.resetRoundState() # exactly what onRoundStarted now does + drive(g, synthesizeCircular(ticks = 240)) + check "accumulate: round 2 starts from round 1's total (trained climbs)", + g.trained > r1 + +proc testGameStartWipesMachines() = + ## A new BATTLE (`resetLearning`) must wipe machines + stats + round state. + var g = initTmHorizonGun() + g.setShift(0.0) + drive(g, synthesizeCircular(ticks = 240)) + check "game-start: samples trained before the wipe", g.trained > 0 + g.resetLearning("game_start") + check "game-start: trained is wiped", g.trained == 0 + check "game-start: side accuracy counters are wiped", g.sideTotal == 0 + check "game-start: the pending label queue is cleared", g.pendingCount == 0 + check "game-start: the observation ring is cleared", g.ringValidCount() == 0 + check "game-start: every clause is back to the Exclude boundary", + g.sideClausesAllExclude() + +proc testTargetChangeResetsMachines() = + ## Reset on TARGET change to a different bot id, gated by the knob. + var g = initTmHorizonGun() + g.setShift(0.0) + g.setResetOnTarget(true) + discard g.targetChanged(7) # first acquisition: never wipes + drive(g, synthesizeCircular(ticks = 240)) + check "target: trained before the change", g.trained > 0 + discard g.targetChanged(7) # same enemy: no wipe + check "target: the same enemy does not wipe the machines", g.trained > 0 + let wiped = g.targetChanged(9) # different enemy: wipe + check "target: a DIFFERENT enemy wipes the machines", wiped and g.trained == 0 + + # Knob OFF: a different enemy must NOT wipe the machines. + var h = initTmHorizonGun() + h.setShift(0.0) + h.setResetOnTarget(false) + discard h.targetChanged(7) + drive(h, synthesizeCircular(ticks = 240)) + check "target: trained before the change (knob off)", h.trained > 0 + let wipedOff = h.targetChanged(9) + check "target: knob off leaves the machines intact", + (not wipedOff) and h.trained > 0 + +proc testLabelCannotCrossBoundary() = + ## The most dangerous interaction: a deferred label must never be resolved + ## against a position from the previous round. At a round boundary BOTH the + ## pending queue and the observation ring are cleared, so the lookup at + ## `fireTick + h` cannot find an old position (and no old pending survives). + var g = initTmHorizonGun() + g.setShift(0.0) + let fx = synthesizeCircular(ticks = 240) + drive(g, fx) + let endTick = fx.states[^1].tick + check "cross-boundary: unresolved labels exist at round end", g.pendingCount > 0 + check "cross-boundary: the round-end observation is in the ring", g.ringHas(endTick) + g.resetRoundState() # the round boundary + check "cross-boundary: the old observation is gone", not g.ringHas(endTick) + check "cross-boundary: the deferred label queue is gone", g.pendingCount == 0 + check "cross-boundary: the ring is empty after the boundary", g.ringValidCount() == 0 + +proc testTickRegressionKeepsMachines() = + ## The tick-regression self-reset (a missed onRoundStarted) must clear only the + ## per-round state and must NOT wipe the machines. + var g = initTmHorizonGun() + g.setShift(0.0) + drive(g, synthesizeCircular(ticks = 120)) + let t = g.trained + let sig = sideTeamCopy(g) + check "regression: samples trained before the new round", t > 0 + # Simulate the server resetting the tick counter to 0 for a new round. + let fx = synthesizeCircular(ticks = 5) + discard g.predict(fx.states[0], bulletSpeed(PowerBins[0])) + check "regression: a tick regression does NOT wipe the machines", g.trained == t + check "regression: the clause states survive the regression", + sideTeamCopy(g) == sig + check "regression: the old observation ring is cleared (one fresh tick only)", + g.ringValidCount() == 1 + +proc testStaleObservationsDropped() = + ## Build a fixture whose `lastSeenTick` is frozen far in the past: every + ## resolved label must be dropped, never trained on. + var g = initTmHorizonGun() + g.setShift(0.0) + var states: seq[WorldState] + for t in 0..<120: + var s = WorldState( + enemyX: 400.0 + 3.0 * t.float, enemyY: 300.0, + enemyHeading: 0.0, enemySpeed: 3.0, enemyEnergy: 100.0, + selfX: 100.0, selfY: 300.0, selfEnergy: 100.0, + arenaWidth: 800.0, arenaHeight: 600.0, tick: t, + enemies: @[EnemyInfo(id: 1, x: 400.0 + 3.0 * t.float, y: 300.0, + heading: 0.0, speed: 3.0, energy: 100.0, + lastSeenTick: 0)]) # frozen -> always stale + states.add s + for state in states: + for b in 0.. 0 + +proc testColdModelEmitsNoShift() = + ## A cold machine must return Pattern's prediction UNCHANGED. Constant-velocity + ## motion is predicted perfectly by Pattern, so no sample trains and the model + ## stays cold for the whole fixture. + var g = initTmHorizonGun() + g.setShift(3.0) + var pm = PatternMatcherGun() + let fx = synthesizeConstantVelocity(ticks = 60, speed = 4.0) + for state in fx.states: + for b in 0.. RIGHT) and check + ## the correction actually rotates Pattern's base aim by the configured -3 deg. + var g = initTmHorizonGun() + g.setShift(3.0, 1.0) + g.trained = 100 # force warm; the fresh head votes 0/0 -> RIGHT + let fx = synthesizeCircular(ticks = 6) + let state = fx.states[2] + let speed = bulletSpeed(PowerBins[0]) + let p = g.predict(state, speed) + # Pattern caches per tick, so this is the exact base point g used. + let q = g.pattern.predict(state, speed) + check "warm: the corrected aim differs from the Pattern base", + not (approx(p.x, q.x, 1e-9) and approx(p.y, q.y, 1e-9)) + let b0 = arctan2(q.y - state.selfY, q.x - state.selfX) + let b1 = arctan2(p.y - state.selfY, p.x - state.selfX) + var d = radToDeg(b1 - b0) + while d > 180.0: d -= 360.0 + while d < -180.0: d += 360.0 + check "warm: the applied rotation is the configured -3.0 deg", + approx(d, -3.0, 1e-6) + +when isMainModule: + testHorizonMaths() + testHorizonBuckets() + testQuadrantNames() + testFeatureShapes() + testLiteralLayout() + testShiftGeometry() + testRoundTrains() + testRoundReset() + testRoundBoundaryKeepsMachines() + testLearningAccumulatesAcrossRounds() + testGameStartWipesMachines() + testTargetChangeResetsMachines() + testLabelCannotCrossBoundary() + testTickRegressionKeepsMachines() + testStaleObservationsDropped() + testColdModelEmitsNoShift() + testWarmShiftMovesAim() + if failures > 0: + echo "\n", failures, " check(s) FAILED" + quit(1) + echo "\nAll tm-horizon checks passed."