feat(guns): scale-aware power selection (+52% damage); TM classifier gun built, measured, DISABLED

TASK 2 - power selection, a clear win. bestPower used an ABSOLUTE
MinHitRate = 0.40 bar. Measured per-bin virtual rates (rolling-100 fraction)
show no bin ever clears 40%, so 11 of 14 guns were stuck at bin 0 (power 1.0)
even where higher bins were comparable:
  Linear  p1.0 44% p1.5 39% p2.0 30% p3.0 29%   old bin 0 -> new bin 3
  Accel   p1.0 44% p1.5 40% p2.0 26% p3.0 29%   old bin 1 -> new bin 3
  Pattern p1.0 50% p1.5 40% p2.0 27% p3.0 12%   old bin 1 -> new bin 2
Replaced with a scale-aware PowerBarFrac = 0.50 (a dimensionless FRACTION of
the gun's own best bin rate). 13 of 14 selections now pick heavier bullets.
Real effect vs DrussGT (8 rounds x 3 runs): hit rate unchanged (7.56% ->
7.47%) but damage dealt +52% (157 -> 239 per run) and rounds end faster.
Same accuracy, half the shots, half again more damage.

TASK 1 - the TM pattern-classifier gun does NOT earn its slot. It was built as
a mixture of experts with a corrected-Granmo TM as a multi-class gate over
HeadOn/Linear/Circular/WallBounce/Accel, labelled by which expert's prediction
was closest to the actual enemy position (an exact, supervised, per-shot
label - no delayed credit). Offline it loses to the best of its OWN experts on
essentially every fixture, and against DrussGT it cost real performance:
  baseline (path+relative)  7.56% real hit rate, damage 157
  + power fix               7.47%,                 damage 239
  + power fix + TM gun      5.59%,                 damage 133
The gun was selected on 806 ticks and fired 24 real shots at 4.2%.
So the tree ships with EnableTmSelector = false: code and wiring kept intact
for re-enabling, but it is not in the active rack.

Worth recording from the clause dump: the gate DOES latch onto meaningful
structure. On energy-threshold-turner, HeadOn's clauses key on the energy bits
(the rule's own driving variable) while Circular keys on distance/velocity. So
the TM is learning something real and interpretable - it simply cannot beat
'always pick the best expert'. Root cause (INFERRED): the closest-expert label
is noisy because several experts are near-tied, and under the path metric the
winner varies by power bin while the gate sees one shared per-tick input, so a
one-vs-rest gate over a saturated 870-bit clause space has no margin to exploit.
(Zero-padding the 2-frame window was tried first and saturated every clause at
256-755 included literals; alternating the two real frames fixed that.)

Also factors the corrected feedback into an exported tmLearnDir and exports the
encoding/TM primitives; the Tsetlin tests still reproduce the documented
mean=13.8 included literals, so the refactor is behaviour-preserving.

Verified: 33/33 guard checks, tsetlin tests green, metric checks green, new
power-selection guard green (13/14 selections change; relative bar still picks
bin 1 and not bin 3 for a [30,25,12,5]% profile), 12/12 offline==online
acceptance under the shipped default.
This commit is contained in:
2026-09-21 05:19:07 +02:00
parent dea4dcb574
commit 57b2ac3849
8 changed files with 601 additions and 60 deletions
+328
View File
@@ -0,0 +1,328 @@
## Tsetlin-machine pattern-classifier gun — a MIXTURE OF EXPERTS with a learned
## TM gate.
##
## Why this exists (and why it is not the existing `tsetlin.nim` gun): the old
## Tsetlin gun uses the TM as a pixel-correction REGRESSOR on a single baseline
## and ranks 12th of 13. Here the TM does what Granmo's machine is actually
## strong at — supervised multi-class classification — on the frame-stacked
## binary encoding that measures 99.35-99.48% held-out accuracy at 2 frames.
##
## Architecture
## ------------
## experts : HeadOn, Linear, Circular, WallBounce, Accel (the existing
## analytic guns, reused unchanged and cheap).
## gate : the TM. Every tick it sees the same 2-frame Gray-coded binary
## vector as the old Tsetlin gun and votes for the expert most
## likely to be right.
## label : EXACTLY observable and supervised — at virtual-bullet
## resolution the FeedbackEvent carries the enemy's actual
## position, so the label is simply which expert's stored
## prediction was CLOSEST. No delayed credit, no eligibility trace.
## output : the winning class's expert prediction for the requested
## bulletSpeed (so the gun implements the ordinary
## `predict(state, bulletSpeed)` interface and drops into the rack).
##
## TM reuse: encoding (`tmEncodeFrame`/`tmEncodeSelf`/`tmEncodeFullVector`) and
## ALL learning primitives come from `guns/tsetlin.nim` — in particular the
## CORRECTED Granmo feedback (`tmLearnDir`/`tmLearnOne`: Type I conditioned on
## the clause output, reachable Type II, the (T - clip(v,-T,T))/(2T) resource
## allocation, and the Eq. 6 all-Exclude bootstrap). Nothing is re-derived here.
##
## Multi-class formulation: Granmo's standard one-clause-team-per-class. Class c
## is a binary TM (`d = +1` for the winning expert, `-1` for the rest) and the
## predicted class is the argmax of the class votes. `TM_N_OUT = 2` independent
## clause teams already live in one `TmNet`, so 5 classes fit in 3 nets.
import std/[math, random, strformat, algorithm]
import gun_harness/gun_interface
import gun_harness/virtual_bullets as vb
import guns/tsetlin
import guns/head_on
import guns/linear
import guns/circular
import guns/wall_bounce
import guns/accel_predictor
const
N_EXPERTS* = 5
N_NETS = (N_EXPERTS + 1) div 2 ## 3 nets × 2 outputs = 6 class teams
SEL_TRACE_SLOTS = 1024 ## exact (fireTick,powerBin) ring, as tsetlin.nim
SEL_MIN_OBS = 50 ## observations before the TM outvotes the bootstrap expert
DebugSelector* = false
ExpertNames*: array[N_EXPERTS, string] =
["HeadOn", "Linear", "Circular", "WallBounce", "Accel"]
type
TmSelTrace = object
fireTick: int
powerBin: int
preds: array[N_EXPERTS, GunPrediction] ## fire-time expert predictions
votes: array[N_NETS * 2, float] ## fire-time class votes (clamped)
cache: array[N_NETS, TmClauseCache] ## fire-time LEARNING clause outputs
input: TmBinaryVector ## fire-time encoded input
alive: bool
TmSelectorGun* = object
nets: array[N_NETS, TmNet]
frameBuff: array[2, TmFrameEncoded] ## [0]=newest, [1]=previous
frameCount: int
lastTick: int
input: TmBinaryVector
curCaches: array[N_NETS, TmClauseCache]
votes: array[N_NETS * 2, float]
winCount*: array[N_EXPERTS, int] ## cumulative winners (bootstrap only)
totalObs*: int
traces: array[SEL_TRACE_SLOTS, TmSelTrace]
# experts
headOn: HeadOnGun
linear: LinearGun
circular: CircularGun
wallBounce: WallBounceGun
accel: AccelGun
# instrumentation
predictCalls*: int
trainCalls*: int
traceMisses*: int
lastChosen*: int
debugGraphics*: bool
# ── helpers ──────────────────────────────────────────────────────────────────
proc selBinForSpeed(spd: float): int {.inline.} =
for i in 0..<len(vb.PowerBins):
if abs(spd - bulletSpeed(vb.PowerBins[i])) < 1e-6:
return i
-1
proc selTraceSlot(fireTick, binIdx: int): int {.inline.} =
((fireTick * len(vb.PowerBins)) + binIdx) mod SEL_TRACE_SLOTS
proc expertPred*(g: var TmSelectorGun, idx: int, state: WorldState,
bulletSpeed: float): GunPrediction =
case idx
of 0: g.headOn.predict(state, bulletSpeed)
of 1: g.linear.predict(state, bulletSpeed)
of 2: g.circular.predict(state, bulletSpeed)
of 3: g.wallBounce.predict(state, bulletSpeed)
of 4: g.accel.predict(state, bulletSpeed)
else: g.headOn.predict(state, bulletSpeed)
proc initTmSelectorGun*(): TmSelectorGun =
# states start at the Exclude boundary (0); one Type I step crosses into Include.
for k in 0..<N_NETS:
for s in result.nets[k].states.mitems: s = 0'i16
result.lastTick = -1
result.lastChosen = 2 # Circular
randomize()
result.debugGraphics = false
proc isWarmedUp*(g: TmSelectorGun): bool {.inline.} =
## The gun needs the 2-frame window to encode; experts handle colder states.
g.frameCount >= 2
# ── class selection ──────────────────────────────────────────────────────────
proc chooseClass(g: TmSelectorGun): int =
## Bootstrap to the empirically best expert until the TM has SEL_MIN_OBS
## labels; afterwards take the argmax class vote (ties broken at random, so no
## index-0 bias toward HeadOn).
if g.totalObs < SEL_MIN_OBS:
var bestCount = -1
for c in 0..<N_EXPERTS:
if g.winCount[c] > bestCount:
bestCount = g.winCount[c]
result = c
if bestCount <= 0: return 2 # Circular — sensible cold default
return
var bestV = -Inf
for c in 0..<N_EXPERTS:
if g.votes[c] > bestV: bestV = g.votes[c]
var tied: seq[int]
for c in 0..<N_EXPERTS:
if g.votes[c] >= bestV - 1e-9: tied.add c
result = tied[rand(tied.len - 1)]
# ── Gun interface ────────────────────────────────────────────────────────────
proc predict*(g: var TmSelectorGun, state: WorldState, bulletSpeed: float): GunPrediction =
inc g.predictCalls
# Encode the current frame and refresh the TM votes at most once per tick.
# The harness calls predict() once per power bin (4×/tick); the TM input does
# not depend on bulletSpeed, so the forward pass is tick-guarded exactly like
# the old Tsetlin gun's window shift.
if state.tick != g.lastTick:
g.lastTick = state.tick
g.frameBuff[1] = g.frameBuff[0]
let dist = hypot(state.enemyX - state.selfX, state.enemyY - state.selfY)
let bearing = radToDeg(arctan2(state.enemyY - state.selfY, state.enemyX - state.selfX))
g.frameBuff[0] = tmEncodeFrame(
bearing, dist, state.enemySpeed, state.enemyHeading,
state.arenaHeight - state.enemyY, state.enemyY,
state.arenaWidth - state.enemyX, state.enemyX,
state.enemyEnergy)
if g.frameCount < 2: inc g.frameCount
# 2-frame window (the measured sweet spot: 39.6 effective literals/clause vs
# 152.8 at 10 frames). All TM_WINDOW_SIZE slots are filled with a REAL frame
# (alternating newest/previous) rather than zero-padding: constant-zero
# literals have an always-true negation, which Type I then includes en masse
# and saturates every clause (measured mean 256-755 included literals).
# Duplicating the two real frames keeps every literal variable. Reuses the
# shared 10-frame encoder/vector so the corrected TM primitives apply
# unchanged.
var window: array[TM_WINDOW_SIZE, TmFrameEncoded]
let prev = if g.frameCount >= 2: g.frameBuff[1] else: g.frameBuff[0]
for i in 0..<TM_WINDOW_SIZE:
window[i] = if (i and 1) == 0: g.frameBuff[0] else: prev
let selfState = tmEncodeSelf(
state.arenaHeight - state.selfY, state.selfY,
state.arenaWidth - state.selfX, state.selfX,
state.selfEnergy, true)
g.input = tmEncodeFullVector(window, selfState)
for k in 0..<N_NETS:
let (_, _, vx, vy) = tmForwardWithCache(g.nets[k], g.input, g.curCaches[k])
g.votes[2 * k] = vx
g.votes[2 * k + 1] = vy
var preds: array[N_EXPERTS, GunPrediction]
for c in 0..<N_EXPERTS:
preds[c] = g.expertPred(c, state, bulletSpeed)
let chosen = g.chooseClass()
g.lastChosen = chosen
when DebugSelector:
echo fmt"[sel-dbg] tick={state.tick} bin={selBinForSpeed(bulletSpeed)} chosen={ExpertNames[chosen]} " &
fmt"votes=[{g.votes[0]:.1f},{g.votes[1]:.1f},{g.votes[2]:.1f},{g.votes[3]:.1f},{g.votes[4]:.1f}] obs={g.totalObs}"
let binIdx = selBinForSpeed(bulletSpeed)
if binIdx >= 0:
let slot = selTraceSlot(state.tick, binIdx)
g.traces[slot] = TmSelTrace(
fireTick: state.tick, powerBin: binIdx,
preds: preds, votes: g.votes, cache: g.curCaches, input: g.input, alive: true)
preds[chosen]
proc onResult*(g: var TmSelectorGun, e: FeedbackEvent) =
let binIdx =
if e.powerBin >= 0 and e.powerBin < len(vb.PowerBins): e.powerBin
else: selBinForSpeed(bulletSpeed(e.bulletPower))
if binIdx < 0:
inc g.traceMisses
return
let slot = selTraceSlot(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
# The label: which expert's fire-time prediction was closest to the actual
# enemy position the virtual bullet resolved against. Exact and supervised.
var winner = 0
var bestD = Inf
for c in 0..<N_EXPERTS:
let d = hypot(t.preds[c].x - e.actualX, t.preds[c].y - e.actualY)
if d < bestD:
bestD = d
winner = c
inc g.winCount[winner]
inc g.totalObs
inc g.trainCalls
let lits = tmMakeLiterals(t.input)
for c in 0..<N_EXPERTS:
let k = c div 2
let o = c mod 2
let d = if c == winner: 1.0 else: -1.0
g.nets[k].tmLearnDir(o, lits, t.cache[k], t.votes[c], d)
t.alive = false
# ── interpretability ─────────────────────────────────────────────────────────
const
FrameBitNames: array[TM_FRAME_BITS, string] = block:
var a: array[TM_FRAME_BITS, string]
for i in 0..<8: a[i] = "bearSin"
for i in 0..<8: a[8+i] = "bearCos"
for i in 0..<7: a[16+i] = "dist"
for i in 0..<5: a[23+i] = "vel"
for i in 0..<8: a[28+i] = "headSin"
for i in 0..<8: a[36+i] = "headCos"
for i in 0..<7: a[44+i] = "wallN"
for i in 0..<7: a[51+i] = "wallS"
for i in 0..<7: a[58+i] = "wallE"
for i in 0..<7: a[65+i] = "wallW"
for i in 0..<11: a[72+i] = "energy"
a
proc bitName(bit: int): string =
## Human name for a base vector bit index (0..TM_N_IN-1).
if bit < TM_FRAME_BITS * TM_WINDOW_SIZE:
let frame = bit div TM_FRAME_BITS
let off = bit mod TM_FRAME_BITS
result = fmt"f{frame}.{FrameBitNames[off]}"
else:
let off = bit - TM_FRAME_BITS * TM_WINDOW_SIZE
if off < 7: result = "self.wallN"
elif off < 14: result = "self.wallS"
elif off < 21: result = "self.wallE"
elif off < 28: result = "self.wallW"
elif off < 39: result = "self.energy"
else: result = "self.canFire"
proc litName(lit: int): string =
## Literal `lit` is positive when `lit < TM_N_IN`, negated otherwise.
if lit < TM_N_IN: bitName(lit) & "=1"
else: bitName(lit - TM_N_IN) & "=0"
proc selectorClauseStats*(g: TmSelectorGun): tuple[nClauses, nActive: int,
meanIncluded: float] =
## Include-count over all class clause teams (5 × 50 clauses).
var total = 0
result.nClauses = N_EXPERTS * TM_N_CLAUSES
for c in 0..<N_EXPERTS:
let k = c div 2
let o = c mod 2
for cl in 0..<TM_N_CLAUSES:
var inc = 0
for lit in 0..<TM_N_LITERALS:
if g.nets[k].states[tmStateIdx(o, cl, lit)] > 0: inc += 1
total += inc
if inc > 0: inc result.nActive
if result.nActive > 0:
result.meanIncluded = total.float / result.nActive.float
proc describeClauses*(g: TmSelectorGun, topN = 10): string =
## Per expert class, the feature literals included most often (and only in
## slots f0/f1 — slots 2..9 are the zero padding). One line per top feature:
## `feature=value × count` where count is how many of the class's 50 clauses
## include it. This is the interpretability payoff: it shows WHAT the gate
## switches on.
for c in 0..<N_EXPERTS:
let k = c div 2
let o = c mod 2
# count clauses per literal
var counts: array[TM_N_LITERALS, int]
for cl in 0..<TM_N_CLAUSES:
for lit in 0..<TM_N_LITERALS:
if g.nets[k].states[tmStateIdx(o, cl, lit)] > 0: inc counts[lit]
var idx: seq[int]
for lit in 0..<TM_N_LITERALS:
# Frames alternate f0 (newest) / f1 (previous); slots 2..9 are duplicates,
# so the unique selection signal lives in the first two frames. Report
# only those to keep the dump readable.
let base = if lit < TM_N_IN: lit else: lit - TM_N_IN
if base >= TM_FRAME_BITS * 2 and base < TM_FRAME_BITS * TM_WINDOW_SIZE: continue
if counts[lit] > 0: idx.add lit
idx.sort(proc(a, b: int): int = counts[b] - counts[a])
result.add fmt"class {c} ({ExpertNames[c]}):"
if idx.len == 0:
result.add " <no active literals>\n"
continue
result.add " "
for i in 0..<min(topN, idx.len):
result.add fmt"{litName(idx[i])}×{counts[idx[i]]}"
if i < min(topN, idx.len) - 1: result.add ", "
result.add "\n"