Files
SirRoboGarage/common_libs/tests/measure_tm_miss_shrink.nim
T
SirStone fd2f7f608c GATE 2: STOP - the TM learns the side but the correction cannot buy hits
Offline four-arm pipeline: extracts the 49-bit draft spec + a 4-bit horizon
one-hot (53 bits) from the DrussGT fixtures, derives fact-based quadrant labels
(side vs the naive guess, magnitude vs the TRAIN median), trains the validated
`tm_diag/tm_core.nim` fresh PER ROUND with a fit / calibrate / eval within-round
split, applies each predicted quadrant's out-of-sample conditional-median offset
and measures the residual angular error. 50 clauses, N=64, s=3.0, 5 epochs
(verdict identical at 1 and 10). Estimated hit = |residual| < atan(18px/range).

POOLED (tr_drussgt_vs_modularbot + _shield, N~9.1-9.4k per horizon):
  h    arm        med|err|   p90|err|   hit%
  15   naive        4.08      14.24     39.3
  15   TM           4.81      13.53     29.0
  15   shuffled     4.45      14.58     34.3
  15   turn-only    4.13      14.10     37.1
  20   naive        6.81      21.60     28.2
  20   TM           7.61      20.85     19.2
  25   naive        9.71      29.13     20.9
  25   TM          10.33      28.63     13.1
  30   naive       12.62      36.12     16.4
  30   TM          13.09      35.17     10.5
Delta hits (pp): TM-naive **-10.3 / -9.0 / -7.8 / -5.9**;
**TM-turn -8.1 / -5.1 / -2.0 / -0.9** (h=15/20/25/30).
Median miss: TM is WORSE by +0.5..+0.8 deg. p90: marginally better by 0.5-1.3 deg.
So it pulls in the TAIL but not the typical miss.
Shuffled control: quadrant accuracy 22.9-24.8% ~= 25% chance, and dHit <= 0 at
every horizon -> NO LEAK. (The mild negative is the expected cost of applying a
noisy offset, not leakage.)

THE MECHANISM, and it is the important part: **the learning is REAL - the TM gets
the side right 59.6-61.0% vs 50% (quadrant 34-35% vs 25% chance; turn-only
53-56%) - but it does not translate into hits because the per-quadrant
conditional-median offsets (~4-16 deg for "big") are FAR LARGER than the body
half-angle (~2.6-3.4 deg at typical range).** The naive guess is essentially
UNBIASED (the median signed error is 0.00 deg at every horizon, from the headroom
study), so its error is centred on the target; shifting an already-centred
distribution away from zero DESTROYS near-target mass. That is why the turn-only
arm loses too (-2.2 to -5.8 pp): any constant shift hurts.

VERDICT AS IMPLEMENTED: **STOP. There is no case for building the new TM gun on
this evidence.**

NOT YET DISTINGUISHED, and worth one cheap test before the idea is declared dead:
whether this is a STRUCTURALLY dead application (no shift can help, because the
baseline is unbiased and the correction is coarser than the target) or merely a
MISCALIBRATED one (the offset was fitted to minimise the conditional MEDIAN of
the error, which is NOT the objective - hits are maximised by the shift that
maximises P(|error| < body), typically a SMALLER shift or none at all on a dense
near-zero distribution). That distinction decides whether the whole "TM predicts
the enemy's position" premise is dead or only this instantiation of it.

Caveats: DrussGT-only; the enemy's movement is a closed-loop response to our
CURRENT movement so the numbers are conditional on how we move now; offline
observation is perfect every tick while live we see the enemy only on scans, so
all of this is an UPPER BOUND. The bullet block was proxy-based (no gun heading or
power is recorded in the fixtures) - INFERRED, and stated as such rather than
silently dropped.
2026-09-22 22:06:01 +02:00

820 lines
30 KiB
Nim

## GATE 2 — does a learned Tsetlin model SHRINK THE MISS, and does that
## translate into hits? OFFLINE ONLY.
##
## Pipeline (all offline, fixtures READ-ONLY):
## 1. Extract the DRAFT 49-bit TM feature spec (walls / us / motion / bullets)
## from the committed DrussGT fixtures, plus a 4-bit one-hot horizon block
## (h in {15,20,25,30}) -> 53 raw bits.
## 2. Build FACT labels: for a sample at tick t and horizon h, look up where
## the enemy ACTUALLY was at t+h (never across a round boundary; the last h
## ticks of each round are dropped). Two binaries:
## (a) side: enemy LEFT / RIGHT of the naive straight-line guess,
## (b) magnitude: |angular error| bigger / smaller than the TRAIN median.
## Four quadrants: left-small / left-big / right-small / right-big.
## 3. Train a real Tsetlin Machine with the validated core at
## `common_libs/tm_diag/tm_core.nim` (one fresh model per round = the
## intended "fresh every round, overfit the current enemy" semantics).
## 4. Map each predicted quadrant to a representative signed angular offset
## (median signed error of the TRAINING samples in that quadrant), apply it
## to the naive aim, and measure the residual angular error.
##
## Four arms + floor:
## naive : straight-line guess (baseline)
## TM : trained model
## shuffled : same pipeline, labels randomised (pipeline-integrity control)
## turn-only : uses ONLY the enemy's current turn direction (critical arm)
## majority : constant majority-quadrant offset (floor)
##
## Metric: median / p90 residual |angular error| (deg) and the estimated hit
## fraction (|residual| < atan(18px / range)). The absolute hit fraction is
## OPTIMISTIC (perfect arrival knowledge every tick = bmPoint-style); only the
## DELTA before/after is meaningful.
##
## Protocol: within-round split, train on the EARLY portion, evaluate on the
## LATER portion. Focus horizons h = 15,20,25 (+30).
##
## Run: nim c -r -d:release --path:common_libs \
## common_libs/tests/measure_tm_miss_shrink.nim
## [--fixtures=a,b] [--epochs=N] [--trainfrac=F] [--clauses=N]
import std/[json, os, strformat, strutils, math, algorithm, tables]
import tm_diag/tm_core
# ── configuration ────────────────────────────────────────────────────────────
const repoRoot* = currentSourcePath().parentDir.parentDir.parentDir
const fixturesDir* = repoRoot / "tools" / "fixtures"
const metaDir* = fixturesDir / "drussgt_meta"
const HORIZONS = [15, 20, 25, 30]
const NH = 4
const N_BASE = 49 # draftTMSpec() bit count
const N_BITS = N_BASE + 4 # + 4-bit horizon one-hot
const N_CLASSES = 4 # left-small, left-big, right-small, right-big
const MIN_I = 12 # need 10 ticks of history for the motion features
const BOT_RADIUS = 18.0
var
cfgClauses = 50
cfgStates = 64
cfgS = 3.0
cfgEpochs = 5
cfgTrainFrac = 0.70
const PRIMARY = ["tr_drussgt_vs_modularbot.jsonl",
"tr_drussgt_vs_modularbot_shield.jsonl"]
# ── small helpers ────────────────────────────────────────────────────────────
proc wrap180(a: float): float {.inline.} =
var r = a
while r > 180.0: r -= 360.0
while r <= -180.0: r += 360.0
r
proc signf(x: float): int {.inline.} =
if x > 1e-9: 1 elif x < -1e-9: -1 else: 0
proc jf(d: JsonNode, k: string): float =
let n = d[k]
case n.kind
of JFloat: n.getFloat
of JInt: float(n.getInt)
else: parseFloat(n.getStr)
# ── data model ───────────────────────────────────────────────────────────────
type
Tick* = object
tick*: int
ex*, ey*, eh*, es*, ee*: float
sx*, sy*, sh*, ss*, se*: float
Rnd* = object
roundNo*: int
st*: seq[Tick]
BulletSeries* = object
## Per-tick proxy for OUR in-flight bullets (INFERRED from self-energy
## drops; the fixture records no gun heading/power).
tta*: seq[int] ## ticks until the nearest in-flight bullet arrives, -1 none
lat*: seq[float] ## lateral offset of the enemy from the fired path (px)
Arm = enum aNaive, aTM, aShuf, aTurn, aMaj
Hist = object
errs: array[Arm, array[NH, seq[float]]]
hits: array[Arm, array[NH, int]]
n: array[NH, int]
# diagnostics
tmQCor: array[NH, int] ## TM predicted the true quadrant
tmQTot: array[NH, int]
tmHitCor: array[NH, int] ## TM predicted the true sign
tmHitTot: array[NH, int]
turnSignCor: array[NH, int] ## sign(turn) == sign(err)
turnSignTot: array[NH, int]
shufQCor: array[NH, int] ## shuffled-model predicted the true quadrant
shufQTot: array[NH, int]
predCounts: array[NH, array[N_CLASSES, int]]
trueCounts: array[NH, array[N_CLASSES, int]]
# ── fixture loading ──────────────────────────────────────────────────────────
proc loadTicks(path: string): seq[Tick] =
for line in lines(path):
let ln = line.strip()
if ln.len == 0: continue
let d = parseJson(ln)
if not d.hasKey("tick"): continue
result.add Tick(tick: d["tick"].getInt,
ex: jf(d, "ex"), ey: jf(d, "ey"), eh: jf(d, "eh"),
es: jf(d, "es"), ee: jf(d, "ee"),
sx: jf(d, "sx"), sy: jf(d, "sy"), sh: jf(d, "sh"),
ss: jf(d, "ss"), se: jf(d, "se"))
proc loadRounds(path: string, ticks: seq[Tick]): seq[Rnd] =
let rp = metaDir / (extractFilename(path) & ".rounds.json")
var spans: seq[(int, int)]
if fileExists(rp):
let j = parseFile(rp)
for r in j["rounds"]:
spans.add (r["startTick"].getInt, r["count"].getInt)
elif ticks.len > 0:
spans.add (ticks[0].tick, ticks.len)
var idxByTick = initTable[int, int]()
for i, t in ticks: idxByTick[t.tick] = i
for sp in spans:
let (s0, c) = sp
if not idxByTick.hasKey(s0): continue
let i0 = idxByTick[s0]
var st: seq[Tick]
for k in 0..<c:
if i0 + k < ticks.len: st.add ticks[i0 + k]
if st.len > 0:
result.add Rnd(roundNo: result.len + 1, st: st)
# ── bullet proxy (INFERRED) ──────────────────────────────────────────────────
proc buildBulletSeries(r: Rnd): BulletSeries =
let L = r.st.len
result.tta = newSeq[int](L)
for i in 0..<L: result.tta[i] = -1
result.lat = newSeq[float](L)
for t0 in 1..<L:
let drop = r.st[t0 - 1].se - r.st[t0].se
if drop <= 0.05 or drop > 3.1: continue # damage, not a fire
var power = drop
if power < 0.1: power = 0.1
if power > 3.0: power = 3.0
let speed = 20.0 - 3.0 * power
let rng = hypot(r.st[t0].ex - r.st[t0].sx, r.st[t0].ey - r.st[t0].sy)
let flight = int(ceil(rng / speed))
let dx = r.st[t0].ex - r.st[t0].sx
let dy = r.st[t0].ey - r.st[t0].sy
let nrm = max(1e-6, hypot(dx, dy))
let ux = dx / nrm
let uy = dy / nrm
for k in 0..flight:
let t = t0 + k
if t >= L: break
let ta = t0 + flight - t
if result.tta[t] < 0 or ta < result.tta[t]:
result.tta[t] = ta
let vx = r.st[t].ex - r.st[t0].sx
let vy = r.st[t].ey - r.st[t0].sy
result.lat[t] = ux * vy - uy * vx
# ── feature extraction: the 49 draft bits (causal, at tick i) ────────────────
#
# Block layout (mirrors draftTMSpec()):
# 0..3 dist-to-nearest-wall (4 one-hot)
# 4..7 which-wall-nearest (4 one-hot)
# 8..13 dist-from-us (6 one-hot)
# 14..16 enemy-heading-vs-line-to-us (3 one-hot)
# 17..19 turn-direction t, t-1, t-2 (3 boolean "was turning left")
# 20..24 ticks-since-reversal (5 one-hot)
# 25..27 turn-consistency-10 (3 one-hot)
# 28..30 distance-moved-10 (3 one-hot)
# 31..33 speed-trend-10 (3 one-hot)
# 34..36 turn-rate-change-5 (3 one-hot)
# 37..41 time-until-bullet (5 one-hot)
# 42..48 bullet-lateral-offset (7 one-hot)
proc buildBase(r: Rnd, bi: BulletSeries, i: int, sinceRev: seq[int]): array[N_BASE, int] =
let s = r.st
let cur = s[i]
# walls
let dL = cur.ex
let dR = 800.0 - cur.ex
let dT = 600.0 - cur.ey
let dBottom = cur.ey
let dmin = min(min(dL, dR), min(dT, dBottom))
var wallBin = 3
if dmin < 50.0: wallBin = 0
elif dmin < 100.0: wallBin = 1
elif dmin < 200.0: wallBin = 2
result[wallBin] = 1
var wb = 0
let walls = [dL, dR, dT, dBottom]
for w in 1..3:
if walls[w] < walls[wb]: wb = w
result[4 + wb] = 1
# us
let rng = hypot(cur.ex - cur.sx, cur.ey - cur.sy)
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
result[8 + ub] = 1
let lane = arctan2(cur.sy - cur.ey, cur.sx - cur.ex)
let hdg = cur.eh * PI / 180.0
let perp = abs(sin(hdg - lane))
var hb = 1
if perp < 0.5: hb = 2
elif perp > 0.866: hb = 0
result[14 + hb] = 1
# motion: turn direction
for k in 0..2:
if i - 1 - k >= 0:
let d = wrap180(s[i - k].eh - s[i - 1 - k].eh)
if d > 1e-6: result[17 + k] = 1
# ticks since reversal
var rb = 4
let sr = sinceRev[i]
if sr < 5: rb = 0
elif sr < 10: rb = 1
elif sr < 20: rb = 2
elif sr < 40: rb = 3
result[20 + rb] = 1
# turn consistency over last 10
var pos = 0
var neg = 0
for k in 0..9:
if i - 1 - k < 0: break
let d = wrap180(s[i - k].eh - s[i - 1 - k].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
result[25 + cb] = 1
# distance moved over 10
let j0 = max(0, i - 10)
let dm = hypot(cur.ex - s[j0].ex, cur.ey - s[j0].ey)
var mb = 1
if dm < 20.0: mb = 0
elif dm > 50.0: mb = 2
result[28 + mb] = 1
# speed trend over 10
let st10 = abs(s[max(0, i - 10)].es)
let spdDiff = abs(cur.es) - st10
var sb = 1
if spdDiff < -0.5: sb = 0
elif spdDiff > 0.5: sb = 2
result[31 + sb] = 1
# turn-rate change: last 5 deltas vs previous 5
var r1 = 0.0
var n1 = 0
for k in 0..4:
if i - 1 - k >= 0:
r1 += abs(wrap180(s[i - k].eh - s[i - 1 - k].eh)); inc n1
var r2 = 0.0
var n2 = 0
for k in 5..9:
if i - 1 - k >= 0:
r2 += abs(wrap180(s[i - k].eh - s[i - 1 - k].eh)); inc n2
let m1 = if n1 > 0: r1 / float(n1) else: 0.0
let m2 = if n2 > 0: r2 / float(n2) else: 0.0
let dtr = m1 - m2
var tb = 1
if dtr < -0.3: tb = 0
elif dtr > 0.3: tb = 2
result[34 + tb] = 1
# bullets
let tta = bi.tta[i]
var b1 = 0
if tta >= 0:
if tta < 5: b1 = 1
elif tta < 10: b1 = 2
elif tta < 20: b1 = 3
else: b1 = 4
result[37 + b1] = 1
let lat = bi.lat[i]
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
result[42 + lb] = 1
proc toLits(base: array[N_BASE, int], h: int): seq[uint8] =
var raw: array[N_BITS, int]
for i in 0..<N_BASE: raw[i] = base[i]
for k, hh in HORIZONS:
if hh == h: raw[N_BASE + k] = 1
result = newSeq[uint8](2 * N_BITS)
for i in 0..<N_BITS:
let v = uint8(if raw[i] != 0: 1 else: 0)
result[i] = v
result[i + N_BITS] = 1'u8 - v
proc sinceRevSeries(r: Rnd): seq[int] =
let L = r.st.len
result = newSeq[int](L)
var lastFlip = -1
var prevSg = 0
for i in 0..<L:
let sg = signf(r.st[i].es)
if sg != 0:
if prevSg != 0 and sg != prevSg: lastFlip = i
prevSg = sg
result[i] = if lastFlip < 0: i + 1000 else: i - lastFlip
# ── TM training (reuses tm_core; no per-sample allocation) ───────────────────
proc trainEpoch(m: var TmMachine, lits: seq[seq[uint8]], labels: seq[int],
caches: var seq[seq[uint8]], votes: var seq[float],
order: seq[int]) =
for s in order:
for c in 0..<m.nClasses:
votes[c] = tmForward(m, m.teams[c], lits[s], caches[c])
for c in 0..<m.nClasses:
let d = if c == labels[s]: 1.0 else: -1.0
tmLearnDir(m, m.teams[c], lits[s], caches[c], votes[c], d)
proc trainMachine(m: var TmMachine, lits: seq[seq[uint8]], labels: seq[int],
epochs: int, seed: uint64) =
m.resetMachine(seed)
var caches = newSeq[seq[uint8]](m.nClasses)
for c in 0..<m.nClasses:
caches[c] = newSeq[uint8](m.nClauses)
var votes = newSeq[float](m.nClasses)
var order = newSeq[int](lits.len)
for i in 0..<lits.len: order[i] = i
for _ in 0..<epochs:
for i in countdown(order.len - 1, 1):
let j = int(m.rng.nextU64() mod uint64(i + 1))
swap(order[i], order[j])
trainEpoch(m, lits, labels, caches, votes, order)
proc predictWith(m: TmMachine, lits: openArray[uint8],
cache: var seq[uint8]): int =
var bestV = -Inf
for c in 0..<m.nClasses:
let v = tmForward(m, m.teams[c], lits, cache)
if v > bestV:
bestV = v
result = c
# ── statistics ───────────────────────────────────────────────────────────────
proc medOf(v: seq[float]): float =
if v.len == 0: return NaN
var s = v
s.sort()
s[s.len div 2]
proc qOf(v: seq[float], q: float): float =
if v.len == 0: return NaN
var s = v
s.sort()
s[min(s.len - 1, max(0, int(q * float(s.len - 1) + 0.5)))]
proc medOfInts(v: seq[int]): float =
if v.len == 0: return NaN
var s = v
s.sort()
float(s[s.len div 2])
proc mergeHist(dst: var Hist, src: Hist) =
for a in Arm:
for hi in 0..<NH:
dst.errs[a][hi].add src.errs[a][hi]
dst.hits[a][hi] += src.hits[a][hi]
for hi in 0..<NH:
dst.n[hi] += src.n[hi]
dst.tmQCor[hi] += src.tmQCor[hi]
dst.tmQTot[hi] += src.tmQTot[hi]
dst.tmHitCor[hi] += src.tmHitCor[hi]
dst.tmHitTot[hi] += src.tmHitTot[hi]
dst.turnSignCor[hi] += src.turnSignCor[hi]
dst.turnSignTot[hi] += src.turnSignTot[hi]
dst.shufQCor[hi] += src.shufQCor[hi]
dst.shufQTot[hi] += src.shufQTot[hi]
for c in 0..<N_CLASSES:
dst.predCounts[hi][c] += src.predCounts[hi][c]
dst.trueCounts[hi][c] += src.trueCounts[hi][c]
const ArmName: array[Arm, string] =
["naive", "TM", "shuffled", "turn-only", "majority"]
# ── per-round pipeline ───────────────────────────────────────────────────────
proc runRound(r: Rnd, bi: BulletSeries, tmCache: var seq[uint8],
emit: bool): Hist =
let s = r.st
let L = s.len
var base = newSeq[array[N_BASE, int]](L)
block:
let sr = sinceRevSeries(r)
for i in 0..<L: base[i] = buildBase(r, bi, i, sr)
# Three-way WITHIN-ROUND split: fit | calibrate | eval. The model is never
# trained on the ticks it is evaluated on, and the quadrant->offset mapping is
# calibrated OUT-OF-SAMPLE so an overfit in-sample median cannot leak.
let fitEnd = max(MIN_I + 1, int(0.50 * float(L)))
let calEnd = max(fitEnd + 1, int(cfgTrainFrac * float(L)))
# local collector: samples with i in [lo,stop) and the label j = i+h < stop
# (so NOTHING here reads past `stop` — no cross-region label leakage).
proc collect(lo, stop, hidx: int):
tuple[hs: seq[int], es: seq[float], ls: seq[seq[uint8]], ts: seq[float]] =
let h = HORIZONS[hidx]
for i in lo..<stop:
let j = i + h
if j >= stop: continue
let cur = s[i]
let gx = cur.ex + cur.es * cos(cur.eh * PI / 180.0) * float(h)
let gy = cur.ey + cur.es * sin(cur.eh * PI / 180.0) * float(h)
let ba = arctan2(s[j].ey - cur.sy, s[j].ex - cur.sx)
let bg = arctan2(gy - cur.sy, gx - cur.sx)
let err = radToDeg(arctan2(sin(ba - bg), cos(ba - bg)))
result.hs.add hidx
result.es.add err
result.ls.add toLits(base[i], h)
result.ts.add(if i - 1 >= 0: wrap180(cur.eh - s[i - 1].eh) else: 0.0)
var trH: seq[int]
var trErr: seq[float]
var trLits: seq[seq[uint8]]
var trTurn: seq[float]
var calH: seq[int]
var calErr: seq[float]
var calLits: seq[seq[uint8]]
var calTurn: seq[float]
var evH: seq[int]
var evIdx: seq[int]
var evErr: seq[float]
var evRange: seq[float]
var evTurn: seq[float]
for hi in 0..<NH:
let h = HORIZONS[hi]
let a = collect(MIN_I, fitEnd, hi)
trH.add a.hs; trErr.add a.es; trLits.add a.ls; trTurn.add a.ts
let b = collect(fitEnd, calEnd, hi)
calH.add b.hs; calErr.add b.es; calLits.add b.ls; calTurn.add b.ts
for i in calEnd..<L:
let j = i + h
if j >= L: continue
let cur = s[i]
let gx = cur.ex + cur.es * cos(cur.eh * PI / 180.0) * float(h)
let gy = cur.ey + cur.es * sin(cur.eh * PI / 180.0) * float(h)
let ba = arctan2(s[j].ey - cur.sy, s[j].ex - cur.sx)
let bg = arctan2(gy - cur.sy, gx - cur.sx)
let err = radToDeg(arctan2(sin(ba - bg), cos(ba - bg)))
evH.add hi
evIdx.add i
evErr.add err
evRange.add hypot(s[j].ex - cur.sx, s[j].ey - cur.sy)
evTurn.add(if i - 1 >= 0: wrap180(cur.eh - s[i - 1].eh) else: 0.0)
# ── labels (true quadrants); magnitude threshold = FIT median |err| ──
var medAbs: array[NH, float]
for hi in 0..<NH:
var absE: seq[float]
for k in 0..<trErr.len:
if trH[k] == hi: absE.add abs(trErr[k])
medAbs[hi] = medOf(absE)
# assemble FIT training set (drop exactly-zero error ticks — no side)
var tlits: seq[seq[uint8]]
var terr: seq[float]
var th: seq[int]
var tturn: seq[float]
var tlab: seq[int]
for k in 0..<trErr.len:
if abs(trErr[k]) < 1e-9: continue
let hi = trH[k]
let cls = ((if trErr[k] > 0.0: 0 else: 2) +
(if abs(trErr[k]) > medAbs[hi]: 1 else: 0))
tlits.add trLits[k]
terr.add trErr[k]
th.add hi
tturn.add trTurn[k]
tlab.add cls
proc sideMedian(errs: seq[float]): array[2, float] =
var l, r: seq[float]
for e in errs:
if e > 1e-9: l.add e
elif e < -1e-9: r.add e
result[0] = medOf(l)
result[1] = medOf(r)
# ── train TM (fresh per round) ──
var tm = newMachine(N_BITS, N_CLASSES, cfgClauses, cfgStates, cfgS,
seed = 12345'u64 + uint64(r.roundNo))
trainMachine(tm, tlits, tlab, cfgEpochs, seed = 999'u64 + uint64(r.roundNo))
# Calibrate OUT-OF-SAMPLE on the calibration slice:
# offset[c] = median(err | model predicts c) on calibration ticks.
# This is the L1-optimal correction for a model-dependent partition.
var repOff: array[NH, array[N_CLASSES, float]]
block:
var clsE: array[NH, array[N_CLASSES, seq[float]]]
var cache = newSeq[uint8](tm.nClauses)
for idx in 0..<calErr.len:
let pc = predictWith(tm, calLits[idx], cache)
clsE[calH[idx]][pc].add calErr[idx]
for hi in 0..<NH:
let sm2 = sideMedian((block:
var e: seq[float]
for idx in 0..<calErr.len:
if calH[idx] == hi: e.add calErr[idx]
e))
for c in 0..<N_CLASSES:
repOff[hi][c] = if clsE[hi][c].len > 0: medOf(clsE[hi][c])
else: (if c < 2: sm2[0] else: sm2[1])
# constant floor: the majority true quadrant (from the fit portion)
var majClass: array[NH, int]
block:
var cnt: array[NH, array[N_CLASSES, int]]
for idx in 0..<tlits.len: inc cnt[th[idx]][tlab[idx]]
for hi in 0..<NH:
var best = 0
for c in 1..<N_CLASSES:
if cnt[hi][c] > cnt[hi][best]: best = c
majClass[hi] = best
# ── shuffled-label control ──
# Permute the fit labels within each horizon, retrain, and calibrate the
# SAME way (offset = median err | shuffled model predicts c).
var slab = tlab
block:
var rng = seedRng(4242'u64 + uint64(r.roundNo))
for hi in 0..<NH:
var pos: seq[int]
for idx in 0..<tlits.len:
if th[idx] == hi: pos.add idx
var perm = pos
for i in countdown(perm.len - 1, 1):
let j = int(rng.nextU64() mod uint64(i + 1))
swap(perm[i], perm[j])
for i in 0..<pos.len: slab[pos[i]] = tlab[perm[i]]
var sm = newMachine(N_BITS, N_CLASSES, cfgClauses, cfgStates, cfgS,
seed = 777'u64 + uint64(r.roundNo))
trainMachine(sm, tlits, slab, cfgEpochs, seed = 555'u64 + uint64(r.roundNo))
var shufOff: array[NH, array[N_CLASSES, float]]
block:
var clsE: array[NH, array[N_CLASSES, seq[float]]]
var cache = newSeq[uint8](sm.nClauses)
for idx in 0..<calErr.len:
let pc = predictWith(sm, calLits[idx], cache)
clsE[calH[idx]][pc].add calErr[idx]
for hi in 0..<NH:
let sm2 = sideMedian((block:
var e: seq[float]
for idx in 0..<calErr.len:
if calH[idx] == hi: e.add calErr[idx]
e))
for c in 0..<N_CLASSES:
shufOff[hi][c] = if clsE[hi][c].len > 0: medOf(clsE[hi][c])
else: (if c < 2: sm2[0] else: sm2[1])
# ── turn-direction-only rule ──
# LEARN the direction of the association per horizon on the CALIBRATION
# slice (the headroom study shows it flips sign with horizon), then
# calibrate the offset on the rule's own predictions.
var turnPred: array[NH, array[2, int]] # [hi][turn>0 ? 0 : 1] -> side
var turnOff: array[NH, array[2, float]]
block:
for hi in 0..<NH:
var posL, posR, negL, negR: int
for idx in 0..<calErr.len:
if calH[idx] != hi: continue
if calTurn[idx] > 1e-6:
if calErr[idx] > 0.0: inc posL else: inc posR
elif calTurn[idx] < -1e-6:
if calErr[idx] > 0.0: inc negL else: inc negR
turnPred[hi][0] = if posL >= posR: 0 else: 1
turnPred[hi][1] = if negL >= negR: 0 else: 1
var offE: array[NH, array[2, seq[float]]]
for idx in 0..<calErr.len:
let hi = calH[idx]
let ps = if calTurn[idx] > 1e-6: turnPred[hi][0]
elif calTurn[idx] < -1e-6: turnPred[hi][1]
else: majClass[hi] div 2
offE[hi][ps].add calErr[idx]
for hi in 0..<NH:
let sm2 = sideMedian((block:
var e: seq[float]
for idx in 0..<calErr.len:
if calH[idx] == hi: e.add calErr[idx]
e))
for sd in 0..1:
turnOff[hi][sd] = if offE[hi][sd].len > 0: medOf(offE[hi][sd])
else: sm2[sd]
# ── evaluate ──
var sc = newSeq[uint8](sm.nClauses)
var tc = newSeq[uint8](tm.nClauses)
for k in 0..<evErr.len:
let hi = evH[k]
let i = evIdx[k]
let err = evErr[k]
let rng = evRange[k]
result.n[hi] += 1
let half = radToDeg(arctan2(BOT_RADIUS, max(1e-6, rng)))
let lits = toLits(base[i], HORIZONS[hi])
let predTM = predictWith(tm, lits, tc)
let predShuf = predictWith(sm, lits, sc)
let predSide =
if evTurn[k] > 1e-6: turnPred[hi][0]
elif evTurn[k] < -1e-6: turnPred[hi][1]
else: majClass[hi] div 2
let offs: array[Arm, float] = [
aNaive: 0.0,
aTM: repOff[hi][predTM],
aShuf: shufOff[hi][predShuf],
aTurn: turnOff[hi][predSide],
aMaj: repOff[hi][majClass[hi]]]
# diagnostics on the SAME eval tick
let trueCls = ((if err > 0.0: 0 else: 2) +
(if abs(err) > medAbs[hi]: 1 else: 0))
inc result.trueCounts[hi][trueCls]
inc result.predCounts[hi][predTM]
inc result.tmQTot[hi]
if predTM == trueCls: inc result.tmQCor[hi]
inc result.shufQTot[hi]
if predShuf == trueCls: inc result.shufQCor[hi]
inc result.tmHitTot[hi]
if (predTM <= 1) == (trueCls <= 1): inc result.tmHitCor[hi]
inc result.turnSignTot[hi]
if (predSide == 0) == (err > 0.0): inc result.turnSignCor[hi]
for a in Arm:
let res = err - offs[a]
result.errs[a][hi].add abs(res)
if abs(res) < half: inc result.hits[a][hi]
# ── per-round table ──
if emit:
echo &" round {r.roundNo:>2} (L={L:>5}) " &
"nEval/h = " & $[result.n[0], result.n[1], result.n[2], result.n[3]]
echo " h arm med p90 hit%"
for hi in 0..<NH:
for a in Arm:
let m = medOf(result.errs[a][hi])
let p = qOf(result.errs[a][hi], 0.9)
let hit = if result.n[hi] > 0: 100.0 * result.hits[a][hi].float / result.n[hi].float else: NaN
echo &" {HORIZONS[hi]:>3} {ArmName[a]:<9} {m:>6.2f} {p:>6.2f} {hit:>6.1f}"
# ── table printing ───────────────────────────────────────────────────────────
proc printPooled(h: Hist, title: string) =
echo "\n" & title
echo " h arm N med|err| p90|err| hit% miss%"
for hi in 0..<NH:
let n = h.n[hi]
for a in Arm:
let m = medOf(h.errs[a][hi])
let p = qOf(h.errs[a][hi], 0.9)
let hit = if n > 0: 100.0 * h.hits[a][hi].float / n.float else: NaN
echo &"{HORIZONS[hi]:>3} {ArmName[a]:<9} {n:>7} {m:>9.2f} {p:>9.2f} {hit:>7.1f} {100.0-hit:>7.1f}"
proc printDeltas(h: Hist, title: string) =
echo "\n" & title
echo " h d(med) TM-naive d(p90) TM-naive d(hit) TM-naive d(hit) TM-turn d(hit) shuf-naive"
for hi in 0..<NH:
let n = h.n[hi].float
if n <= 0: continue
let dm = medOf(h.errs[aTM][hi]) - medOf(h.errs[aNaive][hi])
let dp = qOf(h.errs[aTM][hi], 0.9) - qOf(h.errs[aNaive][hi], 0.9)
let hitNaive = 100.0 * h.hits[aNaive][hi].float / n
let hitTM = 100.0 * h.hits[aTM][hi].float / n
let hitTurn = 100.0 * h.hits[aTurn][hi].float / n
let hitShuf = 100.0 * h.hits[aShuf][hi].float / n
echo &"{HORIZONS[hi]:>3} {dm:>+16.2f} {dp:>+16.2f} " &
&"{hitTM-hitNaive:>+16.1f} {hitTM-hitTurn:>+15.1f} {hitShuf-hitNaive:>+18.1f}"
proc printDiagnostics(h: Hist, title: string) =
## Is the learner actually learning? (quadrant / side accuracy vs chance)
echo "\n" & title
echo " h N TMquad% shufquad% TMside% turnSide% majQuad% pred[Ls Lb Rs Rb] / true[Ls Lb Rs Rb]"
for hi in 0..<NH:
let n = h.n[hi]
if n == 0: continue
let tmq = 100.0 * float(h.tmQCor[hi]) / float(n)
let shufq = 100.0 * float(h.shufQCor[hi]) / float(max(1, h.shufQTot[hi]))
let tms = 100.0 * float(h.tmHitCor[hi]) / float(n)
let ts = 100.0 * float(h.turnSignCor[hi]) / float(max(1, h.turnSignTot[hi]))
var mx = 0
for c in 0..<N_CLASSES: mx = max(mx, h.trueCounts[hi][c])
let maj = 100.0 * float(mx) / float(n)
var pcs = ""
var tcs = ""
for c in 0..<N_CLASSES:
pcs.add $h.predCounts[hi][c] & " "
tcs.add $h.trueCounts[hi][c] & " "
echo &"{HORIZONS[hi]:>3} {n:>6} {tmq:>8.1f} {shufq:>10.1f} {tms:>8.1f} {ts:>10.1f} {maj:>9.1f} [{pcs}] / [{tcs}]"
# ── main ─────────────────────────────────────────────────────────────────────
proc main() =
var names = @PRIMARY
for i in 1..paramCount():
let a = paramStr(i)
if a.startsWith("--fixtures="): names = a[11..^1].split(',')
elif a.startsWith("--epochs="): cfgEpochs = parseInt(a[9..^1])
elif a.startsWith("--trainfrac="): cfgTrainFrac = parseFloat(a[12..^1])
elif a.startsWith("--clauses="): cfgClauses = parseInt(a[10..^1])
echo "=" .repeat(90)
echo "GATE 2 - does a learned Tsetlin model SHRINK THE MISS (and turn it into hits)?"
echo "=" .repeat(90)
echo &"fixtures : {names.join(\", \")}"
echo &"bits : {N_BITS} ({N_BASE} draft + 4 horizon one-hot)"
echo &"TM : {cfgClauses} clauses, {cfgStates} states, s={cfgS}, " &
&"{cfgEpochs} epochs, fresh per round"
echo &"split : within-round, train first {cfgTrainFrac*100:.0f}%, eval later portion"
echo &"label : side=sign(err); mag=|err| > train median (per round,h)"
echo &"estimated hit : |residual| < atan(18px / range) -- ABSOLUTE IS OPTIMISTIC"
echo &"bullet block : INFERRED from self-energy drops (no gun heading recorded)"
echo "=" .repeat(90)
var pooled = Hist()
var pooledByFix = initTable[string, Hist]()
var roundHists = initTable[string, seq[Hist]]()
for name in names:
let path = if name.endsWith(".jsonl"): fixturesDir / name
else: fixturesDir / (name & ".jsonl")
if not fileExists(path):
echo &"# SKIP missing fixture {path}"
continue
let ticks = loadTicks(path)
let rounds = loadRounds(path, ticks)
echo &"\n## FIXTURE {name}: {rounds.len} rounds, {ticks.len} ticks"
var fhist = Hist()
var rlist: seq[Hist]
for r in rounds:
let bi = buildBulletSeries(r)
var cache = newSeq[uint8](cfgClauses)
let rh = runRound(r, bi, cache, emit = true)
mergeHist(fhist, rh)
mergeHist(pooled, rh)
rlist.add rh
echo ""
pooledByFix[name] = fhist
roundHists[name] = rlist
printPooled(fhist, &"## PER-FIXTURE pooled ({name})")
printDiagnostics(fhist, &"## PER-FIXTURE learnability ({name})")
echo "\n" & "=".repeat(90)
printPooled(pooled, "## POOLED PRIMARY (tr_drussgt_vs_modularbot*): four-arm + floor table")
printDeltas(pooled, "## DELTAS (percentage points of estimated hit fraction)")
printDiagnostics(pooled, "## POOLED learnability diagnostics (is the learner learning?)")
echo "\n" & "=" .repeat(90)
echo "## SHUFFLED-CONTROL INTEGRITY (per round, should stay ~flat)"
echo "fixture round h TM-naive shuf-naive turn-naive"
for name in names:
if not roundHists.hasKey(name): continue
for rn, rh in roundHists[name]:
for hi in 0..<NH:
let n = rh.n[hi].float
if n <= 0: continue
let hitNaive = 100.0 * rh.hits[aNaive][hi].float / n
let hitTM = 100.0 * rh.hits[aTM][hi].float / n
let hitShuf = 100.0 * rh.hits[aShuf][hi].float / n
let hitTurn = 100.0 * rh.hits[aTurn][hi].float / n
echo &"{name:<34} {rn+1:>5} {HORIZONS[hi]:>3} {hitTM-hitNaive:>+9.1f} " &
&"{hitShuf-hitNaive:>+11.1f} {hitTurn-hitNaive:>+11.1f}"
when isMainModule:
main()