From 1ea72c7f14cf76da0dc412288283b212a7369d28 Mon Sep 17 00:00:00 2001 From: Davide Cappellini Date: Tue, 22 Sep 2026 01:27:59 +0200 Subject: [PATCH] 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. --- common_libs/guns/tm_pattern.nim | 193 +++++++++++++++--- common_libs/tests/sweep_tm_pattern.nim | 95 ++++++++- common_libs/tests/tm_pattern_sweep_results.md | 185 +++++++++++++++++ 3 files changed, 435 insertions(+), 38 deletions(-) diff --git a/common_libs/guns/tm_pattern.nim b/common_libs/guns/tm_pattern.nim index d0b6ac9..64bb008 100644 --- a/common_libs/guns/tm_pattern.nim +++ b/common_libs/guns/tm_pattern.nim @@ -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.. 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.. 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.. 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..= 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_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..= 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..= 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.