185a32e9eb
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.
255 lines
9.1 KiB
Nim
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()
|