Files
SirRoboGarage/common_libs/tests/test_pattern_radial_offset.nim
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

141 lines
5.6 KiB
Nim

## Guard for the Pattern gun's radial-offset knob (`TR_PATTERN_RAD_OFFSET` /
## `TR_PATTERN_RAD_SCALE`).
##
## Proves the two things the shipped-default contract requires:
## 1. BYTE-IDENTITY — an unconfigured gun (env unset, the shipped path) and a
## gun explicitly set to (1.0, 0.0) produce EXACTLY the same predictions as
## a gun whose knob is never touched, over a real DrussGT fixture. The
## (1.0, 0.0) case short-circuits to the raw aim point, so this is a direct
## proof the shipped prediction is unchanged by the knob.
## 2. ENV — `TR_PATTERN_RAD_SCALE=x` scales only the aim DISTANCE, and
## `TR_PATTERN_RAD_OFFSET=px` shifts only the aim DISTANCE; the BEARING
## (the direction the real gun is pointed, `aimAngle(self, pred)`) is
## unchanged. This is the structural reason a radial offset cannot change
## the real shot direction.
##
## Run: nim c -r --path:common_libs common_libs/tests/test_pattern_radial_offset.nim
import std/[math, os, strformat]
import gun_harness/offline_range
import gun_harness/virtual_bullets
import guns/pattern_matcher
const
repoRoot = currentSourcePath().parentDir.parentDir.parentDir
fixture = repoRoot / "tools" / "fixtures" / "drussgt_vs_crazy.jsonl"
var failures = 0
proc check(name: string, ok: bool) =
if ok: echo "PASS: ", name
else: echo "FAIL: ", name; inc failures
proc clearEnv() =
delEnv("TR_PATTERN_RAD_OFFSET")
delEnv("TR_PATTERN_RAD_SCALE")
proc bearingsClose(a, b: GunPrediction, sx, sy: float): bool =
let ba = arctan2(a.y - sy, a.x - sx)
let bb = arctan2(b.y - sy, b.x - sx)
var d = ba - bb
while d > PI: d -= 2.0 * PI
while d < -PI: d += 2.0 * PI
abs(d) < 1e-12
proc dist(p: GunPrediction, sx, sy: float): float =
hypot(p.x - sx, p.y - sy)
when isMainModule:
let fx = loadFixture(fixture)
# First 600 states is plenty to exercise the linear fallback AND the pattern
# path (history + a match) without a long compile/run.
var states = fx.states
if states.len > 600: states.setLen 600
# ── 1. default-path byte-identity ─────────────────────────────────────────
clearEnv()
var plain = PatternMatcherGun() # never configured -> env path (1.0, 0.0)
var explicit = PatternMatcherGun()
explicit.setRadialCorrection(1.0, 0.0) # explicit shipped-default values
var identical = true
var nPred = 0
for si in 0..<states.len:
let s = states[si]
for b in 0..<len(PowerBins):
let p1 = plain.predict(s, bulletSpeed(PowerBins[b]))
let p2 = explicit.predict(s, bulletSpeed(PowerBins[b]))
inc nPred
if p1.x != p2.x or p1.y != p2.y: identical = false
check &"default parity: unconfigured vs explicit (1.0, 0.0) identical over {nPred} preds",
identical
# ── 2. env scale changes distance only, keeps bearing ─────────────────────
clearEnv()
var g0 = PatternMatcherGun()
g0.setRadialCorrection(1.0, 0.0) # pin the reference to the shipped default
putEnv("TR_PATTERN_RAD_SCALE", "0.95")
var gS = PatternMatcherGun() # reads env on first predict
var distOK = true
var bearingOK = true
var scaledPoints = 0
for si in 0..<states.len:
let s = states[si]
for b in 0..<len(PowerBins):
let p0 = g0.predict(s, bulletSpeed(PowerBins[b]))
let ps = gS.predict(s, bulletSpeed(PowerBins[b]))
let d0 = dist(p0, s.selfX, s.selfY)
let ds = dist(ps, s.selfX, s.selfY)
if d0 > 1e-6:
inc scaledPoints
if abs(ds - d0 * 0.95) > 1e-6: distOK = false
if not bearingsClose(p0, ps, s.selfX, s.selfY): bearingOK = false
else:
if p0.x != ps.x or p0.y != ps.y: distOK = false
check &"env scale: scale=0.95 gives dist*0.95 on {scaledPoints} valid preds", distOK
check "env scale: bearing unchanged (real aim angle invariant)", bearingOK
# ── 3. env offset shifts distance only, keeps bearing ─────────────────────
clearEnv()
var h0 = PatternMatcherGun()
h0.setRadialCorrection(1.0, 0.0)
putEnv("TR_PATTERN_RAD_OFFSET", "-20")
var hO = PatternMatcherGun()
var offOK = true
var offBearingOK = true
var offPoints = 0
for si in 0..<states.len:
let s = states[si]
for b in 0..<len(PowerBins):
let p0 = h0.predict(s, bulletSpeed(PowerBins[b]))
let po = hO.predict(s, bulletSpeed(PowerBins[b]))
let d0 = dist(p0, s.selfX, s.selfY)
let dof = dist(po, s.selfX, s.selfY)
if d0 > 20.0:
inc offPoints
if abs(dof - (d0 - 20.0)) > 1e-6: offOK = false
if not bearingsClose(p0, po, s.selfX, s.selfY): offBearingOK = false
check &"env offset: offset=-20 gives dist-20 on {offPoints} valid preds", offOK
check "env offset: bearing unchanged (real aim angle invariant)", offBearingOK
clearEnv()
# ── 4. unparsable env falls back to the shipped defaults ──────────────────
putEnv("TR_PATTERN_RAD_SCALE", "banana")
putEnv("TR_PATTERN_RAD_OFFSET", "nope")
var gBad = PatternMatcherGun()
discard gBad.predict(states[0], bulletSpeed(PowerBins[0]))
clearEnv()
var gDef = PatternMatcherGun()
discard gDef.predict(states[0], bulletSpeed(PowerBins[0]))
var fallbackOK = true
for si in 0..<min(50, states.len - 1):
let s = states[si]
let pb = gBad.predict(s, bulletSpeed(PowerBins[0]))
let pd = gDef.predict(s, bulletSpeed(PowerBins[0]))
if pb.x != pd.x or pb.y != pd.y: fallbackOK = false
check "env: unparsable values fall back to (1.0, 0.0)", fallbackOK
if failures > 0:
echo "\n", failures, " check(s) FAILED"
quit(1)
echo "\nAll Pattern radial-offset checks passed."