## 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 if hasIncluded: return 1'u8 # Granmo Eq. 6: an all-Exclude clause evaluates to 1 during LEARNING (the # empty conjunction is vacuously true) and 0 during classification. The # learning value is what bootstraps the clauses: at initialisation every # automaton sits at the Exclude boundary, and with the corrected Type I rule # (c=0, lk=1 -> toward Exclude) a classification-only 0 would deadlock every # clause at empty forever. return if learning: 1'u8 else: 0'u8 proc tmForwardWithCache*(net: TmNet, input: TmBinaryVector, cache: var TmClauseCache): (float, float, float, float) = ## Returns (correctionX, correctionY, voteX, voteY). `cache` receives the ## clause outputs under LEARNING semantics (empty clause = 1) for tmLearnOne; ## the returned correction and votes use classification semantics (empty = 0). 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 pol * d > 0.0: # Type I (Table 2), collapsed to the resulting state move: # c=1, lk=1 -> +1 (toward Include) w.p. (s-1)/s # c=0, lk=1 -> -1 (toward Exclude) w.p. 1/s <- the missing counter-force # lk=0 -> -1 (toward Exclude) w.p. 1/s for lit in 0.. Penalty -> toward Include. # (The old code penalised INCLUDED false literals, which is unreachable # when c=1 and the wrong direction.) if cOut == 1'u8: for lit in 0.. 0.0: 1.0 elif error < 0.0: -1.0 else: return net.tmLearnDir(outIdx, lits, cache, vote, d) # ── 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 debug/provenance) linearX, linearY: float # baseline the correction was added to (fix 4) voteX, voteY: float # classification clause sum at fire time input: TmBinaryVector cache: TmClauseCache # LEARNING clause outputs (empty = 1) 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) # Instrumentation: is the TM actually producing a nonzero correction? Before # the feedback-rule fix vHits was effectively identical to Linear because # the clauses over-specified to a mean of ~714 included literals and almost # never fired (correction was nonzero on only 8 of 764 predict calls). predictCalls*: int ## predict() invocations (4/tick in the harness) correctionsNonzero*: int ## predict() calls whose |cx|+|cy| > 1e-9 lastCx*, lastCy*: float ## last correction emitted (debugging/assertions) 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 tmClauseStats*(g: TsetlinGun): TmClauseStats = ## Sample the current include-count distribution of the automata. O(outputs × ## clauses × literals); call on demand, not per-tick. result.nClauses = TM_N_OUT * TM_N_CLAUSES result.minIncluded = high(int) var total = 0 for o in 0.. 0: inc += 1 total += inc if inc > 0: inc result.nActive result.minIncluded = min(result.minIncluded, inc) result.maxIncluded = max(result.maxIncluded, inc) if result.nActive > 0: result.meanIncluded = total.float / result.nActive.float else: result.minIncluded = 0 result.meanIncludedAll = total.float / result.nClauses.float 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.enemyEnergy, # fix 6: enemy energy, not a duplicate of self energy ) # 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, vx, vy) = tmForwardWithCache(g.net, vec, cache) inc g.predictCalls g.lastCx = cx g.lastCy = cy if abs(cx) + abs(cy) > 1e-9: inc g.correctionsNonzero 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, linearX: linearX, linearY: linearY, voteX: vx, voteY: vy, 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 # Fixes 4+5: train on delta = actual - LINEAR baseline. The old code trained # on actual - predX = delta - cx, so tmLearnOne's error became delta - 2*cx and # the fixed point was cx = delta/2. Hits are NOT zeroed: a hit means # |miss| < BotRadius, not residual == 0. let dx = clamp(e.actualX - t.linearX, -TM_RESID_MAX, TM_RESID_MAX) let dy = clamp(e.actualY - t.linearY, -TM_RESID_MAX, TM_RESID_MAX) let lits = tmMakeLiterals(t.input) g.net.tmLearnOne(0, lits, t.cache, t.voteX, dx) g.net.tmLearnOne(1, lits, t.cache, t.voteY, dy) 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}) dx={dx:.1f} dy={dy:.1f} hit={e.hit}" t.alive = false inc g.trainedShots