j130 learned movement: outcome label (P(hit|state,g)) mode + Gate A; pre-registered outcome arms
This commit is contained in:
@@ -511,7 +511,7 @@ proc knownEnvNames*(): seq[string] =
|
|||||||
LearnedDecayEveryEnv, LearnedDecayShiftEnv, LearnedAlphaEnv,
|
LearnedDecayEveryEnv, LearnedDecayShiftEnv, LearnedAlphaEnv,
|
||||||
LearnedTravelEnv, LearnedReversalEnv, LearnedPrefDistEnv,
|
LearnedTravelEnv, LearnedReversalEnv, LearnedPrefDistEnv,
|
||||||
LearnedDistBandEnv, LearnedRadialFracEnv, LearnedWallMarginEnv,
|
LearnedDistBandEnv, LearnedRadialFracEnv, LearnedWallMarginEnv,
|
||||||
LearnedGlobalEnv, LearnedLogEnv,
|
LearnedGlobalEnv, LearnedLabelEnv, LearnedLogEnv,
|
||||||
# harness vars (read by the test framework, inherited by the bot, so they
|
# harness vars (read by the test framework, inherited by the bot, so they
|
||||||
# must NOT be reported as typos)
|
# must NOT be reported as typos)
|
||||||
"TR_SERVER_JAR", "TR_BATTLE_RUNNER", "TR_BATTLE_RUNNER_DIR",
|
"TR_SERVER_JAR", "TR_BATTLE_RUNNER", "TR_BATTLE_RUNNER_DIR",
|
||||||
|
|||||||
@@ -76,6 +76,12 @@ proc run(ticks: int, decayShift: int): LearnedSurferModule =
|
|||||||
for _ in 0..<ticks:
|
for _ in 0..<ticks:
|
||||||
discard result.step(s)
|
discard result.step(s)
|
||||||
|
|
||||||
|
proc runOutcome(ticks: int, decayShift: int): LearnedSurferModule =
|
||||||
|
putEnv(LearnedLabelEnv, "outcome")
|
||||||
|
result = run(ticks, decayShift)
|
||||||
|
putEnv(LearnedLabelEnv, "")
|
||||||
|
loadLearnedEnv()
|
||||||
|
|
||||||
proc counters(m: LearnedSurferModule): int =
|
proc counters(m: LearnedSurferModule): int =
|
||||||
var n = 0
|
var n = 0
|
||||||
for c in m.sbc.counters:
|
for c in m.sbc.counters:
|
||||||
@@ -135,6 +141,31 @@ proc main() =
|
|||||||
let g = run(ticks = 200, decayShift = 1)
|
let g = run(ticks = 200, decayShift = 1)
|
||||||
check "TR_LEARNED_GLOBAL still moves (prior-only map)", g.decisions > 20
|
check "TR_LEARNED_GLOBAL still moves (prior-only map)", g.decisions > 20
|
||||||
delEnv(LearnedGlobalEnv)
|
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(LearnedDecayShiftEnv, "")
|
||||||
putEnv(LearnedDecayEveryEnv, "")
|
putEnv(LearnedDecayEveryEnv, "")
|
||||||
loadLearnedEnv()
|
loadLearnedEnv()
|
||||||
|
|||||||
@@ -62,10 +62,19 @@
|
|||||||
## TR_LEARNED_WALL_MARGIN wall margin (48 px)
|
## TR_LEARNED_WALL_MARGIN wall margin (48 px)
|
||||||
## TR_LEARNED_GLOBAL =1: ignore the state (ABLATION, the same mover
|
## TR_LEARNED_GLOBAL =1: ignore the state (ABLATION, the same mover
|
||||||
## with a pure global histogram)
|
## 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
|
## TR_LEARNED_LOG per-decision log line
|
||||||
|
|
||||||
import std/[math, os]
|
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 gun_harness/gun_interface
|
||||||
import movement_harness/movement_interface
|
import movement_harness/movement_interface
|
||||||
import bitbrain/sbc
|
import bitbrain/sbc
|
||||||
@@ -98,8 +107,19 @@ const
|
|||||||
LearnedRadialFracEnv* = "TR_LEARNED_RADIAL_FRAC"
|
LearnedRadialFracEnv* = "TR_LEARNED_RADIAL_FRAC"
|
||||||
LearnedWallMarginEnv* = "TR_LEARNED_WALL_MARGIN"
|
LearnedWallMarginEnv* = "TR_LEARNED_WALL_MARGIN"
|
||||||
LearnedGlobalEnv* = "TR_LEARNED_GLOBAL"
|
LearnedGlobalEnv* = "TR_LEARNED_GLOBAL"
|
||||||
|
LearnedLabelEnv* = "TR_LEARNED_LABEL"
|
||||||
LearnedLogEnv* = "TR_LEARNED_LOG"
|
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
|
var
|
||||||
LearnedDecayEvery* = 128
|
LearnedDecayEvery* = 128
|
||||||
LearnedDecayShift* = 1
|
LearnedDecayShift* = 1
|
||||||
@@ -111,6 +131,7 @@ var
|
|||||||
LearnedRadialFrac* = 0.35
|
LearnedRadialFrac* = 0.35
|
||||||
LearnedWallMargin* = 48.0
|
LearnedWallMargin* = 48.0
|
||||||
LearnedGlobal* = false
|
LearnedGlobal* = false
|
||||||
|
LearnedLabel* = llHistogram
|
||||||
LearnedLog* = false
|
LearnedLog* = false
|
||||||
|
|
||||||
proc getEnvFloat(name: string, default: float): float =
|
proc getEnvFloat(name: string, default: float): float =
|
||||||
@@ -144,6 +165,10 @@ proc loadLearnedEnv*() =
|
|||||||
LearnedWallMargin = max(0.0, getEnvFloat(LearnedWallMarginEnv, 48.0))
|
LearnedWallMargin = max(0.0, getEnvFloat(LearnedWallMarginEnv, 48.0))
|
||||||
LearnedGlobal = envOn(LearnedGlobalEnv)
|
LearnedGlobal = envOn(LearnedGlobalEnv)
|
||||||
LearnedLog = envOn(LearnedLogEnv)
|
LearnedLog = envOn(LearnedLogEnv)
|
||||||
|
LearnedLabel =
|
||||||
|
case getEnv(LearnedLabelEnv, "").strip().toLowerAscii()
|
||||||
|
of "outcome": llOutcome
|
||||||
|
else: llHistogram
|
||||||
|
|
||||||
loadLearnedEnv()
|
loadLearnedEnv()
|
||||||
|
|
||||||
@@ -201,13 +226,17 @@ type
|
|||||||
power: float64
|
power: float64
|
||||||
ticksLeft: int ## ticks until the nominal arrival
|
ticksLeft: int ## ticks until the nominal arrival
|
||||||
fresh: bool ## created this tick: do not age it yet
|
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
|
stateRow: int ## (vlat, dist) code
|
||||||
stateCol: int ## (room, turn) code
|
stateCol: int ## (room, turn) code
|
||||||
|
|
||||||
LearnedSurferModule* = object
|
LearnedSurferModule* = object
|
||||||
sbc*: Sbc
|
sbc*: Sbc
|
||||||
|
outcome*: Sbc ## llOutcome: (state, candidate bin) -> hit/miss
|
||||||
glob*: array[LS_BINS, int]
|
glob*: array[LS_BINS, int]
|
||||||
glc: int ## learns since the last global-histogram decay
|
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]
|
scores: seq[float]
|
||||||
waves: seq[LSWave]
|
waves: seq[LSWave]
|
||||||
prevEnergy: seq[tuple[id: int, energy: float]]
|
prevEnergy: seq[tuple[id: int, energy: float]]
|
||||||
@@ -234,6 +263,8 @@ proc resetRound*(m: var LearnedSurferModule) =
|
|||||||
m.decisions = 0
|
m.decisions = 0
|
||||||
for i in 0..<LS_BINS: m.glob[i] = 0
|
for i in 0..<LS_BINS: m.glob[i] = 0
|
||||||
m.glc = 0
|
m.glc = 0
|
||||||
|
m.hitGlobal = 0
|
||||||
|
m.missGlobal = 0
|
||||||
|
|
||||||
proc initLearnedSurfer*(): LearnedSurferModule =
|
proc initLearnedSurfer*(): LearnedSurferModule =
|
||||||
result.debugGraphics = false
|
result.debugGraphics = false
|
||||||
@@ -242,12 +273,21 @@ proc initLearnedSurfer*(): LearnedSurferModule =
|
|||||||
max(0, LearnedDecayShift))
|
max(0, LearnedDecayShift))
|
||||||
result.sbc.decayEvery = max(0, LearnedDecayEvery)
|
result.sbc.decayEvery = max(0, LearnedDecayEvery)
|
||||||
result.sbc.decayShift = max(0, LearnedDecayShift)
|
result.sbc.decayShift = max(0, LearnedDecayShift)
|
||||||
|
# The outcome model learns LS_BINS samples per resolved wave, so its decay
|
||||||
|
# period is scaled to the same WAVE cadence as the histogram's (decayEvery
|
||||||
|
# waves), keeping the two labels' forgetting comparable.
|
||||||
|
block:
|
||||||
|
let oe = if LearnedDecayEvery > 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.scores = newSeq[float](LS_BINS)
|
||||||
result.resetRound()
|
result.resetRound()
|
||||||
|
|
||||||
proc resetBattle*(m: var LearnedSurferModule) =
|
proc resetBattle*(m: var LearnedSurferModule) =
|
||||||
## Hard wipe of the learned memory (round the SBC's counters and `clear`).
|
## Hard wipe of the learned memory (round the SBC's counters and `clear`).
|
||||||
m.sbc.clear()
|
m.sbc.clear()
|
||||||
|
m.outcome.clear()
|
||||||
m.resetRound()
|
m.resetRound()
|
||||||
|
|
||||||
proc clearGraphics*(m: var LearnedSurferModule) {.inline.} = discard
|
proc clearGraphics*(m: var LearnedSurferModule) {.inline.} = discard
|
||||||
@@ -260,11 +300,19 @@ proc prior(m: LearnedSurferModule, k: int): float =
|
|||||||
for g in m.glob: tot += g
|
for g in m.glob: tot += g
|
||||||
(float(m.glob[k]) + 1.0) / (float(tot) + float(LS_BINS))
|
(float(m.glob[k]) + 1.0) / (float(tot) + float(LS_BINS))
|
||||||
|
|
||||||
proc learnWave(m: var LearnedSurferModule, row, col, bin: int) =
|
proc learnWave(m: var LearnedSurferModule, w: LSWave, bin: int, hit: bool) =
|
||||||
## One supervised sample: (state) -> (GF bin at arrival), in the counted SBC.
|
## One resolved wave -> training samples.
|
||||||
## The SBC applies its own global decay every `decayEvery` learns; the global
|
## llHistogram: one sample, (state) -> (GF bin at arrival).
|
||||||
## histogram below is aged on the same schedule so the two stay comparable.
|
## llOutcome: LS_BINS samples, (state, candidate g) -> hit, where the label
|
||||||
discard m.sbc.learn([row.int32], [col.int32], bin)
|
## 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]
|
if m.glob[bin] < 255: inc m.glob[bin]
|
||||||
inc m.glc
|
inc m.glc
|
||||||
if LearnedDecayEvery > 0 and LearnedDecayShift > 0 and
|
if LearnedDecayEvery > 0 and LearnedDecayShift > 0 and
|
||||||
@@ -272,6 +320,37 @@ proc learnWave(m: var LearnedSurferModule, row, col, bin: int) =
|
|||||||
for k in 0..<LS_BINS:
|
for k in 0..<LS_BINS:
|
||||||
m.glob[k] = m.glob[k] - (m.glob[k] shr LearnedDecayShift)
|
m.glob[k] = m.glob[k] - (m.glob[k] shr LearnedDecayShift)
|
||||||
m.glc = 0
|
m.glc = 0
|
||||||
|
of llOutcome:
|
||||||
|
let maxA = mea(w.speed)
|
||||||
|
let wbin =
|
||||||
|
if maxA > 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..<LS_BINS:
|
||||||
|
let lab = if hit and abs(float(g - bin)) <= wbin: 1 else: 0
|
||||||
|
discard m.outcome.learn([sc.int32], [g.int32], lab)
|
||||||
|
inc m.glc
|
||||||
|
if hit: inc m.hitGlobal else: inc m.missGlobal
|
||||||
|
if LearnedDecayEvery > 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,
|
proc predictState*(m: var LearnedSurferModule, row, col: int,
|
||||||
p: var array[LS_BINS, float]) =
|
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,
|
startDist: d, power: drop,
|
||||||
ticksLeft: max(1, int(ceil(d / max(bspeed, 1e-9)))),
|
ticksLeft: max(1, int(ceil(d / max(bspeed, 1e-9)))),
|
||||||
fresh: true,
|
fresh: true,
|
||||||
|
selfEnergyAtFire: ws.selfEnergy,
|
||||||
stateRow: row, stateCol: col,
|
stateRow: row, stateCol: col,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -371,7 +451,11 @@ proc computeMove*(m: var LearnedSurferModule, ws: WorldState): MoveCommand =
|
|||||||
if maxA >= 1e-9:
|
if maxA >= 1e-9:
|
||||||
let off = wrapPi(arctan2(botY - w.originY, botX - w.originX) - w.bearing)
|
let off = wrapPi(arctan2(botY - w.originY, botX - w.originX) - w.bearing)
|
||||||
let bin = gfToBin(clamp(off / maxA, -1.0, 1.0))
|
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:
|
if LearnedLog:
|
||||||
echo "[learned] resolve bin=", bin, " state=", w.stateRow, "/",
|
echo "[learned] resolve bin=", bin, " state=", w.stateRow, "/",
|
||||||
w.stateCol, " d=", w.startDist.int, " e=", w.originX.int, ",",
|
w.stateCol, " d=", w.startDist.int, " e=", w.originX.int, ",",
|
||||||
@@ -381,7 +465,10 @@ proc computeMove*(m: var LearnedSurferModule, ws: WorldState): MoveCommand =
|
|||||||
inc i
|
inc i
|
||||||
|
|
||||||
# ── 3. danger of every candidate bin, summed over every live wave ────────
|
# ── 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]]
|
var ps: seq[array[LS_BINS, float]]
|
||||||
|
if LearnedLabel == llHistogram:
|
||||||
ps.setLen(m.waves.len)
|
ps.setLen(m.waves.len)
|
||||||
for j in 0..<m.waves.len:
|
for j in 0..<m.waves.len:
|
||||||
m.predictState(m.waves[j].stateRow, m.waves[j].stateCol, ps[j])
|
m.predictState(m.waves[j].stateRow, m.waves[j].stateCol, ps[j])
|
||||||
@@ -433,7 +520,9 @@ proc computeMove*(m: var LearnedSurferModule, ws: WorldState): MoveCommand =
|
|||||||
let futureY = botY + uy * dodgeDist
|
let futureY = botY + uy * dodgeDist
|
||||||
bestDirs[j] = if gfJ >= curGF: 1.0 else: -1.0
|
bestDirs[j] = if gfJ >= 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
|
# every OTHER live wave contributes its own predicted mass at the bin the
|
||||||
# candidate direction would land in for THAT wave.
|
# candidate direction would land in for THAT wave.
|
||||||
for k in 0..<m.waves.len:
|
for k in 0..<m.waves.len:
|
||||||
@@ -445,7 +534,8 @@ proc computeMove*(m: var LearnedSurferModule, ws: WorldState): MoveCommand =
|
|||||||
let off2 = wrapPi(arctan2(futureY - w2.originY, futureX - w2.originX) -
|
let off2 = wrapPi(arctan2(futureY - w2.originY, futureX - w2.originX) -
|
||||||
w2.bearing)
|
w2.bearing)
|
||||||
let b2 = gfToBin(clamp(off2 / mA2, -1.0, 1.0))
|
let b2 = gfToBin(clamp(off2 / mA2, -1.0, 1.0))
|
||||||
danger += ps[k][b2]
|
if LearnedLabel == llHistogram: danger += ps[k][b2]
|
||||||
|
else: danger += m.predictHit(w2.stateRow, w2.stateCol, b2)
|
||||||
|
|
||||||
let wallHit = futureX < LearnedWallMargin or
|
let wallHit = futureX < LearnedWallMargin or
|
||||||
futureX > ws.arenaWidth - LearnedWallMargin or
|
futureX > ws.arenaWidth - LearnedWallMargin or
|
||||||
@@ -464,12 +554,22 @@ proc computeMove*(m: var LearnedSurferModule, ws: WorldState): MoveCommand =
|
|||||||
m.dir = m.strafeDir
|
m.dir = m.strafeDir
|
||||||
inc m.decisions
|
inc m.decisions
|
||||||
if LearnedLog:
|
if LearnedLog:
|
||||||
var pAvg = 0.0
|
var pAvg, pSafe, pCur: float
|
||||||
|
if LearnedLabel == llHistogram:
|
||||||
|
pAvg = 0.0
|
||||||
for k in 0..<LS_BINS: pAvg += ps[nearest][k] / float(LS_BINS)
|
for k in 0..<LS_BINS: pAvg += ps[nearest][k] / float(LS_BINS)
|
||||||
|
pSafe = ps[nearest][bestBin]
|
||||||
|
pCur = ps[nearest][gfToBin(curGF)]
|
||||||
|
else:
|
||||||
|
pAvg = 0.0
|
||||||
|
for k in 0..<LS_BINS:
|
||||||
|
pAvg += m.predictHit(w.stateRow, w.stateCol, k) / float(LS_BINS)
|
||||||
|
pSafe = m.predictHit(w.stateRow, w.stateCol, bestBin)
|
||||||
|
pCur = m.predictHit(w.stateRow, w.stateCol, gfToBin(curGF))
|
||||||
echo "[learned] wave d=", d.int, " curGF=", (curGF * 100.0).int,
|
echo "[learned] wave d=", d.int, " curGF=", (curGF * 100.0).int,
|
||||||
" bestGF=", (bestGF * 100.0).int, " dir=", m.strafeDir.int,
|
" bestGF=", (bestGF * 100.0).int, " dir=", m.strafeDir.int,
|
||||||
" pSafe=", (ps[nearest][bestBin] * 1000.0).int,
|
" pSafe=", (pSafe * 1000.0).int,
|
||||||
" pCur=", (ps[nearest][gfToBin(curGF)] * 1000.0).int,
|
" pCur=", (pCur * 1000.0).int,
|
||||||
" pAvg=", (pAvg * 1000.0).int,
|
" pAvg=", (pAvg * 1000.0).int,
|
||||||
" state=", m.waves[nearest].stateRow, "/", m.waves[nearest].stateCol
|
" state=", m.waves[nearest].stateRow, "/", m.waves[nearest].stateCol
|
||||||
# perpendicular in the wave's own frame: +90 deg from origin->bot increases
|
# perpendicular in the wave's own frame: +90 deg from origin->bot increases
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -2009,3 +2009,86 @@ one.
|
|||||||
|
|
||||||
**Status: the default is UNCHANGED (`TR_MOVEMENT=strafe`); the module is
|
**Status: the default is UNCHANGED (`TR_MOVEMENT=strafe`); the module is
|
||||||
default-off behind `TR_MOVEMENT=learned`.** Revert = do not set the env var.
|
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.
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user