## MEASUREMENT: does the SHIPPED `Pattern` gun (guns/pattern_matcher.nim, rack ## gun id 5) overshoot radially the way the LINEAR base was shown to? ## ## Background (commit 9cd6e9b): the base `forecastLinear` systematically ## OVERSHOOTS — the enemy is NEARER than the constant-velocity prediction in ## 63-81% of fired bullets and farther in only 4-14%, consistently across all ## six DrussGT captures. A constant short offset (aim distance x0.95 or a fixed ## -20 px) matched or beat the learned radial Tsetlin head on bmPoint. ## ## The gun that actually SHIPS is `Pattern`, not `Linear`. This tool measures the ## SAME signed radial error for Pattern: for every fired virtual bullet, ## ## radialErr = |actual enemy pos at the base arrival tick - fire pos| ## - |Pattern's predicted aim point - fire pos| ## ## (negative = the enemy was NEARER than Pattern predicted = Pattern OVERSHOT). ## The arrival tick mirrors the virtual-bullet resolver exactly: ## arrOff = max(0, ceil(aimDist / bulletSpeed) - 1), arrivalTick = fireTick + arrOff ## because the tracker advances travelDist by bulletSpeed on the spawn tick and ## resolves when travelDist >= fireDist. ## ## It also reports the raw `forecastLinear` error on the identical states, so a ## reader can see whether Pattern is better or worse than the base on the same ## measurement, and pools per power bin as well as overall. ## ## READ-ONLY: loads tools/fixtures/*drussgt*.jsonl; writes nothing. ## ## Run: ## nim c -r -d:release --path:common_libs \ ## common_libs/tests/measure_pattern_radial.nim ## Flags: --set=core|all (default core = the six captures of the 9cd6e9b table) import std/[os, strformat, strutils, json, tables, math, algorithm] import gun_harness/offline_range import gun_harness/gun_interface import gun_harness/virtual_bullets as vb import guns/pattern_matcher import guns/lead_forecast const repoRoot = currentSourcePath().parentDir.parentDir.parentDir fixturesDir = repoRoot / "tools" / "fixtures" type RoundSpan = tuple[start, count: int] Agg = object n: int sum, asum: float errs: seq[float] near0, far0: int ## err < 0 / err > 0 nearBot, farBot: int ## err < -BotRadius / err > +BotRadius proc loadRounds(path: string): seq[RoundSpan] = let dir = path.parentDir let base = path.extractFilename var side = dir / "drussgt_meta" / (base & ".rounds.json") if not fileExists(side): side = dir / (base & ".rounds.json") if not fileExists(side): return @[] let node = parseJson(readFile(side)) if not node.hasKey("rounds"): return @[] for r in node["rounds"]: result.add (r["startTick"].getInt(), r["count"].getInt()) proc addErr(a: var Agg, e: float) = inc a.n a.sum += e a.asum += abs(e) a.errs.add e if e < 0.0: inc a.near0 elif e > 0.0: inc a.far0 if e < -BotRadius: inc a.nearBot elif e > BotRadius: inc a.farBot proc median(a: Agg): float = if a.errs.len == 0: return 0.0 var s = a.errs s.sort() let m = s.len div 2 if s.len mod 2 == 1: s[m] else: 0.5 * (s[m - 1] + s[m]) proc percentile(a: Agg, p: float): float = if a.errs.len == 0: return 0.0 var s = a.errs s.sort() let idx = clamp(int(round(p / 100.0 * float(s.len - 1))), 0, s.len - 1) s[idx] proc reportScope(scope: string, agg: var Agg) = if agg.n == 0: echo &"{scope},0,n/a,n/a,n/a,n/a,n/a,n/a" return let p10 = agg.percentile(10) let p50 = agg.median let p90 = agg.percentile(90) echo &"{scope},{agg.n},{agg.sum/float(agg.n):.2f},{p50:.2f},{agg.asum/float(agg.n):.2f}," & &"{100.0*float(agg.near0)/float(agg.n):.1f},{100.0*float(agg.far0)/float(agg.n):.1f}," & &"{100.0*float(agg.nearBot)/float(agg.n):.1f},{100.0*float(agg.farBot)/float(agg.n):.1f}," & &"{p10:.1f},{p90:.1f}" proc coreSet(): seq[string] = ## The exact six captures of the committed 9cd6e9b base table, so the Pattern ## numbers are directly comparable. for n in ["drussgt_vs_crazy", "drussgt_vs_spinbot", "drussgt_vs_drussgt", "tr_drussgt_vs_crazy", "tr_drussgt_vs_spinbot", "tr_drussgt_vs_modularbot"]: result.add(fixturesDir / (n & ".jsonl")) proc allSet(): seq[string] = for kind, p in walkDir(fixturesDir): if kind == pcFile and p.extractFilename.contains("drussgt") and p.extractFilename.endsWith(".jsonl"): result.add p result.sort() proc main() = var which = "core" for i in 1..paramCount(): let a = paramStr(i) if a.startsWith("--set="): which = a[6..^1] let names = if which == "all": allSet() else: coreSet() echo "# Patterns radial error — negative = enemy NEARER than predicted (OVERSHOOT)" echo "# arrivalTick = fireTick + max(0, ceil(aimDist/speed) - 1); aimDist from the gun's own (px,py)" echo "# scope,n,meanPx,medianPx,meanAbsPx,pctNearer(<0),pctFarther(>0),pctNearer(<-18px),pctFarther(>+18px),p10,p90" var patAll: Agg var linAll: Agg var patCore: Agg var linCore: Agg let coreNames = coreSet() for path in names: if not fileExists(path): continue let fx = loadFixture(path) let fxName = path.extractFilename.replace(".jsonl", "") var pat: Agg var lin: Agg # inline replay so we keep the two errors paired var pose = initTable[int, tuple[x, y: float]]() for s in fx.states: pose[s.tick] = (s.enemyX, s.enemyY) var spans = loadRounds(path) if spans.len == 0: spans = @[(start: fx.states[0].tick, count: fx.states.len)] for sp in spans: var g = PatternMatcherGun() for i in 0..= sp.start + sp.count: continue for b in 0.. 1e-9: let actualR = hypot(pose[at].x - state.selfX, pose[at].y - state.selfY) addErr(pat, actualR - aimDist) let f = forecastLinear(state, speed) let lArr = state.tick + max(0, int(ceil(f.dist / speed)) - 1) if lArr in pose: let actualR = hypot(pose[lArr].x - state.selfX, pose[lArr].y - state.selfY) addErr(lin, actualR - f.dist) reportScope("PATTERN_" & fxName, pat) reportScope("LINEAR_" & fxName, lin) if pat.n > 0: inc patAll.n, pat.n patAll.sum += pat.sum; patAll.asum += pat.asum patAll.near0 += pat.near0; patAll.far0 += pat.far0 patAll.nearBot += pat.nearBot; patAll.farBot += pat.farBot for e in pat.errs: patAll.errs.add e if lin.n > 0: inc linAll.n, lin.n linAll.sum += lin.sum; linAll.asum += lin.asum linAll.near0 += lin.near0; linAll.far0 += lin.far0 linAll.nearBot += lin.nearBot; linAll.farBot += lin.farBot for e in lin.errs: linAll.errs.add e if path in coreNames: inc patCore.n, pat.n patCore.sum += pat.sum; patCore.asum += pat.asum patCore.near0 += pat.near0; patCore.far0 += pat.far0 patCore.nearBot += pat.nearBot; patCore.farBot += pat.farBot for e in pat.errs: patCore.errs.add e inc linCore.n, lin.n linCore.sum += lin.sum; linCore.asum += lin.asum linCore.near0 += lin.near0; linCore.far0 += lin.far0 linCore.nearBot += lin.nearBot; linCore.farBot += lin.farBot for e in lin.errs: linCore.errs.add e echo "" reportScope("PATTERN_CORE_POOLED", patCore) reportScope("LINEAR_CORE_POOLED", linCore) reportScope("PATTERN_ALL_POOLED", patAll) reportScope("LINEAR_ALL_POOLED", linAll) # distribution of the pooled core Pattern error echo "\n# Pattern CORE pooled error distribution (20px bins)" var hist = initOrderedTable[string, int]() let edges = [-1e9, -150.0, -100.0, -60.0, -20.0, 20.0, 60.0, 100.0, 150.0, 1e9] for e in patCore.errs: var label = "?" for k in 0..= edges[k] and e < edges[k+1]: label = &"[{edges[k]:.0f},{edges[k+1]:.0f})" break hist[label] = hist.getOrDefault(label) + 1 for label, c in hist: echo &" {label:<18} {c:>7} {100.0*float(c)/float(max(1,patCore.n)):5.1f}%" when isMainModule: main()