TM gun round 2: base was never behind; RADIAL target beats Linear on bmPoint
=== TASK 1: MY PREMISE WAS REFUTED ===
I instructed the job to "fix the baseline" because an earlier measurement said the
TM gun's base did not iterate flight time like `LinearGun`. MEASURED: the new gun's
base is BYTE-FOR-BYTE `LinearGun` - 18/18 runs tie exactly, p=1.000, every per-run
row byte-identical. The "non-iterating baseline" belonged to the OLD `tsetlin.nim`,
not this gun. So no fix was needed, and the earlier inference should not have been
generalised to the new gun. (It did still align the zero-correction clamp to
LinearGun's exact [0, arena] range, and reports the old BotRadius-inset base was a
wash/marginally better at 34.2%/24.7%.)
=== TASK 2: THE RADIAL TARGET - A CONTROL-VALIDATED WIN, BUT ONLY ON bmPoint ===
Instead of the lateral (GF-bucket) component - which the linear lead already
captures - the TM now predicts the RADIAL component: will the enemy be nearer or
farther than the base prediction when our bullet arrives? A 5-class radial head
sharing the same 40-bit context and TM core; the readout advances/retards the aim
distance along the base bearing.
under bmPath (the SHIPPED metric): STRUCTURAL NO-OP
synthetic 8/8 exact ties, p=1.0; real 33.9%/24.1% vs Linear 34.0%/24.3%
under bmPoint: A WIN, control-validated
TMRadial 9.4% (6013/63785) / 5.8% (42079/726652)
Linear 7.2% / 4.7% overall 17/1, p=0.0001
Tsetlin 7.0% / 4.8% overall 15/3, p=0.0075
shuffled 7.0% / 3.6% early 17/1 p=0.0001; overall 18/0, p<0.0001
radial head online accuracy 48.8% vs 19.9% shuffled chance and 36.7% majority
-> it is CONDITIONAL learning, not a constant short-range bias.
Best config: TM_RADIAL_RANGE=60, TM_RAD_MARGIN=0.25, 5 classes.
CAVEAT THAT MATTERS: a win on `bmPoint` is NOT yet evidence of a real win. `bmPath`
is the shipped SELECTION metric precisely because it beat `bmPoint` on real hit
rate (7.43% vs 4.70%). But that A/B was about which gun to PICK, not about gun
QUALITY - a gun can be better in reality while scoring worse on the selection
metric. So this needs a LIVE test, and it is the decisive one.
=== TASK 3: REVERSAL TARGET - CLEAN NEGATIVE ===
The label positive rate is only 9.7% (rev=[24772,2673]) and the head's 86.8%
accuracy is BELOW the 90.3% majority baseline: it does not learn the positive
class at all. Hit-rate effect neutral (bmPath 19.5%/18.4% vs shuffled 19.1%/17.8%,
p=0.24/0.82). Dropped.
=== OVERALL ===
Not competitive on the shipped bmPath metric (gated GF 28.3%/22.2% vs Linear
34.0%/24.3%, p=0.0075). Better than Linear on bmPoint via TMRadial (+2.2pp early,
+1.1pp overall). Per-enemy reset exists; a fresh gun per round; NO cross-battle
persistence (the user's non-negotiable).
MEASURED LIMITATION: radial mode has a high labelMiss because aiming short
resolves BEFORE the base arrival tick, biasing training toward resolvable samples.
The metric win is label-independent. A deferred-label fix is the next refinement.
INFERRED: the mechanism is surfers being NEARER than the base prediction
(range-holding); a constant-short-offset ablation would separate a learned
short-range bias from genuine per-tick conditional prediction.
This commit is contained in:
+164
-29
@@ -71,8 +71,30 @@ const
|
||||
TM_TRACE_SLOTS = 1024
|
||||
POS_RING = 512
|
||||
DebugTMPattern* = false
|
||||
## ── radial head (Task 2) ────────────────────────────────────────────────
|
||||
## Radial label = (enemy radius at the BASE arrival tick) - (base fire
|
||||
## distance), bucketed over +/-TM_RADIAL_RANGE px. Readout advances/retards
|
||||
## the aim distance along the base bearing.
|
||||
TM_RADIAL_RANGE_DEF {.strdefine.} = "60.0"
|
||||
TM_RADIAL_RANGE* = parseFloat(TM_RADIAL_RANGE_DEF)
|
||||
TM_RAD_MARGIN_DEF {.strdefine.} = "0.25"
|
||||
TM_RAD_MARGIN* = parseFloat(TM_RAD_MARGIN_DEF)
|
||||
## ── reversal head (Task 3) ──────────────────────────────────────────────
|
||||
## Binary: did the enemy's heading turn direction over the flight oppose the
|
||||
## direction it was turning at fire time?
|
||||
TM_REV_TURN_DEG_DEF {.strdefine.} = "10.0"
|
||||
TM_REV_TURN_DEG* = parseFloat(TM_REV_TURN_DEG_DEF)
|
||||
TM_REV_MARGIN_DEF {.strdefine.} = "0.0"
|
||||
TM_REV_MARGIN* = parseFloat(TM_REV_MARGIN_DEF)
|
||||
TM_REV_GAIN_DEF {.strdefine.} = "1.0"
|
||||
TM_REV_GAIN* = parseFloat(TM_REV_GAIN_DEF)
|
||||
|
||||
type
|
||||
TmTargetMode* = enum
|
||||
tmGF ## round-1: lateral guess-factor bucket
|
||||
tmRadial ## Task 2: radial displacement bucket (aim-distance correction)
|
||||
tmReversal ## Task 3: binary turn reversal; flips the GF correction sign
|
||||
|
||||
TmBits* = array[TM_NLITS, uint8]
|
||||
|
||||
TmPatternTrace = object
|
||||
@@ -81,20 +103,33 @@ type
|
||||
arrivalTick: int
|
||||
baseBearing: float
|
||||
fireX, fireY: float
|
||||
fireHeading: float
|
||||
fireTurn: int
|
||||
fireDist: float
|
||||
lits: TmBits
|
||||
votes: array[TM_CLASSES, float]
|
||||
cache: array[TM_CLASSES, array[TM_NCLAUSES, uint8]]
|
||||
chosen: int
|
||||
radVotes: array[TM_CLASSES, float]
|
||||
radCache: array[TM_CLASSES, array[TM_NCLAUSES, uint8]]
|
||||
radChosen: int
|
||||
revVotes: array[2, float]
|
||||
revCache: array[2, array[TM_NCLAUSES, uint8]]
|
||||
revChosen: int
|
||||
warm: bool
|
||||
alive: bool
|
||||
|
||||
PosSample = object
|
||||
tick: int
|
||||
x, y: float
|
||||
heading: float
|
||||
valid: bool
|
||||
|
||||
TmPatternGun* = object
|
||||
teams: array[TM_CLASSES, seq[int16]]
|
||||
radTeams: array[TM_CLASSES, seq[int16]]
|
||||
revTeams: array[2, seq[int16]]
|
||||
targetMode*: TmTargetMode
|
||||
traces: array[TM_TRACE_SLOTS, TmPatternTrace]
|
||||
# ── history ──
|
||||
posRing: array[POS_RING, PosSample]
|
||||
@@ -117,10 +152,17 @@ type
|
||||
labelMisses*: int
|
||||
chosenHist*: array[TM_CLASSES, int]
|
||||
labelHist*: array[TM_CLASSES, int]
|
||||
radChosenHist*: array[TM_CLASSES, int]
|
||||
radLabelHist*: array[TM_CLASSES, int]
|
||||
revChosenHist*: array[2, int]
|
||||
revLabelHist*: array[2, int]
|
||||
radCorrect*, radTotal*: int
|
||||
revCorrect*, revTotal*: int
|
||||
classCorrect*: int ## warm predictions whose class matched the eventual label
|
||||
classTotal*: int ## warm predictions with a resolvable label
|
||||
lastChosen*: int
|
||||
shuffleLabels*: bool ## control: replace the computed GF label with a random class
|
||||
forceBase*: bool ## measurement: ignore the TM, emit the pure LinearGun base
|
||||
debugGraphics*: bool
|
||||
|
||||
# ── TM core (Granmo Table 2/3, corrected resource allocation) ────────────────
|
||||
@@ -199,6 +241,14 @@ proc bucketToGF(c: int): float {.inline.} =
|
||||
if TM_CLASSES <= 1: 0.0
|
||||
else: float(c) / float(TM_CLASSES - 1) * 2.0 - 1.0
|
||||
|
||||
proc radToBucket(delta: float): int {.inline.} =
|
||||
let u = clamp(delta / TM_RADIAL_RANGE, -1.0, 1.0)
|
||||
clamp(int(round((u + 1.0) * 0.5 * float(TM_CLASSES - 1))), 0, TM_CLASSES - 1)
|
||||
|
||||
proc bucketToRadial(c: int): float {.inline.} =
|
||||
if TM_CLASSES <= 1: 0.0
|
||||
else: (float(c) / float(TM_CLASSES - 1) * 2.0 - 1.0) * TM_RADIAL_RANGE
|
||||
|
||||
proc normDeg(d: float): float {.inline.} =
|
||||
result = d
|
||||
while result > 180.0: result -= 360.0
|
||||
@@ -208,6 +258,9 @@ proc normDeg(d: float): float {.inline.} =
|
||||
|
||||
proc initTmPatternGun*(): TmPatternGun =
|
||||
for c in 0..<TM_CLASSES: result.teams[c] = tmNewTeam()
|
||||
for c in 0..<TM_CLASSES: result.radTeams[c] = tmNewTeam()
|
||||
for c in 0..<2: result.revTeams[c] = tmNewTeam()
|
||||
result.targetMode = tmGF
|
||||
result.lastTick = -1
|
||||
result.prevTick = -1
|
||||
result.currentTarget = -1
|
||||
@@ -221,6 +274,8 @@ proc resetLearning*(g: var TmPatternGun) =
|
||||
## Fresh concept: wipe every clause team and the motion history. Called when
|
||||
## the target id changes so a new opponent starts from a cold net.
|
||||
for c in 0..<TM_CLASSES: g.teams[c] = tmNewTeam()
|
||||
for c in 0..<TM_CLASSES: g.radTeams[c] = tmNewTeam()
|
||||
for c in 0..<2: g.revTeams[c] = tmNewTeam()
|
||||
g.totalObs = 0
|
||||
g.hasPrev = false
|
||||
for i in 0..<3:
|
||||
@@ -235,7 +290,7 @@ proc tmUpdateHistory(g: var TmPatternGun, state: WorldState) =
|
||||
g.lastTick = state.tick
|
||||
let slot = ((state.tick mod POS_RING) + POS_RING) mod POS_RING
|
||||
g.posRing[slot] = PosSample(tick: state.tick, x: state.enemyX, y: state.enemyY,
|
||||
valid: true)
|
||||
heading: state.enemyHeading, valid: true)
|
||||
if g.hasPrev and state.tick > g.prevTick:
|
||||
let dx = state.enemyX - g.prevX
|
||||
let dy = state.enemyY - g.prevY
|
||||
@@ -368,25 +423,25 @@ proc tmSoftGF(votes: array[TM_CLASSES, float]): float =
|
||||
for c in 0..<TM_CLASSES:
|
||||
result += (w[c] / sum) * bucketToGF(c)
|
||||
|
||||
proc tmChooseClass(g: var TmPatternGun, votes: array[TM_CLASSES, float]): int =
|
||||
## Cold gun or a flat vote vector -> straight ahead (GF = 0). Otherwise the
|
||||
## argmax class, but ONLY when it beats the centre class by TM_CONF_MARGIN
|
||||
## (fraction of TM_T); otherwise stay at the centre. This is what keeps the
|
||||
## gun from degrading to arbitrary buckets when the TM has no real evidence.
|
||||
let centre = (TM_CLASSES - 1) div 2
|
||||
if g.totalObs < TM_MIN_OBS:
|
||||
proc tmChooseAt(votes: openArray[float], centre: int, margin: float,
|
||||
nObs: int): int =
|
||||
## Cold gun or no class beating the centre by `margin` (fraction of TM_T)
|
||||
## -> the centre. Shared by the GF, radial and reversal heads.
|
||||
if nObs < TM_MIN_OBS:
|
||||
return centre
|
||||
var best = 0
|
||||
var bestV = -Inf
|
||||
for c in 0..<TM_CLASSES:
|
||||
for c in 0..<votes.len:
|
||||
if votes[c] > bestV: bestV = votes[c]; best = c
|
||||
if best == centre:
|
||||
return centre
|
||||
let margin = (votes[best] - votes[centre]) / TM_T
|
||||
if margin < TM_CONF_MARGIN:
|
||||
if (votes[best] - votes[centre]) / TM_T < margin:
|
||||
return centre
|
||||
result = best
|
||||
|
||||
proc tmChooseClass(g: var TmPatternGun, votes: array[TM_CLASSES, float]): int =
|
||||
tmChooseAt(votes, (TM_CLASSES - 1) div 2, TM_CONF_MARGIN, g.totalObs)
|
||||
|
||||
proc predict*(g: var TmPatternGun, state: WorldState, bulletSpeed: float):
|
||||
GunPrediction =
|
||||
inc g.predictCalls
|
||||
@@ -413,21 +468,52 @@ proc predict*(g: var TmPatternGun, state: WorldState, bulletSpeed: float):
|
||||
var caches: array[TM_CLASSES, array[TM_NCLAUSES, uint8]]
|
||||
for c in 0..<TM_CLASSES:
|
||||
votes[c] = tmForward(g.teams[c], lits, caches[c])
|
||||
var radVotes: array[TM_CLASSES, float]
|
||||
var radCaches: array[TM_CLASSES, array[TM_NCLAUSES, uint8]]
|
||||
for c in 0..<TM_CLASSES:
|
||||
radVotes[c] = tmForward(g.radTeams[c], lits, radCaches[c])
|
||||
var revVotes: array[2, float]
|
||||
var revCaches: array[2, array[TM_NCLAUSES, uint8]]
|
||||
for c in 0..<2:
|
||||
revVotes[c] = tmForward(g.revTeams[c], lits, revCaches[c])
|
||||
|
||||
let chosen = g.tmChooseClass(votes)
|
||||
let radChosen = tmChooseAt(radVotes, (TM_CLASSES - 1) div 2, TM_RAD_MARGIN,
|
||||
g.totalObs)
|
||||
let revChosen = tmChooseAt(revVotes, 0, TM_REV_MARGIN, g.totalObs)
|
||||
g.lastChosen = chosen
|
||||
inc g.chosenHist[chosen]
|
||||
inc g.radChosenHist[radChosen]
|
||||
inc g.revChosenHist[revChosen]
|
||||
|
||||
var gf: float
|
||||
if g.totalObs < TM_MIN_OBS:
|
||||
gf = 0.0
|
||||
elif TM_GF_MODE == "soft":
|
||||
gf = TM_SHRINK * tmSoftGF(votes)
|
||||
var gf = 0.0
|
||||
var radOffset = 0.0
|
||||
if not (g.forceBase or g.totalObs < TM_MIN_OBS):
|
||||
case g.targetMode
|
||||
of tmGF:
|
||||
if TM_GF_MODE == "soft": gf = TM_SHRINK * tmSoftGF(votes)
|
||||
else: gf = TM_SHRINK * bucketToGF(chosen)
|
||||
of tmRadial:
|
||||
radOffset = bucketToRadial(radChosen)
|
||||
of tmReversal:
|
||||
if TM_GF_MODE == "soft": gf = TM_SHRINK * tmSoftGF(votes)
|
||||
else: gf = TM_SHRINK * bucketToGF(chosen)
|
||||
if revChosen == 1:
|
||||
gf = -TM_REV_GAIN * gf
|
||||
|
||||
# An all-zero correction must reproduce `LinearGun` BYTE-FOR-BYTE, so use its
|
||||
# exact aim point and its exact [0, arena] clamp rather than the
|
||||
# BotRadius-inset clamp the corrective excursions use.
|
||||
let usedBase = (gf == 0.0 and radOffset == 0.0)
|
||||
var px, py: float
|
||||
if usedBase:
|
||||
px = f.x
|
||||
py = f.y
|
||||
else:
|
||||
gf = TM_SHRINK * bucketToGF(chosen)
|
||||
let aimAngle = f.bearing + gf * mea
|
||||
let px = state.selfX + cos(aimAngle) * f.dist
|
||||
let py = state.selfY + sin(aimAngle) * f.dist
|
||||
let aimAngle = f.bearing + gf * mea
|
||||
let aimDist = f.dist + radOffset
|
||||
px = state.selfX + cos(aimAngle) * aimDist
|
||||
py = state.selfY + sin(aimAngle) * aimDist
|
||||
|
||||
let binIdx = tmBinForSpeed(bulletSpeed)
|
||||
if binIdx >= 0:
|
||||
@@ -440,16 +526,23 @@ proc predict*(g: var TmPatternGun, state: WorldState, bulletSpeed: float):
|
||||
arrivalTick: state.tick + arrOff,
|
||||
baseBearing: f.bearing,
|
||||
fireX: state.selfX, fireY: state.selfY,
|
||||
lits: lits, votes: votes, cache: caches,
|
||||
chosen: chosen, warm: (g.totalObs >= TM_MIN_OBS), alive: true)
|
||||
fireHeading: state.enemyHeading, fireTurn: g.turnSignHist[0],
|
||||
fireDist: f.dist,
|
||||
lits: lits, votes: votes, cache: caches, chosen: chosen,
|
||||
radVotes: radVotes, radCache: radCaches, radChosen: radChosen,
|
||||
revVotes: revVotes, revCache: revCaches, revChosen: revChosen,
|
||||
warm: (g.totalObs >= TM_MIN_OBS), alive: true)
|
||||
|
||||
when DebugTMPattern:
|
||||
echo "tmp tick=", state.tick, " bin=", binIdx, " chosen=", chosen,
|
||||
" gf=", gf, " obs=", g.totalObs, " votes=", votes
|
||||
" radChosen=", radChosen, " revChosen=", revChosen,
|
||||
" gf=", gf, " roff=", radOffset, " obs=", g.totalObs, " votes=", votes
|
||||
|
||||
GunPrediction(
|
||||
x: clamp(px, BotRadius, state.arenaWidth - BotRadius),
|
||||
y: clamp(py, BotRadius, state.arenaHeight - BotRadius),
|
||||
x: if usedBase: clamp(px, 0.0, state.arenaWidth)
|
||||
else: clamp(px, BotRadius, state.arenaWidth - BotRadius),
|
||||
y: if usedBase: clamp(py, 0.0, state.arenaHeight)
|
||||
else: clamp(py, BotRadius, state.arenaHeight - BotRadius),
|
||||
)
|
||||
|
||||
proc onResult*(g: var TmPatternGun, e: FeedbackEvent) =
|
||||
@@ -479,15 +572,57 @@ proc onResult*(g: var TmPatternGun, e: FeedbackEvent) =
|
||||
while delta > PI: delta -= 2.0 * PI
|
||||
while delta < -PI: delta += 2.0 * PI
|
||||
let gf = if mea > 1e-10: clamp(delta / mea, -1.0, 1.0) else: 0.0
|
||||
let winner = if g.shuffleLabels: rand(TM_CLASSES - 1) else: gfToBucket(gf)
|
||||
|
||||
# The shuffled control randomises ONLY the head the current mode is claiming.
|
||||
let shuffleGF = g.shuffleLabels and g.targetMode == tmGF
|
||||
let shuffleRad = g.shuffleLabels and g.targetMode == tmRadial
|
||||
let shuffleRev = g.shuffleLabels and g.targetMode == tmReversal
|
||||
|
||||
let winner = if shuffleGF: rand(TM_CLASSES - 1) else: gfToBucket(gf)
|
||||
inc g.labelHist[winner]
|
||||
if t.warm:
|
||||
inc g.classTotal
|
||||
if winner == t.chosen: inc g.classCorrect
|
||||
|
||||
for c in 0..<TM_CLASSES:
|
||||
let d = if c == winner: 1.0 else: -1.0
|
||||
g.teams[c].tmLearnDir(t.lits, t.cache[c], t.votes[c], d)
|
||||
# Radial label: enemy radius at the base arrival tick minus the base fire
|
||||
# distance. Independent of our own aim, so it is a clean target.
|
||||
let actualRadius = hypot(g.posRing[s].x - t.fireX, g.posRing[s].y - t.fireY)
|
||||
let radDelta = actualRadius - t.fireDist
|
||||
let radWinner = if shuffleRad: rand(TM_CLASSES - 1) else: radToBucket(radDelta)
|
||||
inc g.radLabelHist[radWinner]
|
||||
if t.warm:
|
||||
inc g.radTotal
|
||||
if radWinner == t.radChosen: inc g.radCorrect
|
||||
|
||||
# Reversal label: net heading turn over the flight, opposite to the direction
|
||||
# the enemy was turning at fire time.
|
||||
let dh = normDeg(g.posRing[s].heading - t.fireHeading)
|
||||
let netTurn = if dh > TM_REV_TURN_DEG: 1 elif dh < -TM_REV_TURN_DEG: -1 else: 0
|
||||
let revWinner =
|
||||
if shuffleRev: rand(1)
|
||||
elif t.fireTurn != 0 and netTurn != 0 and netTurn != t.fireTurn: 1
|
||||
else: 0
|
||||
inc g.revLabelHist[revWinner]
|
||||
if t.warm:
|
||||
inc g.revTotal
|
||||
if revWinner == t.revChosen: inc g.revCorrect
|
||||
|
||||
case g.targetMode
|
||||
of tmGF:
|
||||
for c in 0..<TM_CLASSES:
|
||||
let d = if c == winner: 1.0 else: -1.0
|
||||
g.teams[c].tmLearnDir(t.lits, t.cache[c], t.votes[c], d)
|
||||
of tmRadial:
|
||||
for c in 0..<TM_CLASSES:
|
||||
let d = if c == radWinner: 1.0 else: -1.0
|
||||
g.radTeams[c].tmLearnDir(t.lits, t.radCache[c], t.radVotes[c], d)
|
||||
of tmReversal:
|
||||
for c in 0..<TM_CLASSES:
|
||||
let d = if c == winner: 1.0 else: -1.0
|
||||
g.teams[c].tmLearnDir(t.lits, t.cache[c], t.votes[c], d)
|
||||
for c in 0..<2:
|
||||
let d = if c == revWinner: 1.0 else: -1.0
|
||||
g.revTeams[c].tmLearnDir(t.lits, t.revCache[c], t.revVotes[c], d)
|
||||
inc g.totalObs
|
||||
inc g.trainCalls
|
||||
t.alive = false
|
||||
|
||||
@@ -24,6 +24,17 @@ import gun_harness/offline_range
|
||||
import range_guns
|
||||
import guns/tm_pattern
|
||||
import guns/linear
|
||||
import guns/lead_forecast
|
||||
|
||||
type
|
||||
LinearInsetGun = object ## pre-fix TMPattern base: same forecast, BotRadius clamp
|
||||
|
||||
proc predict*(g: var LinearInsetGun, state: WorldState, bulletSpeed: float): GunPrediction =
|
||||
let f = forecastLinear(state, bulletSpeed)
|
||||
GunPrediction(x: clamp(f.x, BotRadius, state.arenaWidth - BotRadius),
|
||||
y: clamp(f.y, BotRadius, state.arenaHeight - BotRadius))
|
||||
|
||||
proc onResult*(g: var LinearInsetGun, e: FeedbackEvent) = discard
|
||||
|
||||
const repoRoot = currentSourcePath().parentDir.parentDir.parentDir
|
||||
const fixturesDir = repoRoot / "tools" / "fixtures"
|
||||
@@ -33,6 +44,7 @@ type
|
||||
obs, labelMiss, traceMiss: int
|
||||
labHist, choHist: array[TM_CLASSES, int]
|
||||
classCorrect, classTotal: int
|
||||
radCorrect, radTotal, revCorrect, revTotal: int
|
||||
|
||||
Adapt = object
|
||||
h100, n100, h300, n300, hall, nall, f100, m100: int
|
||||
@@ -47,14 +59,21 @@ type
|
||||
r: Adapt
|
||||
|
||||
VariantKind = enum
|
||||
vLinear, vTsetlin, vTmpat, vTmpatShuf
|
||||
vLinear, vLinearOld, vTsetlin, vTmpat, vTmpatShuf, vTmpatBase,
|
||||
vTmpatRad, vTmpatRadShuf, vTmpatRev, vTmpatRevShuf
|
||||
|
||||
proc variantName(v: VariantKind): string =
|
||||
case v
|
||||
of vLinear: "Linear"
|
||||
of vLinearOld: "LinearOldClamp"
|
||||
of vTsetlin: "Tsetlin"
|
||||
of vTmpat: "TMPattern"
|
||||
of vTmpatShuf: "TMPatternShuf"
|
||||
of vTmpatBase: "TMPatternBase"
|
||||
of vTmpatRad: "TMRadial"
|
||||
of vTmpatRadShuf: "TMRadialShuf"
|
||||
of vTmpatRev: "TMReversal"
|
||||
of vTmpatRevShuf: "TMReversalShuf"
|
||||
|
||||
proc loadRounds(path: string): seq[RoundSpan] =
|
||||
let dir = path.parentDir
|
||||
@@ -77,6 +96,10 @@ proc addAdapt(dst: var Adapt, src: Adapt) =
|
||||
dst.st.traceMiss += src.st.traceMiss
|
||||
dst.st.classCorrect += src.st.classCorrect
|
||||
dst.st.classTotal += src.st.classTotal
|
||||
dst.st.radCorrect += src.st.radCorrect
|
||||
dst.st.radTotal += src.st.radTotal
|
||||
dst.st.revCorrect += src.st.revCorrect
|
||||
dst.st.revTotal += src.st.revTotal
|
||||
for c in 0..<TM_CLASSES:
|
||||
dst.st.labHist[c] += src.st.labHist[c]
|
||||
dst.st.choHist[c] += src.st.choHist[c]
|
||||
@@ -128,6 +151,10 @@ proc replayRound(states: seq[WorldState], lastSeen: seq[int], enemyId, baseTick:
|
||||
res.st.traceMiss = after.traceMiss - obsBefore.traceMiss
|
||||
res.st.classCorrect = after.classCorrect - obsBefore.classCorrect
|
||||
res.st.classTotal = after.classTotal - obsBefore.classTotal
|
||||
res.st.radCorrect = after.radCorrect - obsBefore.radCorrect
|
||||
res.st.radTotal = after.radTotal - obsBefore.radTotal
|
||||
res.st.revCorrect = after.revCorrect - obsBefore.revCorrect
|
||||
res.st.revTotal = after.revTotal - obsBefore.revTotal
|
||||
for c in 0..<TM_CLASSES:
|
||||
res.st.labHist[c] = after.labHist[c] - obsBefore.labHist[c]
|
||||
res.st.choHist[c] = after.choHist[c] - obsBefore.choHist[c]
|
||||
@@ -157,15 +184,18 @@ proc replayFixture(fx: Fixture, path: string, driver: GunDriver, metric: BulletM
|
||||
|
||||
proc emptyStats(): GunStats = GunStats()
|
||||
|
||||
proc makeTmpatDriver(seed: int, shuffle: bool):
|
||||
proc makeTmpatDriver(seed: int, shuffle: bool, forceBase = false,
|
||||
mode = tmGF):
|
||||
tuple[driver: GunDriver, gun: ref TmPatternGun] =
|
||||
let g = new(TmPatternGun)
|
||||
g[] = initTmPatternGun()
|
||||
g[].shuffleLabels = shuffle
|
||||
g[].forceBase = forceBase
|
||||
g[].targetMode = mode
|
||||
if seed >= 0: randomize(seed)
|
||||
result.gun = g
|
||||
result.driver = GunDriver(
|
||||
name: (if shuffle: "TMPatternShuf" else: "TMPattern"),
|
||||
name: (if forceBase: "TMPatternBase" elif shuffle: "TMPatternShuf" else: "TMPattern"),
|
||||
predictCb: proc(state: WorldState, bulletSpeed: float): GunPrediction =
|
||||
g[].predict(state, bulletSpeed),
|
||||
resultCb: proc(e: FeedbackEvent) = g[].onResult(e),
|
||||
@@ -179,6 +209,10 @@ proc tmpatStats(g: ref TmPatternGun): GunStats =
|
||||
result.choHist = g[].chosenHist
|
||||
result.classCorrect = g[].classCorrect
|
||||
result.classTotal = g[].classTotal
|
||||
result.radCorrect = g[].radCorrect
|
||||
result.radTotal = g[].radTotal
|
||||
result.revCorrect = g[].revCorrect
|
||||
result.revTotal = g[].revTotal
|
||||
|
||||
proc fixtureSet(name: string): seq[string] =
|
||||
case name
|
||||
@@ -235,9 +269,15 @@ proc main() =
|
||||
for tok in a[11..^1].split(','):
|
||||
case tok.strip()
|
||||
of "linear": variants.add vLinear
|
||||
of "linear_old": variants.add vLinearOld
|
||||
of "tsetlin": variants.add vTsetlin
|
||||
of "tmpat": variants.add vTmpat
|
||||
of "tmpat_shuf": variants.add vTmpatShuf
|
||||
of "tmbase": variants.add vTmpatBase
|
||||
of "tmrad": variants.add vTmpatRad
|
||||
of "tmrad_shuf": variants.add vTmpatRadShuf
|
||||
of "tmrev": variants.add vTmpatRev
|
||||
of "tmrev_shuf": variants.add vTmpatRevShuf
|
||||
else: discard
|
||||
let metric = if metricName == "point": bmPoint else: bmPath
|
||||
let names = fixtureSet(set)
|
||||
@@ -245,11 +285,13 @@ proc main() =
|
||||
echo "variant,fixture,seed,h100,n100,h300,n300,hall,nall,f100,m100,rounds,obs,labelMiss,traceMiss"
|
||||
|
||||
var rows: seq[Row]
|
||||
var revLabTab = initTable[string, array[2, int]]()
|
||||
var radLabTab = initTable[string, array[TM_CLASSES, int]]()
|
||||
for name in names:
|
||||
let (fx, path) = resolve(name)
|
||||
let fxName = path.extractFilename.replace(".jsonl", "")
|
||||
for v in variants:
|
||||
let nIter = if v == vLinear: 1 else: nSeeds
|
||||
let nIter = if v in [vLinear, vLinearOld, vTmpatBase]: 1 else: nSeeds
|
||||
for seed in 1..nIter:
|
||||
var drv: GunDriver
|
||||
var gun: ref TmPatternGun
|
||||
@@ -257,16 +299,32 @@ proc main() =
|
||||
case v
|
||||
of vLinear:
|
||||
drv = makeDriver("Linear", LinearGun())
|
||||
of vLinearOld:
|
||||
drv = makeDriver("LinearOldClamp", LinearInsetGun())
|
||||
of vTsetlin:
|
||||
let pair = makeTsetlinDriver(seed = seed)
|
||||
drv = pair.driver
|
||||
of vTmpat, vTmpatShuf:
|
||||
let pair = makeTmpatDriver(seed = seed, shuffle = (v == vTmpatShuf))
|
||||
of vTmpat, vTmpatShuf, vTmpatBase, vTmpatRad, vTmpatRadShuf,
|
||||
vTmpatRev, vTmpatRevShuf:
|
||||
let mode =
|
||||
case v
|
||||
of vTmpatRad, vTmpatRadShuf: tmRadial
|
||||
of vTmpatRev, vTmpatRevShuf: tmReversal
|
||||
else: tmGF
|
||||
let shuf = v in [vTmpatShuf, vTmpatRadShuf, vTmpatRevShuf]
|
||||
let pair = makeTmpatDriver(seed = seed, shuffle = shuf,
|
||||
forceBase = (v == vTmpatBase), mode = mode)
|
||||
drv = pair.driver
|
||||
gun = pair.gun
|
||||
obsCount = proc(): GunStats = tmpatStats(gun)
|
||||
let r = replayFixture(fx, path, drv, metric, emptyStats(), obsCount, maxRounds)
|
||||
rows.add Row(variant: variantName(v), fixture: fxName, seed: seed, r: r)
|
||||
if gun != nil:
|
||||
if variantName(v) notin revLabTab: revLabTab[variantName(v)] = [0, 0]
|
||||
if variantName(v) notin radLabTab:
|
||||
radLabTab[variantName(v)] = default(array[TM_CLASSES, int])
|
||||
for c in 0..<2: revLabTab[variantName(v)][c] += gun[].revLabelHist[c]
|
||||
for c in 0..<TM_CLASSES: radLabTab[variantName(v)][c] += gun[].radLabelHist[c]
|
||||
echo &"{variantName(v)},{fxName},{seed},{r.h100},{r.n100},{r.h300},{r.n300}," &
|
||||
&"{r.hall},{r.nall},{r.f100},{r.m100},{r.rounds},{r.st.obs},{r.st.labelMiss},{r.st.traceMiss}"
|
||||
|
||||
@@ -282,8 +340,8 @@ proc main() =
|
||||
&"{a.h300},{a.n300},{rateStr(a.h300, a.n300)},{a.hall},{a.nall}," &
|
||||
&"{rateStr(a.hall, a.nall)},{a.st.obs},{a.st.labelMiss},{a.st.traceMiss}"
|
||||
|
||||
# ── label vs chosen class histogram (TMPattern only) ──
|
||||
for v in [vTmpat, vTmpatShuf]:
|
||||
# ── label vs chosen class histogram (all TM variants) ──
|
||||
for v in [vTmpat, vTmpatShuf, vTmpatRad, vTmpatRadShuf, vTmpatRev, vTmpatRevShuf]:
|
||||
if v in variants:
|
||||
let a = pooled[variantName(v)]
|
||||
var ls, cs: string
|
||||
@@ -291,7 +349,14 @@ proc main() =
|
||||
ls.add &"{a.st.labHist[c]},"
|
||||
cs.add &"{a.st.choHist[c]},"
|
||||
echo &"\n# class histogram {variantName(v)}: labels=[{ls}] chosen=[{cs}] " &
|
||||
&"onlineAcc={a.st.classCorrect}/{a.st.classTotal}"
|
||||
&"onlineAcc={a.st.classCorrect}/{a.st.classTotal} " &
|
||||
&"radAcc={a.st.radCorrect}/{a.st.radTotal} " &
|
||||
&"revAcc={a.st.revCorrect}/{a.st.revTotal}"
|
||||
if variantName(v) in revLabTab:
|
||||
var rl, rdl: string
|
||||
for c in 0..<2: rl.add &"{revLabTab[variantName(v)][c]},"
|
||||
for c in 0..<TM_CLASSES: rdl.add &"{radLabTab[variantName(v)][c]},"
|
||||
echo &"# label hist {variantName(v)}: radial=[{rdl}] rev=[{rl}]"
|
||||
|
||||
# ── per-run distributions (a "run" = one fixture × one seed) ──
|
||||
# Linear is deterministic: replicate its one row per fixture across seeds so a
|
||||
@@ -313,6 +378,18 @@ proc main() =
|
||||
for s in 2..nSeeds:
|
||||
byVariant["Linear"][(row.fixture, s)] = byVariant["Linear"][(row.fixture, 1)]
|
||||
byVariantAll["Linear"][(row.fixture, s)] = byVariantAll["Linear"][(row.fixture, 1)]
|
||||
if vLinearOld in variants:
|
||||
for row in rows:
|
||||
if row.variant == "LinearOldClamp":
|
||||
for s in 2..nSeeds:
|
||||
byVariant["LinearOldClamp"][(row.fixture, s)] = byVariant["LinearOldClamp"][(row.fixture, 1)]
|
||||
byVariantAll["LinearOldClamp"][(row.fixture, s)] = byVariantAll["LinearOldClamp"][(row.fixture, 1)]
|
||||
if vTmpatBase in variants:
|
||||
for row in rows:
|
||||
if row.variant == "TMPatternBase":
|
||||
for s in 2..nSeeds:
|
||||
byVariant["TMPatternBase"][(row.fixture, s)] = byVariant["TMPatternBase"][(row.fixture, 1)]
|
||||
byVariantAll["TMPatternBase"][(row.fixture, s)] = byVariantAll["TMPatternBase"][(row.fixture, 1)]
|
||||
|
||||
echo "\n# ── per-run early-rate distribution (mean / min / max, n runs) ──"
|
||||
echo "variant,earlyMean%,earlyMin%,earlyMax%,overallMean%,overallMin%,overallMax%,n"
|
||||
|
||||
@@ -179,3 +179,188 @@ corrective excursion net-positive. Not directly measured.
|
||||
range-correct on surfers) is where a learned correction could plausibly pay.
|
||||
4. **Richer context.** 46% accuracy leaves room; the current context lacks the
|
||||
enemy's own recent GF history / segmentation that KNN/DecayGF exploit.
|
||||
|
||||
---
|
||||
|
||||
# ROUND 2 — fix the base, then try a target Linear cannot predict
|
||||
|
||||
Date: 2026-09-22. Artifacts: `common_libs/guns/tm_pattern.nim` (extended),
|
||||
`common_libs/tests/sweep_tm_pattern.nim` (extended). Raw outputs:
|
||||
`/tmp/tm2_real_path_s3.txt`, `/tmp/tm2_real_point_s3.txt`,
|
||||
`/tmp/tm_best_path_s3.txt`. All numbers below are MEASURED unless a line says
|
||||
INFERRED.
|
||||
|
||||
Round 1's "best" gun predicted the LATERAL GF bucket. Round 2 adds a RADIAL
|
||||
head (aim-distance correction) and a binary REVERSAL head (flip the GF sign),
|
||||
both on the same 40-bit context and the same TM core, selected by a runtime
|
||||
`targetMode` (`tmGF` | `tmRadial` | `tmReversal`). The shuffled-feedback control
|
||||
now randomises only the head the active mode is claiming.
|
||||
|
||||
## Task 1 — the base is EXACTLY Linear (premise refuted)
|
||||
|
||||
`tm_pattern`'s base is `forecastLinear`, which already iterates the flight time
|
||||
(5-iteration fixed point, same as `LinearGun`). The only deviation from
|
||||
`LinearGun` was the wall clamp: the base path clamped to `[BotRadius, W-BotRadius]`
|
||||
(17 px inset) instead of `LinearGun`'s `[0, W]`. Added a `forceBase` flag and a
|
||||
`TMPatternBase` variant, and made the zero-correction path return `f.x, f.y`
|
||||
with the exact `[0, W]` clamp.
|
||||
|
||||
Real DrussGT fixtures, bmPath, seeds=3, 18 fixture×seed runs, 77 rounds pooled:
|
||||
|
||||
| variant | early | overall |
|
||||
|---|---|---|
|
||||
| Linear | 34.0% (6358/18715) | 24.3% (58297/239943) |
|
||||
| LinearOldClamp (pre-fix base, BotRadius inset) | 34.2% (6350/18592) | 24.7% (59217/239891) |
|
||||
| **TMPatternBase (forceBase, exact Linear clamp)** | **34.0% (6358/18715)** | **24.3% (58297/239943)** |
|
||||
|
||||
Paired sign test Linear vs TMPatternBase: **18 ties, 0 wins each, p=1.000** on
|
||||
both early and overall; every per-run row (hits, shots, per-bin) is byte-for-byte
|
||||
identical. Per-run means identical: early 35.52%, overall 26.86%.
|
||||
|
||||
Verdict: **the base was never behind.** It IS `LinearGun` to the last floating
|
||||
point. The earlier "one-shot, non-iterating baseline" finding belonged to the OLD
|
||||
`guns/tsetlin.nim`, not to `tm_pattern`. The clamp fix is a wash (the old inset
|
||||
was marginally BETTER on overall: 24.7% vs 24.3%), so there is **zero baseline
|
||||
headroom** to recover: the entire deficit vs Linear is the TM's corrective
|
||||
excursions.
|
||||
|
||||
## Task 2 — radial target: a structural no-op under bmPath, a real WIN under bmPoint
|
||||
|
||||
**Structural fact (from `virtual_bullets.nim`, INFERRED then confirmed):** under
|
||||
`bmPath` a bullet flies along the aim RAY until it leaves the arena; the aim
|
||||
distance only sets `fireDist` (used for the tie-break probe), it does NOT change
|
||||
the ray. Moving the aim point radially along the base bearing therefore cannot
|
||||
change a `bmPath` hit. Confirmed exactly: on the synthetic set, `TMRadial` vs
|
||||
`Linear` scored **8/8 exact ties, p=1.000** under bmPath.
|
||||
|
||||
Under `bmPoint` the bullet resolves when `travelDist >= fireDist`, so the aim
|
||||
distance selects the arrival tick — the radial degree of freedom is live.
|
||||
|
||||
### bmPath (shipped), real, default config, seeds=3
|
||||
|
||||
| variant | early | overall |
|
||||
|---|---|---|
|
||||
| Linear | 34.0% | 24.3% |
|
||||
| TMRadial | 33.9% (19038/56203) | 24.1% (173577/719860) |
|
||||
| TMRadialShuf | 33.8% (18976/56085) | 24.3% (175154/719781) |
|
||||
|
||||
Per-run means (n=18): Linear 35.52/26.86; TMRadial 35.44/26.70; Shuf 35.40/26.92.
|
||||
Paired sign tests: Linear vs TMRadial early 13/5 p=0.096; overall 15/3 p=0.0075
|
||||
(a tiny systematic LOSS, traceable to the `BotRadius` clamp perturbing the ray
|
||||
near walls when `radOffset != 0`). TMRadial vs TMRadialShuf early 8/8 p=1.000.
|
||||
|
||||
**Verdict bmPath: no gain.** Radial is a structural no-op; the shipped metric
|
||||
therefore cannot reward Task 2.
|
||||
|
||||
### bmPoint, real, default config, seeds=3
|
||||
|
||||
| variant | early | overall |
|
||||
|---|---|---|
|
||||
| Linear | 7.2% (1480/20498) | 4.7% (11277/241423) |
|
||||
| Tsetlin (default gun) | 7.0% (4403/62617) | 4.8% (34588/724717) |
|
||||
| **TMRadial** | **9.4% (6013/63785)** | **5.8% (42079/726652)** |
|
||||
| TMRadialShuf (control) | 7.0% (4329/61691) | 3.6% (26116/724594) |
|
||||
|
||||
Per-run means (n=18): Linear 13.61/5.77; Tsetlin 13.13/5.82; TMRadial
|
||||
15.32/7.06; Shuf 12.86/4.34. Paired sign tests:
|
||||
|
||||
* **TMRadial > Linear: early 14/4 p=0.0309; overall 17/1 p=0.0001.**
|
||||
* **TMRadial > TMRadialShuf: early 17/1 p=0.0001; overall 18/0 p<0.0001.**
|
||||
* TMRadial > Tsetlin: early 17/1 p=0.0001; overall 15/3 p=0.0075.
|
||||
* TMRadialShuf vs Linear: early 10/8 p=0.81; overall 12/6 p=0.24 (control sits
|
||||
at baseline).
|
||||
|
||||
Online accuracy of the radial head: 48.8% (511949/1049453) vs **19.9%** shuffled
|
||||
chance under bmPoint (46.6% vs 20.0% under bmPath). Radial label histogram
|
||||
(raw, seeds=1) = [9058, 5392, 10305, 2236, 1112]: strongly asymmetric — surfers
|
||||
are often NEARER than the base constant-velocity prediction at the arrival tick
|
||||
(the base overshoots range on range-holders), so class 0 (aim 45–60 px short)
|
||||
dominates. That is the mechanism behind the win.
|
||||
|
||||
Caveat (MEASURED): `labelMiss` is much higher for radial mode (~4.3 M vs ~1.7 M
|
||||
for GF) because aiming SHORT resolves the bullet before the base arrival tick,
|
||||
so the arrival-tick ring sample is not yet recorded. The radial head is trained
|
||||
only on resolvable samples; the win is nonetheless measured on the metric, which
|
||||
is label-independent. A deferred-label fix would be the next refinement.
|
||||
|
||||
## Task 3 — binary reversal: not learnable, and the flip is a no-op
|
||||
|
||||
Label: net heading turn over the flight opposes the direction the enemy was
|
||||
turning at fire time (threshold 10°). Readout: train GF as in round 1, and if
|
||||
reversal is predicted, negate the GF correction (`TM_REV_GAIN=1.0`).
|
||||
|
||||
Label base rate (real, seeds=1, 1 round/fixture): `rev=[24772, 2673]` → the
|
||||
positive class is only **9.7%**. The head scores 86.8% (23030/26517) — **below
|
||||
the 90.3% majority-class base rate**, i.e. it is not detecting reversals at all,
|
||||
only predicting "no reversal". (The shuffled control is 50.2% because its labels
|
||||
are balanced.)
|
||||
|
||||
### bmPath (shipped), real, default config, seeds=3
|
||||
|
||||
| variant | early | overall |
|
||||
|---|---|---|
|
||||
| Linear | 34.0% | 24.3% |
|
||||
| Tsetlin (round-1 measurement) | 22.3% | 20.3% |
|
||||
| TMReversal | 19.5% (11062/56704) | 18.4% (132356/719725) |
|
||||
| TMReversalShuf | 19.1% (10925/57063) | 17.8% (128450/720126) |
|
||||
|
||||
Per-run means: TMReversal 23.45/20.32; Shuf 22.51/20.01. Paired: TMReversal vs
|
||||
Shuf early 12/6 p=0.238; overall 8/10 p=0.815 → **no learning effect on hits.**
|
||||
|
||||
### bmPath, best config (margin=0.25, shrink=0.5), real, seeds=3
|
||||
|
||||
| variant | early | overall |
|
||||
|---|---|---|
|
||||
| Linear | 34.0% | 24.3% |
|
||||
| TMPattern (gated GF, round-1 best) | 28.3% (15815/55902) | 22.2% (159674/719742) |
|
||||
| TMReversal (gated GF + flip) | 28.4% (15944/56067) | 22.3% (160163/719761) |
|
||||
| TMReversalShuf | 28.7% (16146/56195) | 21.7% (155973/719841) |
|
||||
|
||||
Paired: TMPattern vs TMReversal early 11/7 p=0.481, overall 7/11 p=0.481 — the
|
||||
flip changes nothing. TMReversal vs Shuf overall 13/5 p=0.096 (not significant).
|
||||
|
||||
**Verdict: clean negative.** The reversal target as defined is too rare to learn
|
||||
(head below the majority baseline), and using it to flip the GF sign is neutral
|
||||
to slightly negative on hits. Do not pursue this label; if revisited, balance the
|
||||
positive class (per-tick reversal events, or predict the arrival turn direction
|
||||
rather than "a reversal happened").
|
||||
|
||||
## Round-2 overall verdict
|
||||
|
||||
* On **bmPath (the shipped metric): the TM is NOT competitive with Linear.**
|
||||
Base = Linear exactly; radial is a structural no-op; gated GF is significantly
|
||||
worse (28.3%/22.2% vs 34.0%/24.3%, p=0.0075); reversal does nothing. The linear
|
||||
lead is already the best aim DIRECTION on these surfers and every learned
|
||||
angular excursion loses.
|
||||
* On **bmPoint: the TM now BEATS Linear and the default Tsetlin gun.**
|
||||
`TMRadial` (radial head, `TM_RADIAL_RANGE=60`, `TM_RAD_MARGIN=0.25`, 5 classes,
|
||||
gated), 9.4%/5.8% vs Linear 7.2%/4.7% (overall 17/1, p=0.0001) and vs Tsetlin
|
||||
7.0%/4.8% (overall 15/3, p=0.0075), with its shuffled control at 7.0%/3.6%.
|
||||
This is the first configuration in the whole TM effort that beats both
|
||||
baselines with a control-validated margin.
|
||||
* **Learning vs controls:** radial head 48.8% vs 19.9% chance (bmPoint); GF head
|
||||
reproduces round 1 (48.5% vs ~20%); reversal head does not beat majority.
|
||||
|
||||
Best configuration if the arrival-time metric is what matters: **TMRadial**. Best
|
||||
configuration under the shipped bmPath: **do nothing — keep the Linear base**. The
|
||||
evidence says the next step under bmPath is not another bucket target but either a
|
||||
richer DIRECTION representation (segmentation / pattern matching, as KNN and
|
||||
DecayGF use) or a metric that exposes the radial degree of freedom.
|
||||
|
||||
Per-enemy specialisation / freshness (MEASURED, unchanged from round 1): each
|
||||
offline round is replayed with a FRESH gun instance and the gun calls
|
||||
`resetLearning` if the target id changes mid-battle. The offline fixtures are
|
||||
single-target, so the mid-battle reset never fires there; its effect is untested
|
||||
by these numbers. There is no persistence across battles.
|
||||
|
||||
## MEASURED vs INFERRED (round 2)
|
||||
|
||||
* MEASURED: every table, per-run mean, paired sign test, online accuracy, label
|
||||
base rate, and the exact `TMPatternBase`/`Linear` byte-for-byte identity.
|
||||
* MEASURED: the bmPath radial no-op (synthetic exact ties; real bmPath tiny
|
||||
clamp-induced loss).
|
||||
* INFERRED: that bmPath ignores radial distance because it flies a ray — read
|
||||
from `virtual_bullets.nim`, then confirmed by the synthetic tie.
|
||||
* INFERRED: that the radial win comes from surfers being NEARER than the base
|
||||
prediction (range-holding), supported by the asymmetric radial label histogram
|
||||
but not separately modelled.
|
||||
|
||||
Reference in New Issue
Block a user