From 61def1c3e970bd2d5543eb7aaae6cefca1ca3c26 Mon Sep 17 00:00:00 2001 From: Davide Cappellini Date: Sat, 26 Sep 2026 11:27:03 +0200 Subject: [PATCH] j130 learned movement: outcome label (P(hit|state,g)) mode + Gate A; pre-registered outcome arms --- ModularBot_garage/src/env_report.nim | 2 +- .../tests/test_learned_surfer.nim | 31 ++ common_libs/movements/learned_surfer.nim | 146 +++++- common_libs/tests/outcome_label_gate.py | 419 ++++++++++++++++++ docs/movement_campaign.md | 83 ++++ tools/ab/arms_movement_outcome.txt | 38 ++ 6 files changed, 695 insertions(+), 24 deletions(-) create mode 100644 common_libs/tests/outcome_label_gate.py create mode 100644 tools/ab/arms_movement_outcome.txt diff --git a/ModularBot_garage/src/env_report.nim b/ModularBot_garage/src/env_report.nim index 56cfacb..413f955 100644 --- a/ModularBot_garage/src/env_report.nim +++ b/ModularBot_garage/src/env_report.nim @@ -511,7 +511,7 @@ proc knownEnvNames*(): seq[string] = LearnedDecayEveryEnv, LearnedDecayShiftEnv, LearnedAlphaEnv, LearnedTravelEnv, LearnedReversalEnv, LearnedPrefDistEnv, LearnedDistBandEnv, LearnedRadialFracEnv, LearnedWallMarginEnv, - LearnedGlobalEnv, LearnedLogEnv, + LearnedGlobalEnv, LearnedLabelEnv, LearnedLogEnv, # harness vars (read by the test framework, inherited by the bot, so they # must NOT be reported as typos) "TR_SERVER_JAR", "TR_BATTLE_RUNNER", "TR_BATTLE_RUNNER_DIR", diff --git a/ModularBot_garage/tests/test_learned_surfer.nim b/ModularBot_garage/tests/test_learned_surfer.nim index 67e853f..3243ea0 100644 --- a/ModularBot_garage/tests/test_learned_surfer.nim +++ b/ModularBot_garage/tests/test_learned_surfer.nim @@ -76,6 +76,12 @@ proc run(ticks: int, decayShift: int): LearnedSurferModule = for _ in 0.. 20 delEnv(LearnedGlobalEnv) + + # 8. outcome label: the 2-class counted SBC learns and reads a probability + putEnv(LearnedGlobalEnv, "") + let o = runOutcome(ticks = 600, decayShift = 1) + check "TR_LEARNED_LABEL=outcome still moves", o.decisions > 100 + check "outcome memory accumulated", + (block: + var n = 0 + for c in o.outcome.counters: + if c != 0'u8: inc n + n > 0) + check "outcome hit prior is a probability in [0,1]", + (block: + let p = o.predictHit(0, 0, 10) + p >= 0.0 and p <= 1.0) + let o2 = runOutcome(ticks = 300, decayShift = 1) + let o3 = runOutcome(ticks = 300, decayShift = 1) + check "outcome mode deterministic", + (block: + var n2, n3 = 0 + for c in o2.outcome.counters: + if c != 0'u8: inc n2 + for c in o3.outcome.counters: + if c != 0'u8: inc n3 + n2 == n3) putEnv(LearnedDecayShiftEnv, "") putEnv(LearnedDecayEveryEnv, "") loadLearnedEnv() diff --git a/common_libs/movements/learned_surfer.nim b/common_libs/movements/learned_surfer.nim index c916963..0dd7d95 100644 --- a/common_libs/movements/learned_surfer.nim +++ b/common_libs/movements/learned_surfer.nim @@ -62,10 +62,19 @@ ## TR_LEARNED_WALL_MARGIN wall margin (48 px) ## TR_LEARNED_GLOBAL =1: ignore the state (ABLATION, the same mover ## with a pure global histogram) +## TR_LEARNED_LABEL `histogram` (default) or `outcome` (job j130). +## `outcome` replaces the label (the GF bin we +## cross) with the OUTCOME: a counted SBC over +## (state, candidate bin) estimating P(hit | state, +## g), trained on the dense label `hit AND |g-b| <= +## window` (b = the bin the wave resolved at, window +## = the bot radius as an angle at the wave's +## distance). See docs/movement_campaign.md, +## "outcome label". ## TR_LEARNED_LOG per-decision log line import std/[math, os] -from std/strutils import parseFloat, parseInt, strip +from std/strutils import parseFloat, parseInt, strip, toLowerAscii import gun_harness/gun_interface import movement_harness/movement_interface import bitbrain/sbc @@ -98,8 +107,19 @@ const LearnedRadialFracEnv* = "TR_LEARNED_RADIAL_FRAC" LearnedWallMarginEnv* = "TR_LEARNED_WALL_MARGIN" LearnedGlobalEnv* = "TR_LEARNED_GLOBAL" + LearnedLabelEnv* = "TR_LEARNED_LABEL" LearnedLogEnv* = "TR_LEARNED_LOG" + ## 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. + OutcomePriorAlpha = 1.0 + +type + LearnedLabelMode* = enum + llHistogram ## label = the GF bin the wave crossed us at (j128) + llOutcome ## label = the dense hit outcome (job j130) + var LearnedDecayEvery* = 128 LearnedDecayShift* = 1 @@ -111,6 +131,7 @@ var LearnedRadialFrac* = 0.35 LearnedWallMargin* = 48.0 LearnedGlobal* = false + LearnedLabel* = llHistogram LearnedLog* = false proc getEnvFloat(name: string, default: float): float = @@ -144,6 +165,10 @@ proc loadLearnedEnv*() = LearnedWallMargin = max(0.0, getEnvFloat(LearnedWallMarginEnv, 48.0)) LearnedGlobal = envOn(LearnedGlobalEnv) LearnedLog = envOn(LearnedLogEnv) + LearnedLabel = + case getEnv(LearnedLabelEnv, "").strip().toLowerAscii() + of "outcome": llOutcome + else: llHistogram loadLearnedEnv() @@ -201,13 +226,17 @@ type power: float64 ticksLeft: int ## ticks until the nominal arrival fresh: bool ## created this tick: do not age it yet + selfEnergyAtFire: float64 ## our energy at the fire tick (hit detection) stateRow: int ## (vlat, dist) code stateCol: int ## (room, turn) code LearnedSurferModule* = object sbc*: Sbc + outcome*: Sbc ## llOutcome: (state, candidate bin) -> hit/miss glob*: array[LS_BINS, int] glc: int ## learns since the last global-histogram decay + hitGlobal: int ## llOutcome: hits seen (for the prior) + missGlobal: int ## llOutcome: non-hits seen (for the prior) scores: seq[float] waves: seq[LSWave] prevEnergy: seq[tuple[id: int, energy: float]] @@ -234,6 +263,8 @@ proc resetRound*(m: var LearnedSurferModule) = m.decisions = 0 for i in 0.. 0: LearnedDecayEvery * LS_BINS else: 0 + result.outcome = initCountedSbc(LS_STATES, 2, oe, max(0, LearnedDecayShift)) + result.outcome.decayEvery = oe + result.outcome.decayShift = max(0, LearnedDecayShift) result.scores = newSeq[float](LS_BINS) result.resetRound() proc resetBattle*(m: var LearnedSurferModule) = ## Hard wipe of the learned memory (round the SBC's counters and `clear`). m.sbc.clear() + m.outcome.clear() m.resetRound() proc clearGraphics*(m: var LearnedSurferModule) {.inline.} = discard @@ -260,18 +300,57 @@ proc prior(m: LearnedSurferModule, k: int): float = for g in m.glob: tot += g (float(m.glob[k]) + 1.0) / (float(tot) + float(LS_BINS)) -proc learnWave(m: var LearnedSurferModule, row, col, bin: int) = - ## One supervised sample: (state) -> (GF bin at arrival), in the counted SBC. - ## The SBC applies its own global decay every `decayEvery` learns; the global - ## histogram below is aged on the same schedule so the two stay comparable. - discard m.sbc.learn([row.int32], [col.int32], bin) - if m.glob[bin] < 255: inc m.glob[bin] - inc m.glc - if LearnedDecayEvery > 0 and LearnedDecayShift > 0 and - m.glc >= LearnedDecayEvery: - for k in 0.. training samples. + ## llHistogram: one sample, (state) -> (GF bin at arrival). + ## llOutcome: LS_BINS samples, (state, candidate g) -> hit, where the label + ## is `hit and |g - bin| <= window` (the dense counterfactual: + ## would this wave have hit us at candidate g?). `window` is + ## the bot radius as an angle at the wave's distance, in GF + ## bins — the angular/physical tolerance of the body. The SBC + ## applies its own global decay; the hit/miss counters below + ## are aged on the same schedule. + case LearnedLabel + of llHistogram: + discard m.sbc.learn([w.stateRow.int32], [w.stateCol.int32], bin) + if m.glob[bin] < 255: inc m.glob[bin] + inc m.glc + if LearnedDecayEvery > 0 and LearnedDecayShift > 0 and + m.glc >= LearnedDecayEvery: + for k in 0.. 1e-9: + (arcsin(min(BotRadius / max(w.startDist, 1.0), 1.0)) / maxA) * + float(LS_BINS - 1) / 2.0 + else: 0.0 + let sc = if LearnedGlobal: 0 else: w.stateRow * LS_Q * LS_Q + w.stateCol + for g in 0.. 0 and LearnedDecayShift > 0 and + m.glc >= LearnedDecayEvery * LS_BINS: + m.hitGlobal = m.hitGlobal - (m.hitGlobal shr LearnedDecayShift) + m.missGlobal = m.missGlobal - (m.missGlobal shr LearnedDecayShift) + m.glc = 0 + +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 + ## blended with the global hit rate. + let t = m.hitGlobal + m.missGlobal + let prior = if t == 0: 0.5 else: float(m.hitGlobal) / float(t) + let sc = if LearnedGlobal: 0 else: row * LS_Q * LS_Q + col + let c0 = m.outcome.countAt(sc, g, 0) + let c1 = m.outcome.countAt(sc, g, 1) + let n = c0 + c1 + if n == 0: return prior + (float(c1) + OutcomePriorAlpha * prior) / (float(n) + OutcomePriorAlpha) proc predictState*(m: var LearnedSurferModule, row, col: int, p: var array[LS_BINS, float]) = @@ -341,6 +420,7 @@ proc detectFire(m: var LearnedSurferModule, id: int, ex, ey, eenergy: float, startDist: d, power: drop, ticksLeft: max(1, int(ceil(d / max(bspeed, 1e-9)))), fresh: true, + selfEnergyAtFire: ws.selfEnergy, stateRow: row, stateCol: col, ) @@ -371,7 +451,11 @@ proc computeMove*(m: var LearnedSurferModule, ws: WorldState): MoveCommand = 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)) - m.learnWave(w.stateRow, w.stateCol, bin) + # 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, ",", @@ -381,10 +465,13 @@ proc computeMove*(m: var LearnedSurferModule, ws: WorldState): MoveCommand = inc i # ── 3. danger of every candidate bin, summed over every live wave ──────── + # llHistogram: precompute the predicted arrival-bin distribution per wave. + # llOutcome: danger(wave, g) = P(hit | wave.state, g), computed on demand. var ps: seq[array[LS_BINS, float]] - ps.setLen(m.waves.len) - for j in 0..= curGF: 1.0 else: -1.0 - var danger = ps[nearest][j] + var danger = + if LearnedLabel == llHistogram: ps[nearest][j] + else: m.predictHit(w.stateRow, w.stateCol, j) # every OTHER live wave contributes its own predicted mass at the bin the # candidate direction would land in for THAT wave. for k in 0.. ws.arenaWidth - LearnedWallMargin or @@ -464,12 +554,22 @@ proc computeMove*(m: var LearnedSurferModule, ws: WorldState): MoveCommand = m.dir = m.strafeDir inc m.decisions if LearnedLog: - var pAvg = 0.0 - for k in 0..bot increases diff --git a/common_libs/tests/outcome_label_gate.py b/common_libs/tests/outcome_label_gate.py new file mode 100644 index 0000000..527d2d5 --- /dev/null +++ b/common_libs/tests/outcome_label_gate.py @@ -0,0 +1,419 @@ +#!/usr/bin/env python3 +"""Gate A (OFFLINE VETO) for the OUTCOME-labelled learned movement (job j130). + +Question: does labelling a resolved wave by its OUTCOME (would this wave have +hit me at candidate direction g?) — instead of by the GF bin the wave crossed +at — fix the measured inversion in `learned_surfer_gate.py` section F, where +`corr( P(arrival bin), P(hit | arrival bin) ) = -0.342` over the 31 bins, i.e. +minimising the resolved-position histogram steers INTO the bullets? + +This is a VETO-ONLY harness (`docs/offline_harness_trust.md`): the live panel is +the decider, because the corpus was recorded while the enemy gun reacted to a +DIFFERENT mover. + +-------------------------------------------------------------------------------- +WHAT IS MEASURED +-------------------------------------------------------------------------------- +Corpus `/tmp/tfil_ab2/out` (70 recorded battles, ~55k shots). Per shot fired by +side `s` at the dodging subject side `e` (the same instrument as +`learned_surfer_gate.py`): + * state at the fire tick: vlat, dist, room, turn (the module's 4 fields); + * `b_our` the 31-bin GF of the dodger's position at the NOMINAL arrival + tick (the histogram label the j128 module trains on); + * `b_bullet` the 31-bin GF of the bullet's own straight line (from the fire + event's `dir`) — the physically correct "arrival bin"; + * `w` the body-width angular tolerance in bins: asin(R/d)/maxEA, R=18; + * `hit` the real server hit/miss outcome. + +The label the MODULE can actually compute live (it cannot see bullet bodies) is + `hit(state, g) = hit and |g - b_our| <= w` +i.e. "this wave hit me at b; it would also have hit me at any g within a body +width of b". This is dense: every wave labels all 31 candidates. + +Reported: + A. corr( LEARNED DANGER(g) , realised P(hit | b_our=g) ) over the 31 bins — + the exact metric that reads -0.342 for the histogram label. Computed for + the histogram danger, the module's outcome (hit-window) danger, and the + pure geometric bullet-line danger (the counterfactual if bullet bodies + were visible). + B. state-conditional information under the outcome label: held-out log-loss + of P(label | state, g) vs P(label | g) (state-free), paired per battle. + C. the open-loop decision counterfactual: if the mover picks argmin_g danger, + what fraction of held-out waves would still hit it (using b_bullet as the + ground truth)? Reported for the histogram and the outcome label, plus the + actual recorded trajectory as a floor. + +Run: + python3 common_libs/tests/outcome_label_gate.py \ + --corpus /tmp/tfil_ab2/out \ + --report common_libs/tests/fixtures/outcome_label_gate_report.txt +""" +from __future__ import annotations + +import argparse +import math +import os +import random +import statistics +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import analyze_drussgt_dodge_vs_power as adp # validated per-shot geometry + +NBINS = 31 +R = 18.0 +FIELDS = ["vlat", "dist", "room", "turn"] + +# The canonical, frozen edges hard-coded into `movements/learned_surfer.nim` +# (corpus quantiles). Using the deployed quantiser keeps the gate faithful to +# the module. +CANON = { + "vlat": [-6.736, 0.000, 6.753], + "dist": [431.321, 487.612, 552.670], + "room": [137.965, 206.589, 296.753], + "turn": [-0.142, 0.000, 0.105], +} + + +def wrap180(a): + return ((a + 180.0) % 360.0) - 180.0 + + +def gf_to_bin(gf): + v = max(-1.0, min(1.0, gf)) + return max(0, min(NBINS - 1, int(round((v + 1.0) * 0.5 * (NBINS - 1))))) + + +def bin_to_gf(i): + return i / (NBINS - 1) * 2.0 - 1.0 + + +def code_of(sample, edges): + c = 0 + for f in FIELDS: + cc = 0 + for e in edges[f]: + if sample[f] > e: + cc += 1 + c = c * 4 + cc + return c + + +# ------------------------------------------------------------------ extraction +def extract(runs): + recs = [] + for run in runs: + bn = (os.path.basename(os.path.dirname(run.cap_path)) + "/" + + os.path.basename(run.cap_path).replace(".jsonl", "")) + for sh in run.shots(): + t0, rnd = sh["tick"], sh["rnd"] + rs = run.start[rnd] + re = rs + run.count[rnd] - 1 + r0 = run.by_tick.get(t0) + rp = run.by_tick.get(t0 - 1) + if r0 is None or rp is None or t0 <= rs: + continue + p0x, p0y, speed = sh["_x"], sh["_y"], sh["speed"] + tb0 = math.atan2(r0["ey"] - p0y, r0["ex"] - p0x) + ux, uy = math.cos(tb0), math.sin(tb0) + + def lat_of(r): + dx, dy = r["ex"] - p0x, r["ey"] - p0y + return dx * (-uy) + dy * ux + + lat = lat_of(r0) + vlat = lat - lat_of(rp) + dist = math.hypot(r0["ex"] - p0x, r0["ey"] - p0y) + sg = 1.0 if vlat >= 0 else -1.0 + dxn, dyn = -uy * sg, ux * sg + t = float("inf") + for p, d, lo, hi in ((r0["ex"], dxn, R, 800 - R), + (r0["ey"], dyn, R, 600 - R)): + if abs(d) > 1e-9: + cand = (hi - p) / d if d > 0 else (lo - p) / d + if cand < t: + t = cand + room = 0.0 if t == float("inf") else max(0.0, t) + turn = wrap180(r0["eh"] - rp["eh"]) + + maxea = math.asin(min(8.0 / speed, 1.0)) + kfix = max(1, int(math.ceil(dist / speed))) + r = run.by_tick.get(t0 + kfix) + if r is None or t0 + kfix > re: + continue + off = wrap180(math.atan2(r["ey"] - p0y, r["ex"] - p0x) - tb0) + b_our = gf_to_bin(max(-1.0, min(1.0, off / maxea))) + b_bullet = gf_to_bin(max(-1.0, min(1.0, wrap180( + sh["_dir"] - math.degrees(tb0)) / math.degrees(maxea)))) + w = max(0.0, (math.asin(min(R / max(dist, 1e-6), 1.0)) / maxea) * + (NBINS - 1) / 2.0) + recs.append(dict(battle=bn, vlat=vlat, dist=dist, room=room, + turn=turn, b_our=b_our, b_bullet=b_bullet, + w=w, hit=sh["hit"])) + return recs + + +# ------------------------------------------------------------------- models +def split_battles(battles, seed, frac=0.7): + bs = sorted(battles) + random.Random(seed).shuffle(bs) + k = int(len(bs) * frac) + return set(bs[:k]), set(bs[k:]) + + +class HistModel: + """j128's counted SBC: state -> GF bin, per-cell posterior + prior blend.""" + + def __init__(self, decay=128, shift=1, alpha=5.0): + self.c = [[0] * NBINS for _ in range(256)] + self.g = [0] * NBINS + self.d, self.sh, self.a = decay, shift, alpha + self.lc = 0 + + def learn(self, st, label): + if self.c[st][label] < 255: + self.c[st][label] += 1 + self.g[label] += 1 + self.lc += 1 + if self.d > 0 and self.lc >= self.d: + for row in self.c: + for k in range(NBINS): + row[k] -= row[k] >> self.sh + for k in range(NBINS): + self.g[k] -= self.g[k] >> self.sh + self.lc = 0 + + def prior(self): + tot = sum(self.g) + return [(self.g[k] + 1.0) / (tot + NBINS) for k in range(NBINS)] + + def predict(self, st): + pr = self.prior() + n = sum(self.c[st]) + if n == 0: + return pr + return [((self.c[st][k] / n) + self.a * pr[k]) / (1.0 + self.a) + for k in range(NBINS)] + + +class OutcomeModel: + """The j130 module: counted 2-class SBC per (state, candidate bin). + + `label(state, g) = hit and |g - b_our| <= w` (the live-computable dense + outcome label). P(hit | state, g) is the per-cell posterior blended with the + global hit rate. `state_free` forces every wave into one state cell (the + ablation).""" + + def __init__(self, decay=128, shift=1, alpha=1.0, state_free=False): + self.c = [[[0, 0] for _ in range(NBINS)] for _ in range(256)] + self.tot = [0, 0] + self.d, self.sh, self.a = decay, shift, alpha + self.sf = state_free + self.lc = 0 + + def learn(self, st, g, lab): + c = self.c[0 if self.sf else st][g] + if c[lab] < 255: + c[lab] += 1 + self.tot[lab] += 1 + self.lc += 1 + if self.d > 0 and self.lc >= self.d: + for row in self.c: + for cell in row: + for k in (0, 1): + cell[k] -= cell[k] >> self.sh + for k in (0, 1): + self.tot[k] -= self.tot[k] >> self.sh + self.lc = 0 + + def prior_hit(self): + t = self.tot[0] + self.tot[1] + return 0.5 if t == 0 else self.tot[1] / t + + def predict_hit(self, st, g): + c = self.c[0 if self.sf else st][g] + n = c[0] + c[1] + pr = self.prior_hit() + if n == 0: + return pr + return (c[1] + self.a * pr) / (n + self.a) + + +def hitwin(r, g): + return 1 if (r["hit"] >= 0.5 and abs(g - r["b_our"]) <= r["w"]) else 0 + + +def log2(x): + return math.log2(max(x, 1e-12)) + + +# --------------------------------------------------------------------- gates +def gate_A_correlation(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 = [h[b] / n[b] for b in used] + out = {} + out["hist"] = statistics.correlation([n[b] / len(recs) for b in used], rate) + out["outcome"] = statistics.correlation( + [statistics.fmean([hitwin(r, b) for r in recs]) for b in used], rate) + out["bullet"] = statistics.correlation( + [statistics.fmean([1 if abs(b - r["b_bullet"]) <= r["w"] else 0 + for r in recs]) for b in used], rate) + return out, used, rate + + +def run_split(recs, seed, decay=128, shift=1): + tr_b, te_b = 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(CANON) + + hist = HistModel(decay, shift) + for r in tr: + hist.learn(code_of(r, edges), r["b_our"]) + + om = OutcomeModel(decay, shift, state_free=False) + om0 = OutcomeModel(decay, shift, state_free=True) + for r in tr: + st = code_of(r, edges) + for g in range(NBINS): + lab = hitwin(r, g) + om.learn(st, g, lab) + om0.learn(st, g, lab) + + ll_s, ll_g = [], [] + hit_hist, hit_out, hit_cur = [], [], [] + for r in te: + st = code_of(r, edges) + ph = hist.predict(st) + gh = min(range(NBINS), key=lambda g: ph[g]) + 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_hist.append(real(gh)) + hit_out.append(real(go)) + hit_cur.append(r["hit"]) + # held-out log-loss of the outcome label at every candidate g + for g in range(NBINS): + y = hitwin(r, g) + ll_s.append(-log2(om.predict_hit(st, g)) if y else + -log2(1.0 - om.predict_hit(st, g))) + ll_g.append(-log2(om0.predict_hit(st, g)) if y else + -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_hist=statistics.fmean(hit_hist), + hit_out=statistics.fmean(hit_out), + hit_cur=statistics.fmean(hit_cur), + ) + + +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 = extract(runs) + + lines = [] + + def out(s=""): + print(s) + lines.append(s) + + out("# Outcome-label Gate A — offline sanity check (VETO ONLY)") + 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 (the module's 4 fields, canonical edges)") + out("label : outcome hit(state,g) = hit and |g - b_our| <= w " + "(w = body width as an angle)") + out() + + corr, used, rate = gate_A_correlation(recs) + out("## A. is the danger map the mover MINIMISES aligned with the realised " + "per-bin hit rate?") + out() + out("corr( danger(g) , P(hit | b_our = g) ) over the 31 bins:") + out() + out("| danger map | corr |") + out("|---|---:|") + out(f"| histogram label (j128) — P(arrival bin = g) | {corr['hist']:+.3f} |") + out(f"| **outcome label (j130, the module's live label)** | " + f"**{corr['outcome']:+.3f}** |") + out(f"| geometric bullet-line label (needs bullet bodies) | " + f"{corr['bullet']:+.3f} |") + out() + out("Negative = minimising the danger steers INTO the bullets (the j128 " + "defect). The histogram reproduces the ledger's -0.342.") + out() + out("| bin | P(hit) | P(arrival=bin) | outcome danger |") + out("|---:|---:|---:|---:|") + for b in used: + d = statistics.fmean([hitwin(r, b) for r in recs]) + m = sum(1 for r in recs if r["b_our"] == b) / len(recs) + out(f"| {b} | {rate[used.index(b)]*100:.1f}% | {m*100:.1f}% | {d:.3f} |") + out() + + per = [run_split(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 OUTCOME label") + out() + out("held-out per-candidate log-loss (bits) of the outcome label, " + "state-conditional vs state-free (same rows, same split):") + out() + out("| model | log-loss (bits) |") + out("|---|---:|") + out(f"| state-free P(hit | g) | {ll_g:.4f} |") + out(f"| state-conditional P(hit | 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("If the mover picks argmin_g danger, the fraction of held-out waves " + "whose bullet line would still pass within a body width of g " + "(ground truth = the recorded bullet line b_bullet).") + out() + out("| policy | held-out waves still hit |") + out("|---|---:|") + out(f"| histogram argmin (j128) | {statistics.fmean(p['hit_hist'] for p in per)*100:.2f}% |") + out(f"| outcome argmin (j130) | {statistics.fmean(p['hit_out'] for p in per)*100:.2f}% |") + out(f"| recorded trajectory (floor/ceiling) | {statistics.fmean(p['hit_cur'] for p in per)*100:.2f}% |") + out() + out("The counterfactual is OPEN LOOP: the recorded bullet lines were fired " + "at a different mover, so it cannot predict the live closed loop. It is " + "a veto, not a selection.") + out() + + out("## MEASURED vs INFERRED") + out() + out("* MEASURED: every number above, on the recorded corpus.") + out("* INFERRED: that the offline alignment transfers live. It cannot — " + "see 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() diff --git a/docs/movement_campaign.md b/docs/movement_campaign.md index 364235e..b85f364 100644 --- a/docs/movement_campaign.md +++ b/docs/movement_campaign.md @@ -2009,3 +2009,86 @@ one. **Status: the default is UNCHANGED (`TR_MOVEMENT=strafe`); the module is default-off behind `TR_MOVEMENT=learned`.** Revert = do not set the env var. + +--- + +## Learned movement — outcome label (P(hit)) — PRE-REGISTRATION (written BEFORE any battle) + +**The change.** j128 labelled a resolved wave by the 31-bin **GF bin we crossed +at**, and measured `corr( P(arrival bin), P(hit | arrival bin) ) = −0.342` over +the 31 bins (`learned_surfer_gate.py` section F): the least-visited bins are the +ones the gun lands the most hits in, so minimising the resolved-position +histogram steers **into** the bullets. j130 stops predicting *where* the wave +goes and learns the **outcome** directly: + +> `hit(state, g) = hit and |g − b| <= window(wave)` — would this wave have hit +> me at candidate direction `g`? + +where `b` is the bin the wave resolved at and `window` is the bot's body width +as an angle at that wave's distance, in GF bins +(`asin(18 / d) / asin(8 / speed) · (31−1)/2`). One resolved wave yields a label +for **every** candidate direction (dense), which attacks the volume/starvation +constraint. The learner stays the counted+decayed SBC (`common_libs/bitbrain`, +in a 2-class readout `P(hit | state, g)`), the geometry, penalties and mover are +j128's, so the two labels are isolated against each other. + +**New knob:** `TR_LEARNED_LABEL=histogram` (default — today's behaviour) or +`outcome`; registered in `env_report.knownEnvNames()`. Both are default-off +behind `TR_MOVEMENT=learned`; the shipped `strafe` default is untouched. + +**Gate A (offline veto) — `common_libs/tests/outcome_label_gate.py`, corpus +`/tmp/tfil_ab2/out`, 70 battles, 54 923 shots, split BY BATTLE 70/30, 3 seeds, +the module's canonical state edges.** + +* **Alignment.** `corr( learned danger(g), P(hit | b_our=g) )` over the 31 bins: + histogram **−0.341**; the module's live outcome label (hit-window around the + resolved bin) **+0.566**; the pure geometric bullet-line label (needs bullet + bodies, not available live) −0.230. **The correlation flips positive, so the + veto does NOT fire.** +* **State-conditional information.** Held-out per-candidate log-loss of the + outcome label: state-free `P(hit | g)` **0.1873 bits**, state-conditional + `P(hit | state, g)` **0.3906 bits** (Δ **+0.203**, better in **0/3** splits): + under the outcome label the coarse state does **not** help — it overfits. +* **Open-loop decision counterfactual** (argmin danger, ground truth = the + recorded bullet line; veto-only): histogram 3.53%, outcome 3.33%, recorded + trajectory 10.17% — the counterfactual **barely moves**. + +**Pre-registered arms** (`tools/ab/arms_movement_outcome.txt`), frozen panel +`tools/ab/panel_movement.txt`, 3 runs × 3 rounds, `--reference strafe`: + +| arm | env | isolates | +|---|---|---| +| `strafe` | `TR_MOVEMENT=strafe` | the shipped champion — has to be beaten | +| `learned` | `TR_MOVEMENT=learned` | the **old label** (j128 arrival bin) | +| `learned_outcome` | `+ TR_LEARNED_LABEL=outcome` | the **new label** (dense P(hit)) | +| `learned_outcome_global` | `+ TR_LEARNED_LABEL=outcome TR_LEARNED_GLOBAL=1` | the information control: outcome label, state OFF | + +**Pre-registered decision rules (fixed before any battle):** + +1. **Win leg (primary, the standing rule).** Cross-opponent sign-flip + permutation test on the paired per-opponent Δwins/run, two-sided p < 0.05, + AND the pooled 95% CI excludes 0, AND the point estimate is positive in the + challenger's favour. Only then does an arm "beat" `strafe`. +2. **Mechanism leg.** The same test on the **incoming hit rate** (the dodging + metric, and the mechanism the outcome label claims). A hit-rate win with a + flat win leg is "dodges better, wins the same", not a win. +3. **Information-vs-learner split (declared now).** + * `learned_outcome` ≈ `learned_outcome_global` ⇒ the failure is the + **information** (the state is uninformative under the outcome label too). + * `learned_outcome` > `learned_outcome_global` but `learned_outcome` ≤ + `strafe` ⇒ the state helps relative to its own ablation but the learned + family is still behind the hand-tuned champion. + * `learned_outcome` > `learned` (on hit rate) ⇒ the new label is a genuine + improvement over the old one, even if the family loses to `strafe`. +4. **The default is NOT touched.** `strafe` stays shipped whatever the result. + +**Pre-registered prediction (honest prior).** Gate A's alignment flips positive +but the state buys no held-out information under the outcome label and the +decision counterfactual is flat, so I predict **`learned_outcome` will NOT beat +`strafe` on round wins**, that its hit rate will be within noise of `strafe`'s, +and that `learned_outcome` ≈ `learned_outcome_global` — i.e. the failure is in +the information, not in the learner or the label. A negative is the expected, +fully successful outcome. + +**Session:** `/tmp/ab/j130_outcome`, frozen from the commit that contains this +pre-registration. diff --git a/tools/ab/arms_movement_outcome.txt b/tools/ab/arms_movement_outcome.txt new file mode 100644 index 0000000..1db58bb --- /dev/null +++ b/tools/ab/arms_movement_outcome.txt @@ -0,0 +1,38 @@ +# ───────────────────────────────────────────────────────────────────────────── +# arms_movement_outcome.txt — the OUTCOME-LABELLED learned-movement arms +# (job j130), on the FROZEN panel tools/ab/panel_movement.txt. +# +# Pre-registered in docs/movement_campaign.md, "Learned movement — outcome +# label (P(hit))", BEFORE any battle. REFERENCE is `strafe` — the SHIPPED +# champion. Every delta is (arm − strafe). +# +# j128 measured `corr( P(arrival bin), P(hit | arrival bin) ) = -0.342` over the +# 31 bins: minimising the resolved-position histogram steers INTO the bullets. +# j130 replaces the label with the dense outcome +# hit(state, g) = hit and |g - b_our| <= window(wave) +# and learns P(hit | state, candidate g) with a counted 2-class SBC. +# +# Gate A (common_libs/tests/outcome_label_gate.py, corpus /tmp/tfil_ab2/out): +# the alignment correlation flips to +0.566 (histogram -0.341), so the veto +# does NOT fire; the state-conditional outcome model however is NOT better than +# the state-free one on held-out log-loss, and the open-loop decision +# counterfactual barely moves (3.53% -> 3.33%). The live panel decides. +# +# Format: name | ENV=value ENV=value | label +# ───────────────────────────────────────────────────────────────────────────── + +# 1. THE CHAMPION — the arm a challenger has to beat (round wins + hit rate). +strafe | TR_MOVEMENT=strafe | champion/reference — shipped strafe defaults + +# 2. THE OLD LABEL — j128's state-conditional counted SBC (arrival-bin label), +# so the new label is isolated against the old one on the same binary. +learned | TR_MOVEMENT=learned | j128 histogram label (arrival bin) + +# 3. THE NEW LABEL — the same mover, same geometry, same counted+decayed SBC +# and penalties; only the training label changes (dense hit outcome). +learned_outcome | TR_MOVEMENT=learned TR_LEARNED_LABEL=outcome | outcome label P(hit | state, g) + +# 4. INFORMATION CONTROL — the outcome label with the state forced to one cell +# (state-free outcome model). Isolates whether the state carries anything +# under the new label (Gate A says it does not). +learned_outcome_global | TR_MOVEMENT=learned TR_LEARNED_LABEL=outcome TR_LEARNED_GLOBAL=1 | outcome label, state conditioning OFF