Files
SirRoboGarage/common_libs/tests/sweep_pattern_radial.nim
T
SirStone 185a32e9eb Radial offset: STRUCTURALLY incapable of helping, and Pattern does not overshoot
Follow-up to 9cd6e9b, which found the LINEAR base systematically overshoots (mean
radial error -71..-100px, enemy nearer in 63-81% of shots). The question was
whether the gun that actually ships, `Pattern`, overshoots too - because
correcting a systematic bias would be a cheap win.

1. PATTERN DOES NOT OVERSHOOT. Measured over the DrussGT fixtures (n=250,989):
     Pattern  mean -12.0 px, median  -3.2 px, nearer 52.8% / farther 45.0%
     Linear   mean -87.3 px, median -61.0 px, nearer 83.4% / farther 14.4%  (same states)
   So the overshoot was a property of the CONSTANT-VELOCITY BASE, not of our
   predictions in general. Pattern's pattern-matching does not have it, so there
   was nothing to correct. (All-10-fixture pooled: mean -14.0, median -4.2.)

2. THE AVENUE IS STRUCTURALLY DEAD, not merely unprofitable. The live aim is
   `aimAngle(self, pred)` and a RADIAL-only offset keeps the BEARING unchanged
   (proven exactly by a guard test: bearing is invariant). So a radial offset
   cannot change the fired bullet's direction at all. `bmPath` never scores the
   aim distance either - and measured, every offset arm is BYTE-IDENTICAL to plain
   Pattern on bmPath (33.9%/25.6%). The only real-effect channel is the `shouldFire`
   gate via `distPx`, which is indistinguishable from noise.

3. LIVE A/B CONFIRMS: one frozen binary (built from HEAD + only this change),
   env-only arms, 7 runs x 7 rounds, 8 concurrent, real DrussGT, server-side hit
   rate, exact two-sided permutation test.
     control (plain Pattern)  10.61% / 284 dmg-per-run
     s0.98                    10.89% / 302   (+0.28pp, p=0.62)
     s0.95                    10.02%         (p=0.35)
     o-20                     10.19%         (p=0.46)
   No significant winner.

VERDICT: STOP. This line cannot help the shipped configuration, and the reason is
structural rather than statistical - a radial correction is bearing-invariant, so
it is invisible to the actual shot. The bmPoint "win" the radial TM showed was a
metric artefact of that same irrelevance.

Incidental: the control arm (10.61% / 284) independently replicates the shipped
Pattern-only default's A/B numbers (10.36% / 264, 10.78% / 287, 9.99%).

Kept anyway: `TR_PATTERN_RAD_SCALE` / `TR_PATTERN_RAD_OFFSET` default to
(1.0, 0.0) and the default path is byte-identical (proven over 2400 predictions,
plus bearing invariance and unparsable-value fallback - 6 checks). Adds
measure_pattern_radial.nim, sweep_pattern_radial.nim, test_pattern_radial_offset.nim
and pattern_radial_results.md.

Guards: test_gun_harness 39, test_vbullet_metric 11, test_power_selection 3,
test_adaptive_radar 41, test_tfil_ring_weights 24, test_power_policy 26,
test_ram_decision 28, test_rack_membership 48, test_tm_pattern_registration 20,
acceptance_offline_vs_online 12/12 PASS.
2026-09-22 02:23:41 +02:00

255 lines
9.1 KiB
Nim

## OFFLINE SWEEP: constant radial offset on the SHIPPED `Pattern` gun.
##
## Pattern's radial error was measured (measure_pattern_radial.nim) to be far
## smaller than the base linear forecast's: pooled median -3.2 px vs the base's
## -61 px, near/far 52.8%/45.0% vs 83.4%/14.4%. This sweep asks the empirical
## question anyway: does ANY constant radial offset on Pattern improve either
## virtual metric?
##
## `Pattern` is deterministic and history-dependent, so each fixture is replayed
## ONCE with a fresh gun per arm (one batched pass), exactly the methodology of
## sweep_radial_offset.nim / sweep_tm_pattern.nim.
##
## Arms: Linear (reference), Pattern at (1.0, 0.0) plus a grid of scale and
## fixed-px offsets, and a +30 px opposite-direction control.
##
## Usage:
## nim c -r -d:release --path:common_libs \
## common_libs/tests/sweep_pattern_radial.nim --metric=point
## ... --metric=path
## Flags: --metric=point|path, --set=real|synthetic.
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/linear
import guns/pattern_matcher
const
repoRoot = currentSourcePath().parentDir.parentDir.parentDir
fixturesDir = repoRoot / "tools" / "fixtures"
type
Adapt = object
h100, n100, h300, n300, hall, nall, f100, m100: int
rounds: int
Arm = object
name: string
scale, offsetPx: float
RoundSpan = tuple[start, count: int]
var gDropped = 0
proc addAdapt(dst: var Adapt, src: Adapt) =
inc dst.rounds, src.rounds
dst.h100 += src.h100; dst.n100 += src.n100
dst.h300 += src.h300; dst.n300 += src.n300
dst.hall += src.hall; dst.nall += src.nall
dst.f100 += src.f100; dst.m100 += src.m100
proc rateStr(h, n: int): string =
if n == 0: " n/a " else: &"{h.float / n.float * 100.0:5.1f}%"
proc binomPmf(k, n: int): float =
if k < 0 or k > n: return 0.0
var lg = 0.0
for i in 1..k: lg += ln(float(n - k + i)) - ln(float(i))
exp(lg - float(n) * ln(2.0))
proc signTestP(wins, n: int): float =
if n == 0: return 1.0
let lo = min(wins, n - wins)
var s = 0.0
for k in 0..lo: s += binomPmf(k, n)
min(1.0, 2.0 * s)
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 resolve(name: string): tuple[fx: Fixture, path: string] =
let p = if fileExists(name): name else: fixturesDir / (name & ".jsonl")
(loadFixture(p), p)
proc fixtureSet(name: string): seq[string] =
if name == "synthetic":
for n in SyntheticFixtureNames: result.add n
else:
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 runRound(states: seq[WorldState], lastSeen: seq[int], enemyId, baseTick: int,
drivers: seq[GunDriver], metric: BulletMetric): seq[Adapt] =
var tracker = initTracker(drivers.len, metric)
var accum = newSeq[Adapt](drivers.len)
for ad in accum.mitems: inc ad.rounds
for si in 0..<states.len:
let state = states[si]
for gi in 0..<drivers.len:
var preds: array[len(vb.PowerBins), GunPrediction]
for i in 0..<len(vb.PowerBins):
preds[i] = drivers[gi].predictCb(state, bulletSpeed(vb.PowerBins[i]))
let ready = if drivers[gi].readyCb == nil: true else: drivers[gi].readyCb()
if ready:
tracker.spawnBullets(gi, preds, state, enemyId)
var enemyPositions: Table[int, tuple[x, y: float, lastSeenTick: int, alive: bool]]
var lst = state.tick
if si < lastSeen.len and lastSeen[si] >= 0: lst = lastSeen[si]
if state.enemies.len > 0:
for e in state.enemies:
enemyPositions[e.id] = (x: e.x, y: e.y, lastSeenTick: lst, alive: true)
else:
enemyPositions[enemyId] = (x: state.enemyX, y: state.enemyY,
lastSeenTick: lst, alive: true)
let localTick = state.tick - baseTick
let dref = drivers
tracker.tickBullets(state, enemyPositions,
proc(gunId: GunId, binIdx: int, e: FeedbackEvent) =
inc accum[gunId].nall
if e.hit: inc accum[gunId].hall
if localTick < 100:
inc accum[gunId].n100
if e.hit: inc accum[gunId].h100
if localTick < 300:
inc accum[gunId].n300
if e.hit: inc accum[gunId].h300
let fireTick = e.fireTick - baseTick
if fireTick < 100:
inc accum[gunId].m100
if e.hit: inc accum[gunId].f100
dref[gunId].resultCb(e))
gDropped += tracker.droppedBullets
result = accum
proc runFixtureClean(fx: Fixture, path: string, drivers: seq[GunDriver],
metric: BulletMetric): seq[Adapt] =
var spans =
if fx.meta.source == "synthetic": @[(start: 0, count: fx.states.len)]
else: loadRounds(path)
result = newSeq[Adapt](drivers.len)
if spans.len == 0:
result = runRound(fx.states, fx.lastSeen, fx.enemyId, 0, drivers, metric)
return
for sp in spans:
var st: seq[WorldState]
var ls: seq[int]
for i in 0..<fx.states.len:
let t = fx.states[i].tick
if t >= sp.start and t < sp.start + sp.count:
st.add fx.states[i]
ls.add(if i < fx.lastSeen.len: fx.lastSeen[i] else: -1)
if st.len == 0: continue
let rr = runRound(st, ls, fx.enemyId, sp.start, drivers, metric)
for gi in 0..<drivers.len:
addAdapt(result[gi], rr[gi])
proc patDriver(name: string, scale, offsetPx: float): GunDriver =
var g = PatternMatcherGun()
g.setRadialCorrection(scale, offsetPx)
makeDriver(name, g)
proc main() =
var metricName = "point"
var setName = "real"
for i in 1..paramCount():
let a = paramStr(i)
if a.startsWith("--metric="): metricName = a[9..^1]
elif a.startsWith("--set="): setName = a[6..^1]
let metric = if metricName == "point": bmPoint else: bmPath
let names = fixtureSet(setName)
var arms: seq[Arm]
arms.add Arm(name: "Linear", scale: 1.0, offsetPx: 0.0) # sentinel for Linear
arms.add Arm(name: "Pattern", scale: 1.0, offsetPx: 0.0)
for s in [1.00, 0.98, 0.95, 0.90, 0.85]:
arms.add Arm(name: &"P_s{s:.2f}", scale: s, offsetPx: 0.0)
for o in [-10.0, -20.0, -30.0, -40.0, 30.0]:
arms.add Arm(name: &"P_o{int(o):+d}", scale: 1.0, offsetPx: o)
echo "# Pattern radial-offset sweep: set=", setName, " metric=", metricName,
" fixtures=", names.len
var rows: seq[tuple[arm, fixture: string, r: Adapt]]
for name in names:
let (fx, path) = resolve(name)
let fxName = path.extractFilename.replace(".jsonl", "")
var drivers: seq[GunDriver]
drivers.add makeDriver("Linear", LinearGun())
for i in 1..<arms.len:
drivers.add patDriver(arms[i].name, arms[i].scale, arms[i].offsetPx)
let res = runFixtureClean(fx, path, drivers, metric)
for i, a in arms:
rows.add (a.name, fxName, res[i])
# pooled
var pooled = initTable[string, Adapt]()
for a in arms: pooled[a.name] = Adapt()
for row in rows: addAdapt(pooled[row.arm], row.r)
echo "\n# pooled summary"
echo "arm,early%,early_hits,early_n,overall%,overall_hits,overall_n"
for a in arms:
let p = pooled[a.name]
echo &"{a.name},{rateStr(p.h100, p.n100)},{p.h100},{p.n100}," &
&"{rateStr(p.hall, p.nall)},{p.hall},{p.nall}"
# per-fixture overall for the baseline and the best few arms
echo "\n# per-fixture OVERALL% (early% in parens)"
var hdr = "fixture"
for a in arms: hdr.add "," & a.name
echo hdr
for name in names:
let (_, path) = resolve(name)
let fxName = path.extractFilename.replace(".jsonl", "")
var line = fxName
for a in arms:
var h, n = 0
for row in rows:
if row.arm == a.name and row.fixture == fxName:
h += row.r.hall; n += row.r.nall
line.add &",{rateStr(h, n)}"
echo line
# paired sign test per fixture: arm vs Pattern (default)
echo "\n# paired sign tests vs Pattern (per fixture, exact two-sided binomial, n=6)"
echo "arm,metric,nA>B,nB>A,ties,p"
for a in arms:
if a.name in ["Linear", "Pattern"]: continue
var winsA, winsB, ties, n = 0
for name in names:
let (_, path) = resolve(name)
let fxName = path.extractFilename.replace(".jsonl", "")
var ha, na, hb, nb: int
for row in rows:
if row.fixture != fxName: continue
if row.arm == a.name: ha += row.r.hall; na += row.r.nall
elif row.arm == "Pattern": hb += row.r.hall; nb += row.r.nall
if na == 0 or nb == 0: continue
let ra = ha.float / na.float
let rb = hb.float / nb.float
inc n
if ra > rb: inc winsA
elif rb > ra: inc winsB
else: inc ties
echo &"{a.name},overall,{n},{winsA},{winsB},{ties},{signTestP(winsA, n - ties):.4f}"
if gDropped > 0:
echo &"\n# WARNING: droppedBullets={gDropped}"
when isMainModule:
main()