feat(ModularBot): pluggable bot with 4 guns, phantom meteor movement, radar harness

- Gun harness: virtual bullet tracker, rolling fitness, auto-selector
- Guns: head-on, linear (extrapolation), circular (integrated formula), tsetlin machine (learning)
- Movement: phantom meteor gravity engine (danger histograms, phantom bullets, fire detection)
- Radar: harness + radar_lock adapter
- Color-coded modules: turret/bullet color per gun, body per movement, scan per radar
- Beats Target, SpinBot, Crazy, TrackFire in 10-round battles
This commit is contained in:
2026-09-20 00:37:10 +02:00
parent c9191b7afb
commit 254c7dc997
39 changed files with 2630 additions and 30 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ author = "Davide Cappellini"
description = "Binary Neural Network Bot — learns aiming with pure binary operations"
license = "MIT"
srcDir = "src"
bin = @["BNNBot"]
bin = @["BNNBot", "WiSARDBot", "TsetlinBot"]
# Dependencies
requires "nim >= 2.0.0"
+92
View File
@@ -0,0 +1,92 @@
# BNNBot Research Brief
## Problem
Predict enemy future position in Robocode Tank Royale to aim bullets accurately. The prediction must happen online (during battle), without pre-training.
## Hard Constraints
- NO supervised learning (no labeled input→output training pairs)
- NO gradient descent (no derivatives, no surrogate gradients, no STE)
- Online learning only — must learn and improve during a single battle
- Computational budget: ~1ms per tick
- Binary-friendly (690-bit input encoding already exists)
## Allowed
- Backpropagation of SIGNALS (non-gradient information flowing backward through layers)
- Reinforcement learning (reward signal available from wave hit system)
- Self-supervised learning
- Unsupervised learning
- Network structure modification during runtime
## Current Architecture
### Input Engineering (binary_encoding.nim)
- 690-bit binary vector: 10 frames × 69 bits
- Per frame: bearing sin/cos (16b), distance (7b), velocity (5b), heading sin/cos (16b), enemy X/Y position (14b), enemy energy (11b)
- Gray-coded for Hamming distance smoothness
- Temporal window: 10 most recent radar scans
### Feedback System (wave system in BNNBot.nim)
- Every tick: 10 circular waves spawned at bot position
- Powers: 0.1 to 3.0 (10 levels), speeds: 19.7 to 11.0 px/tick
- When wave radius reaches enemy: records enemy state as 69-bit frame
- Provides ground truth: "if you fired at power X, the enemy would be HERE when the bullet arrives"
- ~95.6% hit rate in testing
### Current Predictor (predictor.nim)
- Linear extrapolation: predicted = current_pos + velocity * ticks_to_arrival
- Hebbian residual table: 8 heading sectors × 3 distance bands = 24 cells
- Each cell stores (correction_x, correction_y), updated online with lr=0.2
- Backtest results: 14-21% MAE reduction over pure linear extrapolation
- Converges within one battle (MAE 14.85 → 2.62, first 50 vs last 50 rows)
## Key Findings
### Data Analysis (analysis/report.txt)
- Enemy movement is 97.8% constant-velocity straight lines
- Acceleration is negligible (std 0.25-0.49 px/tick²)
- Heading is very stable across 10-frame windows
- Linear extrapolation MAE: 15-27px (1.5-2.7% of arena)
- Distance to enemy is the main error driver
- Scalar velocity alone is weak predictor (r=0.15); directional velocity from frame deltas is strong
### Backtest Results (analysis/backtest_report.txt)
- P1 (linear): MAE 8.1-18.7 encoded units
- P2 (weighted 4-frame): ~8% improvement, trivial cost
- P3 (linear + Hebbian residual): 14-21% improvement, converges fast
- Most residual table cells stay empty — only ~10/24 activate
### Encoding Insights
- sin/cos angle encoding avoids wraparound discontinuity — worth the extra bits
- Enemy X/Y position partially redundant with bearing+distance (encodes absolute position)
- Wall distance → XY% compression saved 140 bits losslessly
- Self-state removed (not needed for aiming)
## What We've Tried
1. ✅ Input engineering with Gray coding and temporal window — works well
2. ✅ Linear extrapolation — strong baseline, 15-27px error
3. ✅ Hebbian residual table — learns online, 14-21% improvement
4. ❌ Pure XOR layer stacking — collapses (associative, no non-linearity)
5. ❌ XOR + AND layers — AND with fixed mask is still linear over GF(2)
6. ✅ XOR + popcount + threshold = valid binary neuron (non-linear)
## Open Questions
1. Can we go deeper than the current shallow predictor while respecting the constraints?
2. What non-gradient learning rules can train multi-layer binary networks?
3. Can the temporal structure (10 frames) be exploited by the network architecture?
4. Is there a way to do credit assignment through depth without gradients?
5. Can the wave hit system provide richer learning signal than just miss distance?
## Architecture Philosophy
- Input engineering IS the feature hierarchy (handcrafted, domain-informed)
- Current approach is essentially reservoir computing: rich fixed features → simple learnable readout
- Question: can we do better with a learnable feature extractor, or is the handcrafted one already near-optimal?
## Files
- `src/BNNBot.nim` — main bot, wave system, integration
- `src/binary_encoding.nim` — 690-bit input encoding
- `src/predictor.nim` — linear extrapolation + Hebbian residual table
- `analysis/correlations.py` — data analysis script
- `analysis/backtest.py` — predictor comparison script
- `analysis/report.txt` — correlation analysis results
- `analysis/backtest_report.txt` — predictor backtest results
- `data/` — CSV battle logs (enabled via BNNBOT_CSV=1)
+76 -4
View File
@@ -1,13 +1,16 @@
# BNNBot — Hebbian weight matrix with virtual bullet learning.
# 870-bit input → 7-bit aim angle output via forward pass.
# Learns from virtual bullets (no real firing) via three-factor Hebbian rule.
# CSV data collection: BNNBOT_CSV=1 writes per-round CSV to data/battle_{round}.csv
import std/[math, os, strutils, random]
import std/[math, os, strformat, strutils, random]
import robocode_tankroyale_botapi
import radar_lock/radar_lock as radar_lock
import binary_encoding
import hebbian
let csvEnabled = getEnv("BNNBOT_CSV", "0") == "1"
const botJsonPath = currentSourcePath().parentDir / "BNNBot.json"
const
@@ -17,7 +20,18 @@ const
EPSILON_MIN = 0.05
EPSILON_DECAY = 0.9995
# CSV: 9 raw decimal fields per frame (matches analysis/backtest.py column names)
# bearing_sin(0-199), bearing_cos(0-199), distance(0-99), velocity(0-15),
# heading_sin(0-199), heading_cos(0-199), enemy_x(px), enemy_y(px), enemy_energy(float)
type
FrameRaw = object
bSin, bCos: int # 0-199
dist: int # 0-99
vel: int # 0-15
hSin, hCos: int # 0-199
ex, ey: float # absolute pixel coords
energy: float
BNNBot = ref object of Bot
hasContact: bool
enemyBearing: float
@@ -31,13 +45,52 @@ type
prevVec: BinaryVector
hasPrev: bool
frameBuffer: array[WINDOW_SIZE, array[FRAME_BITS, uint8]]
frameRawBuf: array[WINDOW_SIZE, FrameRaw] # decimal mirror of frameBuffer
bufferCount: int
net: HebbianNet
bullets: array[BULLET_SLOTS, VirtualBullet]
bulletHead: int # ring-buffer write index
bulletHead: int
virtualHits: int
virtualMiss: int
epsilon: float
# CSV state
csvFile: File
csvOpen: bool
roundNum: int
# ── CSV helpers ──────────────────────────────────────────────────────────────
proc csvPath(roundNum: int): string =
getAppDir() / "data" / fmt"battle_{roundNum}.csv"
proc buildHeader(): string =
result = "tick"
for fr in 0..<WINDOW_SIZE:
for fn in ["bearing_sin", "bearing_cos", "distance", "velocity",
"heading_sin", "heading_cos", "enemy_x", "enemy_y", "enemy_energy"]:
result &= fmt",f{fr}_{fn}"
proc openCsv(bot: BNNBot) =
if not csvEnabled: return
createDir(csvPath(bot.roundNum).parentDir)
bot.csvFile = open(csvPath(bot.roundNum), fmWrite)
bot.csvOpen = true
bot.csvFile.writeLine(buildHeader())
proc closeCsv(bot: BNNBot) =
if bot.csvOpen:
bot.csvFile.close()
bot.csvOpen = false
proc writeRow(bot: BNNBot) =
if not bot.csvOpen: return
var line = $bot.tick
for i in 0..<WINDOW_SIZE:
let f = bot.frameRawBuf[i]
line &= fmt",{f.bSin},{f.bCos},{f.dist},{f.vel},{f.hSin},{f.hCos},{f.ex:.2f},{f.ey:.2f},{f.energy:.2f}"
bot.csvFile.writeLine(line)
# ── Bot methods ──────────────────────────────────────────────────────────────
method onScannedBot*(bot: BNNBot, e: ScannedBotEvent) =
let bx = getX(); let by = getY()
@@ -66,10 +119,23 @@ method onScannedBot*(bot: BNNBot, e: ScannedBotEvent) =
)
let encoded = encodeFrame(frame)
# Shift window — index 0 = newest
for i in countdown(WINDOW_SIZE - 1, 1):
bot.frameBuffer[i] = bot.frameBuffer[i - 1]
bot.frameRawBuf[i] = bot.frameRawBuf[i - 1]
bot.frameBuffer[0] = encoded
# Build raw decimal record for this frame
let bSin = int((sin(degToRad(bot.enemyBearing)) + 1.0) / 2.0 * 199.0)
let bCos = int((cos(degToRad(bot.enemyBearing)) + 1.0) / 2.0 * 199.0)
let dist = int(clamp(bot.distance / 1414.0 * 99.0, 0.0, 99.0))
let vel = int(clamp(bot.velocity + 8.0, 0.0, 16.0))
let hSin = int((sin(degToRad(bot.heading)) + 1.0) / 2.0 * 199.0)
let hCos = int((cos(degToRad(bot.heading)) + 1.0) / 2.0 * 199.0)
bot.frameRawBuf[0] = FrameRaw(bSin: bSin, bCos: bCos, dist: dist, vel: vel,
hSin: hSin, hCos: hCos,
ex: e.x, ey: e.y, energy: e.energy)
if bot.bufferCount < WINDOW_SIZE:
inc bot.bufferCount
if bot.bufferCount < WINDOW_SIZE:
@@ -99,11 +165,9 @@ method onScannedBot*(bot: BNNBot, e: ScannedBotEvent) =
let missDistance = hypot(bulletX - bot.lastEnemyX, bulletY - bot.lastEnemyY)
# Shaped reward: +1.0 for perfect hit, decays toward -1.0 as miss distance grows
# Using exponential decay: reward = 2.0 * exp(-missDistance / 36.0) - 1.0
let reward = 2.0 * exp(-missDistance / 36.0) - 1.0
bot.net.learn(b.trace, reward)
# Track hit/miss for display: hit if within 36px, miss otherwise
if missDistance < 36.0:
inc bot.virtualHits
else:
@@ -134,6 +198,9 @@ method onScannedBot*(bot: BNNBot, e: ScannedBotEvent) =
active: true,
)
# ── CSV row ───────────────────────────────────────────────────────────
bot.writeRow()
# ── stats & echo ─────────────────────────────────────────────────────
let hamming = if bot.hasPrev: hammingDistance(bot.prevVec, vec) else: 0
let similarity = if bot.hasPrev: TOTAL_BITS - hamming else: 0
@@ -159,8 +226,13 @@ method onRoundStarted*(bot: BNNBot, e: RoundStartedEvent) =
bot.tick = 0
bot.hasPrev = false
bot.bufferCount = 0
inc bot.roundNum
bot.openCsv()
# net and epsilon persist across rounds (learning carries over)
method onRoundEnded*(bot: BNNBot, e: RoundEndedEventForBot) =
bot.closeCsv()
method onGameStarted*(bot: BNNBot, e: GameStartedEventForBot) =
discard
+11
View File
@@ -0,0 +1,11 @@
{
"name": "TsetlinBot",
"version": "0.1.0",
"authors": ["Davide Cappellini"],
"description": "Regression Tsetlin Machine bot — learns aiming with virtual bullet feedback",
"homepage": "",
"countryCodes": ["IT"],
"gameTypes": ["classic", "1v1"],
"platform": "Nim",
"programmingLang": "Nim"
}
+280
View File
@@ -0,0 +1,280 @@
# TsetlinBot — Regression Tsetlin Machine for aiming.
# Identical to BNNBot except uses tsetlin_predictor instead of hebbian.
import std/[math, os, strutils, random]
import robocode_tankroyale_botapi
import radar_lock/radar_lock as radar_lock
import binary_encoding
import tsetlin_predictor
const botJsonPath = currentSourcePath().parentDir / "TsetlinBot.json"
const
BULLET_SLOTS = 50
EPSILON_START = 0.2
EPSILON_MIN = 0.05
EPSILON_DECAY = 0.9995
# Power levels probed each tick to pick best fire power
POWER_LEVELS = [0.1'f64, 0.4, 0.7, 1.0, 1.3, 1.6, 1.9, 2.2, 2.5, 3.0]
N_POWER = POWER_LEVELS.len
HIT_THRESHOLD = 0.40 # min hit-rate to qualify a power level
MIN_SAMPLES = 5 # samples before trusting a power level
DEFAULT_POWER = 1.0 # cold-start fallback
type
ProbeSlot = object # lightweight wave probe — no learning, just geometry
active: bool
powerIdx: int
fireX: float
fireY: float
aimAngleDeg: float
fireDist: float
bulletSpeed: float
age: int
type
TsetlinBot = ref object of Bot
hasContact: bool
enemyBearing: float
lastEnemyX: float
lastEnemyY: float
hasLastPos: bool
velocity: float
heading: float
distance: float
tick: int
prevVec: BinaryVector
hasPrev: bool
frameBuffer: array[WINDOW_SIZE, array[FRAME_BITS, uint8]]
bufferCount: int
net: TsetlinNet
bullets: array[BULLET_SLOTS, VirtualBullet]
bulletHead: int
virtualHits: int
virtualMiss: int
epsilon: float
aimAngle: float # absolute gun aim angle, updated each scan
firePower: float # dynamically selected fire power
powerStats: array[N_POWER, tuple[hits, total: int]]
probes: array[N_POWER, ProbeSlot] # one probe per power level per tick
method onScannedBot*(bot: TsetlinBot, e: ScannedBotEvent) =
let bx = getX(); let by = getY()
bot.enemyBearing = directionTo(bx, by, e.x, e.y)
bot.distance = distanceTo(bx, by, e.x, e.y)
bot.heading = e.direction
bot.velocity = e.speed
bot.lastEnemyX = e.x
bot.lastEnemyY = e.y
bot.hasLastPos = true
bot.hasContact = true
let arenaW = getArenaWidth().float
let arenaH = getArenaHeight().float
let frame = EnemyScanFrame(
bearing: bot.enemyBearing,
distance: bot.distance,
velocity: bot.velocity,
heading: bot.heading,
enemyWallN: arenaH - e.y,
enemyWallS: e.y,
enemyWallE: arenaW - e.x,
enemyWallW: e.x,
enemyEnergy: e.energy,
)
let encoded = encodeFrame(frame)
for i in countdown(WINDOW_SIZE - 1, 1):
bot.frameBuffer[i] = bot.frameBuffer[i - 1]
bot.frameBuffer[0] = encoded
if bot.bufferCount < WINDOW_SIZE:
inc bot.bufferCount
if bot.bufferCount < WINDOW_SIZE:
return
let selfState = SelfState(
myWallN: arenaH - by,
myWallS: by,
myWallE: arenaW - bx,
myWallW: bx,
myEnergy: getEnergy(),
canFire: getGunHeat() <= 0.0,
)
let selfEncoded = encodeSelf(selfState)
let vec = encodeFullVector(bot.frameBuffer, selfEncoded)
# ── pick best fire power ──────────────────────────────────────────────
bot.firePower = DEFAULT_POWER
var bestPow = -1.0
for pi in 0..<N_POWER:
let s = bot.powerStats[pi]
if s.total >= MIN_SAMPLES:
let rate = s.hits.float / s.total.float
if rate >= HIT_THRESHOLD and POWER_LEVELS[pi] > bestPow:
bestPow = POWER_LEVELS[pi]
bot.firePower = POWER_LEVELS[pi]
let selectedSpeed = 20.0 - 3.0 * bot.firePower
# ── Bug 4 fix: forward pass returns (cx, cy) pixel corrections ────────
var cache: ClauseCache
let (cx, cy) = bot.net.forwardWithCache(vec, cache)
# epsilon-greedy exploration: perturb the correction
var corrX = cx
var corrY = cy
if rand(1.0) < bot.epsilon:
corrX += rand(20.0) - 10.0
corrY += rand(20.0) - 10.0
bot.epsilon = max(EPSILON_MIN, bot.epsilon * EPSILON_DECAY)
# Convert (cx, cy) correction to aim angle.
# Linear extrapolation first, then TM residual correction on top.
let bx2 = getX(); let by2 = getY()
var aimAngle = bot.enemyBearing
if bot.hasLastPos:
let ticksToArrive = bot.distance / selectedSpeed
let extrapolX = cos(degToRad(bot.heading)) * bot.velocity * ticksToArrive
let extrapolY = sin(degToRad(bot.heading)) * bot.velocity * ticksToArrive
let targetX = bot.lastEnemyX + extrapolX + corrX
let targetY = bot.lastEnemyY + extrapolY + corrY
aimAngle = directionTo(bx2, by2, targetX, targetY)
var aimOffset = aimAngle - bot.enemyBearing
while aimOffset > 180.0: aimOffset -= 360.0
while aimOffset < -180.0: aimOffset += 360.0
bot.aimAngle = bot.enemyBearing + aimOffset
# ── store virtual bullet (with clause cache for eligibility) ──────────
let slot = bot.bulletHead mod BULLET_SLOTS
bot.bulletHead = slot + 1
bot.bullets[slot] = VirtualBullet(
trace: EligibilityTrace(input: vec, clauseOut: cache, age: 0, alive: true),
fireX: bx2,
fireY: by2,
aimAngleDeg: bot.enemyBearing + aimOffset,
fireDist: bot.distance,
bulletSpeed: selectedSpeed,
active: true,
)
# ── spawn one probe per power level ──────────────────────────────────
for pi in 0..<N_POWER:
let spd = 20.0 - 3.0 * POWER_LEVELS[pi]
let probeTicks = bot.distance / spd
let probeExtraX = cos(degToRad(bot.heading)) * bot.velocity * probeTicks
let probeExtraY = sin(degToRad(bot.heading)) * bot.velocity * probeTicks
let probeTargetX = bot.lastEnemyX + probeExtraX + corrX
let probeTargetY = bot.lastEnemyY + probeExtraY + corrY
let probeAimAngle = directionTo(bx2, by2, probeTargetX, probeTargetY)
bot.probes[pi] = ProbeSlot(
active: true,
powerIdx: pi,
fireX: bx2,
fireY: by2,
aimAngleDeg: probeAimAngle,
fireDist: bot.distance,
bulletSpeed: spd,
age: 0,
)
# ── stats & echo ─────────────────────────────────────────────────────
let hamming = if bot.hasPrev: hammingDistance(bot.prevVec, vec) else: 0
let similarity = if bot.hasPrev: TOTAL_BITS - hamming else: 0
let overlap = if bot.hasPrev: popcount(bitwiseAnd(bot.prevVec, vec)) else: 0
let totalVirtual = bot.virtualHits + bot.virtualMiss
let hitPct = if totalVirtual > 0: bot.virtualHits.float / totalVirtual.float * 100.0 else: 0.0
echo align($bot.tick, 4), " ",
hamming, " ", similarity, " ", overlap, " ",
formatFloat(aimOffset, ffDecimal, 2), " ",
bot.virtualHits, " ", bot.virtualMiss, " ",
formatFloat(hitPct, ffDecimal, 1), "% ",
"pwr=", formatFloat(bot.firePower, ffDecimal, 1)
bot.prevVec = vec
bot.hasPrev = true
method onRoundStarted*(bot: TsetlinBot, e: RoundStartedEvent) =
setAdjustGunForBodyTurn(true)
setAdjustRadarForBodyTurn(true)
setAdjustRadarForGunTurn(true)
radar_lock.init()
bot.hasContact = false
bot.hasLastPos = false
bot.tick = 0
bot.hasPrev = false
bot.bufferCount = 0
for i in 0..<BULLET_SLOTS:
bot.bullets[i].active = false
for i in 0..<N_POWER:
bot.probes[i].active = false
bot.bulletHead = 0
# net and epsilon persist across rounds
method onGameStarted*(bot: TsetlinBot, e: GameStartedEventForBot) =
discard
method run*(bot: TsetlinBot) =
while isRunning():
inc bot.tick
setTargetSpeed(0.0)
setTurnRate(0.0)
if not bot.hasContact:
setRadarTurnRate(45.0)
go()
continue
setRadarTurnRate(radar_lock.doRadar(getRadarDirection(), bot.enemyBearing))
# ── age & settle virtual bullets (game ticks) ─────────────────────
if bot.hasLastPos:
for idx in 0..<BULLET_SLOTS:
var b = addr bot.bullets[idx]
if not b.active: continue
inc b.trace.age
let bulletDist = b.bulletSpeed * float(b.trace.age)
if bulletDist >= b.fireDist or b.trace.age >= TRACE_MAX_AGE:
let bulletX = b.fireX + cos(degToRad(b.aimAngleDeg)) * bulletDist
let bulletY = b.fireY + sin(degToRad(b.aimAngleDeg)) * bulletDist
let residualX = bot.lastEnemyX - bulletX
let residualY = bot.lastEnemyY - bulletY
let missDistance = hypot(residualX, residualY)
bot.net.learn(b.trace, residualX, residualY)
if missDistance < 36.0: inc bot.virtualHits
else: inc bot.virtualMiss
b.active = false
for pi in 0..<N_POWER:
var p = addr bot.probes[pi]
if not p.active: continue
inc p.age
let pd = p.bulletSpeed * float(p.age)
if pd >= p.fireDist or p.age >= TRACE_MAX_AGE:
let px = p.fireX + cos(degToRad(p.aimAngleDeg)) * pd
let py = p.fireY + sin(degToRad(p.aimAngleDeg)) * pd
let md = hypot(px - bot.lastEnemyX, py - bot.lastEnemyY)
inc bot.powerStats[pi].total
if md < 36.0: inc bot.powerStats[pi].hits
p.active = false
# Aim gun and fire when ready
let gunDir = getGunDirection()
let gunDelta = normalizeRelativeAngle(bot.aimAngle - gunDir)
setGunTurnRate(gunDelta.clamp(-20.0, 20.0))
if getGunHeat() <= 0.0 and abs(gunDelta) < 2.0:
discard setFire(bot.firePower)
go()
when isMainModule:
randomize()
var bot = TsetlinBot(
net: initTsetlinNet(),
epsilon: EPSILON_START,
firePower: DEFAULT_POWER,
)
start(bot, botJsonPath)
+11
View File
@@ -0,0 +1,11 @@
{
"name": "WiSARDBot",
"version": "0.1.0",
"authors": ["Davide Cappellini"],
"description": "WiSARD (K=14) regression predictor bot — 63 neurons × 16384-entry LUTs over 870-bit binary state",
"homepage": "",
"countryCodes": ["IT"],
"gameTypes": ["classic", "1v1"],
"platform": "Nim",
"programmingLang": "Nim"
}
+288
View File
@@ -0,0 +1,288 @@
# WiSARDBot — WiSARD regression predictor for aiming.
# Identical to TsetlinBot except uses wisard_predictor instead of tsetlin_predictor.
import std/[math, os, strutils, random]
import robocode_tankroyale_botapi
import radar_lock/radar_lock as radar_lock
import binary_encoding
import wisard_predictor
const botJsonPath = currentSourcePath().parentDir / "WiSARDBot.json"
const
BULLET_SLOTS = 50
EPSILON_START = 0.2
EPSILON_MIN = 0.05
EPSILON_DECAY = 0.9995
# Power levels probed each tick to pick best fire power
POWER_LEVELS = [0.1'f64, 0.4, 0.7, 1.0, 1.3, 1.6, 1.9, 2.2, 2.5, 3.0]
N_POWER = POWER_LEVELS.len
HIT_THRESHOLD = 0.40 # min hit-rate to qualify a power level
MIN_SAMPLES = 5 # samples before trusting a power level
DEFAULT_POWER = 1.0 # cold-start fallback
type
ProbeSlot = object # lightweight wave probe — no learning, just geometry
active: bool
powerIdx: int
fireX: float
fireY: float
aimAngleDeg: float
fireDist: float
bulletSpeed: float
age: int
type
WiSARDBot = ref object of Bot
hasContact: bool
enemyBearing: float
lastEnemyX: float
lastEnemyY: float
hasLastPos: bool
velocity: float
heading: float
distance: float
tick: int
prevVec: BinaryVector
hasPrev: bool
frameBuffer: array[WINDOW_SIZE, array[FRAME_BITS, uint8]]
bufferCount: int
net: WiSARDNet
bullets: array[BULLET_SLOTS, VirtualBullet]
bulletHead: int
virtualHits: int
virtualMiss: int
epsilon: float
aimAngle: float # absolute gun aim angle, updated each scan
firePower: float # dynamically selected fire power
powerStats: array[N_POWER, tuple[hits, total: int]]
probes: array[N_POWER, ProbeSlot] # one probe per power level per tick
method onScannedBot*(bot: WiSARDBot, e: ScannedBotEvent) =
let bx = getX(); let by = getY()
bot.enemyBearing = directionTo(bx, by, e.x, e.y)
bot.distance = distanceTo(bx, by, e.x, e.y)
bot.heading = e.direction
bot.velocity = e.speed
bot.lastEnemyX = e.x
bot.lastEnemyY = e.y
bot.hasLastPos = true
bot.hasContact = true
let arenaW = getArenaWidth().float
let arenaH = getArenaHeight().float
# lastEnemyX/Y already stored above — available for wave resolution in run()
let frame = EnemyScanFrame(
bearing: bot.enemyBearing,
distance: bot.distance,
velocity: bot.velocity,
heading: bot.heading,
enemyWallN: arenaH - e.y,
enemyWallS: e.y,
enemyWallE: arenaW - e.x,
enemyWallW: e.x,
enemyEnergy: e.energy,
)
let encoded = encodeFrame(frame)
for i in countdown(WINDOW_SIZE - 1, 1):
bot.frameBuffer[i] = bot.frameBuffer[i - 1]
bot.frameBuffer[0] = encoded
if bot.bufferCount < WINDOW_SIZE:
inc bot.bufferCount
if bot.bufferCount < WINDOW_SIZE:
return
let selfState = SelfState(
myWallN: arenaH - by,
myWallS: by,
myWallE: arenaW - bx,
myWallW: bx,
myEnergy: getEnergy(),
canFire: getGunHeat() <= 0.0,
)
let selfEncoded = encodeSelf(selfState)
let vec = encodeFullVector(bot.frameBuffer, selfEncoded)
# ── pick best fire power ──────────────────────────────────────────────
bot.firePower = DEFAULT_POWER
var bestPow = -1.0
for pi in 0..<N_POWER:
let s = bot.powerStats[pi]
if s.total >= MIN_SAMPLES:
let rate = s.hits.float / s.total.float
if rate >= HIT_THRESHOLD and POWER_LEVELS[pi] > bestPow:
bestPow = POWER_LEVELS[pi]
bot.firePower = POWER_LEVELS[pi]
let selectedSpeed = 20.0 - 3.0 * bot.firePower
# ── WiSARD forward pass: compute addresses then predict correction ────
let addrs = bot.net.computeAddresses(vec)
let (cx, cy) = bot.net.predictCorrection(addrs)
# Convert (cx, cy) correction to aim angle offset.
# Linear extrapolation first; cx/cy are residual corrections on top.
let bx2 = getX(); let by2 = getY()
var aimAngle = bot.enemyBearing
if bot.hasLastPos:
let ticksToArrive = bot.distance / selectedSpeed
let extrapolX = cos(degToRad(bot.heading)) * bot.velocity * ticksToArrive
let extrapolY = sin(degToRad(bot.heading)) * bot.velocity * ticksToArrive
let targetX = bot.lastEnemyX + extrapolX + cx
let targetY = bot.lastEnemyY + extrapolY + cy
echo "AIM: enemyXY=(" & formatFloat(bot.lastEnemyX, ffDecimal, 1) & "," & formatFloat(bot.lastEnemyY, ffDecimal, 1) &
") heading=" & formatFloat(bot.heading, ffDecimal, 1) &
" vel=" & formatFloat(bot.velocity, ffDecimal, 1) &
" ticks=" & formatFloat(ticksToArrive, ffDecimal, 1) &
" extrapol=(" & formatFloat(extrapolX, ffDecimal, 1) & "," & formatFloat(extrapolY, ffDecimal, 1) &
") corr=(" & formatFloat(cx, ffDecimal, 1) & "," & formatFloat(cy, ffDecimal, 1) &
") target=(" & formatFloat(targetX, ffDecimal, 1) & "," & formatFloat(targetY, ffDecimal, 1) &
") aimAng=" & formatFloat(aimAngle, ffDecimal, 1) &
" bearing=" & formatFloat(bot.enemyBearing, ffDecimal, 1)
aimAngle = directionTo(bx2, by2, targetX, targetY)
var aimOffset = aimAngle - bot.enemyBearing
# Normalise to [-180, 180]
while aimOffset > 180.0: aimOffset -= 360.0
while aimOffset < -180.0: aimOffset += 360.0
# epsilon-greedy exploration: perturb the correction
if rand(1.0) < bot.epsilon:
aimOffset += rand(10.0) - 5.0
bot.epsilon = max(EPSILON_MIN, bot.epsilon * EPSILON_DECAY)
bot.aimAngle = bot.enemyBearing + aimOffset
# ── store virtual bullet (with addresses for eligibility) ────────────
let slot = bot.bulletHead mod BULLET_SLOTS
bot.bulletHead = slot + 1
bot.bullets[slot] = VirtualBullet(
trace: WaveTrace(addrs: addrs, valid: true, age: 0),
fireX: bx2,
fireY: by2,
aimAngleDeg: bot.enemyBearing + aimOffset,
fireDist: bot.distance,
bulletSpeed: selectedSpeed,
active: true,
)
# ── spawn one probe per power level (each uses its own extrapolation) ─
for pi in 0..<N_POWER:
let probeSpeed = 20.0 - 3.0 * POWER_LEVELS[pi]
let probeTicks = bot.distance / probeSpeed
let probeExtrapolX = cos(degToRad(bot.heading)) * bot.velocity * probeTicks
let probeExtrapolY = sin(degToRad(bot.heading)) * bot.velocity * probeTicks
let probeTargetX = bot.lastEnemyX + probeExtrapolX + cx
let probeTargetY = bot.lastEnemyY + probeExtrapolY + cy
let probeAimAngle = directionTo(bx2, by2, probeTargetX, probeTargetY)
bot.probes[pi] = ProbeSlot(
active: true,
powerIdx: pi,
fireX: bx2,
fireY: by2,
aimAngleDeg: probeAimAngle,
fireDist: bot.distance,
bulletSpeed: probeSpeed,
age: 0,
)
# ── stats & echo ─────────────────────────────────────────────────────
let hamming = if bot.hasPrev: hammingDistance(bot.prevVec, vec) else: 0
let similarity = if bot.hasPrev: TOTAL_BITS - hamming else: 0
let overlap = if bot.hasPrev: popcount(bitwiseAnd(bot.prevVec, vec)) else: 0
let totalVirtual = bot.virtualHits + bot.virtualMiss
let hitPct = if totalVirtual > 0: bot.virtualHits.float / totalVirtual.float * 100.0 else: 0.0
echo align($bot.tick, 4), " ",
hamming, " ", similarity, " ", overlap, " ",
formatFloat(aimOffset, ffDecimal, 2), " ",
bot.virtualHits, " ", bot.virtualMiss, " ",
formatFloat(hitPct, ffDecimal, 1), "% ",
"pwr=", formatFloat(bot.firePower, ffDecimal, 1)
bot.prevVec = vec
bot.hasPrev = true
method onRoundStarted*(bot: WiSARDBot, e: RoundStartedEvent) =
setAdjustGunForBodyTurn(true)
setAdjustRadarForBodyTurn(true)
setAdjustRadarForGunTurn(true)
radar_lock.init()
bot.hasContact = false
bot.hasLastPos = false
bot.tick = 0
bot.hasPrev = false
bot.bufferCount = 0
for i in 0..<BULLET_SLOTS:
bot.bullets[i].active = false
for i in 0..<N_POWER:
bot.probes[i].active = false
bot.bulletHead = 0
# net and epsilon persist across rounds
method onGameStarted*(bot: WiSARDBot, e: GameStartedEventForBot) =
discard
method run*(bot: WiSARDBot) =
while isRunning():
inc bot.tick
setTargetSpeed(0.0)
setTurnRate(0.0)
# ── age & settle virtual bullets every game tick ──────────────────
if bot.hasLastPos:
for idx in 0..<BULLET_SLOTS:
var b = addr bot.bullets[idx]
if not b.active: continue
inc b.trace.age
let bulletDist = b.bulletSpeed * float(b.trace.age)
if bulletDist >= b.fireDist or b.trace.age >= TRACE_MAX_AGE:
let bulletX = b.fireX + cos(degToRad(b.aimAngleDeg)) * bulletDist
let bulletY = b.fireY + sin(degToRad(b.aimAngleDeg)) * bulletDist
let missDistance = hypot(bulletX - bot.lastEnemyX, bulletY - bot.lastEnemyY)
let resX = bot.lastEnemyX - bulletX
let resY = bot.lastEnemyY - bulletY
bot.net.learnCorrection(b.trace.addrs, resX, resY)
if missDistance < 36.0: inc bot.virtualHits
else: inc bot.virtualMiss
b.active = false
for pi in 0..<N_POWER:
var p = addr bot.probes[pi]
if not p.active: continue
inc p.age
let pd = p.bulletSpeed * float(p.age)
if pd >= p.fireDist or p.age >= TRACE_MAX_AGE:
let px = p.fireX + cos(degToRad(p.aimAngleDeg)) * pd
let py = p.fireY + sin(degToRad(p.aimAngleDeg)) * pd
let md = hypot(px - bot.lastEnemyX, py - bot.lastEnemyY)
inc bot.powerStats[pi].total
if md < 36.0: inc bot.powerStats[pi].hits
p.active = false
if not bot.hasContact:
setRadarTurnRate(45.0)
go()
continue
setRadarTurnRate(radar_lock.doRadar(getRadarDirection(), bot.enemyBearing))
# Aim gun and fire when ready
let gunDir = getGunDirection()
let gunDelta = normalizeRelativeAngle(bot.aimAngle - gunDir)
setGunTurnRate(gunDelta.clamp(-20.0, 20.0))
if getGunHeat() <= 0.0 and abs(gunDelta) < 2.0:
discard setFire(bot.firePower)
go()
when isMainModule:
randomize()
var bot = WiSARDBot(
net: initWiSARD(),
epsilon: EPSILON_START,
firePower: DEFAULT_POWER,
)
start(bot, botJsonPath)
+31 -1
View File
@@ -28,7 +28,10 @@ type
myEnergy*: float # 11 bits
canFire*: bool # 1 bit
const MAX_DISTANCE = 1414.0 # diagonal of 1000x1000 arena
const
MAX_DISTANCE = 1414.0 # diagonal of 1000x1000 arena
NUM_FRAME_FIELDS* = 9 # for CSV output: bearing_sin, bearing_cos, distance, velocity, heading_sin, heading_cos, enemy_x, enemy_y, energy
FIELD_NAMES* = ["bearing_sin", "bearing_cos", "distance", "velocity", "heading_sin", "heading_cos", "wallN", "wallS", "energy"]
proc toGray(value: int): int =
value xor (value shr 1)
@@ -137,6 +140,33 @@ proc bitwiseAnd*(a, b: BinaryVector): BinaryVector =
for i in 0..<TOTAL_BITS:
result[i] = a[i] and b[i]
proc formatVectorBinary*(vec: BinaryVector): string =
formatBinary(vec)
proc formatVectorDecimal*(vec: BinaryVector): string =
# Placeholder: output popcount or some summary per frame
result = ""
for i in 0..<WINDOW_SIZE:
var cnt = 0
for j in 0..<FRAME_BITS:
cnt += int(vec[i * FRAME_BITS + j])
if i > 0: result &= ","
result &= $cnt
proc formatFrameBinary*(frame: array[FRAME_BITS, uint8]): string =
for b in frame:
result &= (if b == 1: "1" else: "0")
proc formatFrameDecimal*(frame: array[FRAME_BITS, uint8]): string =
# Nine decimal fields from the frame bits
# bearing_sin (0-199), bearing_cos (0-199), distance (0-99), velocity (0-15), heading_sin (0-199), heading_cos (0-199), wall (0-99), wall (0-99), energy (0-1500/10)
result = "199,199,99,15,199,199,99,99,150" # placeholder max values
proc decodeFrameFields*(frame: array[FRAME_BITS, uint8]): array[9, float] =
# Decode frame to 9 decimal field values (enemy X, Y for field indices 6, 7)
# This is a placeholder—actual decoding would reverse the toBits() encoding
result = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 50.0, 50.0, 100.0]
# Output encoding -------------------------------------------------------
const
+50
View File
@@ -0,0 +1,50 @@
import std/math
import binary_encoding
const
N_SECTORS = 8
N_BANDS = 3
LEARNING_RATE = 0.2
BAND_THR_LO = 33.0
BAND_THR_HI = 66.0
type
ResidualTable* = object
corrections*: array[N_SECTORS * N_BANDS, tuple[cx, cy: float]]
proc initPredictor*(): ResidualTable = discard # zero-init is correct
proc getSector*(headingSin, headingCos: int): int =
let s = headingSin.float / 199.0 * 2.0 - 1.0
let c = headingCos.float / 199.0 * 2.0 - 1.0
var deg = radToDeg(arctan2(s, c))
if deg < 0.0: deg += 360.0
int(deg / 45.0) mod N_SECTORS
proc getBand*(distance: int): int =
if distance.float < BAND_THR_LO: 0
elif distance.float < BAND_THR_HI: 1
else: 2
proc predict*(table: ResidualTable,
f0, f1: array[NUM_FRAME_FIELDS, int],
power: float): tuple[predX, predY: float] =
# field indices: 2=distance, 4=heading_sin, 5=heading_cos, 6=enemy_x, 7=enemy_y
let bulletSpd = 20.0 - 3.0 * power
let distancePx = f0[2].float / 99.0 * 1414.0
let ticks = distancePx / bulletSpd
let vx = float(f0[6] - f1[6])
let vy = float(f0[7] - f1[7])
let sector = getSector(f0[4], f0[5])
let band = getBand(f0[2])
let corr = table.corrections[sector * N_BANDS + band]
result.predX = f0[6].float + vx * ticks + corr.cx
result.predY = f0[7].float + vy * ticks + corr.cy
proc sectorBand*(f0: array[NUM_FRAME_FIELDS, int]): tuple[sector, band: int] =
(getSector(f0[4], f0[5]), getBand(f0[2]))
proc learn*(table: var ResidualTable, sector, band: int,
residualX, residualY: float) =
table.corrections[sector * N_BANDS + band].cx += LEARNING_RATE * residualX
table.corrections[sector * N_BANDS + band].cy += LEARNING_RATE * residualY
+178
View File
@@ -0,0 +1,178 @@
# Regression Tsetlin Machine for aiming correction.
# Input: 870 bits → 1740 literals (bit + complement)
# N_OUT=2 outputs: cx correction, cy correction (pixel offsets).
# 64 clauses per output (32 positive polarity, 32 negative).
# Clause output = AND of all included literals.
# Regression: vote ∈ [-N_CLAUSES/2, N_CLAUSES/2] → scaled to [-RESID_MAX, RESID_MAX].
# Online learning via Type I / Ib / Type II stochastic feedback.
import binary_encoding
import std/[math, random]
const
N_IN* = TOTAL_BITS # 870
N_OUT* = 2 # cx, cy pixel corrections
N_LITERALS = N_IN * 2 # 1740 (bit + complement)
N_CLAUSES = 64 # per output; first 32 = pos polarity
HALF_CLAUSES = N_CLAUSES div 2
N_STATES = 15 # automaton range [-N_STATES..N_STATES] (int8 ok)
T* = float(HALF_CLAUSES) # = 32.0; vote clamped to [-T, T]
S = 4.0 # specificity (higher = sparser clauses)
RESID_MAX* = 80.0 # residual correction range (on top of linear extrapolation)
# ponytail: N_STATES=15 fits int8, keeps array small; raise if underfitting
TRACE_MAX_AGE* = 40
type
# int8 automaton: positive = include literal, negative = exclude
TsetlinNet* = object
# states[out][clause][literal] — flattened
states: array[N_OUT * N_CLAUSES * N_LITERALS, int8]
# Eligibility: store clause outputs (one bit per clause per output)
ClauseCache* = array[N_OUT * N_CLAUSES, uint8]
EligibilityTrace* = object
input*: BinaryVector
clauseOut*: ClauseCache
age*: int
alive*: bool
VirtualBullet* = object
trace*: EligibilityTrace
fireX*: float
fireY*: float
aimAngleDeg*: float
fireDist*: float
bulletSpeed*: float
active*: bool
# ── helpers ────────────────────────────────────────────────────────────
proc stateIdx(outIdx, clause, lit: int): int {.inline.} =
(outIdx * N_CLAUSES + clause) * N_LITERALS + lit
proc clausePolarity(clause: int): float {.inline.} =
# first HALF_CLAUSES = +1 polarity, rest = -1
if clause < HALF_CLAUSES: 1.0 else: -1.0
proc evalClause(net: TsetlinNet, outIdx, clause: int,
literals: array[N_LITERALS, uint8]): uint8 =
## Returns 1 if clause fires (AND of all included literals satisfied).
## Empty clause (no included literals) returns 0 — silent, not vacuously true.
var hasIncluded = false
for lit in 0..<N_LITERALS:
let s = net.states[stateIdx(outIdx, clause, lit)]
if s > 0:
hasIncluded = true
if literals[lit] == 0:
return 0'u8
return if hasIncluded: 1'u8 else: 0'u8
proc makeLiterals(input: BinaryVector): array[N_LITERALS, uint8] =
for i in 0..<N_IN:
result[i] = input[i]
result[i + N_IN] = 1'u8 - input[i]
proc computeVote(net: TsetlinNet, outIdx: int,
literals: array[N_LITERALS, uint8]): float =
for c in 0..<N_CLAUSES:
result += clausePolarity(c) * float(evalClause(net, outIdx, c, literals))
result = clamp(result, -T, T)
# ── public API ─────────────────────────────────────────────────────────
proc initTsetlinNet*(): TsetlinNet =
# Bug 2 fix: init at 0 (boundary). One Type I step crosses into Include.
for s in result.states.mitems:
s = 0'i8
proc forward*(net: TsetlinNet, input: BinaryVector): (float, float) =
## Returns (cx, cy) pixel corrections.
let literals = makeLiterals(input)
let cx = computeVote(net, 0, literals) / T * RESID_MAX
let cy = computeVote(net, 1, literals) / T * RESID_MAX
return (cx, cy)
proc forwardWithCache*(net: TsetlinNet, input: BinaryVector,
cache: var ClauseCache): (float, float) =
## Like forward but also populates cache for eligibility tracing.
let literals = makeLiterals(input)
var vx = 0.0
var vy = 0.0
for c in 0..<N_CLAUSES:
let o = evalClause(net, 0, c, literals)
cache[c] = o
vx += clausePolarity(c) * float(o)
for c in 0..<N_CLAUSES:
let o = evalClause(net, 1, c, literals)
cache[N_CLAUSES + c] = o
vy += clausePolarity(c) * float(o)
vx = clamp(vx, -T, T)
vy = clamp(vy, -T, T)
return (vx / T * RESID_MAX, vy / T * RESID_MAX)
proc learnOne(net: var TsetlinNet, outIdx: int, literals: array[N_LITERALS, uint8],
clauseOut: ClauseCache, residual: float) =
## Regression TM update for one output dimension.
# Recompute predicted from cached clause outputs
var vote = 0.0
for c in 0..<N_CLAUSES:
vote += clausePolarity(c) * float(clauseOut[outIdx * N_CLAUSES + c])
vote = clamp(vote, -T, T)
let predicted = vote / T * RESID_MAX
let error = residual - predicted
# Bug 1 fix: proper feedback probability gated on normalized error
let pFeedback = min(1.0, abs(error) / (2.0 * RESID_MAX))
for c in 0..<N_CLAUSES:
if pFeedback <= 0.0: continue
if rand(1.0) >= pFeedback: continue
let pol = clausePolarity(c)
let cOut = clauseOut[outIdx * N_CLAUSES + c]
if (error > 0.0 and pol > 0.0) or (error < 0.0 and pol < 0.0):
# Type I feedback: grow clause toward current input
if cOut == 1'u8:
# Type Ia: clause fires — reinforce matching features
for lit in 0..<N_LITERALS:
let si = stateIdx(outIdx, c, lit)
var st = int(net.states[si])
if literals[lit] == 1'u8:
if rand(1.0) < (S - 1.0) / S:
st = min(st + 1, N_STATES)
else:
if rand(1.0) < 1.0 / S:
st = max(st - 1, -N_STATES)
net.states[si] = int8(st)
else:
# Type Ib: clause silent, should fire — grow toward current input
for lit in 0..<N_LITERALS:
let si = stateIdx(outIdx, c, lit)
var st = int(net.states[si])
if literals[lit] == 1'u8:
if rand(1.0) < (S - 1.0) / S:
st = min(st + 1, N_STATES)
else:
if rand(1.0) < 1.0 / S:
st = max(st - 1, -N_STATES)
net.states[si] = int8(st)
else:
# Bug 3 fix: Type II — decrement (toward exclude) false literals in Include range
if cOut == 1'u8:
for lit in 0..<N_LITERALS:
if literals[lit] == 0'u8:
let si = stateIdx(outIdx, c, lit)
var st = int(net.states[si])
if st > 0: # only if currently in Include range
st = max(st - 1, -N_STATES)
net.states[si] = int8(st)
proc learn*(net: var TsetlinNet, trace: EligibilityTrace,
residualX: float, residualY: float) =
## Online TM update from a resolved virtual bullet.
## residualX/Y: pixel correction needed (actual_target - aimed_point).
if not trace.alive: return
let literals = makeLiterals(trace.input)
net.learnOne(0, literals, trace.clauseOut, residualX)
net.learnOne(1, literals, trace.clauseOut, residualY)
+87
View File
@@ -0,0 +1,87 @@
## WiSARD regression predictor — K=14, ~50 neurons, 16384 entries each.
## Input: TOTAL_BITS-bit BinaryVector. Output: (dx, dy) correction.
## Online learning via eligibility traces (addresses stored per pending wave).
import std/[math, random]
import binary_encoding
const
K* = 14
N_BITS_PAD = ((TOTAL_BITS + K - 1) div K) * K # pad to multiple of K
N_NEURONS* = N_BITS_PAD div K # ceil(690/14) = 50
LAST_BITS = if TOTAL_BITS mod K == 0: K else: TOTAL_BITS mod K # bits in last neuron
LUT_SIZE = 1 shl K # 16384
LAST_LUT_SIZE = 1 shl LAST_BITS # smaller LUT for last neuron
BLEACH_THRESHOLD* = 1 # only use entries with count > this
type
LutEntry = object
sumX: float
sumY: float
count: int
WiSARDNet* = object
perm*: array[N_BITS_PAD, int]
luts*: array[N_NEURONS, array[LUT_SIZE, LutEntry]]
# Eligibility trace: LUT addresses computed at fire time, replayed on resolution
WaveTrace* = object
addrs*: array[N_NEURONS, int]
age*: int
valid*: bool
# Virtual bullet slot (mirrors tsetlin_predictor's VirtualBullet)
VirtualBullet* = object
trace*: WaveTrace
fireX*: float
fireY*: float
aimAngleDeg*: float
fireDist*: float
bulletSpeed*: float
active*: bool
const TRACE_MAX_AGE* = 40
proc initWiSARD*(seed: int64 = 42): WiSARDNet =
## Build fixed random permutation over TOTAL_BITS; padding slots duplicate
## valid indices (random) to avoid zero-bias.
var rng = initRand(seed)
for i in 0..<TOTAL_BITS: result.perm[i] = i
# Fisher-Yates shuffle over real bits only
for i in countdown(TOTAL_BITS - 1, 1):
let j = rng.rand(i)
swap(result.perm[i], result.perm[j])
# Padding slots get random valid indices (no bias toward bit 0)
for i in TOTAL_BITS..<N_BITS_PAD:
result.perm[i] = rng.rand(TOTAL_BITS - 1)
proc computeAddresses*(ws: WiSARDNet, vec: BinaryVector): array[N_NEURONS, int] =
for n in 0..<N_NEURONS:
var laddr = 0
let bits = if n == N_NEURONS - 1: LAST_BITS else: K
for b in 0..<bits:
laddr = (laddr shl 1) or int(vec[ws.perm[n * K + b]])
result[n] = laddr
proc predictCorrection*(ws: WiSARDNet, addrs: array[N_NEURONS, int]): tuple[cx, cy: float] =
## Average (sumX/count, sumY/count) across neurons with count > BLEACH_THRESHOLD.
var sx = 0.0; var sy = 0.0; var active = 0
for n in 0..<N_NEURONS:
let e = ws.luts[n][addrs[n]]
if e.count > BLEACH_THRESHOLD:
sx += e.sumX / float(e.count)
sy += e.sumY / float(e.count)
inc active
if active == 0: return (0.0, 0.0)
(sx / float(active), sy / float(active))
proc learnCorrection*(ws: var WiSARDNet, addrs: array[N_NEURONS, int],
dx, dy: float) =
## Accumulate (dx, dy) residuals at the addressed LUT entries.
if dx.isNaN or dy.isNaN or dx.classify == fcInf or dx.classify == fcNegInf or
dy.classify == fcInf or dy.classify == fcNegInf: return
for n in 0..<N_NEURONS:
let a = addrs[n]
ws.luts[n][a].sumX += dx
ws.luts[n][a].sumY += dy
ws.luts[n][a].count += 1