j131 learned movement: real bullet-endpoint resolution (TR_LEARNED_REAL_EVENTS, default off) + exact-geometry Gate A/B (inversion NOT fixed; state still the constraint)

This commit is contained in:
2026-09-26 11:54:11 +02:00
parent d5047598b5
commit cc332138b3
6 changed files with 569 additions and 14 deletions
+130 -13
View File
@@ -71,6 +71,13 @@
## = the bot radius as an angle at the wave's
## distance). See docs/movement_campaign.md,
## "outcome label".
## TR_LEARNED_REAL_EVENTS =1: resolve a wave on the REAL bullet event
## (onHitByBullet / an enemy bullet intercepted by
## ours) using the exact origin->endpoint line and
## the real flight time, dropping the wave at once.
## Default off (arrival-deadline proxy). Enemy WALL
## hits are owner-private on server 0.35.5 and stay
## deadline misses - see the const-block note.
## TR_LEARNED_LOG per-decision log line
import std/[math, os]
@@ -108,8 +115,30 @@ const
LearnedWallMarginEnv* = "TR_LEARNED_WALL_MARGIN"
LearnedGlobalEnv* = "TR_LEARNED_GLOBAL"
LearnedLabelEnv* = "TR_LEARNED_LABEL"
LearnedRealEventsEnv* = "TR_LEARNED_REAL_EVENTS"
LearnedLogEnv* = "TR_LEARNED_LOG"
## Real-event matching (job j131).
## A wave is normally resolved on the nominal arrival tick
## `ceil(startDist/speed)`. With `TR_LEARNED_REAL_EVENTS=1` a wave is instead
## resolved by the REAL server event that carries the bullet's endpoint:
## * `onHitByBullet` -> the bullet HIT us; endpoint = our impact point;
## * a bullet-vs-bullet intercept of an ENEMY bullet (our bullet hit theirs)
## -> the enemy bullet's endpoint/heading are in the event.
## The bullet's raw straight line (origin at fire -> endpoint) then gives the
## EXACT GF bin and the real flight time (a cross-check on the energy-drop
## speed inference), and the wave is dropped immediately (no ghost build-up).
##
## ON THE RUNNING SERVER (0.35.5, verified from the server bytecode +
## `TurnToTickEventForBotMapper`) an ENEMY bullet that hits a WALL produces a
## `BulletHitWallEvent` only for the bullet's OWNER (`addPrivateBotEvent(
## bullet.botId, ...)`), and `bulletStates` is filtered to the bot's own
## bullets. So a wall HIT is NOT observable by the dodger; those waves fall
## back to the arrival deadline and are labelled a MISS. `resolveEnemyBullet`
## accepts a wall endpoint anyway so a future/other server can feed it.
RealEventsMatchTol = 12.0 ## max |real flight - nominal| to accept a match, ticks
RealEventsGrace = 8 ## ticks past nominal before an unmatched wave resolves
## Number of joint (vlat,dist,room,turn) state codes = 4^4.
LS_STATES = LS_Q * LS_Q * LS_Q * LS_Q
## Prior-mix weight for the 2-class outcome readout.
@@ -132,6 +161,7 @@ var
LearnedWallMargin* = 48.0
LearnedGlobal* = false
LearnedLabel* = llHistogram
LearnedRealEvents* = false
LearnedLog* = false
proc getEnvFloat(name: string, default: float): float =
@@ -164,6 +194,7 @@ proc loadLearnedEnv*() =
LearnedRadialFrac = clamp(getEnvFloat(LearnedRadialFracEnv, 0.35), 0.0, 1.0)
LearnedWallMargin = max(0.0, getEnvFloat(LearnedWallMarginEnv, 48.0))
LearnedGlobal = envOn(LearnedGlobalEnv)
LearnedRealEvents = envOn(LearnedRealEventsEnv)
LearnedLog = envOn(LearnedLogEnv)
LearnedLabel =
case getEnv(LearnedLabelEnv, "").strip().toLowerAscii()
@@ -219,6 +250,8 @@ proc roomToWall(px, py, dx, dy, arenaW, arenaH: float64): float64 =
type
LSWave = object
ownerId: int ## enemy that fired (energies are per-enemy)
fireTick: int ## `ws.tick` at the fire tick (real flight time)
originX, originY: float64
bearing: float64 ## enemy -> us at the fire tick (centre line)
speed: float64
@@ -246,6 +279,9 @@ type
prevHeading: float64
debugGraphics*: bool
decisions*: int ## decisions taken (diagnostic)
resolvedReal*: int ## waves resolved by a real bullet event
resolvedDead*: int ## waves resolved on the arrival deadline
lastFlightErr*: float ## real flight - nominal flight, last resolution
proc resetRound*(m: var LearnedSurferModule) =
## Per-ROUND reset: the waves and the smoothed global prior are per round, but
@@ -261,6 +297,9 @@ proc resetRound*(m: var LearnedSurferModule) =
m.prevY = 0.0
m.prevHeading = 0.0
m.decisions = 0
m.resolvedReal = 0
m.resolvedDead = 0
m.lastFlightErr = 0.0
for i in 0..<LS_BINS: m.glob[i] = 0
m.glc = 0
m.hitGlobal = 0
@@ -292,6 +331,7 @@ proc resetBattle*(m: var LearnedSurferModule) =
proc clearGraphics*(m: var LearnedSurferModule) {.inline.} = discard
proc removeBulletNear*(m: var LearnedSurferModule, x, y: float) {.inline.} = discard
proc liveWaves*(m: LearnedSurferModule): int {.inline.} = m.waves.len
# ── the learner ─────────────────────────────────────────────────────────────
@@ -339,6 +379,79 @@ proc learnWave(m: var LearnedSurferModule, w: LSWave, bin: int, hit: bool) =
m.missGlobal = m.missGlobal - (m.missGlobal shr LearnedDecayShift)
m.glc = 0
proc resolveWaveIdx(m: var LearnedSurferModule, idx, bin: int, hit: bool,
currentTick: int) =
## One live wave -> one training sample + removal. Shared by the arrival
## deadline (unobserved wall misses) and the real-event path, so a wave is
## ALWAYS trained and dropped exactly once - no ghost accumulation.
let w = m.waves[idx]
let nom = w.startDist / max(w.speed, 1e-9)
let realFlight = float(currentTick - w.fireTick)
m.lastFlightErr = realFlight - nom
if LearnedLog:
echo "[learned] resolve bin=", bin, " state=", w.stateRow, "/",
w.stateCol, " d=", w.startDist.int, " e=", w.originX.int, ",",
w.originY.int, " flight=", realFlight.int, " nominal=", nom.int,
" hit=", hit
m.learnWave(w, bin, hit)
m.waves.del(idx)
proc missileLineBin(w: LSWave, x, y, headingRad: float): int =
## GF bin of the bullet's real straight line through `(x,y)` (the endpoint),
## falling back to the real heading when the endpoint is degenerate. This is
## the EXACT geometry: origin at the fire tick + real endpoint, no timing
## guess.
let maxA = mea(w.speed)
if maxA < 1e-9: return gfToBin(0.0)
let ex = x - w.originX
let ey = y - w.originY
let lineDir =
if hypot(ex, ey) > 1.0: arctan2(ey, ex)
else: headingRad
gfToBin(clamp(wrapPi(lineDir - w.bearing) / maxA, -1.0, 1.0))
proc resolveEnemyBullet*(m: var LearnedSurferModule, x, y, headingRad: float,
ownerId, currentTick: int, hit: bool): bool =
## Resolve (and DROP) the live wave matching a REAL enemy-bullet event.
##
## `x,y` the bullet's real endpoint (our impact point for a HIT, the
## wall point for a wall hit, the intercept point for a
## bullet-vs-bullet hit),
## `headingRad` the bullet's real heading (fallback when the endpoint is
## degenerate),
## `hit` true only for a HIT on us.
## The exact straight line origin->endpoint sets the label's GF bin and the
## real flight time `currentTick - fireTick` is recorded, which cross-checks
## the energy-drop speed inference. No-op unless `TR_LEARNED_REAL_EVENTS=1`.
result = false
if not LearnedRealEvents or m.waves.len == 0: return
# The wave whose nominal arrival is closest to now is the one this bullet
# belongs to; ownerId disambiguates when several enemies are firing.
var best = -1
var bestKey = Inf
for i in 0..<m.waves.len:
let w = m.waves[i]
if ownerId >= 0 and w.ownerId != ownerId: continue
let key = abs(float(currentTick - w.fireTick) -
w.startDist / max(w.speed, 1e-9))
if key < bestKey:
bestKey = key
best = i
if best < 0: # no wave from that enemy: fall back to time-only matching
for i in 0..<m.waves.len:
let w = m.waves[i]
let key = abs(float(currentTick - w.fireTick) -
w.startDist / max(w.speed, 1e-9))
if key < bestKey:
bestKey = key
best = i
if best < 0 or bestKey > RealEventsMatchTol: return
let w = m.waves[best]
let bin = missileLineBin(w, x, y, headingRad)
m.resolveWaveIdx(best, bin, hit, currentTick)
inc m.resolvedReal
result = true
proc predictHit*(m: LearnedSurferModule, row, col, g: int): float =
## P(hit | state, candidate bin g) — the `outcome` danger (lower = safer),
## from the 2-class counted SBC read out with the per-cell posterior and
@@ -416,6 +529,7 @@ proc detectFire(m: var LearnedSurferModule, id: int, ex, ey, eenergy: float,
let col = code(room, RoomEdges) * LS_Q + code(turn, TurnEdges)
m.waves.add LSWave(
ownerId: id, fireTick: ws.tick,
originX: ex, originY: ey, bearing: bearing, speed: bspeed,
startDist: d, power: drop,
ticksLeft: max(1, int(ceil(d / max(bspeed, 1e-9)))),
@@ -445,24 +559,27 @@ proc computeMove*(m: var LearnedSurferModule, ws: WorldState): MoveCommand =
m.waves[i].fresh = false # created this tick: not one tick old yet
else:
dec m.waves[i].ticksLeft
if m.waves[i].ticksLeft <= 0:
# With real events on, wait `RealEventsGrace` ticks past the nominal arrival
# so a late HitByBullet can still claim the wave; a wave no event claims is
# a WALL MISS (the wall event is owner-private - see the const block).
let deadline = if LearnedRealEvents: -RealEventsGrace else: 0
if m.waves[i].ticksLeft <= deadline:
let w = m.waves[i]
let maxA = mea(w.speed)
if maxA >= 1e-9:
let off = wrapPi(arctan2(botY - w.originY, botX - w.originX) - w.bearing)
let bin = gfToBin(clamp(off / maxA, -1.0, 1.0))
# llOutcome label: did THIS wave hit us? Our own energy dropped since
# the fire tick. (One wave is live at a time in 1v1; ramming also drops
# energy, so this is a proxy, not an oracle.)
let hit = ws.selfEnergy < w.selfEnergyAtFire - 0.01
m.learnWave(w, bin, hit)
if LearnedLog:
echo "[learned] resolve bin=", bin, " state=", w.stateRow, "/",
w.stateCol, " d=", w.startDist.int, " e=", w.originX.int, ",",
w.originY.int
m.waves.del(i)
else:
inc i
# llOutcome label: did THIS wave hit us? The energy drop is the proxy;
# with real events on the HIT is taken from `onHitByBullet` instead, so
# an unmatched wave is a wall MISS.
let hit = (not LearnedRealEvents) and
ws.selfEnergy < w.selfEnergyAtFire - 0.01
m.resolveWaveIdx(i, bin, hit, ws.tick)
inc m.resolvedDead
else:
m.waves.del(i)
continue
inc i
# ── 3. danger of every candidate bin, summed over every live wave ────────
# llHistogram: precompute the predicted arrival-bin distribution per wave.
+224
View File
@@ -0,0 +1,224 @@
#!/usr/bin/env python3
"""Exact-geometry Gate A/B for the learned movement (job j131).
Question: does labelling/resolving a wave by the REAL bullet endpoint (the
exact origin->endpoint straight line, available live from `onHitByBullet` and
from a bullet-vs-bullet intercept) fix the danger-map inversion that j128
measured with the histogram label (`corr = -0.342`) and that j130 replaced with
a suspicious live-computable proxy (`corr = +0.566`)?
This is the SAME corpus, SAME per-shot extraction and SAME metric as
`outcome_label_gate.py` (which job j130 used), so the three danger maps are
computed under ONE consistent computation and are directly comparable:
(a) histogram label danger(g) = P(arrival bin = g) (j128)
(b) outcome proxy label danger(g) = P(hit and |g - b_our| <= w) (j130 live)
(c) EXACT bullet line danger(g) = P(|g - b_bullet| <= w) (this job)
`b_our` is the GF of OUR position at the nominal arrival tick; `b_bullet` is
the GF of the bullet's own straight line (from the recorded fire direction -
exactly the line the real endpoint would give). `w` is the body half-width as an
angle, in bins. Both correlations use the SAME realised per-bin hit rate
`P(hit | b_our = g)` (the j128 metric), and a second, bullet-conditioned target
is printed as a cross-check.
Gate B: held-out per-candidate log-loss of the EXACT (bullet-line) label,
state-conditional vs state-free, the same measurement j130 ran for its proxy.
Run:
python3 common_libs/tests/exact_geometry_gate.py \
--corpus /tmp/tfil_ab2/out \
--report common_libs/tests/fixtures/exact_geometry_gate_report.txt
"""
from __future__ import annotations
import argparse
import os
import statistics
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import outcome_label_gate as olg # validated extraction + metric
import analyze_drussgt_dodge_vs_power as adp
NBINS = olg.NBINS
def p_hist(recs, b):
return sum(1 for r in recs if r["b_our"] == b) / len(recs)
def p_proxy(recs, b):
"""j130 live label: P(hit and |b - b_our| <= w)."""
return statistics.fmean(1 if (r["hit"] >= 0.5 and abs(b - r["b_our"]) <= r["w"])
else 0 for r in recs)
def p_exact(recs, b):
"""Exact bullet-line label: P(|b - b_bullet| <= w)."""
return statistics.fmean(1 if abs(b - r["b_bullet"]) <= r["w"] else 0
for r in recs)
def p_exact_hit(recs, b):
"""Exact bullet-line AND hit: P(hit and |b - b_bullet| <= w)."""
return statistics.fmean(1 if (r["hit"] >= 0.5 and abs(b - r["b_bullet"]) <= r["w"])
else 0 for r in recs)
def correlations(recs):
n = [0] * NBINS
h = [0] * NBINS
for r in recs:
n[r["b_our"]] += 1
h[r["b_our"]] += r["hit"]
used = [b for b in range(NBINS) if n[b] > 0]
rate_our = [h[b] / n[b] for b in used]
nb = [0] * NBINS
hb = [0] * NBINS
for r in recs:
nb[r["b_bullet"]] += 1
hb[r["b_bullet"]] += r["hit"]
usedb = [b for b in range(NBINS) if nb[b] > 0]
rate_bullet = [hb[b] / nb[b] for b in usedb]
maps = {
"histogram (j128): P(arrival = g)": [p_hist(recs, b) for b in used],
"outcome proxy (j130 live): P(hit & |g-b_our|<=w)": [p_proxy(recs, b) for b in used],
"EXACT bullet line: P(|g-b_bullet|<=w)": [p_exact(recs, b) for b in used],
"EXACT bullet line & hit: P(hit & |g-b_bullet|<=w)": [p_exact_hit(recs, b) for b in used],
}
out = {}
for name, d in maps.items():
out[name] = (statistics.correlation(d, rate_our),
statistics.correlation([d[used.index(b)] if b in used else 0.0
for b in usedb], rate_bullet))
return out, used, rate_our, usedb, rate_bullet
def run_split_exact(recs, seed, decay=128, shift=1):
tr_b, te_b = olg.split_battles({r["battle"] for r in recs}, seed)
tr = [r for r in recs if r["battle"] in tr_b]
te = [r for r in recs if r["battle"] in te_b]
edges = dict(olg.CANON)
om = olg.OutcomeModel(decay, shift, state_free=False)
om0 = olg.OutcomeModel(decay, shift, state_free=True)
for r in tr:
st = olg.code_of(r, edges)
for g in range(NBINS):
lab = 1 if (r["hit"] >= 0.5 and abs(g - r["b_bullet"]) <= r["w"]) else 0
om.learn(st, g, lab)
om0.learn(st, g, lab)
ll_s, ll_g, hit_out = [], [], []
for r in te:
st = olg.code_of(r, edges)
go = min(range(NBINS), key=lambda g: om.predict_hit(st, g))
real = lambda g: 1 if abs(g - r["b_bullet"]) <= r["w"] else 0
hit_out.append(real(go))
for g in range(NBINS):
y = 1 if (r["hit"] >= 0.5 and abs(g - r["b_bullet"]) <= r["w"]) else 0
ll_s.append(-olg.log2(om.predict_hit(st, g)) if y
else -olg.log2(1.0 - om.predict_hit(st, g)))
ll_g.append(-olg.log2(om0.predict_hit(st, g)) if y
else -olg.log2(1.0 - om0.predict_hit(st, g)))
return dict(seed=seed,
ll_state=statistics.fmean(ll_s),
ll_statefree=statistics.fmean(ll_g),
delta=statistics.fmean([a - b for a, b in zip(ll_s, ll_g)]),
hit_out=statistics.fmean(hit_out))
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--corpus", default="/tmp/tfil_ab2/out")
ap.add_argument("--report", default=None)
ap.add_argument("--seeds", type=int, default=3)
args = ap.parse_args()
runs = adp.discover_tfil(args.corpus)
recs = olg.extract(runs)
lines = []
def out(s=""):
print(s)
lines.append(s)
out("# Exact-geometry Gate A/B — learned movement (job j131)")
out()
out(f"corpus : {args.corpus}")
out(f"battles : {len(runs)}")
out(f"shots : {len(recs)}")
out(f"base hit : {statistics.fmean(r['hit'] for r in recs)*100:.2f}%")
out("state : vlat, dist, room, turn (module's 4 fields, canonical edges)")
out()
corr, used, rate_our, usedb, rate_bullet = correlations(recs)
out("## A. danger-map alignment (ONE consistent computation)")
out()
out("corr( danger(g) , P(hit | b_our = g) ) [the j128 metric, = -0.342 hist]")
out("corr( danger(g) , P(hit | b_bullet = g) ) [same danger, bullet-conditioned target]")
out()
out("| danger map | corr vs P(hit\\|b_our=g) | corr vs P(hit\\|b_bullet=g) |")
out("|---|---:|---:|")
for name, (c_our, c_bul) in corr.items():
out(f"| {name} | {c_our:+.3f} | {c_bul:+.3f} |")
out()
out("Negative = minimising the danger steers INTO where the observed hits")
out("happen (the j128 defect). The exact bullet line is the physically")
out("correct 'would this wave hit me at g' map; if its correlation is still")
out("negative, exact geometry does NOT fix the inversion.")
out()
out("| bin | P(hit\\|b_our) | P(hit\\|b_bullet) | hist danger | proxy danger | exact danger |")
out("|---:|---:|---:|---:|---:|---:|")
rb = {b: rate_bullet[usedb.index(b)] for b in usedb}
for b in used:
rb_str = f"{rb[b]*100:.1f}%" if b in rb else "—"
out(f"| {b} | {rate_our[used.index(b)]*100:.1f}% | "
f"{rb_str} | "
f"{p_hist(recs, b):.3f} | {p_proxy(recs, b):.3f} | {p_exact(recs, b):.3f} |")
out()
per = [run_split_exact(recs, s) for s in range(args.seeds)]
ll_s = statistics.fmean(p["ll_state"] for p in per)
ll_g = statistics.fmean(p["ll_statefree"] for p in per)
out("## B. state-conditional information under the EXACT bullet-line label")
out()
out("held-out per-candidate log-loss (bits) of the exact label, "
"state-conditional vs state-free (same rows, same split):")
out()
out("| model | log-loss (bits) |")
out("|---|---:|")
out(f"| state-free P(label | g) | {ll_g:.4f} |")
out(f"| state-conditional P(label | state, g) | {ll_s:.4f} |")
out(f"| Δ (state − state-free) | {ll_s - ll_g:+.4f} |")
out()
neg = sum(1 for p in per if p["delta"] < 0)
out(f"state conditioning is better in {neg}/{len(per)} splits "
f"(negative Δ = better).")
out()
out("## C. open-loop decision counterfactual (VETO ONLY)")
out()
out("argmin_g danger with the recorded bullet line as ground truth:")
out()
out(f"| exact-label argmin (j131) | "
f"{statistics.fmean(p['hit_out'] for p in per)*100:.2f}% |")
out()
out("## MEASURED vs INFERRED")
out()
out("* MEASURED: every number above, on the recorded corpus.")
out("* INFERRED: that an offline alignment transfers live — it cannot, the")
out(" corpus is open loop (`docs/offline_harness_trust.md`).")
if args.report:
os.makedirs(os.path.dirname(args.report), exist_ok=True)
with open(args.report, "w") as f:
f.write("\n".join(lines) + "\n")
if __name__ == "__main__":
main()