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.
Gun evaluation previously required a full end-to-end battle (Java server +
battle runner + websocket IPC to 2 bot processes, 50 rounds, ~3.4 min) and
yielded only ~300-900 REAL shots across 13 guns -- far too few to rank
guns, which is why tuning needed many repetitions.
VirtualTracker is already a pure function of (WorldState stream, gun list);
the only reason it needed Java was where WorldState came from. So the range
replays a seq[WorldState] through the SAME tracker: offline and online
scores are the same metric by construction, not an approximation.
ACCEPTANCE TEST (the point of the whole thing): record one live round, replay
it offline, compare per-gun virtual hit rates. 12/12 deterministic guns match
EXACTLY, reproduced twice. Tsetlin is compared separately because tmLearnOne
calls rand(). Getting to 12/12 exposed two real ordering quirks in the live
loop: run() calls go() before the aim/fire block, so tickBullets resolves
against the NEXT tick's scan while the prediction used the previous one; and
if the target dies during that go() the final tick's spawn+resolution is
skipped entirely. The recorder emits an end marker for the second case.
The 5th (selected-gun) predict call was verified to be a no-op.
Measured cost: 8 fixtures (1770 ticks, ~92k virtual bullets, 13 guns) replay
in 2.9 s, ~32k virtual bullets/s -- roughly 70x faster and 100x more samples
than a live gauntlet.
Also adds a per-tick WorldState recorder behind const RecordWorldState
(default off, mirrors the ShotLog idiom) which records the state the bot
ACTUALLY builds, staleness included, rather than true positions -- recording
the latter would hand the guns perfect information and produce flattering
scores.
9 new guard checks (33 total, all passing), including fixture round-trip,
replay determinism, stationary->HeadOn 100%, constant-velocity->Linear>HeadOn,
and the energy-threshold turner crossing at t=41.
nimble bin output lands at the garage root, so the existing
'*_garage/out/' ignore rule never covered it and every build dirtied
the tree with a 1.1 MB binary. File stays on disk; build regenerates it.
- Removed non-existent exported/ directory from layout docs
- Replaced hardcoded bot binary entries with *_garage/out/ pattern for build directories
- Added all known bot binaries (GotoTest, OscillatorBot, PPO_Bot, QBot, SAC_LSTM_Bot)
to .gitignore with clarifying comment about Nim's compilation target structure
Nim places compiled binaries at bot root (no extension) and in out/ subdirs.
The out/ pattern catches all build outputs; specific bot binaries listed for
root-level executables since gitignore lacks a reliable "no-extension files" glob.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>