fix(guns): speed-sensitive caches, dead stop-shot branch, exact TM trace pairing

Four guns cached a whole prediction per tick while predict() is called once
per power bin, so every bin after the first (and the real fired shot, which
shares lastState) reused the power-1.0 lead. Fixed by caching only the
speed-INDEPENDENT derived state and recomputing the lead per requested speed:
- stop_shot: also fixes prevSpeed being written before it was read, which
  made abs(speed) < abs(prev) permanently false and the entire
  stop-prediction branch unreachable (it was just Linear).
- displacement: the cache key included bulletSpeed, so the guard missed on
  all four bins and the 15-tick window advanced ~4x/tick, making the
  inferred velocity ~4x too small.
- averaged_lead: tick cache removed outright. pattern_matcher: split into
  speed-independent match+path and per-call lead.

FeedbackEvent gains fireTick/powerBin (additive; only virtual_bullets
constructs one) so guns can pair feedback to the exact shot instead of
guessing by coordinates. tsetlin uses it: traces are now keyed exactly by
(fireTick, powerBin) with a 1024-slot ring, and the 10-frame window shifts
at most once per tick (it was shifting ~4-5x/tick, so isWarmedUp tripped
after ~2 ticks).

KNOWN INCOMPLETE: tsetlin still does not diverge from Linear in battle. The
two named bugs are fixed (a 600-tick sim shows trainedShots=2141,
traceMisses=0, and a fixed-input probe converges to a 9.6px correction), but
the TM's clause feedback itself is broken: ~131 of 1740 literals end up
included per clause, so its conjunction never fires. Sweeping TM_S,
TM_N_CLAUSES and a two-branch Type-I update did not change the correction
from 0. Needs a real TM fix or removal, not another bug fix.

First-ever guard tests for the gun selector: common_libs/tests/
test_gun_harness.nim (14 checks, headless, no Java). There were none before,
which is how six broken guns survived a full analysis cycle. Against the
previous HEAD, 5 of these checks FAIL - that is the regression guard.
This commit is contained in:
2026-09-20 22:47:26 +02:00
parent 0cc682152d
commit e53690036b
9 changed files with 480 additions and 171 deletions
+5 -8
View File
@@ -10,17 +10,16 @@ type AveragedLeadGun* = object
linear: LinearGun
circular: CircularGun
wallBounce: WallBounceGun
cachedTick: int
cachedPred: GunPrediction # per-tick cache; ponytail: single cache, extend if multi-power needed
debugGraphics*: bool
proc initAveragedLeadGun*(): AveragedLeadGun =
AveragedLeadGun(wallBounce: initWallBounceGun(), debugGraphics: false)
proc predict*(g: var AveragedLeadGun, state: WorldState, bulletSpeed: float): GunPrediction =
if state.tick == g.cachedTick:
return g.cachedPred
# No tick cache: every sub-gun's lead depends on bulletSpeed (dist/bulletSpeed),
# so caching one result per tick and reusing it for all four power bins would
# silently collapse every bin onto the first. linear/wallBounce are stateless
# and cheap; circular only caches its speed-independent omega internally.
let lp = g.linear.predict(state, bulletSpeed)
let cp = g.circular.predict(state, bulletSpeed)
let wp = g.wallBounce.predict(state, bulletSpeed)
@@ -30,9 +29,7 @@ proc predict*(g: var AveragedLeadGun, state: WorldState, bulletSpeed: float): Gu
px = clamp(px, BotRadius, state.arenaWidth - BotRadius)
py = clamp(py, BotRadius, state.arenaHeight - BotRadius)
g.cachedTick = state.tick
g.cachedPred = GunPrediction(x: px, y: py)
g.cachedPred
GunPrediction(x: px, y: py)
proc onResult*(g: var AveragedLeadGun, e: FeedbackEvent) =
discard # analytical average — no learning
+33 -29
View File
@@ -14,35 +14,40 @@ type
posY: array[WindowSize + 1, float]
count: int # frames collected so far
head: int # ring-buffer head
lastTick: int # for per-tick cache
cacheSpeed: float
cachePred: GunPrediction
# Per-tick derived state. The ring must advance exactly ONCE per tick and the
# average per-tick velocity is speed-independent, so both are computed once
# per tick and shared by all four power bins. The iterative bullet lead is
# recomputed from (dx, dy) on every call.
derivedTick: int
dx, dy: float
ready: bool
debugGraphics*: bool
proc predict*(g: var DisplacementGun, state: WorldState, bulletSpeed: float): GunPrediction =
# Per-tick cache: same tick + same speed => same prediction
if state.tick == g.lastTick and bulletSpeed == g.cacheSpeed:
return g.cachePred
# Sample the enemy position exactly once per tick (the harness calls predict()
# 4-5x/tick, once per power bin). Keying the old cache on bulletSpeed too made
# every bin miss, so the nominal 15-tick window was actually advanced ~4x/tick.
if state.tick != g.derivedTick:
g.derivedTick = state.tick
# Push current position into ring buffer
g.head = (g.head + 1) mod (WindowSize + 1)
g.posX[g.head] = state.enemyX
g.posY[g.head] = state.enemyY
if g.count < WindowSize + 1:
inc g.count
# Push current position into ring buffer
g.head = (g.head + 1) mod (WindowSize + 1)
g.posX[g.head] = state.enemyX
g.posY[g.head] = state.enemyY
if g.count < WindowSize + 1:
inc g.count
# Need at least N+1 frames; fall back to head-on if not enough
if g.count < WindowSize + 1:
g.ready = false
else:
g.ready = true
# Oldest frame is (head + 1) mod (WindowSize + 1)
let oldest = (g.head + 1) mod (WindowSize + 1)
g.dx = (state.enemyX - g.posX[oldest]) / WindowSize.float
g.dy = (state.enemyY - g.posY[oldest]) / WindowSize.float
g.lastTick = state.tick
g.cacheSpeed = bulletSpeed
# Need at least N+1 frames; fall back to head-on if not enough
if g.count < WindowSize + 1:
g.cachePred = GunPrediction(x: state.enemyX, y: state.enemyY)
return g.cachePred
# Oldest frame is (head + 1) mod (WindowSize + 1)
let oldest = (g.head + 1) mod (WindowSize + 1)
let dx = (state.enemyX - g.posX[oldest]) / WindowSize.float
let dy = (state.enemyY - g.posY[oldest]) / WindowSize.float
if not g.ready:
return GunPrediction(x: state.enemyX, y: state.enemyY)
# Iterate time estimate 5 times
let dist0 = hypot(state.enemyX - state.selfX, state.enemyY - state.selfY)
@@ -50,17 +55,16 @@ proc predict*(g: var DisplacementGun, state: WorldState, bulletSpeed: float): Gu
var px = state.enemyX
var py = state.enemyY
for _ in 0..4:
px = state.enemyX + dx * ticks
py = state.enemyY + dy * ticks
px = state.enemyX + g.dx * ticks
py = state.enemyY + g.dy * ticks
ticks = hypot(px - state.selfX, py - state.selfY) / bulletSpeed
px = clamp(px, 0.0, state.arenaWidth)
py = clamp(py, 0.0, state.arenaHeight)
g.cachePred = GunPrediction(x: px, y: py)
g.cachePred
GunPrediction(x: px, y: py)
proc onResult*(g: var DisplacementGun, e: FeedbackEvent) =
discard # analytical gun — no learning
proc initDisplacementGun*(): DisplacementGun =
DisplacementGun(count: 0, head: 0, lastTick: -1, debugGraphics: false)
DisplacementGun(count: 0, head: 0, derivedTick: -1, debugGraphics: false)
+61 -44
View File
@@ -23,11 +23,19 @@ type
prevSpeed: float
prevTick: int
hasPrev: bool
# per-tick cache — avoid re-searching for multiple power bins
cacheTick: int
cacheX: float
cacheY: float
# Per-tick, speed-INDEPENDENT pattern state. Both the history search and the
# replayed enemy path depend only on observed movement, never on bulletSpeed,
# so they are built at most once per tick. The bullet lead (number of replay
# steps + coast) is derived from this path on every call.
cacheValid: bool
cacheTick: int
bestMatch: int ## -1 = no usable match (linear fallback)
playStart: int
playAvail: int
pathX: array[HistorySize + 1, float]
pathY: array[HistorySize + 1, float]
pathHeading: array[HistorySize + 1, float] ## radians after s steps
pathSpeed: array[HistorySize + 1, float] ## speed after s steps
debugGraphics*: bool
# --- circular buffer helpers ---
@@ -55,11 +63,11 @@ proc linearPredict(state: WorldState, bulletSpeed: float): (float, float) =
# --- pattern search + play-forward ---
proc searchAndProject(g: PatternMatcherGun, state: WorldState,
bulletSpeed: float): (float, float) =
## Returns projected (x, y). Falls back to linear if history too short.
proc findBestMatch(g: PatternMatcherGun): int =
## Speed-independent history search. Returns the start index of the best
## matching pattern, or -1 when there is not enough history.
if g.count < PatternLen * 2:
return linearPredict(state, bulletSpeed)
return -1
# key = last PatternLen entries
let keyStart = g.count - PatternLen
@@ -79,14 +87,28 @@ proc searchAndProject(g: PatternMatcherGun, state: WorldState,
if score < bestScore:
bestScore = score
bestMatch = i
bestMatch
if bestMatch < 0:
return linearPredict(state, bulletSpeed)
# play forward from bestMatch + PatternLen
let playStart = bestMatch + PatternLen
let playAvail = g.count - 1 - playStart # ticks we can replay
proc buildPath(g: var PatternMatcherGun, state: WorldState, bestMatch: int) =
## Precompute the matched pattern replayed forward from the current state.
## Only depends on observed movement, so it is valid for every power bin.
g.playStart = bestMatch + PatternLen
g.playAvail = g.count - 1 - g.playStart # ticks we can replay
g.pathX[0] = state.enemyX
g.pathY[0] = state.enemyY
g.pathHeading[0] = degToRad(state.enemyHeading)
g.pathSpeed[0] = state.enemySpeed
for s in 1 .. g.playAvail:
let m = g.readAt(g.playStart + s - 1)
g.pathHeading[s] = g.pathHeading[s - 1] + m.headingDelta
g.pathSpeed[s] = m.velocity
g.pathX[s] = g.pathX[s - 1] + cos(g.pathHeading[s]) * g.pathSpeed[s]
g.pathY[s] = g.pathY[s - 1] + sin(g.pathHeading[s]) * g.pathSpeed[s]
proc projectFromPath(g: PatternMatcherGun, state: WorldState,
bulletSpeed: float): (float, float) =
## Speed-dependent lead: walk the cached path as far as this bulletSpeed's
## estimated flight time reaches, then coast linearly for the remainder.
# iterative time estimate
let dist0 = hypot(state.enemyX - state.selfX, state.enemyY - state.selfY)
var t = dist0 / bulletSpeed
@@ -94,22 +116,14 @@ proc searchAndProject(g: PatternMatcherGun, state: WorldState,
var ey = state.enemyY
for _ in 0..4:
let steps = min(int(t + 0.5), playAvail)
ex = state.enemyX
ey = state.enemyY
var heading = degToRad(state.enemyHeading)
var speed = state.enemySpeed
for s in 0 ..< steps:
let m = g.readAt(playStart + s)
heading += m.headingDelta
speed = m.velocity
ex += cos(heading) * speed
ey += sin(heading) * speed
let steps = min(int(t + 0.5), g.playAvail)
ex = g.pathX[steps]
ey = g.pathY[steps]
# if we ran out of replay data, coast linearly from last simulated pos
let remaining = t - steps.float
if remaining > 0.0:
ex += cos(heading) * speed * remaining
ey += sin(heading) * speed * remaining
ex += cos(g.pathHeading[steps]) * g.pathSpeed[steps] * remaining
ey += sin(g.pathHeading[steps]) * g.pathSpeed[steps] * remaining
let ndx = ex - state.selfX
let ndy = ey - state.selfY
t = sqrt(ndx * ndx + ndy * ndy) / bulletSpeed
@@ -125,30 +139,33 @@ proc predict*(g: var PatternMatcherGun, state: WorldState,
if bulletSpeed <= 0.0:
return GunPrediction(x: state.enemyX, y: state.enemyY)
# Update history once per tick
if g.hasPrev and state.tick > g.prevTick:
var dh = degToRad(state.enemyHeading) - degToRad(g.prevHeading)
# wrap to [-π, π]
while dh > PI: dh -= 2.0 * PI
while dh < -PI: dh += 2.0 * PI
g.write(MoveTick(velocity: g.prevSpeed, headingDelta: dh))
# Roll history forward and (re)build the speed-independent pattern path at
# most once per tick. The old code returned a single cached (x, y) per tick,
# so all four power bins shared bin 0's lead.
if not g.cacheValid or state.tick != g.cacheTick:
if g.hasPrev:
var dh = degToRad(state.enemyHeading) - degToRad(g.prevHeading)
# wrap to [-π, π]
while dh > PI: dh -= 2.0 * PI
while dh < -PI: dh += 2.0 * PI
g.write(MoveTick(velocity: g.prevSpeed, headingDelta: dh))
if not g.hasPrev or state.tick > g.prevTick:
g.prevHeading = state.enemyHeading
g.prevSpeed = state.enemySpeed
g.prevTick = state.tick
g.hasPrev = true
g.cacheValid = false # new tick invalidates cache
g.cacheValid = true
g.cacheTick = state.tick
# Return cached result for same-tick calls (multiple power bins)
if g.cacheValid and state.tick == g.cacheTick:
return GunPrediction(x: g.cacheX, y: g.cacheY)
g.bestMatch = g.findBestMatch()
if g.bestMatch >= 0:
g.buildPath(state, g.bestMatch)
let (px, py) = g.searchAndProject(state, bulletSpeed)
g.cacheX = px
g.cacheY = py
g.cacheTick = state.tick
g.cacheValid = true
if g.bestMatch < 0:
let (px, py) = linearPredict(state, bulletSpeed)
return GunPrediction(x: px, y: py)
let (px, py) = g.projectFromPath(state, bulletSpeed)
GunPrediction(x: px, y: py)
proc onResult*(g: var PatternMatcherGun, e: FeedbackEvent) =
+61 -60
View File
@@ -15,9 +15,15 @@ type StopShotGun* = object
prevHeading: float
prevTick: int
frames: int
cachedTick: int
cachedPredX: float
cachedPredY: float
# Per-tick derived state. It depends only on the ENEMY's motion (speed delta,
# chosen deceleration, simulated stop point), never on the bullet speed, so it
# is computed once per tick and shared by all four power bins. The speed-
# dependent lead is recomputed from it on every call.
derivedTick: int
warmEnough: bool
decelerating: bool
decel: float
stopX, stopY: float
debugGraphics*: bool
proc initStopShotGun*(): StopShotGun = StopShotGun(debugGraphics: false)
@@ -26,73 +32,68 @@ proc predict*(g: var StopShotGun, state: WorldState, bulletSpeed: float): GunPre
if bulletSpeed <= 0.0:
return GunPrediction(x: state.enemyX, y: state.enemyY)
# Per-tick cache: all power bins share one prediction
if state.tick == g.cachedTick:
return GunPrediction(x: g.cachedPredX, y: g.cachedPredY)
defer:
g.cachedTick = state.tick
g.cachedPredX = result.x
g.cachedPredY = result.y
# Update history
let isNew = state.tick > g.prevTick
if isNew:
g.prevSpeed = state.enemySpeed
g.prevHeading = state.enemyHeading
g.prevTick = state.tick
inc g.frames
# Roll the observation window forward at most once per tick. prevSpeed must
# hold the PREVIOUS tick's speed when deceleration is tested, so it is read
# before being overwritten with the current tick's speed. The old code
# overwrote it first, making `prev == speed` and the stop branch unreachable.
if state.tick != g.derivedTick:
g.derivedTick = state.tick
let prev = g.prevSpeed
if state.tick > g.prevTick:
g.prevSpeed = state.enemySpeed
g.prevHeading = state.enemyHeading
g.prevTick = state.tick
inc g.frames
g.warmEnough = g.frames >= 2
# Detect deceleration: |speed| is shrinking toward zero.
g.decelerating = g.warmEnough and abs(state.enemySpeed) < abs(prev) and
abs(state.enemySpeed) > 0.01
if g.decelerating:
# Pick decel rate: braking (speed toward zero on same sign) = 2, else 1
g.decel = if state.enemySpeed * prev > 0.0: BrakeDecel else: CoastDecel
# Simulate the enemy coasting to a stop from its current position/heading.
let headRad = degToRad(state.enemyHeading)
let startSpeed = state.enemySpeed
var v = startSpeed
var sx = state.enemyX
var sy = state.enemyY
while abs(v) > 0.001:
sx += v * cos(headRad)
sy += v * sin(headRad)
v += (if v > 0.0: -g.decel else: g.decel)
if (v > 0.0) != (startSpeed > 0.0): v = 0.0 # crossed zero
g.stopX = sx
g.stopY = sy
# Need 2+ frames to detect deceleration
if g.frames < 2:
result = GunPrediction(x: state.enemyX, y: state.enemyY)
return
if not g.warmEnough:
return GunPrediction(x: state.enemyX, y: state.enemyY)
let speed = state.enemySpeed
let prev = g.prevSpeed
let speed = state.enemySpeed
let headRad = degToRad(state.enemyHeading)
let dist = hypot(state.enemyX - state.selfX, state.enemyY - state.selfY)
# Speed-dependent lead — always recomputed, never cached across power bins.
let bulletTicks = dist / bulletSpeed
# Detect deceleration: |speed| is shrinking
let decelerating = abs(speed) < abs(prev) and abs(speed) > 0.01
if not decelerating:
if not g.decelerating:
# Linear fallback
let headRad = degToRad(state.enemyHeading)
let dist = hypot(state.enemyX - state.selfX, state.enemyY - state.selfY)
let t = dist / bulletSpeed
var px = state.enemyX + cos(headRad) * speed * t
var py = state.enemyY + sin(headRad) * speed * t
result = GunPrediction(
var px = state.enemyX + cos(headRad) * speed * bulletTicks
var py = state.enemyY + sin(headRad) * speed * bulletTicks
return GunPrediction(
x: clamp(px, 0.0, state.arenaWidth),
y: clamp(py, 0.0, state.arenaHeight)
)
return
# Pick decel rate: braking (speed toward zero on same sign) = 2, else 1
# ponytail: simplified; TR has exact rules per direction but this is close enough
let decel = if speed * prev > 0.0: BrakeDecel else: CoastDecel
# If the bullet arrives well after the enemy stops, aim at the stop point;
# otherwise blend a linear lead. stopTicks is speed-independent, so only this
# comparison depends on the requested bulletSpeed.
let stopTicks = abs(speed) / g.decel
let px = if bulletTicks >= stopTicks: g.stopX
else: state.enemyX + cos(headRad) * speed * bulletTicks
let py = if bulletTicks >= stopTicks: g.stopY
else: state.enemyY + sin(headRad) * speed * bulletTicks
# Simulate stop position
let headRad = degToRad(state.enemyHeading)
var v = speed
var sx = state.enemyX
var sy = state.enemyY
while abs(v) > 0.001:
sx += v * cos(headRad)
sy += v * sin(headRad)
let step = if v > 0.0: -decel else: decel
v += step
if (v > 0.0) != (speed > 0.0): v = 0.0 # crossed zero
# Bullet travel time to current pos, check against ticks to stop
let dist = hypot(state.enemyX - state.selfX, state.enemyY - state.selfY)
let bulletTicks = dist / bulletSpeed
let stopTicks = abs(speed) / decel
# If bullet arrives well after stop, aim at stop; otherwise linear blend
let px = if bulletTicks >= stopTicks: sx else: state.enemyX + cos(headRad) * speed * bulletTicks
let py = if bulletTicks >= stopTicks: sy else: state.enemyY + sin(headRad) * speed * bulletTicks
result = GunPrediction(
GunPrediction(
x: clamp(px, BotRadius, state.arenaWidth - BotRadius),
y: clamp(py, BotRadius, state.arenaHeight - BotRadius)
)
+76 -30
View File
@@ -4,6 +4,7 @@
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) ─────
@@ -188,13 +189,18 @@ proc tmLearnOne(net: var TmNet, outIdx: int, lits: array[TM_N_LITERALS, uint8],
# ── 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
# 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
predX, predY: float # key: matches FeedbackEvent.prediction
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
@@ -203,14 +209,30 @@ type
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]
traceHead: int
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..<len(vb.PowerBins):
if abs(spd - bulletSpeed(vb.PowerBins[i])) < 1e-6:
return i
-1
proc tmTraceSlot(fireTick, binIdx: int): int {.inline.} =
## Exact (fireTick, powerBin) key -> 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
@@ -228,11 +250,16 @@ proc predict*(g: var TsetlinGun, state: WorldState, bulletSpeed: float): GunPred
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
# 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
@@ -258,29 +285,48 @@ proc predict*(g: var TsetlinGun, state: WorldState, bulletSpeed: float): GunPred
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
# 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
# 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
# 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