fix(tsetlin): make the TM actually learn - saturation 714 -> 13.8 literals/clause
The gun has never contributed anything: Tsetlin.vHits was byte-for-byte equal to Linear.vHits in every measured round of every run, because its learned correction was always exactly 0. Six diagnosed defects fixed, plus one that was required to make the first one work: 1. Type I now conditions on the clause output. It previously rewarded included true literals unconditionally, omitting Granmo's (c=0, lk=1) -> toward Exclude counter-force, so true literals ratcheted toward Include forever. This was the root cause of the saturation. 2. Type II was unreachable dead code: its guard required cOut==1 AND lits[lit]==0 AND st>0 (included), but cOut==1 guarantees every included literal is 1. Its direction was wrong too - it should increment EXCLUDED false literals when the clause fires. 3. Resource allocation restored: Granmo's (T - clip(v,-T,T))/(2T) target replaces |error|/(2*RESID_MAX); TM_T was only an output normaliser. 4. Label baseline fixed - the factor-2 shrink. predX = linearX + cx, so the label was delta - cx while the learner's output IS cx, giving error = delta - 2cx and a fixed point of cx = delta/2: HALF the needed correction even with perfect feedback. TmTrace now stores linearX/linearY and training uses delta. 5. Hits no longer zero their label (a hit means |miss| < 18px, not 0). 6. The enemy-energy feature was duplicated - tmEncodeFrame passed state.selfEnergy with a stale comment claiming enemyEnergy was absent, while WorldState.enemyEnergy exists. Enemy-energy rules were literally unrepresentable. 7. REQUIRED EXTRA: tmEvalClause now implements Granmo Eq. 6 - an all-Exclude clause outputs 1 during learning and 0 during classification. Without it, fix #1 deadlocks every clause at empty. MEASURED EFFECT (energy-threshold-turner fixture, seed 1): mean included literals per active clause 714.0 -> 13.8 active clauses 100/100 -> 53/100 nonzero corrections 8/764 -> 708/764 Tsetlin virtual hits (Linear = 27/400) 27/400 -> 69/400 Divergence achieved: offline on 7/8 fixtures, and in a live gauntlet (RandomMover: Tsetlin 199/1200 vs Linear 288/1200, vDropped=vStarved=0). Tsetlin now LEARNS but is not yet competitive with Linear - the regression head is untuned, flagged as follow-up rather than claimed as a win. Also ignores compiled test harnesses that have no file extension, which the existing '**/tests/test_*' rule misses.
This commit is contained in:
+127
-44
@@ -9,16 +9,16 @@ import gun_harness/virtual_bullets as vb # PowerBins (power-bin count for trace
|
||||
# ── Binary encoding (adapted from BNNBot_garage/src/binary_encoding.nim) ─────
|
||||
|
||||
const
|
||||
TM_FRAME_BITS = 83
|
||||
TM_SELF_BITS = 40
|
||||
TM_WINDOW_SIZE = 10
|
||||
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]
|
||||
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)
|
||||
|
||||
@@ -28,8 +28,8 @@ proc tmToBits(value: int, bits: int): seq[uint8] =
|
||||
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 =
|
||||
proc tmEncodeFrame*(bearing, distance, velocity, heading,
|
||||
wallN, wallS, wallE, wallW, energy: float): TmFrameEncoded =
|
||||
var offset = 0
|
||||
|
||||
# bearing sin: 8 bits
|
||||
@@ -72,7 +72,7 @@ proc tmEncodeFrame(bearing, distance, velocity, heading,
|
||||
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 =
|
||||
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)
|
||||
@@ -83,8 +83,8 @@ proc tmEncodeSelf(wallN, wallS, wallE, wallW, energy: float, canFire: bool): TmS
|
||||
offset += 11
|
||||
result[offset] = if canFire: 1'u8 else: 0'u8
|
||||
|
||||
proc tmEncodeFullVector(window: array[TM_WINDOW_SIZE, TmFrameEncoded],
|
||||
self: TmSelfEncoded): TmBinaryVector =
|
||||
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:
|
||||
@@ -119,72 +119,114 @@ proc tmStateIdx(outIdx, clause, lit: int): int {.inline.} =
|
||||
proc tmPolarity(clause: int): float {.inline.} =
|
||||
if clause < TM_HALF: 1.0 else: -1.0
|
||||
|
||||
type
|
||||
TmClauseStats* = object
|
||||
## Include-count statistics over both clause teams (TM_N_OUT * TM_N_CLAUSES).
|
||||
## "Active" = a clause with at least one included literal at the sampled
|
||||
## moment. A healthy TM settles into a SPARSE regime (single digits to low
|
||||
## tens of literals per clause). Saturation at hundreds of literals/clause
|
||||
## (measured mean ≈714 on the energy-threshold fixture before the fix) is the
|
||||
## failure signature of the broken Type I feedback rule.
|
||||
nClauses*: int
|
||||
nActive*: int
|
||||
minIncluded*: int
|
||||
maxIncluded*: int
|
||||
meanIncluded*: float ## mean include count over ACTIVE clauses
|
||||
meanIncludedAll*: float ## mean include count over ALL clauses (incl. empty)
|
||||
|
||||
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 =
|
||||
lits: array[TM_N_LITERALS, uint8],
|
||||
learning = false): 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
|
||||
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) =
|
||||
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..<TM_N_CLAUSES:
|
||||
let o = tmEvalClause(net, 0, c, lits)
|
||||
cache[c] = o
|
||||
cache[c] = tmEvalClause(net, 0, c, lits, learning = true)
|
||||
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
|
||||
cache[TM_N_CLAUSES + c] = tmEvalClause(net, 1, c, lits, learning = true)
|
||||
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)
|
||||
(vx / TM_T * TM_RESID_MAX, vy / TM_T * TM_RESID_MAX, vx, vy)
|
||||
|
||||
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)
|
||||
cache: TmClauseCache, vote: float, residual: float) =
|
||||
## One faithful Granmo Table 2/3 update against a continuous residual target.
|
||||
##
|
||||
## `cache` holds clause outputs under LEARNING semantics (empty = 1, Eq. 6).
|
||||
## `vote` is the classification-semantics clause sum at prediction time,
|
||||
## already clamped to [-TM_T, TM_T]. `residual` is the correction target
|
||||
## delta = actual - linear baseline (see onResult), NOT actual - prediction.
|
||||
##
|
||||
## Regression adaptation: the "label" direction is d = sign(residual -
|
||||
## predicted), i.e. which way the correction must move. The resource
|
||||
## allocation of Granmo Eq. 8-11 collapses to a single probability
|
||||
## p = (T - d*clip(v,-T,T)) / (2T), applied both to the aligned clauses
|
||||
## (Type I) and the opposed ones (Type II), exactly as Algorithm 1 lines 11-22.
|
||||
let predicted = vote / TM_T * TM_RESID_MAX
|
||||
let error = residual - predicted
|
||||
let pFeedback = min(1.0, abs(error) / (2.0 * TM_RESID_MAX))
|
||||
let d = if error > 0.0: 1.0 elif error < 0.0: -1.0 else: return
|
||||
let pFeedback = (TM_T - d * vote) / (2.0 * TM_T)
|
||||
if pFeedback <= 0.0: return
|
||||
|
||||
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
|
||||
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..<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)
|
||||
if cOut == 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)
|
||||
else:
|
||||
if rand(1.0) < 1.0 / TM_S: st = max(st - 1, -TM_N_STATES)
|
||||
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
|
||||
# Type II (Table 3): the only non-Inaction cell is
|
||||
# c=1, lk=0, Exclude action -> 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..<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))
|
||||
if net.states[si] <= 0:
|
||||
net.states[si] = int16(min(int(net.states[si]) + 1, TM_N_STATES))
|
||||
|
||||
# ── TsetlinGun public type ────────────────────────────────────────────────────
|
||||
|
||||
@@ -200,9 +242,11 @@ 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 the directional residual
|
||||
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
|
||||
cache: TmClauseCache # LEARNING clause outputs (empty = 1)
|
||||
alive: bool
|
||||
|
||||
TsetlinGun* = object
|
||||
@@ -214,6 +258,13 @@ type
|
||||
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.} =
|
||||
@@ -239,6 +290,28 @@ proc initTsetlinGun*(): TsetlinGun =
|
||||
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..<TM_N_OUT:
|
||||
for c in 0..<TM_N_CLAUSES:
|
||||
var inc = 0
|
||||
for lit in 0..<TM_N_LITERALS:
|
||||
if g.net.states[tmStateIdx(o, c, lit)] > 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)
|
||||
@@ -248,7 +321,7 @@ proc predict*(g: var TsetlinGun, state: WorldState, bulletSpeed: float): GunPred
|
||||
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)
|
||||
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
|
||||
@@ -280,7 +353,11 @@ proc predict*(g: var TsetlinGun, state: WorldState, bulletSpeed: float): GunPred
|
||||
let vec = tmEncodeFullVector(g.frameBuffer, selfState)
|
||||
|
||||
var cache: TmClauseCache
|
||||
let (cx, cy) = tmForwardWithCache(g.net, vec, cache)
|
||||
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)
|
||||
@@ -295,6 +372,10 @@ proc predict*(g: var TsetlinGun, state: WorldState, bulletSpeed: float): GunPred
|
||||
powerBin: binIdx,
|
||||
predX: predX,
|
||||
predY: predY,
|
||||
linearX: linearX,
|
||||
linearY: linearY,
|
||||
voteX: vx,
|
||||
voteY: vy,
|
||||
input: vec,
|
||||
cache: cache,
|
||||
alive: true,
|
||||
@@ -319,14 +400,16 @@ proc onResult*(g: var TsetlinGun, e: FeedbackEvent) =
|
||||
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)
|
||||
# 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, rx)
|
||||
g.net.tmLearnOne(1, lits, t.cache, ry)
|
||||
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}) rx={rx:.1f} ry={ry:.1f} hit={e.hit}"
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user