Files
SirRoboGarage/common_libs/guns/pattern_matcher.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

229 lines
9.4 KiB
Nim

## Pattern-matching gun: searches movement history for a matching sequence,
## 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, 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
velocity: float ## signed speed (px/tick)
headingDelta: float ## heading change in radians this tick
PatternMatcherGun* = object
buf: array[HistorySize, MoveTick]
head: int ## next write index (circular)
count: int ## filled entries (capped at HistorySize)
prevHeading: float
prevSpeed: float
prevTick: int
hasPrev: bool
# Per-tick, speed-INDEPENDENT pattern state. Both the history search and the
# replayed enemy path depend only on observed movement, never on bulletSpeed,
# so they are built at most once per tick. The bullet lead (number of replay
# steps + coast) is derived from this path on every call.
cacheValid: bool
cacheTick: int
bestMatch: int ## -1 = no usable match (linear fallback)
playStart: int
playAvail: int
pathX: array[HistorySize + 1, float]
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 ---
proc write(g: var PatternMatcherGun, m: MoveTick) {.inline.} =
g.buf[g.head] = m
g.head = (g.head + 1) mod HistorySize
if g.count < HistorySize: inc g.count
proc readAt(g: PatternMatcherGun, i: int): MoveTick {.inline.} =
## i = 0 is oldest, i = count-1 is newest
g.buf[(g.head - g.count + i + HistorySize * 2) mod HistorySize]
# --- linear fallback (same style as linear.nim) ---
proc linearPredict(state: WorldState, bulletSpeed: float): (float, float) =
let dist = hypot(state.enemyX - state.selfX, state.enemyY - state.selfY)
let t = dist / bulletSpeed
let hRad = degToRad(state.enemyHeading)
let ex = clamp(state.enemyX + cos(hRad) * state.enemySpeed * t,
BotRadius, state.arenaWidth - BotRadius)
let ey = clamp(state.enemyY + sin(hRad) * state.enemySpeed * t,
BotRadius, state.arenaHeight - BotRadius)
(ex, ey)
# --- pattern search + play-forward ---
proc findBestMatch(g: PatternMatcherGun): int =
## Speed-independent history search. Returns the start index of the best
## matching pattern, or -1 when there is not enough history.
if g.count < PatternLen * 2:
return -1
# key = last PatternLen entries
let keyStart = g.count - PatternLen
# scan backwards for best match (exclude the key itself)
var bestScore = Inf
var bestMatch = -1
let scanEnd = g.count - PatternLen - 1 # last valid match start
for i in countdown(scanEnd, 0):
var score = 0.0
for k in 0 ..< PatternLen:
let a = g.readAt(keyStart + k)
let b = g.readAt(i + k)
let dv = a.velocity - b.velocity
let dh = a.headingDelta - b.headingDelta
score += dv * dv + dh * dh
if score < bestScore:
bestScore = score
bestMatch = i
bestMatch
proc buildPath(g: var PatternMatcherGun, state: WorldState, bestMatch: int) =
## Precompute the matched pattern replayed forward from the current state.
## Only depends on observed movement, so it is valid for every power bin.
g.playStart = bestMatch + PatternLen
g.playAvail = g.count - 1 - g.playStart # ticks we can replay
g.pathX[0] = state.enemyX
g.pathY[0] = state.enemyY
g.pathHeading[0] = degToRad(state.enemyHeading)
g.pathSpeed[0] = state.enemySpeed
for s in 1 .. g.playAvail:
let m = g.readAt(g.playStart + s - 1)
g.pathHeading[s] = g.pathHeading[s - 1] + m.headingDelta
g.pathSpeed[s] = m.velocity
g.pathX[s] = g.pathX[s - 1] + cos(g.pathHeading[s]) * g.pathSpeed[s]
g.pathY[s] = g.pathY[s - 1] + sin(g.pathHeading[s]) * g.pathSpeed[s]
proc projectFromPath(g: PatternMatcherGun, state: WorldState,
bulletSpeed: float): (float, float) =
## Speed-dependent lead: walk the cached path as far as this bulletSpeed's
## estimated flight time reaches, then coast linearly for the remainder.
# iterative time estimate
let dist0 = hypot(state.enemyX - state.selfX, state.enemyY - state.selfY)
var t = dist0 / bulletSpeed
var ex = state.enemyX
var ey = state.enemyY
for _ in 0..4:
let steps = min(int(t + 0.5), g.playAvail)
ex = g.pathX[steps]
ey = g.pathY[steps]
# if we ran out of replay data, coast linearly from last simulated pos
let remaining = t - steps.float
if remaining > 0.0:
ex += cos(g.pathHeading[steps]) * g.pathSpeed[steps] * remaining
ey += sin(g.pathHeading[steps]) * g.pathSpeed[steps] * remaining
let ndx = ex - state.selfX
let ndy = ey - state.selfY
t = sqrt(ndx * ndx + ndy * ndy) / bulletSpeed
ex = clamp(ex, BotRadius, state.arenaWidth - BotRadius)
ey = clamp(ey, BotRadius, state.arenaHeight - BotRadius)
(ex, ey)
# --- 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:
return GunPrediction(x: state.enemyX, y: state.enemyY)
# Roll history forward and (re)build the speed-independent pattern path at
# most once per tick. The old code returned a single cached (x, y) per tick,
# so all four power bins shared bin 0's lead.
if not g.cacheValid or state.tick != g.cacheTick:
if g.hasPrev:
var dh = degToRad(state.enemyHeading) - degToRad(g.prevHeading)
# wrap to [-π, π]
while dh > PI: dh -= 2.0 * PI
while dh < -PI: dh += 2.0 * PI
g.write(MoveTick(velocity: g.prevSpeed, headingDelta: dh))
g.prevHeading = state.enemyHeading
g.prevSpeed = state.enemySpeed
g.prevTick = state.tick
g.hasPrev = true
g.cacheValid = true
g.cacheTick = state.tick
g.bestMatch = g.findBestMatch()
if g.bestMatch >= 0:
g.buildPath(state, g.bestMatch)
if g.bestMatch < 0:
let (px, py) = linearPredict(state, bulletSpeed)
return g.applyRadial(state, px, py)
let (px, py) = g.projectFromPath(state, bulletSpeed)
g.applyRadial(state, px, py)
proc onResult*(g: var PatternMatcherGun, e: FeedbackEvent) =
discard # pattern matcher learns from movement observation, not feedback