## Tsetlin Machine gun — regression TM for aiming correction on top of linear extrapolation. ## Self-contained: includes binary encoding and TM predictor inline. ## Implements Gun interface: predict(state, bulletSpeed) → GunPrediction, onResult(FeedbackEvent). import std/[math, random, strformat] import gun_harness/gun_interface import gun_harness/virtual_bullets as vb # PowerBins (power-bin count for trace keys) # ── Binary encoding (adapted from BNNBot_garage/src/binary_encoding.nim) ───── const TM_FRAME_BITS = 83 TM_SELF_BITS = 40 TM_WINDOW_SIZE = 10 TM_TOTAL_BITS* = TM_FRAME_BITS * TM_WINDOW_SIZE + TM_SELF_BITS # 870 TM_MAX_DISTANCE = 1414.0 # diagonal of 1000x1000 arena type TmBinaryVector = array[TM_TOTAL_BITS, uint8] TmFrameEncoded = array[TM_FRAME_BITS, uint8] TmSelfEncoded = array[TM_SELF_BITS, uint8] proc tmToGray(value: int): int = value xor (value shr 1) proc tmToBits(value: int, bits: int): seq[uint8] = result = newSeq[uint8](bits) let gray = tmToGray(value) for i in 0.. 0: hasIncluded = true if lits[lit] == 0: return 0'u8 return if hasIncluded: 1'u8 else: 0'u8 proc tmForwardWithCache(net: TmNet, input: TmBinaryVector, cache: var TmClauseCache): (float, float) = let lits = tmMakeLiterals(input) var vx = 0.0; var vy = 0.0 for c in 0..= pFeedback: continue let pol = tmPolarity(c) let cOut = cache[outIdx * TM_N_CLAUSES + c] if (error > 0.0 and pol > 0.0) or (error < 0.0 and pol < 0.0): # Type I / Ib feedback for lit in 0.. 0: net.states[si] = int16(max(st - 1, -TM_N_STATES)) # ── TsetlinGun public type ──────────────────────────────────────────────────── const # Ring of pending traces keyed EXACTLY by (fireTick, powerBin). A power-3 shot # can take ~fireDist/speed ~ 128 ticks to resolve, and the rack stores 4 traces # per tick, so 1024 slots (> 128*4) guarantee a live trace is never overwritten # by a newer one. The old 64-slot ring held only ~13 ticks of traces. TM_TRACE_SLOTS = 1024 DebugTM* = false # set true to print [tm-dbg] lines per onResult call type TmTrace = object fireTick: int # key part: tick the bullet was fired powerBin: int # key part: power bin the bullet belonged to predX, predY: float # stored prediction, for the directional residual input: TmBinaryVector cache: TmClauseCache alive: bool TsetlinGun* = object net: TmNet frameBuffer: array[TM_WINDOW_SIZE, TmFrameEncoded] bufferCount: int frameTick: int # last tick the window was shifted (once per tick) traces: array[TM_TRACE_SLOTS, TmTrace] shotCount: int ## total onResult calls received trainedShots*: int ## onResult calls that found and trained their exact trace traceMisses*: int ## onResult calls whose trace was gone (integrity counter) debugGraphics*: bool proc tmBinForSpeed(spd: float): int {.inline.} = ## Map a virtual-bullet speed back to its power-bin index. for i in 0.. ring slot. TM_TRACE_SLOTS is a multiple of ## the bin count and larger than maxResolveTicks*bins, so live traces never ## collide with newer ones; unresolved traces are evicted after ~256 ticks. ((fireTick * len(vb.PowerBins)) + binIdx) mod TM_TRACE_SLOTS proc initTsetlinGun*(): TsetlinGun = # states init at 0 (boundary); one Type I step crosses into Include for s in result.net.states.mitems: s = 0'i16 result.frameTick = -1 randomize() result.debugGraphics = false proc isWarmedUp*(g: TsetlinGun): bool {.inline.} = g.bufferCount >= TM_WINDOW_SIZE proc predict*(g: var TsetlinGun, state: WorldState, bulletSpeed: float): GunPrediction = # Encode current frame and push into window let dist = hypot(state.enemyX - state.selfX, state.enemyY - state.selfY) let bearing = radToDeg(arctan2(state.enemyY - state.selfY, state.enemyX - state.selfX)) let frame = tmEncodeFrame( bearing, dist, state.enemySpeed, state.enemyHeading, state.arenaHeight - state.enemyY, state.enemyY, state.arenaWidth - state.enemyX, state.enemyX, state.selfEnergy, # use self energy as proxy (enemy energy not in WorldState) ) # Shift window: index 0 = newest. Do this at most once per tick — the harness # calls predict() 4-5x/tick (once per power bin), which used to shift the # 10-frame window ~4-5x/tick (representing ~2 real ticks and tripping # isWarmedUp after 2-3 ticks instead of 10). if state.tick != g.frameTick: g.frameTick = state.tick for i in countdown(TM_WINDOW_SIZE - 1, 1): g.frameBuffer[i] = g.frameBuffer[i - 1] g.frameBuffer[0] = frame if g.bufferCount < TM_WINDOW_SIZE: inc g.bufferCount # Warm-up: until window is full, fall back to linear extrapolation let ticksToArrive = if bulletSpeed > 0.0: dist / bulletSpeed else: 1.0 let headingRad = degToRad(state.enemyHeading) let linearX = state.enemyX + cos(headingRad) * state.enemySpeed * ticksToArrive let linearY = state.enemyY + sin(headingRad) * state.enemySpeed * ticksToArrive if g.bufferCount < TM_WINDOW_SIZE: return GunPrediction(x: clamp(linearX, 0.0, state.arenaWidth), y: clamp(linearY, 0.0, state.arenaHeight)) let selfState = tmEncodeSelf( state.arenaHeight - state.selfY, state.selfY, state.arenaWidth - state.selfX, state.selfX, state.selfEnergy, true, # canFire not in WorldState; assume true ) let vec = tmEncodeFullVector(g.frameBuffer, selfState) var cache: TmClauseCache let (cx, cy) = tmForwardWithCache(g.net, vec, cache) let predX = clamp(linearX + cx, 0.0, state.arenaWidth) let predY = clamp(linearY + cy, 0.0, state.arenaHeight) # Store trace keyed exactly by (fireTick, powerBin) so the resolution event # can find it no matter how many other guns/bins fired in between. let binIdx = tmBinForSpeed(bulletSpeed) if binIdx >= 0: let slot = tmTraceSlot(state.tick, binIdx) g.traces[slot] = TmTrace( fireTick: state.tick, powerBin: binIdx, predX: predX, predY: predY, input: vec, cache: cache, alive: true, ) GunPrediction(x: predX, y: predY) proc onResult*(g: var TsetlinGun, e: FeedbackEvent) = inc g.shotCount # Exact pairing: index the trace by the tick the bullet was fired and the power # bin it belonged to. The old coordinate-matched 64-slot ring lost the trace # long before a long shot resolved, so the TM never trained and its output was # pure linear extrapolation. let binIdx = if e.powerBin >= 0 and e.powerBin < len(vb.PowerBins): e.powerBin else: tmBinForSpeed(bulletSpeed(e.bulletPower)) if binIdx < 0: inc g.traceMisses return let slot = tmTraceSlot(e.fireTick, binIdx) var t = addr g.traces[slot] if not t.alive or t.fireTick != e.fireTick or t.powerBin != binIdx: inc g.traceMisses return # Directional residual: actual enemy pos minus our prediction # On hit residual is 0 (we were right); on miss we push toward actual position. let rx = if e.hit: 0.0 else: clamp(e.actualX - t.predX, -TM_RESID_MAX, TM_RESID_MAX) let ry = if e.hit: 0.0 else: clamp(e.actualY - t.predY, -TM_RESID_MAX, TM_RESID_MAX) let lits = tmMakeLiterals(t.input) g.net.tmLearnOne(0, lits, t.cache, rx) g.net.tmLearnOne(1, lits, t.cache, ry) when DebugTM: echo fmt"[tm-dbg] shot={g.shotCount} tick={e.fireTick} bin={binIdx} miss={e.missDistance:.1f}px predicted=({t.predX:.0f},{t.predY:.0f}) actual=({e.actualX:.0f},{e.actualY:.0f}) rx={rx:.1f} ry={ry:.1f} hit={e.hit}" t.alive = false inc g.trainedShots