Files
SirRoboGarage/common_libs/guns/tsetlin.nim
T
SirStone 2cc2a3bd87 fix(ModularBot): ram loop prevention, dead-target guards, cleaner logging
- 30-tick cooldown after ghost-stuck/timeout ram exit prevents re-entry loop
- enemy_tracker.update() skips dead bots to prevent same-tick scan resurrection
- TFIL graphics cleared when ramming is active movement
- [config] logs: white base with green-highlighted changes only
- [ram:enter] logs trigger reason and key values on false→true transition
- [death] and [target-invalid] logs retained for diagnostics
2026-09-20 20:44:45 +02:00

287 lines
11 KiB
Nim
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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
# ── 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..<bits:
result[bits - 1 - i] = uint8((gray shr i) and 1)
proc tmEncodeFrame(bearing, distance, velocity, heading,
wallN, wallS, wallE, wallW, energy: float): TmFrameEncoded =
var offset = 0
# bearing sin: 8 bits
let bSin = tmToBits(clamp(int((sin(degToRad(bearing)) + 1.0) / 2.0 * 199.0), 0, 199), 8)
for i in 0..<8: result[offset + i] = bSin[i]
offset += 8
# bearing cos: 8 bits
let bCos = tmToBits(clamp(int((cos(degToRad(bearing)) + 1.0) / 2.0 * 199.0), 0, 199), 8)
for i in 0..<8: result[offset + i] = bCos[i]
offset += 8
# distance %: 7 bits
let distBits = tmToBits(clamp(int(distance / TM_MAX_DISTANCE * 99.0), 0, 99), 7)
for i in 0..<7: result[offset + i] = distBits[i]
offset += 7
# velocity: 5 bits
let velBits = tmToBits(clamp(int(velocity + 8.0), 0, 16), 5)
for i in 0..<5: result[offset + i] = velBits[i]
offset += 5
# heading sin: 8 bits
let hSin = tmToBits(clamp(int((sin(degToRad(heading)) + 1.0) / 2.0 * 199.0), 0, 199), 8)
for i in 0..<8: result[offset + i] = hSin[i]
offset += 8
# heading cos: 8 bits
let hCos = tmToBits(clamp(int((cos(degToRad(heading)) + 1.0) / 2.0 * 199.0), 0, 199), 8)
for i in 0..<8: result[offset + i] = hCos[i]
offset += 8
# wall distances: 4×7 bits
for wall in [wallN, wallS, wallE, wallW]:
let wBits = tmToBits(clamp(int(wall / 1000.0 * 99.0), 0, 99), 7)
for i in 0..<7: result[offset + i] = wBits[i]
offset += 7
# energy: 11 bits
let eBits = tmToBits(clamp(int(energy * 10.0), 0, 1500), 11)
for i in 0..<11: result[offset + i] = eBits[i]
proc tmEncodeSelf(wallN, wallS, wallE, wallW, energy: float, canFire: bool): TmSelfEncoded =
var offset = 0
for wall in [wallN, wallS, wallE, wallW]:
let wBits = tmToBits(clamp(int(wall / 1000.0 * 99.0), 0, 99), 7)
for i in 0..<7: result[offset + i] = wBits[i]
offset += 7
let eBits = tmToBits(clamp(int(energy * 10.0), 0, 1500), 11)
for i in 0..<11: result[offset + i] = eBits[i]
offset += 11
result[offset] = if canFire: 1'u8 else: 0'u8
proc tmEncodeFullVector(window: array[TM_WINDOW_SIZE, TmFrameEncoded],
self: TmSelfEncoded): TmBinaryVector =
var offset = 0
for i in 0..<TM_WINDOW_SIZE:
for j in 0..<TM_FRAME_BITS:
result[offset] = window[i][j]; inc offset
for j in 0..<TM_SELF_BITS:
result[offset] = self[j]; inc offset
# ── Tsetlin Machine (adapted from BNNBot_garage/src/tsetlin_predictor.nim) ───
const
TM_N_IN = TM_TOTAL_BITS # 870
TM_N_OUT = 2 # cx, cy pixel corrections
TM_N_LITERALS = TM_N_IN * 2 # 1740
TM_N_CLAUSES = 50 # per output; issue #184 default
TM_HALF = TM_N_CLAUSES div 2
TM_N_STATES = 32 # automaton range [-32..32]
TM_T = float(TM_HALF) # vote clamped to [-T, T]
TM_S = 1.5 # specificity
TM_RESID_MAX = 80.0 # pixel correction range
# ponytail: TM_N_STATES=32 needs int16 (int8 only fits ≤127, fine here); raise N_CLAUSES if underfitting
type
TmClauseCache = array[TM_N_OUT * TM_N_CLAUSES, uint8]
TmNet = object
states: array[TM_N_OUT * TM_N_CLAUSES * TM_N_LITERALS, int16]
# ponytail: int16 to safely hold [-32..32]; TM_N_STATES=32 fits int8 too but int16 is safer
proc tmStateIdx(outIdx, clause, lit: int): int {.inline.} =
(outIdx * TM_N_CLAUSES + clause) * TM_N_LITERALS + lit
proc tmPolarity(clause: int): float {.inline.} =
if clause < TM_HALF: 1.0 else: -1.0
proc tmMakeLiterals(input: TmBinaryVector): array[TM_N_LITERALS, uint8] =
for i in 0..<TM_N_IN:
result[i] = input[i]
result[i + TM_N_IN] = 1'u8 - input[i]
proc tmEvalClause(net: TmNet, outIdx, clause: int,
lits: array[TM_N_LITERALS, uint8]): uint8 =
var hasIncluded = false
for lit in 0..<TM_N_LITERALS:
let s = net.states[tmStateIdx(outIdx, clause, lit)]
if s > 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..<TM_N_CLAUSES:
let o = tmEvalClause(net, 0, c, lits)
cache[c] = o
vx += tmPolarity(c) * float(o)
for c in 0..<TM_N_CLAUSES:
let o = tmEvalClause(net, 1, c, lits)
cache[TM_N_CLAUSES + c] = o
vy += tmPolarity(c) * float(o)
vx = clamp(vx, -TM_T, TM_T)
vy = clamp(vy, -TM_T, TM_T)
(vx / TM_T * TM_RESID_MAX, vy / TM_T * TM_RESID_MAX)
proc tmLearnOne(net: var TmNet, outIdx: int, lits: array[TM_N_LITERALS, uint8],
cache: TmClauseCache, residual: float) =
var vote = 0.0
for c in 0..<TM_N_CLAUSES:
vote += tmPolarity(c) * float(cache[outIdx * TM_N_CLAUSES + c])
vote = clamp(vote, -TM_T, TM_T)
let predicted = vote / TM_T * TM_RESID_MAX
let error = residual - predicted
let pFeedback = min(1.0, abs(error) / (2.0 * TM_RESID_MAX))
for c in 0..<TM_N_CLAUSES:
if pFeedback <= 0.0: continue
if rand(1.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..<TM_N_LITERALS:
let si = tmStateIdx(outIdx, c, lit)
var st = int(net.states[si])
if lits[lit] == 1'u8:
if rand(1.0) < (TM_S - 1.0) / TM_S: st = min(st + 1, TM_N_STATES)
else:
if rand(1.0) < 1.0 / TM_S: st = max(st - 1, -TM_N_STATES)
net.states[si] = int16(st)
else:
# Type II: shrink false literals in include range
if cOut == 1'u8:
for lit in 0..<TM_N_LITERALS:
if lits[lit] == 0'u8:
let si = tmStateIdx(outIdx, c, lit)
var st = int(net.states[si])
if st > 0:
net.states[si] = int16(max(st - 1, -TM_N_STATES))
# ── TsetlinGun public type ────────────────────────────────────────────────────
const
TM_TRACE_SLOTS = 64 # ring buffer of pending traces
# ponytail: 64 slots >> TRACE_MAX_AGE=40 ticks, safe margin; grow if many guns/bins
DebugTM* = false # set true to print [tm-dbg] lines per onResult call
type
TmTrace = object
predX, predY: float # key: matches FeedbackEvent.prediction
input: TmBinaryVector
cache: TmClauseCache
alive: bool
TsetlinGun* = object
net: TmNet
frameBuffer: array[TM_WINDOW_SIZE, TmFrameEncoded]
bufferCount: int
traces: array[TM_TRACE_SLOTS, TmTrace]
traceHead: int
shotCount: int ## total onResult calls received
debugGraphics*: bool
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
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
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 by prediction coords
let slot = g.traceHead mod TM_TRACE_SLOTS
g.traces[slot] = TmTrace(predX: predX, predY: predY, input: vec, cache: cache, alive: true)
g.traceHead = (slot + 1) mod TM_TRACE_SLOTS
GunPrediction(x: predX, y: predY)
proc onResult*(g: var TsetlinGun, e: FeedbackEvent) =
inc g.shotCount
# Find matching trace by prediction coords
for i in 0..<TM_TRACE_SLOTS:
var t = addr g.traces[i]
if not t.alive: continue
if abs(t.predX - e.prediction.x) > 0.01 or abs(t.predY - e.prediction.y) > 0.01:
continue
# 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} 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
break