aed579b3af
The user's requirement: "every battle i means from round 1 to round end-battle, so retain all learning until the enemy change." What was built wiped the Tsetlin machines EVERY ROUND, in two places (`onRoundStarted` and the gun's own tick-regression self-reset), so in a 7-round battle each round started cold, trained ~360 samples and threw them away - discarding most of its one chance to do what was asked: overfit the current enemy over the whole battle. THE FIX - two kinds of state, two triggers: - **`resetRoundState` (per ROUND)**: the observation ring, pending/deferred labels, the bullet proxy, motion history, per-tick caches, `roundStartTrained`. These MUST clear every round, because bots teleport back to the starting corners between rounds - an old position would build a garbage label. (That exact class of bug shipped 36-58% wrong labels in the old gun.) - **`resetLearning` (per BATTLE / per ENEMY)**: both Tsetlin machines, `trained`, `sideCorrect/sideTotal`, all histograms, the magnitude median, `pendingDropped`, `observedTargetId`. These now SURVIVE round boundaries. Triggers for the machine wipe: `onGameStarted` (primary) plus a redundant `roundNumber <= 1` fallback in `onRoundStarted`; and a TARGET CHANGE (`targetChanged`, knob `TR_TMHORIZON_RESET_ON_TARGET` default on - a no-op in 1v1, fires on melee target switches; first acquisition never wipes). The tick-regression self-reset now clears ONLY per-round state. Still NO cross-battle persistence: grep for file I/O in the gun finds none. PROOF IT WORKS (live 2-round battle, `TR_RACK_PATTERN=off TR_RACK_TMHORIZON=both`): [tmh-reset] reason=game_start trained_was=0 [tmh-reset] reason=round1 trained_was=0 ...exactly TWO reset lines in the whole battle, both at battle start, and NONE at the round-2 boundary. And the per-round summaries: [tmh-round] trained=1249 thisRound=1249 ... sideAcc=711/1177 (60.4%) [tmh-round] trained=2226 thisRound=977 ... sideAcc=1361/2154 (63.2%) `trained` CLIMBED 1249 -> 2226 across the boundary, and side accuracy rose 60.4% -> 63.2% in round 2 (one battle - suggestive, not proof). Unit tests: `test_tm_horizon` 79 (was 54), including "trained SURVIVES the boundary", "clause states SURVIVE", "trained climbs round1->round2", "game-start wipes and all clauses end Exclude", "different enemy wipes / same enemy does not / knob-off does not", and crucially "a label CANNOT be built across a round boundary" (ringValidCount==0, ringHas(oldTick)==false, pendingCount==0) - the single most dangerous interaction of this change. Guards: test_tm_horizon 79, test_rack_membership 48, test_gun_harness 39, test_vbullet_metric 11, test_power_selection 3, test_adaptive_radar 41, test_tfil_ring_weights 24, test_power_policy 26, test_ram_decision 40, test_selector_tiebreak 19, test_tm_pattern_registration 20, test_vbullet_admit_gate 12, test_tm_diag 48, test_tm_automata_diag 55, test_tm_clause_shape 66. acceptance_offline_vs_online 12/12 VERDICT PASS. Shipped rack unchanged: DefaultRackMembership is still Pattern-only, TMHORIZON off. Residual (pre-existing, out of scope, stated): the internal base `PatternMatcherGun` has a rolling move-history buffer that is NOT cleared at round boundaries - it never was, and the SHIPPED Pattern gun carries history across rounds too. It cannot affect label correctness (labels come from `g.ring`), only base-prediction quality in a round's first ticks.
851 lines
34 KiB
Nim
851 lines
34 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.
|
|
##
|
|
## 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
|
|
|
|
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
|
|
|
|
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
|
|
# ── 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
|
|
|
|
# ── 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 initTmHorizonGun*(): TmHorizonGun =
|
|
result.pattern = PatternMatcherGun()
|
|
result.sideMachine = newMachine(TMH_N_BITS, 2, TMH_NCLAUSES, TMH_NSTATES,
|
|
TMH_S, seed = 1)
|
|
result.magMachine = newMachine(TMH_N_BITS, 2, TMH_NCLAUSES, TMH_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 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
|
|
|
|
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
|
|
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 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 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)
|
|
|
|
# ── 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
|
|
if p.sidePred == sideLabel: inc g.sideCorrect
|
|
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
|
|
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 the same log switch), so the user can
|
|
## watch it learn across the round.
|
|
g.ensureConfig()
|
|
if not g.logEnabled: return
|
|
let thisRound = g.trained - g.roundStartTrained
|
|
let acc = if g.sideTotal > 0: g.sideCorrect.float / g.sideTotal.float * 100.0
|
|
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"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
|