795a0e59fe
Wire the verified common_libs/bitbrain ADE+SBC library into ModularBot as a
fine-grained angular corrector on top of Pattern's prediction, the shape the
offline gate test measured (argmax readout over N correction classes).
- common_libs/guns/bitbrain_gun.nim: new gun. Input = the existing TMHorizon
53 bits (tmhBaseBits + tmhLits); output = argmax class centre over
+-TR_BITBRAIN_RANGE, applied by rotating the Pattern point around the shooter
exactly as tmhApplyShift does. Label = the +h-tick fact from TmHorizonGun's
own observation ring (never across a round). Prequential (defer + resolve).
AD layer synthesised online for our binary inputs (center=0): heuristic
cold-start thresholds + running-histogram ~1% percentile init + the library's
adaptThresholds. Memory modes perRound (default, measured best) / retained /
decay (periodic partial SBC wipe). Lazy network build + local RNG, so the
default path builds nothing and consumes no global randomness.
- tm_horizon.nim: export tmhUpdateHistory and add tmhObservedAt (label seam).
- selector.nim: register BITBRAIN at rack id 16, default rmOff, in the SAME
commit as the id and the wiring (the aed579b admission bug is not repeated).
- ModularBot.nim: id 16 wired through predict/spawn/onResult/resets/colors,
arrays grown 16->17, spawn gated on rack admission, per-round/per-battle/
target reset hooks.
- env_report.nim: report every TR_BITBRAIN_* knob + add names to the known set.
- tests: update the rack length literals; new test_bitbrain_registration
(default-parity: off, lazy, global-RNG clean).
Guard counts unchanged: rack 48, tm_pattern_registration 20, vbullet_admit 12,
env_report 25, and the rest of the suite green.
1106 lines
46 KiB
Nim
1106 lines
46 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
|
|
|
|
let pred =
|
|
if shift == 0.0: base
|
|
else: tmhApplyShift(state.selfX, state.selfY, base.x, base.y, shift)
|
|
|
|
let aimDeg = radToDeg(arctan2(pred.y - state.selfY, pred.x - state.selfX))
|
|
g.tmhLog(state, bulletSpeed, h, side, mag, ev.sideConf, shift, aimDeg)
|
|
pred
|
|
|
|
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
|