Files
SirStone f58d65d2e8 TMComposites gate: per-gun confidence faithful for 3 guns; no pair composes
Adds a per-sample intrinsic-confidence field (GunPrediction.confidence,
threaded through FeedbackEvent/VirtualBullet, populated by Pattern, DecayGF,
KNN, GuessFactor, Tsetlin, TMHorizon) and an offline recorder + analyzer that
reproduce the paper's Figure 2 per gun and its Eq-8 composite.

Measured on 3 held-out tr-bridge DrussGT battles (33k ticks, ~133k samples/gun):
- FAITHFUL: DecayGF (rho +0.133), KNN (+0.090), Pattern (+0.064, weak).
- GuessFactor is ANTI-faithful (rho -0.067); Tsetlin c_max is useless (0.001).
- No pair of guns specialises complementarily: the same gun dominates both
  high-confidence slices in every pair.
- Eq-8 alpha-normalised confidence-weighted composite: 18.41% vs Pattern
  20.45% (McNemar p=3.1e-126). Faithful-only variant 18.68%, still loses.
  Shuffle control passes weakly (composite > shuffle, p=4e-14) so ~0.7pp of
  competence is real but ~2pp short. Offline veto: design is dead.

See docs/tmcomposites_gate.md.
2026-09-25 22:04:39 +02:00

1109 lines
47 KiB
Nim

## tm_horizon.nim — a HORIZON-BASED Tsetlin Machine that CORRECTS the Pattern gun.
##
## Design (the exact brief this file implements):
## * BASE — the shipped best gun, `PatternMatcherGun` (guns/pattern_matcher).
## Pattern supplies the prediction; the TM only CORRECTS it. The old
## mistake of layering a TM on a weak base (Linear) is not repeated.
## * INPUT — the 49-bit draft spec (`tm_diag/feature_spec.draftTMSpec`) PLUS a
## 4-bit horizon one-hot, i.e. 53 raw bits. The draft blocks are:
## walls(8) / us(9) / motion(20) / bullets(12) = 49
## The horizon block is derived from the ACTUAL bullet flight time,
## `speed = 20 - 3*power`, `h = round(dist / speed)`, clamped to
## [10, 50] (the measured 5..9 dead zone is excluded).
## * OUTPUT — TWO BINARIES, four quadrants:
## (a) is the enemy LEFT or RIGHT of the base prediction?
## (b) is the angular correction bigger or smaller than the median?
## A multi-class head created the majority-class trap before; two
## balanced binaries cannot.
## * LABEL — a FACT, not a correction: at tick t and horizon h, look up where
## the enemy ACTUALLY was at t+h in our OWN observation ring. Never
## cross a round boundary (pending samples are cleared on reset; the
## last h ticks simply never resolve). Only samples whose enemy was
## observed within `TMH_STALE_MAX` ticks are used — stale
## observations are guesses and guessing in the answer key is what
## shipped wrong labels before.
## * TRAIN — the machines SURVIVE round boundaries: learning accumulates
## across every round of the same battle/enemy ("every battle i means
## from round 1 to round end-battle, so retain all learning until the
## enemy change"). Two different triggers wipe two different kinds of
## state:
## - `resetRoundState` (per-ROUND) clears ONLY the observation ring,
## the deferred-label queue and the motion/bullet/per-tick
## history. It runs from `onRoundStarted` and on a tick
## regression. Bots teleport back to their corners between
## rounds, so old positions are meaningless and would poison the
## labels.
## - `resetLearning` (per-BATTLE / per-ENEMY) wipes the Tsetlin
## machines and every learned statistic, then clears the round
## state too. It runs on a NEW BATTLE (`onGameStarted`, with a
## round-1 fallback) and when the TARGET changes to a different
## bot id (`TR_TMHORIZON_RESET_ON_TARGET`, default on).
## There is still NO persistence across battles: nothing is written
## to disk and nothing carries into a different battle.
## * APPLY — a SMALL hit-optimal-style correction (default a few degrees,
## `TR_TMHORIZON_SHIFT`), NOT the conditional median. Gate 2b showed
## the error median (4-16 deg) is catastrophically wrong when the
## hit-optimal shift is only ~±2-3.5 deg. `TR_TMHORIZON_SHIFT=0`
## disables the correction entirely (pure predict arm).
##
## EXPECTED OUTCOME: this design is PREDICTED TO LOSE. Gate 2b measured that it
## needs ~80% side accuracy to break even on hits while the achievable signal is
## ~60%, so it is expected to lose to Pattern. It is built anyway because the
## user asked to see it in a real battle and because offline metrics have been
## wrong before. See `common_libs/tests/gate2b_hit_optimal_results.txt`.
##
## COST: per-tick work is cached the way Pattern caches its path (base bits once
## per tick, one TM evaluation per horizon bucket), so it stays in the
## neighbourhood of the 0.36 ms/tick TM gun rather than Tsetlin's ~5.3 ms/tick.
##
## RE-ADAPTATION (all knobs default to the behaviour above — nothing regresses):
## The user's failure mode is "it learns the enemy, the enemy adapts, and we
## are too slow to re-adapt". The cause is that the machines accumulate EVERY
## resolved sample for the whole battle, so old evidence weighs as much as new.
## Three independent, off-by-default fixes:
## * TR_TMHORIZON_WINDOW=N (>0): every TR_TMHORIZON_RETRAIN_EVERY samples
## (default 50) rebuild BOTH heads from scratch and retrain on the last N
## resolved samples from a bounded ring. Stale evidence ages out.
## * TR_TMHORIZON_RESET_DROP=pp (>0): track the rolling-100 side accuracy;
## when it falls more than `pp` below its own recent peak, treat it as
## "the enemy changed", rebuild the heads and re-learn from the recent
## window. The rolling-100/300 accuracy and its per-round min/mean/end are
## always available in `roundSummary`; TR_TMHORIZON_ACCURVE=1 logs a
## `[tmh-acc]` curve line every 25 warm samples so the decay is visible.
## * TR_TMHORIZON_NSTATES=K: runtime automata state count (inertia), so the
## fast/slow trade-off can be swept without a rebuild. Default = the
## compile-time TMH_NSTATES.
## Offline measurement (`tests/measure_tm_readapt.nim`, prequential side
## accuracy on the DrussGT fixtures): on `tr_drussgt_vs_modularbot` the
## keep-everything arm sits at ~76% late accuracy, the sliding window at ~85%,
## the change-detection re-learn at ~84%, and a shuffled-label control stays at
## ~50%. Lowering the state count from 64 to 8 lifts the keep-everything arm to
## ~83% but leaves the window flat — i.e. forgetting and low inertia are
## substitutes, and forgetting is the stronger one.
##
## Coordinate system: 0° = East, CCW positive (Tank Royale standard).
import std/[math, os, strutils, strformat]
import gun_harness/gun_interface
import guns/pattern_matcher
import tm_diag/tm_core
const
## ── feature geometry ────────────────────────────────────────────────────
TMH_N_BASE* = 49 ## the draftTMSpec() bit count
TMH_NH* = 4 ## horizon one-hot width
TMH_N_BITS* = TMH_N_BASE + TMH_NH ## 53 raw bits
TMH_NLITS* = 2 * TMH_N_BITS ## pos + neg literals
## ── horizon range (measured: 5..9 is a dead zone with no signal) ────────
TMH_H_MIN* = 10
TMH_H_MAX* = 50
## ── classifier shape (cheap; two binaries) ─────────────────────────────
TMH_NCLAUSES* {.intdefine.} = 40
TMH_NSTATES* {.intdefine.} = 64
TMH_S_DEF {.strdefine.} = "3.0"
TMH_S* = parseFloat(TMH_S_DEF)
## Cold gate: below this many resolved samples the TM emits no correction.
TMH_MIN_OBS* {.intdefine.} = 24
## A resolved sample is only used when the enemy was observed this recently.
TMH_STALE_MAX* {.intdefine.} = 8
## ── rings ───────────────────────────────────────────────────────────────
TMH_POS_RING* = 256 ## observation ring (> max horizon + lookback)
TMH_PENDING_CAP* = 512 ## deferred-label queue (<= 4 spawns/tick * 50)
TMH_BULLETS* = 32 ## our own in-flight bullets (proxy)
## Magnitude-median histogram resolution. Fine enough that the median does not
## collapse to 0.0 when the bulk of |err| sits in the first bin (which would
## label every sample BIG and reintroduce a majority-class trap).
TMH_ABS_RES* = 0.1
TMH_ABS_BINS* = 640 ## 0.1 deg bins up to 64 deg
## ── env knobs ───────────────────────────────────────────────────────────
TMH_SHIFT_ENV* = "TR_TMHORIZON_SHIFT" ## deg; 0 = no correction arm
TMH_BIG_MULT_ENV* = "TR_TMHORIZON_BIG_MULT" ## scale when magnitude is BIG
TMH_LOG_ENV* = "TR_TMHORIZON_LOG" ## 1 = per-change thinking log
## Reset the machines when the TARGET changes to a different bot id. Default
## ON: in 1v1 there is one enemy so it is a no-op; in melee it fires on target
## switches. Set 0 to observe melee without the per-switch wipe.
TMH_RESET_ON_TARGET_ENV* = "TR_TMHORIZON_RESET_ON_TARGET"
TMH_SHIFT_DEFAULT* = 2.0
TMH_BIG_MULT_DEFAULT* = 1.5
TMH_RESET_ON_TARGET_DEFAULT* = true
## ── re-adaptation knobs (all default to today's behaviour) ─────────────
## Sliding-window / forgetting: >0 rebuilds both heads on the most recent N
## resolved training samples every `TMH_RETRAIN_EVERY` new samples, so stale
## evidence ages out. 0 = keep everything (current shipped behaviour).
TMH_WINDOW_ENV* = "TR_TMHORIZON_WINDOW"
## Change detection: when the rolling side accuracy falls more than this many
## percentage points below its own recent peak, treat it as "the enemy
## changed" and rebuild + re-learn from the recent window. 0 = off.
TMH_RESET_DROP_ENV* = "TR_TMHORIZON_RESET_DROP"
## Runtime automata state count (inertia). Default = the compile-time value.
TMH_NSTATES_ENV* = "TR_TMHORIZON_NSTATES"
## 1 = log the rolling accuracy every `TMH_ACC_LOG_EVERY` warm samples.
TMH_ACCURVE_ENV* = "TR_TMHORIZON_ACCURVE"
## How often the sliding/window mode does its full retrain, and how many
## epochs that retrain runs. Defaults keep the retrain cheap.
TMH_RETRAIN_EVERY_ENV* = "TR_TMHORIZON_RETRAIN_EVERY"
TMH_EPOCHS_ENV* = "TR_TMHORIZON_EPOCHS"
## ── fixed instrumentation geometry ──────────────────────────────────────
TMH_ACC_CAP* = 512 ## rolling-accuracy ring (> the 300 window)
TMH_ACC_WIN1* = 100 ## fast rolling window (samples)
TMH_ACC_WIN2* = 300 ## slow rolling window (samples)
TMH_ACC_LOG_EVERY* = 25 ## ACCURVE: one line every N warm samples
TMH_RETRAIN_EVERY_DEF* = 50
TMH_EPOCHS_DEF* = 1
## When only RESET_DROP is on (WINDOW == 0) the re-learn uses this window.
TMH_RESET_WINDOW_DEF* = 200
TMH_RESET_MIN_SAMPLES* = 100 ## never judge a drop before this many samples
TMH_RESET_COOLDOWN* = 100 ## samples between two change-triggered resets
type
TmhTick* = object
tick*: int
ex*, ey*, eh*, es*: float
lastSeenTick*: int
TmhBullet = object
## INFERRED proxy for one of OUR real bullets: detected from a self-energy
## drop (0.05 < drop <= 3.1), direction proxied by self->enemy at fire time —
## exactly the offline `buildBulletSeries` model.
arrivalTick: int
ux, uy: float
sx, sy: float
active: bool
TmhSample = object
## One resolved training sample kept in the bounded ring that feeds the
## sliding-window rebuild and the change-detection re-learn.
lits: array[TMH_NLITS, uint8]
side: uint8
mag: uint8
TmhPending* = object
## One deferred training sample. `lits` is the exact literal vector the TM
## saw at fire time; the label is resolved h ticks later.
fireTick*: int
horizon*: int
bucket*: int
selfX*, selfY*: float
baseBearing*: float
sidePred*: int
magPred*: int
warm*: bool
lits*: array[TMH_NLITS, uint8]
TmhEval = object
valid: bool
side: int
sideConf: float
mag: int
TmHorizonGun* = object
pattern*: PatternMatcherGun
sideMachine: TmMachine
magMachine: TmMachine
# per-class scratch caches (avoid per-sample allocation)
sideScratch: array[2, seq[uint8]]
magScratch: array[2, seq[uint8]]
# observation ring
ring: array[TMH_POS_RING, TmhTick]
ringValid: array[TMH_POS_RING, bool]
# deferred labels
pending: array[TMH_PENDING_CAP, TmhPending]
pendingCount*: int
pendingDropped*: int
# our own bullets (energy-drop proxy)
bullets: array[TMH_BULLETS, TmhBullet]
bulletCount: int
# per-tick caches
cachedBits: array[TMH_N_BASE, uint8]
cachedBitsTick: int
bitsValid: bool
bucketEval: array[TMH_NH, TmhEval]
# motion history
lastTick: int
hasPrev: bool
sinceRev: int
lastNonzeroSign: int
selfPrevEnergy: float
hasSelfPrev: bool
# enqueue dedupe (predict is called once per power bin)
lastEnqTick: int
lastEnqBucket: int
# logging
lastLogKey: string
lastLogTick: int
shiftConfigured: bool
shiftDeg*: float
bigMult*: float
logEnabled*: bool
# round seeding
seedCounter: int
# per-battle config: reset the machines when the target changes enemy id
resetOnTarget*: bool
# the enemy id the machines currently represent (-1 = none). Used by
# `targetChanged` so first acquisition never wipes and a new BATTLE clears it.
observedTargetId*: int
# cumulative `trained` at the start of the current round, so the per-round
# summary can report `thisRound` while `trained` keeps climbing.
roundStartTrained*: int
# magnitude-median histogram (TMH_ABS_RES deg bins)
absHist: array[TMH_ABS_BINS, int]
absCount: int
# ── re-adaptation: knobs + bounded sample ring ──────────────────────────
windowN*: int ## TR_TMHORIZON_WINDOW (0 = keep everything)
resetDrop*: float ## TR_TMHORIZON_RESET_DROP (0 = off), percentage points
retrainEvery*: int
retrainEpochs*: int
nStates*: int ## runtime automata state count (inertia)
buffer: seq[TmhSample]
bufCap: int
bufCount: int ## total resolved samples ever appended
sinceRetrain: int
# ── rolling side accuracy (change detection + curve) ────────────────────
accRing: array[TMH_ACC_CAP, uint8]
accPos: int
accCount*: int ## total warm samples recorded in the ring
accPeak*: float ## peak rolling-100 accuracy (percent)
sinceResetDrop: int
resetDrops*: int ## change-triggered re-learns
accurveEnabled*: bool
accSnapMin*: float
accSnapMax*: float
accSnapSum*: float
accSnapN*: int
lastAccLogCount: int
# ── instrumentation ─────────────────────────────────────────────────────
trained*: int
sideCorrect*, sideTotal*: int
sidePredHist*: array[2, int]
magPredHist*: array[2, int]
sideLabelHist*: array[2, int]
magLabelHist*: array[2, int]
quadHist*: array[4, int]
lastSidePred*: int
lastMagPred*: int
# ── small pure helpers ───────────────────────────────────────────────────────
proc wrapDeg(d: float): float {.inline.} =
result = d
while result > 180.0: result -= 360.0
while result < -180.0: result += 360.0
proc wrapRad(r: float): float {.inline.} =
result = r
while result > PI: result -= 2.0 * PI
while result < -PI: result += 2.0 * PI
proc signf(x: float): int {.inline.} =
if x > 1e-9: 1 elif x < -1e-9: -1 else: 0
proc envFloatT(name: string, default: float): float =
let v = getEnv(name, "")
if v.len == 0: return default
try: parseFloat(v.strip())
except ValueError: default
proc envBoolT(name: string, default: bool): bool =
case getEnv(name, "").strip().toLowerAscii()
of "1", "true", "yes", "on": true
of "0", "false", "no", "off": false
else: default
proc envIntT(name: string, default: int): int =
let v = getEnv(name, "")
if v.len == 0: return default
try: parseInt(v.strip())
except ValueError: default
# ── horizon maths (pure, unit-tested) ────────────────────────────────────────
proc tmhHorizonFor*(dist, bulletSpeed: float): int =
## Derive the horizon from the ACTUAL bullet flight time. `speed = 20-3*power`
## so `bulletSpeed` already encodes the energy-aware power policy's power.
## Clamped to the measured live band [10, 50].
if bulletSpeed <= 0.0: return TMH_H_MIN
result = int(round(dist / bulletSpeed))
if result < TMH_H_MIN: result = TMH_H_MIN
elif result > TMH_H_MAX: result = TMH_H_MAX
proc tmhHorizonBucket*(h: int): int =
## 4 one-hot buckets over [10, 50]: 10-19, 20-29, 30-39, 40-50.
if h < 20: 0
elif h < 30: 1
elif h < 40: 2
else: 3
proc tmhQuadrantName*(side, mag: int): string =
## `side` 1 = LEFT (positive angular error, CCW), 0 = RIGHT; `mag` 1 = BIG.
## `side < 0` means the model is cold and emits no correction.
if side < 0: return "COLD"
let s = if side == 1: "LEFT" else: "RIGHT"
let m = if mag == 1: "LARGE" else: "SMALL"
m & "-" & s
proc tmhApplyShift*(selfX, selfY, px, py, shiftDeg: float): GunPrediction =
## Rotate the base prediction point around the shooter by `shiftDeg` degrees
## (positive = CCW). The aim DISTANCE is preserved; only the bearing moves.
let dx = px - selfX
let dy = py - selfY
let d = hypot(dx, dy)
if d < 1e-9: return GunPrediction(x: px, y: py)
let b = arctan2(dy, dx) + degToRad(shiftDeg)
GunPrediction(x: selfX + cos(b) * d, y: selfY + sin(b) * d)
proc tmhLits*(base: array[TMH_N_BASE, uint8],
bucket: int): array[TMH_NLITS, uint8] =
## Pack the 49 draft bits + the 4-bit horizon one-hot into the pos-then-neg
## literal layout the TM core uses.
var raw: array[TMH_N_BITS, uint8]
for i in 0..<TMH_N_BASE: raw[i] = base[i]
raw[TMH_N_BASE + bucket] = 1'u8
for i in 0..<TMH_N_BITS:
result[i] = raw[i]
result[i + TMH_N_BITS] = 1'u8 - raw[i]
# ── construction / reset ─────────────────────────────────────────────────────
proc ensureBuffer(g: var TmHorizonGun) =
## Grow the resolved-sample ring to hold whichever window the configured
## forgetting/re-learn mode needs. Never shrinks, so switching knobs at
## runtime cannot lose already-buffered samples.
let want = max(g.windowN,
(if g.resetDrop > 0.0: TMH_RESET_WINDOW_DEF else: 0))
if want > g.bufCap:
g.bufCap = want
g.buffer.setLen(want)
proc initTmHorizonGun*(): TmHorizonGun =
result.pattern = PatternMatcherGun()
# Runtime inertia: read once here so the state count can be swept with an env
# knob instead of a rebuild. Default is the compile-time TMH_NSTATES.
result.nStates = clamp(envIntT(TMH_NSTATES_ENV, TMH_NSTATES), 2, 4096)
result.windowN = max(0, envIntT(TMH_WINDOW_ENV, 0))
result.resetDrop = max(0.0, envFloatT(TMH_RESET_DROP_ENV, 0.0))
result.retrainEvery = max(1, envIntT(TMH_RETRAIN_EVERY_ENV, TMH_RETRAIN_EVERY_DEF))
result.retrainEpochs = max(1, envIntT(TMH_EPOCHS_ENV, TMH_EPOCHS_DEF))
result.accurveEnabled = envBoolT(TMH_ACCURVE_ENV, false)
result.ensureBuffer()
result.sideMachine = newMachine(TMH_N_BITS, 2, TMH_NCLAUSES, result.nStates,
TMH_S, seed = 1)
result.magMachine = newMachine(TMH_N_BITS, 2, TMH_NCLAUSES, result.nStates,
TMH_S, seed = 2)
for c in 0..1:
result.sideScratch[c] = newSeq[uint8](TMH_NCLAUSES)
result.magScratch[c] = newSeq[uint8](TMH_NCLAUSES)
result.lastTick = -1
result.lastEnqTick = -1
result.lastEnqBucket = -1
result.shiftDeg = TMH_SHIFT_DEFAULT
result.bigMult = TMH_BIG_MULT_DEFAULT
result.resetOnTarget = TMH_RESET_ON_TARGET_DEFAULT
result.observedTargetId = -1
result.lastSidePred = -1
result.lastMagPred = -1
proc ensureConfig(g: var TmHorizonGun) {.inline.} =
## Lazily read the env knobs on first use (like Pattern's radial knobs), so a
## unit test can poke the env in-process and an unset env cannot move the gun.
if g.shiftConfigured: return
g.shiftDeg = envFloatT(TMH_SHIFT_ENV, TMH_SHIFT_DEFAULT)
g.bigMult = envFloatT(TMH_BIG_MULT_ENV, TMH_BIG_MULT_DEFAULT)
g.logEnabled = envBoolT(TMH_LOG_ENV, false)
g.resetOnTarget = envBoolT(TMH_RESET_ON_TARGET_ENV, TMH_RESET_ON_TARGET_DEFAULT)
g.shiftConfigured = true
proc setShift*(g: var TmHorizonGun, shiftDeg: float, bigMult = TMH_BIG_MULT_DEFAULT) =
## Explicit per-gun override (tests / offline sweeps). Writes the same fields
## the env path writes, so the measured code path is identical.
g.shiftDeg = shiftDeg
g.bigMult = bigMult
g.shiftConfigured = true
proc setResetOnTarget*(g: var TmHorizonGun, on: bool) =
## Explicit per-gun override (tests). Writes the same field the env path does
## and freezes config, so a later `ensureConfig` cannot move it.
g.resetOnTarget = on
g.shiftConfigured = true
proc setWindow*(g: var TmHorizonGun, n: int) =
## Explicit per-gun override (tests / offline sweeps): sliding-window width.
g.windowN = max(0, n)
g.ensureBuffer()
proc setResetDrop*(g: var TmHorizonGun, pp: float) =
## Explicit per-gun override (tests): change-detection threshold in pp.
g.resetDrop = max(0.0, pp)
g.ensureBuffer()
proc setRetrainConfig*(g: var TmHorizonGun, every, epochs: int) =
g.retrainEvery = max(1, every)
g.retrainEpochs = max(1, epochs)
proc setAccurve*(g: var TmHorizonGun, on: bool) =
g.accurveEnabled = on
proc resetRoundState*(g: var TmHorizonGun) =
## PER-ROUND wipe ONLY. Clears the observation ring, the deferred-label queue
## and every piece of motion / bullet / per-tick history that is meaningless
## once the bots teleport back to their starting corners. The Tsetlin machines
## and their learned statistics are LEFT UNTOUCHED: learning accumulates across
## rounds of the same battle/enemy. This is the ONLY reset that runs at a plain
## round boundary.
g.pendingCount = 0
g.bulletCount = 0
g.hasPrev = false
g.hasSelfPrev = false
g.sinceRev = 0
g.lastNonzeroSign = 0
g.lastTick = -1
g.lastEnqTick = -1
g.lastEnqBucket = -1
g.bitsValid = false
g.lastLogKey = ""
g.lastLogTick = -1
g.lastSidePred = -1
g.lastMagPred = -1
for i in 0..<TMH_NH:
g.bucketEval[i] = TmhEval()
for i in 0..<TMH_POS_RING: g.ringValid[i] = false
# A fresh round starts here: remember the cumulative count so the per-round
# summary can report `thisRound` while `trained` keeps climbing.
g.roundStartTrained = g.trained
# Per-round accuracy-curve snapshots (the rolling ring itself SURVIVES the
# boundary: change detection must see the whole battle).
g.accSnapMin = 0.0
g.accSnapMax = 0.0
g.accSnapSum = 0.0
g.accSnapN = 0
proc resetLearning*(g: var TmHorizonGun, reason = "") =
## PER-BATTLE / PER-ENEMY wipe. Wipes the Tsetlin machines and every learned
## statistic, then clears the per-round state too (a new enemy makes the old
## observations and pending labels meaningless). Called on a NEW BATTLE
## (`onGameStarted`, with a round-1 fallback) and when the TARGET changes to a
## different bot id. NEVER called at a plain round boundary — that is
## `resetRoundState`. There is still no persistence across battles.
let trainedWas = g.trained
inc g.seedCounter
g.sideMachine.resetMachine(seed = 1000'u64 + uint64(g.seedCounter))
g.magMachine.resetMachine(seed = 2000'u64 + uint64(g.seedCounter))
g.trained = 0
g.sideCorrect = 0
g.sideTotal = 0
g.pendingDropped = 0
g.observedTargetId = -1
for i in 0..1:
g.sidePredHist[i] = 0
g.magPredHist[i] = 0
g.sideLabelHist[i] = 0
g.magLabelHist[i] = 0
for i in 0..<4: g.quadHist[i] = 0
for i in 0..<TMH_ABS_BINS: g.absHist[i] = 0
g.absCount = 0
# Re-adaptation state: the bounded sample ring and the rolling-accuracy ring
# are part of "learning", so a new battle/enemy wipes them.
g.bufCount = 0
g.sinceRetrain = 0
g.accCount = 0
g.accPos = 0
g.accPeak = 0.0
g.sinceResetDrop = 0
g.resetDrops = 0
g.lastAccLogCount = 0
g.resetRoundState()
if reason.len > 0:
g.ensureConfig()
if g.logEnabled:
echo fmt"[tmh-reset] reason={reason} trained_was={trainedWas}"
proc targetChanged*(g: var TmHorizonGun, enemyId: int): bool =
## Per-ENEMY reset: when the selected target changes to a DIFFERENT bot id,
## wipe the machines (the user's reset condition is literally "until the enemy
## change"). First acquisition (`observedTargetId < 0`) never wipes, so the
## round-start pick does not cold-start the gun every round. Gated by
## `TR_TMHORIZON_RESET_ON_TARGET` (default on); returns true when it wiped.
g.ensureConfig()
if not g.resetOnTarget: return false
if enemyId < 0: return false
if g.observedTargetId >= 0 and enemyId != g.observedTargetId:
g.resetLearning("target_change")
g.observedTargetId = enemyId
return true
g.observedTargetId = enemyId
false
# ── history / observation ring ───────────────────────────────────────────────
proc ringAt(g: TmHorizonGun, tick: int): tuple[ok: bool, t: TmhTick] =
let slot = ((tick mod TMH_POS_RING) + TMH_POS_RING) mod TMH_POS_RING
if g.ringValid[slot] and g.ring[slot].tick == tick:
(true, g.ring[slot])
else:
(false, TmhTick())
proc ringValidCount*(g: TmHorizonGun): int =
## Observability / test seam: how many observation-ring slots are valid.
for i in 0..<TMH_POS_RING:
if g.ringValid[i]: inc result
proc tmhObservedAt*(g: TmHorizonGun, tick: int, selfX, selfY: float
): tuple[ok: bool, bearing, dist: float, lastSeenTick: int] =
## Exported seam for the BitBrain corrector: the enemy's OBSERVED bearing and
## distance (relative to `(selfX, selfY)`) at `tick`, if the observation ring
## holds that exact tick. `lastSeenTick` lets the caller apply the same
## staleness rule TMHorizon uses before trusting the label. `ok = false` when
## the tick is missing (e.g. across a round boundary or before warm-up).
let r = g.ringAt(tick)
if not r.ok:
return (false, 0.0, 0.0, 0)
(true, arctan2(r.t.ey - selfY, r.t.ex - selfX),
hypot(r.t.ex - selfX, r.t.ey - selfY), r.t.lastSeenTick)
proc ringHas*(g: TmHorizonGun, tick: int): bool =
## Observability / test seam: is there a valid observation exactly at `tick`?
## Used to prove a round boundary drops the old positions (so a label can never
## be built from a position across the boundary).
g.ringAt(tick).ok
proc sideClauseStates*(g: TmHorizonGun): seq[int16] =
## Test / observability seam: a snapshot of the side head's learned clause
## states, so a test can prove the machines survive or are wiped by a reset.
g.sideMachine.teams[0] & g.sideMachine.teams[1]
proc headStates*(g: TmHorizonGun): int =
## Test seam: the runtime automata state count both heads were built with.
g.sideMachine.nStates
proc sideClausesAllExclude*(g: TmHorizonGun): bool =
## Test seam: true when every side clause is at the Exclude boundary.
for c in 0..<g.sideMachine.nClasses:
for i in 0..<g.sideMachine.teams[c].len:
if g.sideMachine.teams[c][i] != 0: return false
true
proc addBullet(g: var TmHorizonGun, b: TmhBullet) =
if g.bulletCount < TMH_BULLETS:
g.bullets[g.bulletCount] = b
inc g.bulletCount
else:
for i in 1..<TMH_BULLETS: g.bullets[i - 1] = g.bullets[i]
g.bullets[TMH_BULLETS - 1] = b
proc bulletFeatures(g: var TmHorizonGun, state: WorldState): tuple[tta: int, lat: float] =
## Nearest in-flight (proxy) bullet: ticks-to-arrival and the enemy's lateral
## offset from that bullet's path. `tta < 0` means none.
result.tta = -1
result.lat = 0.0
var w = 0
for i in 0..<g.bulletCount:
var b = g.bullets[i]
if not b.active: continue
if state.tick > b.arrivalTick:
b.active = false
continue
g.bullets[w] = b
inc w
let ta = b.arrivalTick - state.tick
if result.tta < 0 or ta < result.tta:
result.tta = ta
let vx = state.enemyX - b.sx
let vy = state.enemyY - b.sy
result.lat = b.ux * vy - b.uy * vx
g.bulletCount = w
proc tmhUpdateHistory*(g: var TmHorizonGun, state: WorldState) =
## Once per tick: write the observation ring, advance the reversal counter and
## the self-energy-drop bullet proxy.
let slot = ((state.tick mod TMH_POS_RING) + TMH_POS_RING) mod TMH_POS_RING
var lst = state.tick
if state.enemies.len > 0: lst = state.enemies[0].lastSeenTick
g.ring[slot] = TmhTick(tick: state.tick, ex: state.enemyX, ey: state.enemyY,
eh: state.enemyHeading, es: state.enemySpeed,
lastSeenTick: lst)
g.ringValid[slot] = true
let sg = signf(state.enemySpeed)
if sg != 0:
if g.lastNonzeroSign != 0 and sg != g.lastNonzeroSign: g.sinceRev = 0
else: inc g.sinceRev
g.lastNonzeroSign = sg
else:
inc g.sinceRev
if g.hasSelfPrev:
let drop = g.selfPrevEnergy - state.selfEnergy
if drop > 0.05 and drop <= 3.1:
let power = clamp(drop, 0.1, 3.0)
let speed = 20.0 - 3.0 * power
let dx = state.enemyX - state.selfX
let dy = state.enemyY - state.selfY
let nrm = max(1e-6, hypot(dx, dy))
let flight = int(ceil(nrm / speed))
g.addBullet(TmhBullet(arrivalTick: state.tick + flight,
ux: dx / nrm, uy: dy / nrm,
sx: state.selfX, sy: state.selfY, active: true))
g.selfPrevEnergy = state.selfEnergy
g.hasSelfPrev = true
# ── the 49 draft bits, live ──────────────────────────────────────────────────
proc tmhBaseBits*(g: var TmHorizonGun, state: WorldState): array[TMH_N_BASE, uint8] =
## The draftTMSpec() blocks, computed from the live bot state + the observation
## ring. Block layout (mirrors draftTMSpec):
## 0..3 dist-to-nearest-wall | 4..7 which-wall-nearest
## 8..13 dist-from-us | 14..16 enemy-heading-vs-line-to-us
## 17..19 turn-direction t,t-1,t-2 | 20..24 ticks-since-reversal
## 25..27 turn-consistency-10 | 28..30 distance-moved-10
## 31..33 speed-trend-10 | 34..36 turn-rate-change-5
## 37..41 time-until-bullet | 42..48 bullet-lateral-offset
var b: array[TMH_N_BASE, uint8]
template put1(idx: int) = b[idx] = 1'u8
let ex = state.enemyX
let ey = state.enemyY
# walls (8)
let dL = ex
let dR = state.arenaWidth - ex
let dT = state.arenaHeight - ey
let dB = ey
let dmin = min(min(dL, dR), min(dT, dB))
if dmin < 50.0: put1(0)
elif dmin < 100.0: put1(1)
elif dmin < 200.0: put1(2)
else: put1(3)
var wb = 0
let walls = [dL, dR, dT, dB]
for w in 1..3:
if walls[w] < walls[wb]: wb = w
put1(4 + wb)
# us (9)
let rng = hypot(ex - state.selfX, ey - state.selfY)
var ub = 5
if rng < 100.0: ub = 0
elif rng < 200.0: ub = 1
elif rng < 300.0: ub = 2
elif rng < 400.0: ub = 3
elif rng < 600.0: ub = 4
put1(8 + ub)
let lane = arctan2(state.selfY - ey, state.selfX - ex)
let hdg = degToRad(state.enemyHeading)
let perp = abs(sin(hdg - lane))
var hb = 1
if perp < 0.5: hb = 2
elif perp > 0.866: hb = 0
put1(14 + hb)
# motion (20)
for k in 0..2:
let cur = g.ringAt(state.tick - k)
let prv = g.ringAt(state.tick - k - 1)
if cur.ok and prv.ok:
let d = wrapDeg(cur.t.eh - prv.t.eh)
if d > 1e-6: put1(17 + k)
var rb = 4
let sr = g.sinceRev
if sr < 5: rb = 0
elif sr < 10: rb = 1
elif sr < 20: rb = 2
elif sr < 40: rb = 3
put1(20 + rb)
var pos = 0
var neg = 0
for k in 0..9:
let cur = g.ringAt(state.tick - k)
let prv = g.ringAt(state.tick - k - 1)
if cur.ok and prv.ok:
let d = wrapDeg(cur.t.eh - prv.t.eh)
if d > 1e-6: inc pos
elif d < -1e-6: inc neg
let tot = pos + neg
let cons = if tot > 0: max(pos, neg).float / tot.float else: 0.0
var cb = 0
if cons > 0.8: cb = 2
elif cons >= 0.5: cb = 1
put1(25 + cb)
let j10 = g.ringAt(state.tick - 10)
let dm = if j10.ok: hypot(ex - j10.t.ex, ey - j10.t.ey) else: 0.0
var mb = 1
if dm < 20.0: mb = 0
elif dm > 50.0: mb = 2
put1(28 + mb)
let spd10 = if j10.ok: abs(j10.t.es) else: abs(state.enemySpeed)
let spdDiff = abs(state.enemySpeed) - spd10
var sb = 1
if spdDiff < -0.5: sb = 0
elif spdDiff > 0.5: sb = 2
put1(31 + sb)
var r1 = 0.0
var n1 = 0
for k in 0..4:
let cur = g.ringAt(state.tick - k)
let prv = g.ringAt(state.tick - k - 1)
if cur.ok and prv.ok:
r1 += abs(wrapDeg(cur.t.eh - prv.t.eh)); inc n1
var r2 = 0.0
var n2 = 0
for k in 5..9:
let cur = g.ringAt(state.tick - k)
let prv = g.ringAt(state.tick - k - 1)
if cur.ok and prv.ok:
r2 += abs(wrapDeg(cur.t.eh - prv.t.eh)); inc n2
let m1 = if n1 > 0: r1 / n1.float else: 0.0
let m2 = if n2 > 0: r2 / n2.float else: 0.0
let dtr = m1 - m2
var tb = 1
if dtr < -0.3: tb = 0
elif dtr > 0.3: tb = 2
put1(34 + tb)
# bullets (12) — INFERRED from self-energy drops (no gun heading live)
let (tta, lat) = g.bulletFeatures(state)
var b1 = 0
if tta >= 0:
if tta < 5: b1 = 1
elif tta < 10: b1 = 2
elif tta < 20: b1 = 3
else: b1 = 4
put1(37 + b1)
var lb = 3
if lat < -72.0: lb = 0
elif lat < -36.0: lb = 1
elif lat < -18.0: lb = 2
elif lat <= 18.0: lb = 3
elif lat <= 36.0: lb = 4
elif lat <= 72.0: lb = 5
else: lb = 6
put1(42 + lb)
result = b
# ── TM evaluation / training (no per-sample allocation) ──────────────────────
proc tmhEvalOne(m: TmMachine, lits: array[TMH_NLITS, uint8],
caches: var array[2, seq[uint8]]): tuple[cls: int, conf: float] =
var votes: array[2, float]
for c in 0..1:
votes[c] = tmForward(m, m.teams[c], lits, caches[c])
let cls = if votes[1] > votes[0]: 1 else: 0
let margin = abs(votes[1] - votes[0])
let conf =
if m.half > 0: clamp(margin / (2.0 * float(m.half)), 0.0, 1.0)
else: 0.0
(cls, conf)
proc tmhTrainOne(m: var TmMachine, lits: array[TMH_NLITS, uint8], label: int,
caches: var array[2, seq[uint8]]) =
var votes: array[2, float]
for c in 0..1:
votes[c] = tmForward(m, m.teams[c], lits, caches[c])
for c in 0..1:
let d = if c == label: 1.0 else: -1.0
tmLearnDir(m, m.teams[c], lits, caches[c], votes[c], d)
# ── sliding window / change detection ────────────────────────────────────────
proc bufferedCount*(g: TmHorizonGun): int =
## Test/observability seam: how many resolved samples the bounded ring has
## seen since the last battle/enemy reset.
g.bufCount
proc bufferCapacity*(g: TmHorizonGun): int =
## Test/observability seam: the allocated ring capacity (0 = buffering off).
g.bufCap
proc rollingAcc*(g: TmHorizonGun, n: int): float =
## Side accuracy over the last `min(n, accCount)` recorded warm samples.
## This is the measurement that shows whether accuracy decays when the
## enemy changes. Returns 0.0 when nothing has been recorded yet.
if g.accCount == 0: return 0.0
let k = min(n, g.accCount)
if k <= 0: return 0.0
var cor = 0
for i in 0..<k:
let idx = ((g.accPos - 1 - i) mod TMH_ACC_CAP + TMH_ACC_CAP) mod TMH_ACC_CAP
cor += int(g.accRing[idx])
cor.float / k.float
proc retrainFromBuffer*(g: var TmHorizonGun, n: int) =
## FULL rebuild of both heads from scratch, then train on the most recent
## `min(n, buffered)` resolved samples (oldest -> newest). `trained` is NOT
## reset: it counts resolved samples over the battle, so the cold gate and the
## per-round summary stay monotone while the MACHINES forget. The rebuild is
## DETERMINISTIC given the buffer (fixed seed), so an A/B rerun is repeatable.
g.sideMachine.resetMachine(seed = 1000'u64)
g.magMachine.resetMachine(seed = 2000'u64)
if g.bufCap <= 0: return
let k = min(n, min(g.bufCount, g.bufCap))
if k <= 0: return
for _ in 0..<g.retrainEpochs:
for j in 0..<k:
let idx = ((g.bufCount - k + j) mod g.bufCap + g.bufCap) mod g.bufCap
let s = g.buffer[idx]
tmhTrainOne(g.sideMachine, s.lits, int(s.side), g.sideScratch)
tmhTrainOne(g.magMachine, s.lits, int(s.mag), g.magScratch)
proc recordWarm(g: var TmHorizonGun, correct: bool) =
## Record one warm sample's side correctness, update the rolling windows and
## the change-detection peak, and fire the re-learn trigger when the rolling
## accuracy has fallen far enough below its own recent peak.
g.accRing[g.accPos] = (if correct: 1'u8 else: 0'u8)
g.accPos = (g.accPos + 1) mod TMH_ACC_CAP
inc g.accCount
let a100 = g.rollingAcc(TMH_ACC_WIN1) * 100.0
if g.accSnapN == 0:
g.accSnapMin = a100
g.accSnapMax = a100
else:
if a100 < g.accSnapMin: g.accSnapMin = a100
if a100 > g.accSnapMax: g.accSnapMax = a100
g.accSnapSum += a100
inc g.accSnapN
if a100 > g.accPeak: g.accPeak = a100
inc g.sinceResetDrop
if g.resetDrop > 0.0 and g.accCount >= TMH_RESET_MIN_SAMPLES and
(g.accPeak - a100) > g.resetDrop and g.sinceResetDrop >= TMH_RESET_COOLDOWN:
let n = if g.windowN > 0: g.windowN else: TMH_RESET_WINDOW_DEF
g.retrainFromBuffer(n)
g.accPeak = a100
g.sinceResetDrop = 0
inc g.resetDrops
if g.accurveEnabled and (g.accCount mod TMH_ACC_LOG_EVERY == 0) and
g.accCount != g.lastAccLogCount:
g.lastAccLogCount = g.accCount
let a300 = g.rollingAcc(TMH_ACC_WIN2) * 100.0
echo fmt"[tmh-acc] trained={g.trained} warm={g.accCount} acc100={a100:.1f} " &
fmt"acc300={a300:.1f} peak={g.accPeak:.1f} resets={g.resetDrops}"
# ── magnitude median (running histogram) ─────────────────────────────────────
proc recordAbs(g: var TmHorizonGun, a: float) =
var idx = int(a / TMH_ABS_RES)
if idx < 0: idx = 0
if idx >= TMH_ABS_BINS: idx = TMH_ABS_BINS - 1
inc g.absHist[idx]
inc g.absCount
proc medianAbs(g: TmHorizonGun): float =
if g.absCount == 0: return 0.0
let half = g.absCount div 2
var cum = 0
for i in 0..<TMH_ABS_BINS:
cum += g.absHist[i]
if cum > half: return i.float * TMH_ABS_RES + TMH_ABS_RES * 0.5
63.95
# ── deferred label resolution ────────────────────────────────────────────────
proc tmhResolveOne(g: var TmHorizonGun, state: WorldState,
p: TmhPending): bool =
## Resolve one due sample against our OWN observation at `fireTick + horizon`.
## Returns false when the sample must be DROPPED (missing / stale
## observation); a perfectly balanced (zero-error) sample is consumed without
## training and counts as resolved.
let slot = ((state.tick mod TMH_POS_RING) + TMH_POS_RING) mod TMH_POS_RING
if not g.ringValid[slot] or g.ring[slot].tick != state.tick: return false
if state.tick - g.ring[slot].lastSeenTick > TMH_STALE_MAX: return false
let act = g.ring[slot]
let ba = arctan2(act.ey - p.selfY, act.ex - p.selfX)
let err = wrapRad(ba - p.baseBearing)
let absE = abs(err)
if absE >= 1e-9:
let med = g.medianAbs()
let sideLabel = if err > 0.0: 1 else: 0
let magLabel = if absE > med: 1 else: 0
if p.warm:
inc g.sideTotal
let correct = p.sidePred == sideLabel
if correct: inc g.sideCorrect
# Rolling accuracy + change detection (the re-adaptation measurement).
g.recordWarm(correct)
inc g.sideLabelHist[sideLabel]
inc g.magLabelHist[magLabel]
tmhTrainOne(g.sideMachine, p.lits, sideLabel, g.sideScratch)
tmhTrainOne(g.magMachine, p.lits, magLabel, g.magScratch)
inc g.trained
# Bounded ring feeding the sliding-window rebuild / change-detection
# re-learn. Appended AFTER the online update so a retrain triggered this
# tick sees this sample too.
if g.bufCap > 0:
g.buffer[g.bufCount mod g.bufCap] =
TmhSample(lits: p.lits, side: uint8(sideLabel), mag: uint8(magLabel))
inc g.bufCount
inc g.sinceRetrain
if g.windowN > 0 and g.sinceRetrain >= g.retrainEvery:
g.sinceRetrain = 0
g.retrainFromBuffer(g.windowN)
g.recordAbs(absE)
true
proc tmhResolvePending(g: var TmHorizonGun, state: WorldState) =
## Resolve every sample due at this tick, compacting the queue in place.
## Samples whose horizon reaches past the round end are simply never due and
## are discarded by the next `resetRoundState` (a plain round boundary).
var w = 0
for i in 0..<g.pendingCount:
let p = g.pending[i]
let due = p.fireTick + p.horizon
if due > state.tick:
g.pending[w] = p
inc w
elif due == state.tick:
if not g.tmhResolveOne(state, p):
inc g.pendingDropped
else:
inc g.pendingDropped
g.pendingCount = w
proc tmhEnqueue(g: var TmHorizonGun, state: WorldState, baseBearing: float,
h, bucket: int, lits: array[TMH_NLITS, uint8],
sidePred, magPred: int, warm: bool) =
if g.pendingCount >= TMH_PENDING_CAP:
inc g.pendingDropped
return
g.pending[g.pendingCount] = TmhPending(
fireTick: state.tick, horizon: h, bucket: bucket,
selfX: state.selfX, selfY: state.selfY, baseBearing: baseBearing,
sidePred: sidePred, magPred: magPred, warm: warm, lits: lits)
inc g.pendingCount
if warm:
inc g.sidePredHist[sidePred]
inc g.magPredHist[magPred]
inc g.quadHist[sidePred * 2 + magPred]
# ── logging ──────────────────────────────────────────────────────────────────
proc tmhLog(g: var TmHorizonGun, state: WorldState, bulletSpeed: float,
h, side, mag: int, sideConf, shift: float, aimDeg: float) =
## ONE change-gated line (behind `TR_TMHORIZON_LOG=1`) so the user can see it
## think per shot, not per tick. Format:
## [tmh] h=22 p=2.0 pred=LARGE-LEFT shift=-3.0 aim=173.2 gun=Pattern conf=0.62 trained=1841
if not g.logEnabled: return
let q = tmhQuadrantName(side, mag)
# Gate on the PREDICTION (quadrant) and the applied shift — NOT on the horizon,
# which changes almost every tick as the range moves and would spam the log.
let key = fmt"{q}|{shift:.1f}"
if key == g.lastLogKey: return
if state.tick == g.lastLogTick: return
g.lastLogKey = key
g.lastLogTick = state.tick
let power = (20.0 - bulletSpeed) / 3.0
echo fmt"[tmh] h={h} p={power:.1f} pred={q} shift={shift:.1f} " &
fmt"aim={aimDeg:.1f} gun=Pattern conf={sideConf:.2f} trained={g.trained}"
proc roundSummary*(g: var TmHorizonGun) =
## Per-round summary on round end (behind `TR_TMHORIZON_LOG=1`, or
## `TR_TMHORIZON_ACCURVE=1` for the accuracy curve alone), so the user can
## watch it learn across the round. The rolling windows are the re-adaptation
## measurement: `acc100` = last 100 warm samples, `acc300` = last 300, and
## `accCurve[min/mean/end]` the per-round spread of the rolling-100 snapshot.
g.ensureConfig()
if not g.logEnabled and not g.accurveEnabled: return
let thisRound = g.trained - g.roundStartTrained
let acc = if g.sideTotal > 0: g.sideCorrect.float / g.sideTotal.float * 100.0
else: 0.0
let a100 = g.rollingAcc(TMH_ACC_WIN1) * 100.0
let a300 = g.rollingAcc(TMH_ACC_WIN2) * 100.0
let snapMean = if g.accSnapN > 0: g.accSnapSum / float(g.accSnapN) else: 0.0
echo fmt"[tmh-round] trained={g.trained} thisRound={thisRound} pending={g.pendingCount} " &
fmt"dropped={g.pendingDropped} sideAcc={g.sideCorrect}/{g.sideTotal} " &
fmt"({acc:.1f}%) " &
fmt"acc100={a100:.1f}% acc300={a300:.1f}% " &
fmt"accCurve[min={g.accSnapMin:.1f} mean={snapMean:.1f} end={a100:.1f} n={g.accSnapN}] " &
fmt"resets={g.resetDrops} window={g.windowN} resetDrop={g.resetDrop:.1f} " &
fmt"quad=[SR:{g.quadHist[0]} LR:{g.quadHist[1]} " &
fmt"SL:{g.quadHist[2]} LL:{g.quadHist[3]}] " &
fmt"sidePred=[R:{g.sidePredHist[0]} L:{g.sidePredHist[1]}] " &
fmt"magPred=[S:{g.magPredHist[0]} B:{g.magPredHist[1]}] " &
fmt"labels=[side R:{g.sideLabelHist[0]} L:{g.sideLabelHist[1]} " &
fmt"mag S:{g.magLabelHist[0]} B:{g.magLabelHist[1]}]"
# ── Gun interface ────────────────────────────────────────────────────────────
proc isWarmedUp*(g: TmHorizonGun): bool {.inline.} = true
proc predict*(g: var TmHorizonGun, state: WorldState,
bulletSpeed: float): GunPrediction =
g.ensureConfig()
# Round boundary: a tick regression means a new round. Clear ONLY the
# per-round state (ring + deferred labels); the machines SURVIVE so learning
# accumulates across the whole battle.
if state.tick < g.lastTick: g.resetRoundState()
# Once-per-tick: observe the world, then resolve any labels now due.
if state.tick != g.lastTick:
g.tmhUpdateHistory(state)
g.tmhResolvePending(state)
g.lastTick = state.tick
for i in 0..<TMH_NH: g.bucketEval[i] = TmhEval()
# The base prediction is Pattern. The TM only corrects it.
let base = g.pattern.predict(state, bulletSpeed)
if bulletSpeed <= 0.0: return base
let dist = hypot(state.enemyX - state.selfX, state.enemyY - state.selfY)
let h = tmhHorizonFor(dist, bulletSpeed)
let bucket = tmhHorizonBucket(h)
# Base bits cached once per tick (Pattern-style per-tick caching).
if not g.bitsValid or g.cachedBitsTick != state.tick:
g.cachedBits = g.tmhBaseBits(state)
g.cachedBitsTick = state.tick
g.bitsValid = true
let lits = tmhLits(g.cachedBits, bucket)
# Evaluate each horizon bucket at most once per tick.
if not g.bucketEval[bucket].valid:
let (sideCls, sideConf) = tmhEvalOne(g.sideMachine, lits, g.sideScratch)
let (magCls, _) = tmhEvalOne(g.magMachine, lits, g.magScratch)
g.bucketEval[bucket] = TmhEval(valid: true, side: sideCls,
sideConf: sideConf, mag: magCls)
let ev = g.bucketEval[bucket]
let warm = g.trained >= TMH_MIN_OBS
let side = if warm: ev.side else: -1
let mag = if warm: ev.mag else: -1
g.lastSidePred = side
g.lastMagPred = mag
let baseBearing = arctan2(base.y - state.selfY, base.x - state.selfX)
# Enqueue one sample per (tick, bucket); predict runs once per power bin.
if g.lastEnqTick != state.tick or g.lastEnqBucket != bucket:
g.tmhEnqueue(state, baseBearing, h, bucket, lits, side, mag, warm)
g.lastEnqTick = state.tick
g.lastEnqBucket = bucket
# Small hit-optimal-style correction (NOT the conditional median).
var shift = 0.0
if warm and g.shiftDeg != 0.0:
let magScale = if mag == 1: g.bigMult else: 1.0
let sgn = if side == 1: 1.0 else: -1.0
shift = sgn * g.shiftDeg * magScale
# TMComposites Eq 4 analogue: the side-machine's class-sum margin (normalised
# by the clause half-count) is the per-sample confidence in WHICH WAY to shift.
var predOut = base
if shift != 0.0:
predOut = tmhApplyShift(state.selfX, state.selfY, base.x, base.y, shift)
if warm: predOut.confidence = ev.sideConf
let aimDeg = radToDeg(arctan2(predOut.y - state.selfY, predOut.x - state.selfX))
g.tmhLog(state, bulletSpeed, h, side, mag, ev.sideConf, shift, aimDeg)
predOut
proc onResult*(g: var TmHorizonGun, e: FeedbackEvent) =
## The label comes from our own observation ring, not from virtual-bullet
## feedback, so there is nothing to do here. The hook exists for the rack.
discard