diff --git a/.gitignore b/.gitignore index b71a81a..08654b2 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,12 @@ worktrees/ # Test fixtures are data, not logs - the *.jsonl rule above was written for # training logs and silently excluded the entire gun-range fixture set. !tools/fixtures/**/*.jsonl + +# Compiled test harnesses have no extension; the test_* rule misses them +common_libs/tests/tm_measure +common_libs/tests/run_range +common_libs/tests/gen_synthetic_fixtures +common_libs/tests/acceptance_offline_vs_online +common_libs/tests/test_tsetlin_gun +common_libs/tests/test_tsetlin_live +common_libs/tests/test_tm_pattern_learning diff --git a/common_libs/guns/tsetlin.nim b/common_libs/guns/tsetlin.nim index f6e78cc..f69be0a 100644 --- a/common_libs/guns/tsetlin.nim +++ b/common_libs/guns/tsetlin.nim @@ -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.. 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.. 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..= 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.. 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.. 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.. 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 diff --git a/common_libs/tests/range_guns.nim b/common_libs/tests/range_guns.nim index d45a92e..92786b5 100644 --- a/common_libs/tests/range_guns.nim +++ b/common_libs/tests/range_guns.nim @@ -40,3 +40,20 @@ proc buildAllGunDrivers*(seed = -1): seq[GunDriver] = makeDriver("DecayGF", initDecayGFGun()), makeDriver("KNN", initKNNGun()), ] + +proc makeTsetlinDriver*(seed = -1): tuple[driver: GunDriver, gun: ref TsetlinGun] = + ## Same as makeDriver("Tsetlin", ...) but keeps a handle to the concrete gun, + ## so a test can read its clause-sparsity / correction instrumentation after a + ## replay. `makeDriver` heap-boxes a copy internally and drops the handle. + let g = new(TsetlinGun) + g[] = initTsetlinGun() + if seed >= 0: + randomize(seed) + result.gun = g + result.driver = GunDriver( + name: "Tsetlin", + predictCb: proc(state: WorldState, bulletSpeed: float): GunPrediction = + g[].predict(state, bulletSpeed), + resultCb: proc(e: FeedbackEvent) = g[].onResult(e), + readyCb: proc(): bool = g[].isWarmedUp(), + ) diff --git a/common_libs/tests/test_tm_pattern_learning.nim b/common_libs/tests/test_tm_pattern_learning.nim new file mode 100644 index 0000000..774e8d9 --- /dev/null +++ b/common_libs/tests/test_tm_pattern_learning.nim @@ -0,0 +1,504 @@ +## L2 — can a Tsetlin Machine learn a high-level pattern? +## +## A STANDALONE Granmo binary classifier (Table 2/3, resource allocation +## Eq. 8-11, empty clause = 1 during learning per Eq. 6) over the SAME +## frame-stacked binary encoding the Tsetlin gun uses (870 bits = 10 frames × 83 +## + 40 self bits). It is independent of the gun: its own clause teams, its own +## training loop. +## +## The source of truth is `synthesizeEnergyThresholdTurner` in +## `gun_harness/offline_range.nim`: +## RULE: energy(t) = max(5, e0 - decay*t) +## straight while energy >= 30, hard turn each tick below 30. +## So the label is literally `energy(t) < 30`, a propositional predicate over the +## 11-bit Gray-coded energy field of the current frame. The experiment asks +## whether the TM recovers it, and — the payoff — whether the learned clauses are +## readable as that rule. +## +## Train and test use DIFFERENT trajectories (different e0/decay/speed/start +## position/hardTurnDeg, same threshold=30). A model that memorises position or +## the training trajectory cannot generalise; one that reads the energy bits can. +## +## Run: nim c -r common_libs/tests/test_tm_pattern_learning.nim + +import std/[math, strformat, random, algorithm, strutils] + +import gun_harness/offline_range +import guns/tsetlin # reuse tmEncodeFrame / tmEncodeSelf / tmEncodeFullVector + +# ── standalone Granmo binary TM ────────────────────────────────────────────── + +const + C_N_IN = TM_TOTAL_BITS # 870 + C_N_LITS = C_N_IN * 2 # 1740 literals (each bit + its negation) + C_N_POS = 24 + C_N_NEG = 24 + C_N_CLAUSES = C_N_POS + C_N_NEG + C_N_STATES = 64 # automaton state range [-64, 64] + C_S = 3.9 # Granmo specificity + C_T = float(C_N_POS) # summation target + +type + ClsNet = object + states: array[C_N_CLAUSES * C_N_LITS, int16] + + Sample = object + lits: array[C_N_LITS, uint8] + y: int + +# Fast local xorshift so the experiment is reproducible without touching the +# gun's std/random stream. +var rngState: uint64 = 0x9E3779B97F4A7C15'u64 + +proc seedRng(s: uint64) = rngState = (if s == 0: 1'u64 else: s) +proc nextU64(): uint64 {.inline.} = + rngState = rngState xor (rngState shl 13) + rngState = rngState xor (rngState shr 7) + rngState = rngState xor (rngState shl 17) + rngState +proc rand01(): float {.inline.} = float(nextU64() shr 40) / 16777216.0 # 24-bit + +proc litIdx(c, lit: int): int {.inline.} = c * C_N_LITS + lit +proc polarity(c: int): float {.inline.} = + if c < C_N_POS: 1.0 else: -1.0 + +proc evalClause(net: ClsNet, c: int, lits: array[C_N_LITS, uint8], + learning: bool): uint8 = + var hasInc = false + for lit in 0.. 0: + hasInc = true + if lits[lit] == 0'u8: return 0'u8 + if hasInc: return 1'u8 + if learning: return 1'u8 + return 0'u8 + +proc forward(net: ClsNet, lits: array[C_N_LITS, uint8], learning: bool): float = + var v = 0.0 + for c in 0..= 0.0: 1 else: 0 + +proc typeIFeedback(net: var ClsNet, c: int, lits: array[C_N_LITS, uint8]) = + ## Granmo Table 2, collapsed to the resulting state move. + let cOut = evalClause(net, c, lits, learning = true) + for lit in 0..= p: continue + let isPos = c < C_N_POS + if (y == 1 and isPos) or (y == 0 and not isPos): + typeIFeedback(net, c, lits) + else: + typeIIFeedback(net, c, lits) + +# ── dataset: frame-stacked encoding of energy-threshold-turner trajectories ── + +proc makeLiterals(vec: TmBinaryVector): array[C_N_LITS, uint8] = + for i in 0.. readable propositional logic ─────────── + +proc bitField(bitIdx: int): string = + ## Decode a bit index of the 870-bit frame-stacked vector to "f.[]". + if bitIdx < TM_FRAME_BITS * TM_WINDOW_SIZE: + let f = bitIdx div TM_FRAME_BITS + let off = bitIdx mod TM_FRAME_BITS + if off < 8: return &"f{f}.bearingSin[{off}]" + if off < 16: return &"f{f}.bearingCos[{off-8}]" + if off < 23: return &"f{f}.distance[{off-16}]" + if off < 28: return &"f{f}.velocity[{off-23}]" + if off < 36: return &"f{f}.headingSin[{off-28}]" + if off < 44: return &"f{f}.headingCos[{off-36}]" + if off < 51: return &"f{f}.wallN[{off-44}]" + if off < 58: return &"f{f}.wallS[{off-51}]" + if off < 65: return &"f{f}.wallE[{off-58}]" + if off < 72: return &"f{f}.wallW[{off-65}]" + return &"f{f}.energy[{off-72}]" + else: + let off = bitIdx - TM_FRAME_BITS * TM_WINDOW_SIZE + if off < 7: return &"self.wallN[{off}]" + if off < 14: return &"self.wallS[{off-7}]" + if off < 21: return &"self.wallE[{off-14}]" + if off < 28: return &"self.wallW[{off-21}]" + if off < 39: return &"self.energy[{off-28}]" + return "self.canFire" + +proc describeLiteral(lit: int): string = + let bitIdx = if lit < C_N_IN: lit else: lit - C_N_IN + let negated = lit >= C_N_IN + (if negated: "!" else: "") & bitField(bitIdx) + +proc isEnergyField(lit: int): bool = + let bitIdx = if lit < C_N_IN: lit else: lit - C_N_IN + if bitIdx < TM_FRAME_BITS * TM_WINDOW_SIZE: + let off = bitIdx mod TM_FRAME_BITS + return off >= 72 and off < 83 + let off = bitIdx - TM_FRAME_BITS * TM_WINDOW_SIZE + return off >= 28 and off < 39 + +proc included(net: ClsNet, c: int): seq[int] = + for lit in 0.. 0: result.add lit + +proc clauseString(net: ClsNet, c: int, maxLits = 10): string = + let inc = included(net, c) + if inc.len == 0: return "TRUE (empty)" + var parts: seq[string] + for i, lit in inc: + if i >= maxLits: + parts.add &"… (+{inc.len - maxLits} more)" + break + parts.add describeLiteral(lit) + result = parts.join(" ∧ ") + +# ── leakage controls: which channel does the TM actually read? ────────────── + +type + FieldChannel = enum + fcBearing, fcDistance, fcVelocity, fcHeading, fcWalls, fcEnergy, fcSelf + +proc channelOf(bitIdx: int): FieldChannel = + if bitIdx < TM_FRAME_BITS * TM_WINDOW_SIZE: + let off = bitIdx mod TM_FRAME_BITS + if off < 16: return fcBearing + if off < 23: return fcDistance + if off < 28: return fcVelocity + if off < 44: return fcHeading + if off < 72: return fcWalls + return fcEnergy + return fcSelf + +proc maskChannel(s: Sample, ch: FieldChannel): Sample = + ## Zero out every bit of one channel (the literal becomes the constant 0, its + ## negation the constant 1). NOTE: this also breaks any clause that happens to + ## INCLUDE an inert literal on that channel, so a large drop is not proof of + ## causal use — the counterfactual probe below is the reliable test. + result = s + for i in 0.. maxLits: result.add &" … (+{totalVary - maxLits} varying)" + +# ── main experiment ────────────────────────────────────────────────────────── + +proc accuracy(net: ClsNet, data: seq[Sample]): float = + if data.len == 0: return 0.0 + var ok = 0 + for s in data: + if predictLabel(net, s.lits) == s.y: inc ok + ok.float / data.len.float + +proc confusion(net: ClsNet, data: seq[Sample]): tuple[tp, tn, fp, fn: int] = + for s in data: + let p = predictLabel(net, s.lits) + if p == 1 and s.y == 1: inc result.tp + elif p == 0 and s.y == 0: inc result.tn + elif p == 1 and s.y == 0: inc result.fp + else: inc result.fn + +proc shuffleRun(train: seq[Sample], labels: seq[int], seedState: uint64, + epochs: int): ClsNet = + seedRng(seedState) + var order: seq[int] + for i in 0.. 0: inc activeClauses + totalInc += inc.len + maxInc = max(maxInc, inc.len) + for lit in inc: + if isEnergyField(lit): inc energyInc + let bitIdx = if lit < C_N_IN: lit else: lit - C_N_IN + if varying[bitIdx]: inc varyingInc + echo &"input bits varying across dataset: {nVary}/{C_N_IN}" + echo &"learned clauses: {activeClauses}/{C_N_CLAUSES} non-empty, " & + &"mean {float(totalInc)/float(max(activeClauses,1)):.1f} literals/clause (nominal), " & + &"mean {float(varyingInc)/float(max(activeClauses,1)):.1f} effective (varying), max {maxInc}" + if totalInc > 0: + echo &"energy-field literals: {energyInc}/{totalInc} ({energyInc.float/totalInc.float*100:.0f}%)" + echo "" + + # Counterfactual energy probe: force all frames' energy below / above the + # threshold and see how often the prediction follows. A model that reads the + # energy threshold is near 100%; one that reads a proxy (e.g. heading) stays + # near its default rate. + var cfLow = test + var cfHigh = test + for i in 0.. energy 10 / 50): " & + &"follows low {followLow*100 div test.len}%, high {followHigh*100 div test.len}%, mean {energyFollow*100:.1f}%" + echo "" + + echo "field-masking probe (TEST accuracy with one channel zeroed; confounded by inert literals):" + for ch in [fcEnergy, fcHeading, fcVelocity, fcDistance, fcBearing, fcWalls, fcSelf]: + var masked = test + for i in 0.. {accuracy(net, masked)*100:6.2f}%" + echo "" + + echo "── learned clauses (positive class: predicts TURN) ──" + echo " [varying literals only; inert constant-bit literals omitted]" + for c in 0.. 0: + echo &" C{c:>2}: {clauseStringVarying(net, c, varying)}" + echo "" + echo "── learned clauses (negative class: predicts STRAIGHT) ──" + for c in C_N_POS.. 0: + echo &" C{c:>2}: {clauseStringVarying(net, c, varying)}" + echo "" + + # Ablation: single-frame input removes the 10-frame redundancy. If the clause + # bloat is a redundancy artefact, effective width should fall here. + echo "" + echo "── ablation: single-frame input (current frame only) ──" + let train1 = gather(TrainCfgs, singleFrame = true) + let test1 = gather(TestCfgs, singleFrame = true) + var labels1: seq[int] + for s in train1: labels1.add s.y + let net1 = shuffleRun(train1, labels1, 20250920'u64, Epochs) + let teAcc1 = accuracy(net1, test1) + let varying1 = varyingBits(train1) + var total1, varyingInc1, active1 = 0 + for c in 0.. 0: inc active1 + total1 += inc.len + for lit in inc: + let bitIdx = if lit < C_N_IN: lit else: lit - C_N_IN + if varying1[bitIdx]: inc varyingInc1 + echo &"single-frame TEST accuracy = {teAcc1*100:.2f}% (majority {testMajority*100:.2f}%), " & + &"mean {float(total1)/float(max(active1,1)):.1f} nominal / " & + &"{float(varyingInc1)/float(max(active1,1)):.1f} effective literals/clause" + for c in 0.. 0: + echo &" C{c:>2}: {clauseStringVarying(net1, c, varying1, 8)}" + echo "" + + # The two classes are separable by the rule, so a model that found it should + # clearly beat the majority baseline and the shuffled-label control. + if teAcc > testMajority + 0.05: + echo &"VERDICT: the TM generalised above majority ({teAcc*100:.1f}% vs {testMajority*100:.1f}%), " & + &"while the shuffled-label control stayed at {ctrlAcc*100:.1f}%." + else: + echo &"VERDICT: no evidence the TM learned the rule " & + &"(TEST {teAcc*100:.1f}% vs majority {testMajority*100:.1f}%)." + echo "" + + var energyVaries = false + for i in 0..5 points", + teAcc > testMajority + 0.05 + check "shuffled-label control does NOT beat the majority baseline by >5 points", + ctrlAcc <= testMajority + 0.05 + + if failures > 0: + echo "\n", failures, " check(s) FAILED" + quit(1) + echo "\nAll pattern-learning checks passed." + +when isMainModule: + main() diff --git a/common_libs/tests/test_tsetlin_gun.nim b/common_libs/tests/test_tsetlin_gun.nim new file mode 100644 index 0000000..c026a86 --- /dev/null +++ b/common_libs/tests/test_tsetlin_gun.nim @@ -0,0 +1,78 @@ +## L1 / L3 tests for the repaired Tsetlin gun. +## +## L1 (clause sparsity + nonzero correction): before the fix the gun's Type I +## feedback never conditioned on the clause output, so every frequently-true +## literal ratcheted toward Include with no counter-force. Measured on +## `energy-threshold-turner` the clauses saturated at mean ≈714 included +## literals/clause (max 747, 100/100 active) and the correction was nonzero on +## only 8 of 764 predict calls — i.e. `Tsetlin.vHits` was effectively `Linear`. +## +## L3 (divergence): with the corrected Granmo Table 2/3 rules, the learning core +## is seeded (`makeTsetlinDriver(seed=1)`) so these numbers are reproducible. +## +## Run: nim c -r common_libs/tests/test_tsetlin_gun.nim + +import std/strformat + +import gun_harness/offline_range +import range_guns +import guns/tsetlin +import guns/linear + +var failures = 0 +proc check(name: string, ok: bool) = + if ok: echo "PASS: ", name + else: echo "FAIL: ", name; inc failures + +proc main() = + # ── L1 ──────────────────────────────────────────────────────────────────── + block: + let fx = synthesizeEnergyThresholdTurner() + let (drv, gun) = makeTsetlinDriver(seed = 1) + let reps = replayFixture(fx, @[drv]) + let st = gun[].tmClauseStats() + echo &"L1 energy-threshold-turner (ticks={fx.states.len}):" + echo &" clauses: mean={st.meanIncluded:.1f} max={st.maxIncluded} " & + &"active={st.nActive}/{st.nClauses} meanAll={st.meanIncludedAll:.1f}" + echo &" correction nonzero on {gun[].correctionsNonzero}/{gun[].predictCalls} predict calls" + echo &" trainedShots={gun[].trainedShots} traceMisses={gun[].traceMisses} hits={reps[0].hits}/{reps[0].shots}" + check "L1: mean included literals/clause fell to a sparse regime (< 40; was ~714)", st.meanIncluded < 40.0 + check "L1: at least some clauses are active (the TM is doing something)", st.nActive > 0 + check "L1: the learned correction is nonzero", gun[].correctionsNonzero > 0 + check "L1: correction is nonzero on most predicts", gun[].correctionsNonzero * 2 > gun[].predictCalls + check "L1: trace pairing intact (trainedShots > 0, traceMisses == 0)", + gun[].trainedShots > 0 and gun[].traceMisses == 0 + echo "" + + # ── L3 ──────────────────────────────────────────────────────────────────── + block: + var diverged = false + var energyT, energyL: GunReport + echo "L3 divergence over synthetic range fixtures (seed=1):" + for name in SyntheticFixtureNames: + let fx = synthesizeByName(name) + let (drv, gun) = makeTsetlinDriver(seed = 1) + let reps = replayFixture(fx, @[drv, makeDriver("Linear", LinearGun())]) + let t = reps[0] + let l = reps[1] + echo &" {name:<26} Tsetlin {t.hits:>4}/{t.shots:<4} Linear {l.hits:>4}/{l.shots:<4} " & + &"nzCorr={gun[].correctionsNonzero}/{gun[].predictCalls}" + if t.hits != l.hits: diverged = true + if name == "energy-threshold-turner": + energyT = t + energyL = l + echo "" + echo &"L3 energy-threshold-turner: Tsetlin {energyT.hits}/{energyT.shots} " & + &"({energyT.hitRate()*100:.1f}%) vs Linear {energyL.hits}/{energyL.shots} " & + &"({energyL.hitRate()*100:.1f}%)" + check "L3: Tsetlin.vHits diverges from Linear.vHits on at least one range fixture", diverged + check "L3: Tsetlin.vHits != Linear.vHits on energy-threshold-turner", + energyT.hits != energyL.hits + + if failures > 0: + echo "\n", failures, " check(s) FAILED" + quit(1) + echo "\nAll Tsetlin-gun checks passed." + +when isMainModule: + main() diff --git a/common_libs/tests/test_tsetlin_live.nim b/common_libs/tests/test_tsetlin_live.nim new file mode 100644 index 0000000..2d30cf8 --- /dev/null +++ b/common_libs/tests/test_tsetlin_live.nim @@ -0,0 +1,69 @@ +## L4: one live gauntlet proving `Tsetlin.vHits != Linear.vHits`. +## +## Runs ModularBot against a chosen adversary through the real Tank Royale server +## and reads the per-gun virtual-bullet window from /tmp/gun_stats.jsonl (written +## by ModularBot.onRoundEnded, independent of the RecordWorldState flag). +## +## Usage: nim c -r common_libs/tests/check_tsetlin_live.nim [AdversaryName] [rounds] +## default adversary RandomMover, 3 rounds. +## Skips cleanly when the TR JARs are unavailable. + +import std/[os, strformat, json, strutils] +import test_framework/test_framework + +const + repoRoot = currentSourcePath().parentDir.parentDir.parentDir + modularBotDir = repoRoot / "ModularBot_garage" + adversariesDir = repoRoot / "common_libs" / "test_framework" / "adversaries" + statsPath = "/tmp/gun_stats.jsonl" + +proc main() = + let adversary = if paramCount() >= 1: paramStr(1) else: "RandomMover" + let rounds = if paramCount() >= 2: parseInt(paramStr(2)) else: 3 + let advDir = adversariesDir / adversary + if not dirExists(advDir): + echo "Skipping: adversary not found: ", advDir + quit(0) + if getEnv("TR_SERVER_JAR", "").len == 0 and + not fileExists("/home/davide/Projects/tank-royale/server/build/libs/robocode-tankroyale-server-0.35.5-all.jar"): + echo "Skipping: TR server JAR not found" + quit(0) + if fileExists(statsPath): removeFile(statsPath) + + echo &"live battle: ModularBot vs {adversary}, {rounds} round(s)" + let battle = runBattle(@[modularBotDir, advDir], rounds = rounds, + timeout = 300000, maxSpeed = true) + for r in battle.results: + echo &" {r.name:<14} rank={r.rank} score={r.totalScore}" + + var tHits, tShots, lHits, lShots, vDrop, vStarve = 0 + var rows = 0 + if fileExists(statsPath): + for line in lines(statsPath): + let s = line.strip() + if s.len == 0: continue + let d = parseJson(s) + if not d.hasKey("guns"): continue + inc rows + for g in d["guns"]: + case g["name"].getStr() + of "Tsetlin": tHits += g["vHits"].getInt(); tShots += g["vShots"].getInt() + of "Linear": lHits += g["vHits"].getInt(); lShots += g["vShots"].getInt() + else: discard + vDrop = d["vDropped"].getInt() + vStarve = d["vStarved"].getInt() + + echo "" + echo &"rounds logged: {rows}" + echo &"Tsetlin vHits={tHits}/{tShots} Linear vHits={lHits}/{lShots}" + echo &"vDropped={vDrop} vStarved={vStarve}" + if tHits != lHits: + echo "VERDICT: DIVERGED — Tsetlin.vHits != Linear.vHits" + else: + echo "VERDICT: IDENTICAL — the fix did not change the live outcome for this opponent" + if vDrop != 0 or vStarve != 0: + echo "WARNING: vDropped/vStarved are non-zero" + quit(1) + +when isMainModule: + main()