## tm_horizon.nim — a HORIZON-BASED Tsetlin Machine that CORRECTS the Pattern gun. ## ## Design (the exact brief this file implements): ## * BASE — the shipped best gun, `PatternMatcherGun` (guns/pattern_matcher). ## Pattern supplies the prediction; the TM only CORRECTS it. The old ## mistake of layering a TM on a weak base (Linear) is not repeated. ## * INPUT — the 49-bit draft spec (`tm_diag/feature_spec.draftTMSpec`) PLUS a ## 4-bit horizon one-hot, i.e. 53 raw bits. The draft blocks are: ## walls(8) / us(9) / motion(20) / bullets(12) = 49 ## The horizon block is derived from the ACTUAL bullet flight time, ## `speed = 20 - 3*power`, `h = round(dist / speed)`, clamped to ## [10, 50] (the measured 5..9 dead zone is excluded). ## * OUTPUT — TWO BINARIES, four quadrants: ## (a) is the enemy LEFT or RIGHT of the base prediction? ## (b) is the angular correction bigger or smaller than the median? ## A multi-class head created the majority-class trap before; two ## balanced binaries cannot. ## * LABEL — a FACT, not a correction: at tick t and horizon h, look up where ## the enemy ACTUALLY was at t+h in our OWN observation ring. Never ## cross a round boundary (pending samples are cleared on reset; the ## last h ticks simply never resolve). Only samples whose enemy was ## observed within `TMH_STALE_MAX` ticks are used — stale ## observations are guesses and guessing in the answer key is what ## shipped wrong labels before. ## * TRAIN — the machines SURVIVE round boundaries: learning accumulates ## across every round of the same battle/enemy ("every battle i means ## from round 1 to round end-battle, so retain all learning until the ## enemy change"). Two different triggers wipe two different kinds of ## state: ## - `resetRoundState` (per-ROUND) clears ONLY the observation ring, ## the deferred-label queue and the motion/bullet/per-tick ## history. It runs from `onRoundStarted` and on a tick ## regression. Bots teleport back to their corners between ## rounds, so old positions are meaningless and would poison the ## labels. ## - `resetLearning` (per-BATTLE / per-ENEMY) wipes the Tsetlin ## machines and every learned statistic, then clears the round ## state too. It runs on a NEW BATTLE (`onGameStarted`, with a ## round-1 fallback) and when the TARGET changes to a different ## bot id (`TR_TMHORIZON_RESET_ON_TARGET`, default on). ## There is still NO persistence across battles: nothing is written ## to disk and nothing carries into a different battle. ## * APPLY — a SMALL hit-optimal-style correction (default a few degrees, ## `TR_TMHORIZON_SHIFT`), NOT the conditional median. Gate 2b showed ## the error median (4-16 deg) is catastrophically wrong when the ## hit-optimal shift is only ~±2-3.5 deg. `TR_TMHORIZON_SHIFT=0` ## disables the correction entirely (pure predict arm). ## ## EXPECTED OUTCOME: this design is PREDICTED TO LOSE. Gate 2b measured that it ## needs ~80% side accuracy to break even on hits while the achievable signal is ## ~60%, so it is expected to lose to Pattern. It is built anyway because the ## user asked to see it in a real battle and because offline metrics have been ## wrong before. See `common_libs/tests/gate2b_hit_optimal_results.txt`. ## ## COST: per-tick work is cached the way Pattern caches its path (base bits once ## per tick, one TM evaluation per horizon bucket), so it stays in the ## neighbourhood of the 0.36 ms/tick TM gun rather than Tsetlin's ~5.3 ms/tick. ## ## RE-ADAPTATION (all knobs default to the behaviour above — nothing regresses): ## The user's failure mode is "it learns the enemy, the enemy adapts, and we ## are too slow to re-adapt". The cause is that the machines accumulate EVERY ## resolved sample for the whole battle, so old evidence weighs as much as new. ## Three independent, off-by-default fixes: ## * TR_TMHORIZON_WINDOW=N (>0): every TR_TMHORIZON_RETRAIN_EVERY samples ## (default 50) rebuild BOTH heads from scratch and retrain on the last N ## resolved samples from a bounded ring. Stale evidence ages out. ## * TR_TMHORIZON_RESET_DROP=pp (>0): track the rolling-100 side accuracy; ## when it falls more than `pp` below its own recent peak, treat it as ## "the enemy changed", rebuild the heads and re-learn from the recent ## window. The rolling-100/300 accuracy and its per-round min/mean/end are ## always available in `roundSummary`; TR_TMHORIZON_ACCURVE=1 logs a ## `[tmh-acc]` curve line every 25 warm samples so the decay is visible. ## * TR_TMHORIZON_NSTATES=K: runtime automata state count (inertia), so the ## fast/slow trade-off can be swept without a rebuild. Default = the ## compile-time TMH_NSTATES. ## Offline measurement (`tests/measure_tm_readapt.nim`, prequential side ## accuracy on the DrussGT fixtures): on `tr_drussgt_vs_modularbot` the ## keep-everything arm sits at ~76% late accuracy, the sliding window at ~85%, ## the change-detection re-learn at ~84%, and a shuffled-label control stays at ## ~50%. Lowering the state count from 64 to 8 lifts the keep-everything arm to ## ~83% but leaves the window flat — i.e. forgetting and low inertia are ## substitutes, and forgetting is the stronger one. ## ## Coordinate system: 0° = East, CCW positive (Tank Royale standard). import std/[math, os, strutils, strformat] import gun_harness/gun_interface import guns/pattern_matcher import tm_diag/tm_core const ## ── feature geometry ──────────────────────────────────────────────────── TMH_N_BASE* = 49 ## the draftTMSpec() bit count TMH_NH* = 4 ## horizon one-hot width TMH_N_BITS* = TMH_N_BASE + TMH_NH ## 53 raw bits TMH_NLITS* = 2 * TMH_N_BITS ## pos + neg literals ## ── horizon range (measured: 5..9 is a dead zone with no signal) ──────── TMH_H_MIN* = 10 TMH_H_MAX* = 50 ## ── classifier shape (cheap; two binaries) ───────────────────────────── TMH_NCLAUSES* {.intdefine.} = 40 TMH_NSTATES* {.intdefine.} = 64 TMH_S_DEF {.strdefine.} = "3.0" TMH_S* = parseFloat(TMH_S_DEF) ## Cold gate: below this many resolved samples the TM emits no correction. TMH_MIN_OBS* {.intdefine.} = 24 ## A resolved sample is only used when the enemy was observed this recently. TMH_STALE_MAX* {.intdefine.} = 8 ## ── rings ─────────────────────────────────────────────────────────────── TMH_POS_RING* = 256 ## observation ring (> 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 ## ── re-adaptation knobs (all default to today's behaviour) ───────────── ## Sliding-window / forgetting: >0 rebuilds both heads on the most recent N ## resolved training samples every `TMH_RETRAIN_EVERY` new samples, so stale ## evidence ages out. 0 = keep everything (current shipped behaviour). TMH_WINDOW_ENV* = "TR_TMHORIZON_WINDOW" ## Change detection: when the rolling side accuracy falls more than this many ## percentage points below its own recent peak, treat it as "the enemy ## changed" and rebuild + re-learn from the recent window. 0 = off. TMH_RESET_DROP_ENV* = "TR_TMHORIZON_RESET_DROP" ## Runtime automata state count (inertia). Default = the compile-time value. TMH_NSTATES_ENV* = "TR_TMHORIZON_NSTATES" ## 1 = log the rolling accuracy every `TMH_ACC_LOG_EVERY` warm samples. TMH_ACCURVE_ENV* = "TR_TMHORIZON_ACCURVE" ## How often the sliding/window mode does its full retrain, and how many ## epochs that retrain runs. Defaults keep the retrain cheap. TMH_RETRAIN_EVERY_ENV* = "TR_TMHORIZON_RETRAIN_EVERY" TMH_EPOCHS_ENV* = "TR_TMHORIZON_EPOCHS" ## ── fixed instrumentation geometry ────────────────────────────────────── TMH_ACC_CAP* = 512 ## rolling-accuracy ring (> the 300 window) TMH_ACC_WIN1* = 100 ## fast rolling window (samples) TMH_ACC_WIN2* = 300 ## slow rolling window (samples) TMH_ACC_LOG_EVERY* = 25 ## ACCURVE: one line every N warm samples TMH_RETRAIN_EVERY_DEF* = 50 TMH_EPOCHS_DEF* = 1 ## When only RESET_DROP is on (WINDOW == 0) the re-learn uses this window. TMH_RESET_WINDOW_DEF* = 200 TMH_RESET_MIN_SAMPLES* = 100 ## never judge a drop before this many samples TMH_RESET_COOLDOWN* = 100 ## samples between two change-triggered resets 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 TmhSample = object ## One resolved training sample kept in the bounded ring that feeds the ## sliding-window rebuild and the change-detection re-learn. lits: array[TMH_NLITS, uint8] side: uint8 mag: uint8 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 # ── re-adaptation: knobs + bounded sample ring ────────────────────────── windowN*: int ## TR_TMHORIZON_WINDOW (0 = keep everything) resetDrop*: float ## TR_TMHORIZON_RESET_DROP (0 = off), percentage points retrainEvery*: int retrainEpochs*: int nStates*: int ## runtime automata state count (inertia) buffer: seq[TmhSample] bufCap: int bufCount: int ## total resolved samples ever appended sinceRetrain: int # ── rolling side accuracy (change detection + curve) ──────────────────── accRing: array[TMH_ACC_CAP, uint8] accPos: int accCount*: int ## total warm samples recorded in the ring accPeak*: float ## peak rolling-100 accuracy (percent) sinceResetDrop: int resetDrops*: int ## change-triggered re-learns accurveEnabled*: bool accSnapMin*: float accSnapMax*: float accSnapSum*: float accSnapN*: int lastAccLogCount: 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 proc envIntT(name: string, default: int): int = let v = getEnv(name, "") if v.len == 0: return default try: parseInt(v.strip()) except ValueError: 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.0: TMH_RESET_WINDOW_DEF else: 0)) if want > g.bufCap: g.bufCap = want g.buffer.setLen(want) proc initTmHorizonGun*(): TmHorizonGun = result.pattern = PatternMatcherGun() # Runtime inertia: read once here so the state count can be swept with an env # knob instead of a rebuild. Default is the compile-time TMH_NSTATES. result.nStates = clamp(envIntT(TMH_NSTATES_ENV, TMH_NSTATES), 2, 4096) result.windowN = max(0, envIntT(TMH_WINDOW_ENV, 0)) result.resetDrop = max(0.0, envFloatT(TMH_RESET_DROP_ENV, 0.0)) result.retrainEvery = max(1, envIntT(TMH_RETRAIN_EVERY_ENV, TMH_RETRAIN_EVERY_DEF)) result.retrainEpochs = max(1, envIntT(TMH_EPOCHS_ENV, TMH_EPOCHS_DEF)) result.accurveEnabled = envBoolT(TMH_ACCURVE_ENV, false) result.ensureBuffer() result.sideMachine = newMachine(TMH_N_BITS, 2, TMH_NCLAUSES, result.nStates, TMH_S, seed = 1) result.magMachine = newMachine(TMH_N_BITS, 2, TMH_NCLAUSES, result.nStates, TMH_S, seed = 2) for c in 0..1: result.sideScratch[c] = newSeq[uint8](TMH_NCLAUSES) result.magScratch[c] = newSeq[uint8](TMH_NCLAUSES) result.lastTick = -1 result.lastEnqTick = -1 result.lastEnqBucket = -1 result.shiftDeg = TMH_SHIFT_DEFAULT result.bigMult = TMH_BIG_MULT_DEFAULT result.resetOnTarget = TMH_RESET_ON_TARGET_DEFAULT result.observedTargetId = -1 result.lastSidePred = -1 result.lastMagPred = -1 proc ensureConfig(g: var TmHorizonGun) {.inline.} = ## Lazily read the env knobs on first use (like Pattern's radial knobs), so a ## unit test can poke the env in-process and an unset env cannot move the gun. if g.shiftConfigured: return g.shiftDeg = envFloatT(TMH_SHIFT_ENV, TMH_SHIFT_DEFAULT) g.bigMult = envFloatT(TMH_BIG_MULT_ENV, TMH_BIG_MULT_DEFAULT) g.logEnabled = envBoolT(TMH_LOG_ENV, false) g.resetOnTarget = envBoolT(TMH_RESET_ON_TARGET_ENV, TMH_RESET_ON_TARGET_DEFAULT) g.shiftConfigured = true proc setShift*(g: var TmHorizonGun, shiftDeg: float, bigMult = TMH_BIG_MULT_DEFAULT) = ## Explicit per-gun override (tests / offline sweeps). Writes the same fields ## the env path writes, so the measured code path is identical. g.shiftDeg = shiftDeg g.bigMult = bigMult g.shiftConfigured = true proc setResetOnTarget*(g: var TmHorizonGun, on: bool) = ## Explicit per-gun override (tests). Writes the same field the env path does ## and freezes config, so a later `ensureConfig` cannot move it. g.resetOnTarget = on g.shiftConfigured = true proc setWindow*(g: var TmHorizonGun, n: int) = ## Explicit per-gun override (tests / offline sweeps): sliding-window width. g.windowN = max(0, n) g.ensureBuffer() proc setResetDrop*(g: var TmHorizonGun, pp: float) = ## Explicit per-gun override (tests): change-detection threshold in pp. g.resetDrop = max(0.0, pp) g.ensureBuffer() proc setRetrainConfig*(g: var TmHorizonGun, every, epochs: int) = g.retrainEvery = max(1, every) g.retrainEpochs = max(1, epochs) proc setAccurve*(g: var TmHorizonGun, on: bool) = g.accurveEnabled = on proc resetRoundState*(g: var TmHorizonGun) = ## PER-ROUND wipe ONLY. Clears the observation ring, the deferred-label queue ## and every piece of motion / bullet / per-tick history that is meaningless ## once the bots teleport back to their starting corners. The Tsetlin machines ## and their learned statistics are LEFT UNTOUCHED: learning accumulates across ## rounds of the same battle/enemy. This is the ONLY reset that runs at a plain ## round boundary. g.pendingCount = 0 g.bulletCount = 0 g.hasPrev = false g.hasSelfPrev = false g.sinceRev = 0 g.lastNonzeroSign = 0 g.lastTick = -1 g.lastEnqTick = -1 g.lastEnqBucket = -1 g.bitsValid = false g.lastLogKey = "" g.lastLogTick = -1 g.lastSidePred = -1 g.lastMagPred = -1 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) # ── sliding window / change detection ──────────────────────────────────────── proc bufferedCount*(g: TmHorizonGun): int = ## Test/observability seam: how many resolved samples the bounded ring has ## seen since the last battle/enemy reset. g.bufCount proc bufferCapacity*(g: TmHorizonGun): int = ## Test/observability seam: the allocated ring capacity (0 = buffering off). g.bufCap proc rollingAcc*(g: TmHorizonGun, n: int): float = ## Side accuracy over the last `min(n, accCount)` recorded warm samples. ## This is the measurement that shows whether accuracy decays when the ## enemy changes. Returns 0.0 when nothing has been recorded yet. if g.accCount == 0: return 0.0 let k = min(n, g.accCount) if k <= 0: return 0.0 var cor = 0 for i in 0.. newest). `trained` is NOT ## reset: it counts resolved samples over the battle, so the cold gate and the ## per-round summary stay monotone while the MACHINES forget. The rebuild is ## DETERMINISTIC given the buffer (fixed seed), so an A/B rerun is repeatable. g.sideMachine.resetMachine(seed = 1000'u64) g.magMachine.resetMachine(seed = 2000'u64) if g.bufCap <= 0: return let k = min(n, min(g.bufCount, g.bufCap)) if k <= 0: return for _ in 0.. g.accSnapMax: g.accSnapMax = a100 g.accSnapSum += a100 inc g.accSnapN if a100 > g.accPeak: g.accPeak = a100 inc g.sinceResetDrop if g.resetDrop > 0.0 and g.accCount >= TMH_RESET_MIN_SAMPLES and (g.accPeak - a100) > g.resetDrop and g.sinceResetDrop >= TMH_RESET_COOLDOWN: let n = if g.windowN > 0: g.windowN else: TMH_RESET_WINDOW_DEF g.retrainFromBuffer(n) g.accPeak = a100 g.sinceResetDrop = 0 inc g.resetDrops if g.accurveEnabled and (g.accCount mod TMH_ACC_LOG_EVERY == 0) and g.accCount != g.lastAccLogCount: g.lastAccLogCount = g.accCount let a300 = g.rollingAcc(TMH_ACC_WIN2) * 100.0 echo fmt"[tmh-acc] trained={g.trained} warm={g.accCount} acc100={a100:.1f} " & fmt"acc300={a300:.1f} peak={g.accPeak:.1f} resets={g.resetDrops}" # ── 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 let correct = p.sidePred == sideLabel if correct: inc g.sideCorrect # Rolling accuracy + change detection (the re-adaptation measurement). g.recordWarm(correct) 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 # Bounded ring feeding the sliding-window rebuild / change-detection # re-learn. Appended AFTER the online update so a retrain triggered this # tick sees this sample too. if g.bufCap > 0: g.buffer[g.bufCount mod g.bufCap] = TmhSample(lits: p.lits, side: uint8(sideLabel), mag: uint8(magLabel)) inc g.bufCount inc g.sinceRetrain if g.windowN > 0 and g.sinceRetrain >= g.retrainEvery: g.sinceRetrain = 0 g.retrainFromBuffer(g.windowN) 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 `TR_TMHORIZON_LOG=1`, or ## `TR_TMHORIZON_ACCURVE=1` for the accuracy curve alone), so the user can ## watch it learn across the round. The rolling windows are the re-adaptation ## measurement: `acc100` = last 100 warm samples, `acc300` = last 300, and ## `accCurve[min/mean/end]` the per-round spread of the rolling-100 snapshot. g.ensureConfig() if not g.logEnabled and not g.accurveEnabled: return let thisRound = g.trained - g.roundStartTrained let acc = if g.sideTotal > 0: g.sideCorrect.float / g.sideTotal.float * 100.0 else: 0.0 let a100 = g.rollingAcc(TMH_ACC_WIN1) * 100.0 let a300 = g.rollingAcc(TMH_ACC_WIN2) * 100.0 let snapMean = if g.accSnapN > 0: g.accSnapSum / float(g.accSnapN) 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"acc100={a100:.1f}% acc300={a300:.1f}% " & fmt"accCurve[min={g.accSnapMin:.1f} mean={snapMean:.1f} end={a100:.1f} n={g.accSnapN}] " & fmt"resets={g.resetDrops} window={g.windowN} resetDrop={g.resetDrop:.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