Files
SirRoboGarage/common_libs/guns/bitbrain_gun.nim
T
SirStone c305ef4212 BitBrain campaign phase 1: the missing gain sweep and BitBrain as a lead-gain corrector
Task A — the gain region Phase 0 never covered (gain < 1). Extend the
prediction-quality ruler with gain 0.25/0.50/0.75 arms and a fixed causal
per-band arm. Full 70-run result: the hitProxy-argmax curve is
[1.00, 1.00, 1.00, 0.00, 0.00] — Pattern below 300 px, HeadOn above —
worth +0.13 pp at 300-450 and +2.16 pp at 450+ (0.0767 -> 0.0984). Lead
correlation is identical for every g>0 (Pearson is scale-invariant), so a
shrinking gain adds no lead information; and the least-squares optimum
[1,1,1,1,0.25,0.25] diverges from the hitProxy optimum because Pattern's
lead errors are bimodal.

Task B — rebuild guns/bitbrain_gun.nim as a lead-gain corrector:
aim = LOS + gain*(patternAim - LOS), gain learned online per range band by
ranking candidate gains on the hit-probability proxy (the observed lead
label via tmhObservedAt), gated to range >= 300 px. ADE+SBC output removed.
Offline (70 runs): Pattern below 300 px, +0.37 pp at 300-450, +1.90 pp at
450+ (hitProxy 0.0957 vs 0.0767), matching the fixed rule to within 0.27 pp
at 450+ and exceeding it at 300-450. ~0.0007 ms/tick marginal (old gun
~0.114 ms/tick). Default off; rack membership, env report and guard tests
(bitbrain 32, registration 13, rack 48, tm_pattern 20, env_report) unchanged
and green.

Ledger: docs/bitbrain_campaign.md Phase 1, including the three on-file
negatives and the causal-shippability note. No live claim.
2026-09-25 00:10:19 +02:00

448 lines
21 KiB
Nim
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## bitbrain_gun.nim — BitBrain (id 16), REBUILT as a LEAD-GAIN CORRECTOR.
##
## ── WHY THIS FILE WAS REWRITTEN (Phase 0/1 evidence) ──────────────────────────
## The previous design was an ADDITIVE angular shift: an ADE+SBC network
## classified the +h-tick angular error over ±`TR_BITBRAIN_RANGE` degrees and
## added the argmax class centre to Pattern's bearing. Phase 0 measured it as
## statistically identical to Pattern (`docs/bitbrain_gun_verdict.md`,
## commit d93ce44) and as carrying no measurable aim information
## (450+: 16.200 deg vs Pattern's 16.193; `docs/bitbrain_campaign.md` §0.3.5).
##
## Phase 1 measured the actual lever. The gain sweep found that gains >= 1 are
## strictly worse at every band and that the optimal gain is BELOW 1.0 at long
## range (450+: ~0.25). A fractional gain leaves the Pearson lead *correlation*
## unchanged (correlation is invariant under positive scaling), so a smaller
## gain does not add information — it shrinks the magnitude of an uninformative
## Pattern lead toward the low-variance static (HeadOn) aim. The right output is
## therefore a multiplicative GAIN on Pattern's lead, not a class-based additive
## shift. See `docs/bitbrain_campaign.md` §Phase 1 for the measured curve.
##
## ── THE DESIGN ────────────────────────────────────────────────────────────────
## * BASE — the shipped Pattern gun's prediction (`guns/pattern_matcher`),
## reached through the TmHorizonGun observation ring.
## * OUTPUT — `aim = LOS + gain * (patternAim - LOS)`, i.e. Pattern's lead over
## the line of sight is multiplied by a learned `gain` (one of
## `BB_CAND`, so it may be BELOW 1.0 — the point).
## * LABEL — the same deferred-label path the old corrector used: at fire
## time we remember the base lead and the aim tolerance; `h =
## round(dist/speed)` ticks later `tmhObservedAt` returns the
## enemy's OBSERVED bearing from the firing position. `requiredLead
## = observedBearing - LOS` and `baseLead = baseBearing - LOS`, so a
## candidate gain scores a hit on this sample when
## `|gain*baseLead - requiredLead| <= tolerance`.
## * TRAIN — ONLINE / PREQUENTIAL per range band: for each candidate gain we
## count the fraction of resolved samples that would have been
## within the target's angular half-width (`atan(18/range)`, the
## SAME tolerance the offline ruler uses). The band's gain is the
## argmax hit rate. THIS is the key lesson of Phase 1: the
## least-squares gain and the hit-probability-optimal gain DIVERGE
## (Pattern's lead errors are bimodal), so the learner optimises the
## hit-probability proxy directly instead of mean squared error.
## * STATE — the range band (the ruler's 5 bands). Range is known causally at
## fire time, so a per-band gain table is shippable with no learning
## at all; BitBrain learns that table online. The correction is
## additionally gated to bands with range >= 300 px
## (`BB_GAIN_BAND_MIN`), where Phase 1 measured Pattern's lead to be
## uninformative. That gate is causal (range is known).
##
## The gain statistics are battle-scale: a round boundary wipes the observation
## ring and deferred labels but NOT the gain counts (a new round is not a new
## enemy). `resetLearning` wipes them on a new battle / target change; with
## `TR_BITBRAIN_MEM=decay` every `TR_BITBRAIN_DECAY` resolved samples decays the
## counts by `TR_BITBRAIN_DECAY_FRAC` toward the gain-1.0 column.
##
## ── WHAT IS STILL HERE ONLY FOR THE BOOT REPORT / GUARD TESTS ─────────────────
## The ADE+SBC network is GONE from the gun. The 53-bit TMH input, the class
## geometry (`bbCenterDeg`/`bbClassOf`), `TR_BITBRAIN_N`/`NADE`/`WARMUP`/`ADAPT`/
## `CALIB`/`SEED` and `TR_BITBRAIN_RANGE` are retained as resolved configuration
## so the boot report (`env_report.nim`) and the registration guard tests keep
## working unchanged; they no longer affect the gain learner. The generic
## `common_libs/bitbrain/` library is untouched and still tested by
## `test_bitbrain.nim`.
##
## DEFAULT OFF / PARITY: this gun is admitted ONLY when `TR_RACK_BITBRAIN` says so
## (default `off`). The shipped rack never calls `predict`, so `ensureInit` never
## runs and the shipped bot is byte-for-byte unchanged.
import std/[math, os, strutils, strformat]
import gun_harness/gun_interface
import guns/tm_horizon
import guns/pattern_matcher
const
## ── env knobs (all resolved once at gun construction) ─────────────────────
BB_MEM_ENV* = "TR_BITBRAIN_MEM" ## perRound|retained|decay
BB_N_ENV* = "TR_BITBRAIN_N" ## (legacy geometry; inert)
BB_NADE_ENV* = "TR_BITBRAIN_NADE" ## (legacy ADE count; inert)
BB_RANGE_ENV* = "TR_BITBRAIN_RANGE" ## (legacy class half-range; inert)
BB_LOG_ENV* = "TR_BITBRAIN_LOG" ## 1 = per-change [bb] log
BB_MIN_OBS_ENV* = "TR_BITBRAIN_MIN_OBS" ## samples before a band is trusted
BB_WARMUP_ENV* = "TR_BITBRAIN_WARMUP" ## (legacy; inert)
BB_ADAPT_ENV* = "TR_BITBRAIN_ADAPT" ## (legacy; inert)
BB_CALIB_ENV* = "TR_BITBRAIN_CALIB" ## (legacy; inert)
BB_DECAY_ENV* = "TR_BITBRAIN_DECAY" ## decay interval (samples)
BB_DECAY_FRAC_ENV* = "TR_BITBRAIN_DECAY_FRAC" ## per-decay count shrink
BB_SEED_ENV* = "TR_BITBRAIN_SEED" ## (legacy; inert)
BB_RESET_ON_TARGET_ENV* = "TR_BITBRAIN_RESET_ON_TARGET"
## ── fixed geometry ────────────────────────────────────────────────────────
BB_PENDING_CAP* = 512 ## deferred-label queue (>= 4 buckets x 50 ticks)
## ── the gain learner ──────────────────────────────────────────────────────
BB_NBANDS* = 5 ## the ruler's range bands
BB_NHB* = 4 ## horizon buckets (for the per-tick label dedupe)
BB_BAND_LO* = [0.0, 100.0, 200.0, 300.0, 450.0]
BB_BAND_HI* = [100.0, 200.0, 300.0, 450.0, 1.0e18]
## The candidate lead gains the band selector picks from. 0.0 == HeadOn (aim
## at the current position) and 1.0 == Pattern (use the full lead).
BB_CAND* = [0.0, 0.25, 0.50, 0.75, 1.0]
BB_NCAND* = 5
BB_BB_RADIUS* = 18.0 ## hit-detection radius in px (ruler tolerance)
## Apply the correction only from this band up (range >= BB_BAND_LO[3] = 300).
## [MEASURED] below 300 Pattern's lead is informative and shrinking it loses
## hits; see the header note.
BB_GAIN_BAND_MIN* = 3
## ── shipped defaults ──────────────────────────────────────────────────────
BB_N_DEF = 32
BB_NADE_DEF = 256
BB_RANGE_DEF = 40.0
BB_MIN_OBS_DEF = 8
BB_WARMUP_DEF = 400
BB_ADAPT_DEF = 32
BB_CALIB_DEF = 512
BB_DECAY_DEF = 250
BB_DECAY_FRAC_DEF = 0.02
BB_SEED_DEF = 20240921
BB_RESET_ON_TARGET_DEF = true
type
BitMemMode* = enum
bmPerRound, bmRetained, bmDecay
BbPending = object
## One deferred training sample. `lead` is Pattern's lead over LOS at fire
## time (radians) and `tol` the target's angular half-width then; the label
## is resolved `horizon` ticks later.
fireTick: int
horizon: int
band: int
selfX*, selfY: float
baseBearing: float
lead: float
tolDeg: float
BitBrainGun* = object
tmh: TmHorizonGun
initialized: bool
# ── resolved config (kept in the boot report) ─────────────────────────────
nClasses*: int
maxDeg*: float
nAde*: int
memMode*: BitMemMode
logEnabled*: bool
minObs*: int
warmupN*: int
adaptEvery*: int
calibEvery*: int
decayEvery*: int
decayFrac*: float
seed*: int64
resetOnTarget*: bool
# ── gain learner: hit counts per (range band x candidate gain) ────────────
bandHits*: array[BB_NBANDS, array[BB_NCAND, float64]]
bandN*: array[BB_NBANDS, float64]
trained*: int
sinceDecay: int
decays*: int
# ── readout / accounting ──────────────────────────────────────────────────
lastGain*: array[BB_NBANDS, float]
corrections*: int
lastLogKey: string
# ── deferred labels ───────────────────────────────────────────────────────
pending: array[BB_PENDING_CAP, BbPending]
pendingCount*: int
pendingDropped*: int
# ── per-tick caches ───────────────────────────────────────────────────────
lastTick: int
lastEnqTick: int
lastEnqBucket: int
observedTargetId*: int
# ── small pure helpers ───────────────────────────────────────────────────────
proc wrapRadBB(r: float): float {.inline.} =
result = r
while result > PI: result -= 2.0 * PI
while result < -PI: result += 2.0 * PI
proc memModeName*(m: BitMemMode): string =
case m
of bmPerRound: "perRound"
of bmRetained: "retained"
of bmDecay: "decay"
proc parseMemMode*(value: string): BitMemMode =
## Empty / unknown values fall back to the shipped `perRound`, so a typo
## cannot silently select another regime.
case value.strip().toLowerAscii()
of "retained", "retain", "accum", "accumulate": bmRetained
of "decay", "forget", "age": bmDecay
else: bmPerRound
proc envFloatBB(name: string, default: float): float =
let v = getEnv(name, "")
if v.len == 0: return default
try: parseFloat(v.strip()) except ValueError: default
proc envIntBB(name: string, default: int): int =
let v = getEnv(name, "")
if v.len == 0: return default
try: parseInt(v.strip()) except ValueError: default
proc envBoolBB(name: string, default: bool): bool =
case getEnv(name, "").strip().toLowerAscii()
of "1", "true", "yes", "on": true
of "0", "false", "no", "off": false
else: default
proc bbCenterDeg*(k, nClasses: int, maxDeg: float): float =
## Centre (degrees) of correction class `k` over ±maxDeg. Retained for the
## registration guard test and the boot report; inert for the gain learner.
let w = 2.0 * maxDeg / float(nClasses)
-maxDeg + (float(k) + 0.5) * w
proc bbClassOf*(errRad: float, nClasses: int, maxDeg: float): int =
## Bin a signed angular error (radians) into one of `nClasses` bins over
## [−maxDeg, +maxDeg]. Retained for the registration guard test; inert.
let x = radToDeg(errRad)
var k = int((x + maxDeg) / (2.0 * maxDeg) * float(nClasses))
if k < 0: k = 0
if k >= nClasses: k = nClasses - 1
k
proc bbBandOf*(range: float): int {.inline.} =
## Range band (the ruler's bands), known causally at fire time.
for b in 0 ..< BB_NBANDS:
if range >= BB_BAND_LO[b] and range < BB_BAND_HI[b]: return b
BB_NBANDS - 1
proc bbTolDeg*(range: float): float {.inline.} =
## The target's angular half-width at `range` — atan(18/range) — i.e. the exact
## tolerance the offline ruler uses for its hit-probability proxy.
radToDeg(arctan2(BB_BB_RADIUS, max(range, 1e-9)))
# ── construction / lazy init ─────────────────────────────────────────────────
proc initBitBrainGun*(): BitBrainGun =
result.nClasses = clamp(envIntBB(BB_N_ENV, BB_N_DEF), 2, 512)
result.nAde = clamp(envIntBB(BB_NADE_ENV, BB_NADE_DEF), 8, 4096)
result.maxDeg = clamp(envFloatBB(BB_RANGE_ENV, BB_RANGE_DEF), 1.0, 180.0)
result.memMode = parseMemMode(getEnv(BB_MEM_ENV, ""))
result.logEnabled = envBoolBB(BB_LOG_ENV, false)
result.minObs = max(1, envIntBB(BB_MIN_OBS_ENV, BB_MIN_OBS_DEF))
result.warmupN = max(0, envIntBB(BB_WARMUP_ENV, BB_WARMUP_DEF))
result.adaptEvery = max(1, envIntBB(BB_ADAPT_ENV, BB_ADAPT_DEF))
result.calibEvery = max(1, envIntBB(BB_CALIB_ENV, BB_CALIB_DEF))
result.decayEvery = max(1, envIntBB(BB_DECAY_ENV, BB_DECAY_DEF))
result.decayFrac = clamp(envFloatBB(BB_DECAY_FRAC_ENV, BB_DECAY_FRAC_DEF), 0.0, 1.0)
result.seed = int64(envIntBB(BB_SEED_ENV, BB_SEED_DEF))
result.resetOnTarget = envBoolBB(BB_RESET_ON_TARGET_ENV, BB_RESET_ON_TARGET_DEF)
result.lastTick = -1
result.lastEnqTick = -1
result.lastEnqBucket = -1
result.observedTargetId = -1
for b in 0 ..< BB_NBANDS: result.lastGain[b] = 1.0
proc ensureInit*(g: var BitBrainGun) =
## Build the observation ring on first use. No network, no global-RNG use, so
## the shipped default path is untouched and construction stays cheap.
if g.initialized: return
g.initialized = true
g.tmh = initTmHorizonGun()
# ── the gain learner ─────────────────────────────────────────────────────────
proc bbAccumulate(g: var BitBrainGun, leadDeg, reqDeg, tolDeg: float, band: int) =
## Score every candidate gain on this resolved sample: a candidate "hits" when
## it would have put the aim within the target's angular half-width.
for ci in 0 ..< BB_NCAND:
if abs(BB_CAND[ci] * leadDeg - reqDeg) <= tolDeg:
g.bandHits[band][ci] += 1.0
g.bandN[band] += 1.0
inc g.trained
proc bbApplyDecay(g: var BitBrainGun) =
## Forgetting for `TR_BITBRAIN_MEM=decay`: shrink the hit counts and, more
## strongly, pull them toward the gain-1.0 column so stale evidence ages out.
let f = 1.0 - g.decayFrac
if f >= 1.0: return
for b in 0 ..< BB_NBANDS:
for ci in 0 ..< BB_NCAND:
g.bandHits[b][ci] *= f
g.bandN[b] *= f
inc g.decays
proc bbGain(g: BitBrainGun, band: int): float =
## The band's gain is the candidate with the highest observed hit rate.
## Ties keep the SMALLER candidate (the scan is ascending), which is the
## conservative choice for the long-range regime this corrector targets.
## Returns 1.0 (Pattern) below the range gate or when the band is cold.
if band < BB_GAIN_BAND_MIN: return 1.0
if g.bandN[band] < float(g.minObs): return 1.0
var best = 4 # gain 1.0
var bestRate = -1.0
for ci in 0 ..< BB_NCAND:
let rate = g.bandHits[band][ci] / g.bandN[band]
if rate > bestRate:
bestRate = rate
best = ci
BB_CAND[best]
# ── deferred-label resolution (prequential learning) ─────────────────────────
proc resolvePending(g: var BitBrainGun, state: WorldState) =
var w = 0
for i in 0 ..< g.pendingCount:
let p = g.pending[i]
let due = p.fireTick + p.horizon
if due > state.tick:
g.pending[w] = p
inc w
elif due == state.tick:
let obs = tmhObservedAt(g.tmh, state.tick, p.selfX, p.selfY)
if obs.ok and (state.tick - obs.lastSeenTick) <= TMH_STALE_MAX:
let err = wrapRadBB(obs.bearing - p.baseBearing)
let reqLead = wrapRadBB(err + p.lead)
g.bbAccumulate(radToDeg(p.lead), radToDeg(reqLead), p.tolDeg, p.band)
inc g.sinceDecay
if g.memMode == bmDecay and g.sinceDecay >= g.decayEvery:
g.bbApplyDecay()
g.sinceDecay = 0
else:
inc g.pendingDropped
else:
inc g.pendingDropped
g.pendingCount = w
# ── logging ──────────────────────────────────────────────────────────────────
proc bbLog(g: var BitBrainGun, state: WorldState, band: int, gain: float) =
## ONE change-gated `[bb]` line (behind TR_BITBRAIN_LOG=1) so a user tailing
## the GUI log sees the gain the corrector is applying.
if not g.logEnabled: return
let key = fmt"{gain:.2f}|{band}"
if key == g.lastLogKey: return
g.lastLogKey = key
var rate = 0.0
for ci in 0 ..< BB_NCAND:
if abs(BB_CAND[ci] - gain) < 1e-9: rate = g.bandHits[band][ci] / max(1.0, g.bandN[band])
echo fmt"[bb] t={state.tick} band={BB_BAND_LO[band]:.0f}+ gain={gain:.2f} " &
fmt"rate={rate:.3f} n={g.bandN[band]:.0f} trained={g.trained} " &
fmt"pend={g.pendingCount} dropped={g.pendingDropped} mode={memModeName(g.memMode)}"
# ── reset hooks (mirroring TmHorizonGun) ─────────────────────────────────────
proc resetRound(g: var BitBrainGun) =
## PER-ROUND reset: observation ring, deferred labels and per-tick caches (the
## bots teleport between rounds). The gain counts are deliberately KEPT — they
## are battle-scale and a new round is not a new enemy.
g.tmh.resetRoundState()
g.pendingCount = 0
g.lastTick = -1
g.lastEnqTick = -1
g.lastEnqBucket = -1
g.lastLogKey = ""
proc resetRoundState*(g: var BitBrainGun) =
if not g.initialized: return
g.resetRound()
proc resetLearning*(g: var BitBrainGun, reason = "") =
## PER-BATTLE / PER-ENEMY wipe: gain counts, counters and the round state.
if not g.initialized: return
for b in 0 ..< BB_NBANDS:
for ci in 0 ..< BB_NCAND: g.bandHits[b][ci] = 0.0
g.bandN[b] = 0.0
g.lastGain[b] = 1.0
g.trained = 0
g.sinceDecay = 0
g.decays = 0
g.corrections = 0
g.observedTargetId = -1
g.resetRound()
if reason.len > 0 and g.logEnabled:
echo fmt"[bb-reset] reason={reason}"
proc targetChanged*(g: var BitBrainGun, enemyId: int): bool =
## Per-ENEMY reset: wipe when the target changes to a different bot id. First
## acquisition never wipes, so the round-start pick does not cold-start us.
if not g.resetOnTarget: return false
if enemyId < 0: return false
if g.observedTargetId >= 0 and enemyId != g.observedTargetId:
g.resetLearning("target_change")
g.observedTargetId = enemyId
return true
g.observedTargetId = enemyId
false
# ── Gun interface ────────────────────────────────────────────────────────────
proc isWarmedUp*(g: BitBrainGun): bool {.inline.} = true
proc networkBytes*(g: BitBrainGun): int =
## No neural network is held any more; kept for the boot report / guard test.
0
proc predict*(g: var BitBrainGun, state: WorldState,
bulletSpeed: float): GunPrediction =
g.ensureInit()
# Round boundary: a tick regression means a new round.
if state.tick < g.lastTick: g.resetRound()
# Once per tick: observe the world, then resolve any labels now due.
if state.tick != g.lastTick:
tmhUpdateHistory(g.tmh, state)
g.resolvePending(state)
g.lastTick = state.tick
# The base prediction is Pattern; BitBrain only scales its lead over LOS.
let base = g.tmh.pattern.predict(state, bulletSpeed)
if bulletSpeed <= 0.0: return base
let dist = hypot(state.enemyX - state.selfX, state.enemyY - state.selfY)
let h = tmhHorizonFor(dist, bulletSpeed)
let hb = tmhHorizonBucket(h)
let band = bbBandOf(dist)
let los = arctan2(state.enemyY - state.selfY, state.enemyX - state.selfX)
let baseBearing = arctan2(base.y - state.selfY, base.x - state.selfX)
let lead = wrapRadBB(baseBearing - los)
# Enqueue one deferred sample per (tick, horizon bucket): `predict` runs once
# per power bin, so all four horizons contribute evidence.
if g.lastEnqTick != state.tick or g.lastEnqBucket != hb:
if g.pendingCount < BB_PENDING_CAP:
g.pending[g.pendingCount] = BbPending(
fireTick: state.tick, horizon: h, band: band,
selfX: state.selfX, selfY: state.selfY,
baseBearing: baseBearing, lead: lead, tolDeg: bbTolDeg(dist))
inc g.pendingCount
else:
inc g.pendingDropped
g.lastEnqTick = state.tick
g.lastEnqBucket = hb
# Readout: a fractional gain may be BELOW 1.0. When cold / gated out the
# learner returns 1.0 and the base prediction is returned unchanged.
let gain = g.bbGain(band)
g.lastGain[band] = gain
if abs(gain - 1.0) < 1e-9: return base
inc g.corrections
g.bbLog(state, band, gain)
tmhApplyShift(state.selfX, state.selfY, base.x, base.y,
radToDeg((gain - 1.0) * lead))
proc onResult*(g: var BitBrainGun, e: FeedbackEvent) =
## Labels come from our own observation ring, not from virtual-bullet
## feedback, so there is nothing to do here. The hook exists for the rack.
discard