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.
This commit is contained in:
@@ -2,13 +2,33 @@
|
||||
## then plays it forward to predict future position.
|
||||
## Reference: https://robowiki.net/wiki/Pattern_Matching
|
||||
## Coordinate system: 0° = East, CCW positive (Tank Royale standard).
|
||||
##
|
||||
## RADIAL OFFSET KNOB (`TR_PATTERN_RAD_OFFSET` / `TR_PATTERN_RAD_SCALE`):
|
||||
## the measured base-linear forecast systematically OVERSHOOTS range on these
|
||||
## range-holding surfers (commit 9cd6e9b), so this gun exposes a constant radial
|
||||
## correction on the predicted aim point: the BEARING is untouched, the aim
|
||||
## DISTANCE becomes `dist * scale + offsetPx`. Both default to (1.0, 0.0), and
|
||||
## the default path returns the raw prediction UNCHANGED — byte-for-byte, so an
|
||||
## unset environment cannot perturb the shipped gun. See
|
||||
## `common_libs/tests/test_pattern_radial_offset.nim` for the parity proof and
|
||||
## `common_libs/tests/measure_pattern_radial.nim` for the measurement.
|
||||
|
||||
import std/math
|
||||
import std/[math, os, strutils]
|
||||
import gun_harness/gun_interface
|
||||
|
||||
const
|
||||
HistorySize* = 500
|
||||
PatternLen* = 10 # ticks used as search key; ponytail: fixed, expose if tuning needed
|
||||
PatternRadOffsetEnvVar* = "TR_PATTERN_RAD_OFFSET" ## px; negative = aim short
|
||||
PatternRadScaleEnvVar* = "TR_PATTERN_RAD_SCALE" ## multiplier on aim distance
|
||||
|
||||
proc patternEnvFloat(name: string, default: float): float =
|
||||
## Read an env knob like every other runtime switch in the harness: unset or
|
||||
## unparsable falls back to the shipped default, so a typo cannot move the gun.
|
||||
let v = getEnv(name, "")
|
||||
if v.len == 0: return default
|
||||
try: parseFloat(v.strip())
|
||||
except ValueError: default
|
||||
|
||||
type
|
||||
MoveTick = object
|
||||
@@ -36,6 +56,10 @@ type
|
||||
pathY: array[HistorySize + 1, float]
|
||||
pathHeading: array[HistorySize + 1, float] ## radians after s steps
|
||||
pathSpeed: array[HistorySize + 1, float] ## speed after s steps
|
||||
# ── radial offset knob (defaults leave the prediction byte-identical) ──
|
||||
radScale*: float ## multiplier on the predicted aim distance
|
||||
radOffset*: float ## px added to the predicted aim distance
|
||||
radConfigured*: bool ## true once the env/setter has populated the two above
|
||||
debugGraphics*: bool
|
||||
|
||||
# --- circular buffer helpers ---
|
||||
@@ -134,6 +158,38 @@ proc projectFromPath(g: PatternMatcherGun, state: WorldState,
|
||||
|
||||
# --- Gun interface ---
|
||||
|
||||
proc ensureRadialConfig(g: var PatternMatcherGun) {.inline.} =
|
||||
## Lazily read the radial env knobs on first use, so the live binary can be
|
||||
## re-tuned with `TR_PATTERN_RAD_*` and a unit test can poke the env in-process.
|
||||
if g.radConfigured: return
|
||||
g.radScale = patternEnvFloat(PatternRadScaleEnvVar, 1.0)
|
||||
g.radOffset = patternEnvFloat(PatternRadOffsetEnvVar, 0.0)
|
||||
g.radConfigured = true
|
||||
|
||||
proc setRadialCorrection*(g: var PatternMatcherGun, scale, offsetPx: float) =
|
||||
## Explicit per-gun override used by the offline sweep. Writes the same fields
|
||||
## the env path writes, so the measured code path is identical.
|
||||
g.radScale = scale
|
||||
g.radOffset = offsetPx
|
||||
g.radConfigured = true
|
||||
|
||||
proc applyRadial(g: var PatternMatcherGun, state: WorldState,
|
||||
px, py: float): GunPrediction =
|
||||
## Scale/shift the aim DISTANCE along the (unchanged) base bearing. The
|
||||
## (1.0, 0.0) case returns the raw point, so the shipped default is
|
||||
## byte-identical to the pre-knob gun. `radOffset` may not pull the aim point
|
||||
## behind the shooter; the distance is floored at 0.
|
||||
g.ensureRadialConfig()
|
||||
if g.radScale == 1.0 and g.radOffset == 0.0:
|
||||
return GunPrediction(x: px, y: py)
|
||||
let dx = px - state.selfX
|
||||
let dy = py - state.selfY
|
||||
let d = hypot(dx, dy)
|
||||
if d < 1e-9:
|
||||
return GunPrediction(x: px, y: py)
|
||||
let nd = max(0.0, d * g.radScale + g.radOffset)
|
||||
GunPrediction(x: state.selfX + dx / d * nd, y: state.selfY + dy / d * nd)
|
||||
|
||||
proc predict*(g: var PatternMatcherGun, state: WorldState,
|
||||
bulletSpeed: float): GunPrediction =
|
||||
if bulletSpeed <= 0.0:
|
||||
@@ -163,10 +219,10 @@ proc predict*(g: var PatternMatcherGun, state: WorldState,
|
||||
|
||||
if g.bestMatch < 0:
|
||||
let (px, py) = linearPredict(state, bulletSpeed)
|
||||
return GunPrediction(x: px, y: py)
|
||||
return g.applyRadial(state, px, py)
|
||||
|
||||
let (px, py) = g.projectFromPath(state, bulletSpeed)
|
||||
GunPrediction(x: px, y: py)
|
||||
g.applyRadial(state, px, py)
|
||||
|
||||
proc onResult*(g: var PatternMatcherGun, e: FeedbackEvent) =
|
||||
discard # pattern matcher learns from movement observation, not feedback
|
||||
|
||||
Reference in New Issue
Block a user