j176 could not attribute the 11.9 deg aim error at 450+ px: the corpus had
no gun id and no bot-side belief, so staleness was an inverse (unidentifiable)
problem and a good gun was indistinguishable from a bad one. Both are cheap to
log and impossible to recover later.
New default-off knob TR_CAPTURE_AIM (presence-only). It appends TWO record
kinds to the EXISTING TR_RECORD_WORLDSTATE file:
aim_scan - one per onScannedBot, written BEFORE the tracker update, so it is
the pre-update belief by construction: tick, raw scanned values
(ex,ey,eh,es,ee), our own state (sx,sy,sh,ss), the gun in force, the
PREVIOUS belief (bx,by,bh,bs,blst), the scan parity age = tick - blst,
and the radar-lock context (rlock, rdir, lbear, boff).
aim_fire - one per real shot: gun, power, the aim angle handed to setFire,
the turret angle and the signed turret error, gunHeat, the predicted
intercept (ax,ay) and implied TOF, and the exact WorldState the predictor
consumed (ex,ey,eh,es,ee,sx,sy) with the tick it came from (lst).
Row builders live in a new pure module src/aim_capture.nim - no bot API, no
env reads - so the offline guard test and the live bot go through the SAME
builders and a field the test proves present is a field the bot writes.
Per-tick world-state rows also gain a `gun` id. offline_range.nim skips
aim_* lines (they carry no `ex`), so the annotations are inert to the replay.
No aim model changed.
Knob registered in env_report.nim (context field, effective-value emit) and
knownEnvNames(); documented in .env.example. Defaults OFF, diagnostic only,
never live-tested.
Verification (no battle, no Java, no server, no GUI):
- default parity: per-gun shots/hits over 20026 ticks of
tr_drussgt_vs_modularbot.jsonl byte-for-byte identical to the golden
generated from the PRE-CHANGE tree (git archive 55e92bc); the golden was
regenerated from that pre-change tree and re-diffed, so it is not
self-referential. Boot [env] block of the pre- and post-change binaries is
identical except pid/cmdline/build line and the new knob's own line.
- test_aim_capture: ALL PASS (every aim_scan/aim_fire key present, plus the
annotation-inertness replay).
- guards: test_tfil_commit_env 159/0, test_env_report 25/0,
test_tfil_ring_weights 24/0, test_vbullet_draw 30/0.
- .env.example round-trip (env_report via the j172 harness): 210 effective
values + 70 [x]/[modules] lines, 0 diffs, 0 dropped keys, 0 warnings.
- clean `git archive HEAD` + nim c -d:release: [SuccessX].
TR_RAM_FLOOR_ENERGY and TR_RAM_ENEMY_ENERGY, both default 0.0, plus
common_libs/movements/ram_decision.nim, the fire gate in ModularBot.nim, two
offline measure_* tools and the A/B record.
The j163 A/B (450 runs/arm, 0 env mis-set) is a clean negative: round-win
40.30% -> 39.70%, sign-flip p=0.7676, MDE 4.73 pp. DO NOT ADOPT;
TR_RAM_FLOOR_ENERGY stays 0.0. The mechanism barely fired (0.04% of ticks, not
the 9.6% the offline ruler predicted), so the null does not prove the knob
inert.
# Conflicts:
# common_libs/tests/test_tfil_commit_env.nim
The_floor_is_lava_ring carried raw commitTicks with no arrival guard and no
no-reversal guard. Port the behaviour of tfil's j144 fix on RING-SPECIFIC env
names (TR_TFIL_RING_COMMIT_ARRIVAL, TR_TFIL_RING_NOREV_SPEED) so the two forks
never share a namespace. Both default OFF: with them unset the ring mover is
byte-for-byte the pre-change mover over the whole 20026-tick fixture replay
(golden generated from git show HEAD:..., checked by tfil_ring_replay.nim).
Structural differences from tfil, all noted in the code:
* ring has no TfilTileReplanMode - the tile-crossing cancel is unconditional
self-tile, so the arrival guard is just 'not TfilRingCommitArrival'.
* ring has no replanReason enum, so the arrival/danger/expiry outcomes are a
local bool; the default-off path keeps ring's original dec/no-dec exactly.
* ring's MinCommitTicks is 0 (tfil's is 5), so the arrival branch is evaluated
from the first committed tick. Left as is: changing it would change the
default path.
* ring's ScoredTile carries no turnDeg, so the no-reversal offsets are
computed by ringTileOffTravel at the pick site.
TR_TFIL_COMMIT_MARGIN (tfil's hysteresis) is deliberately NOT ported: it is a
third knob, outside the two named, and inert at its 0.0 default.
No other tfil mechanism touched: no turn-cost tiebreak, TR_TFIL_ARRIVE_TICKS,
TR_FIRE_LAG, heat-field override, corridor bound, hold, or geometry weighting.
No default changed anywhere.
Decisive measurement for the j160 firing floor, on the recorded closed-loop
corpus (8149 recordings / 35163 rounds / 34.46M ticks, state only):
* 61.2% of rounds end with self energy crossing 0. Energy at death: median
0.83, p90 8.90, max 24.83 -- the bot dies BROKE, so the floor's premise is
real. Time at energy<=0 is a median of 1 tick: the round ends on the
crossing tick, there is no recoverable disabled window.
* Reserve that would have absorbed the killing blow: median 0.40, p75 2.00,
p90 6.90.
* Cannot climb back out: at energy<=5 the next tick brings a landed hit 0.232%
of the time and death 0.663% (2.9x). At <=20 recovery is 2x more likely,
which is why a floor at 20 is the wrong value.
* Cost: floor 5 blocks 9.58% of ticks, median run 53, mean run 118, banking
~9.9 energy against a p75 overshoot of 2.0.
* Measured caveat: the recorded ledger closes exactly (residual -0.00 over
35065 rounds), so these captures do NOT charge firepower cost; the cost
column is derived from the game rules, and the landed-hit power (mode 1.0,
mean 1.42) is what sets the bracket.
Honest read: worth an A/B, materially different in magnitude from the geometry
arm (smaller damage cost, stronger and directly measured safety claim), so NOT
the clean 'protects against nothing' negative.
docs/ram_floor_exhaustion_ab.md: replaces the draft with the measurement plus a
re-sized A/B proposal (4 arms, 42 runs/opponent/arm, 630 runs/arm for MDE 0.10
wins/run, ~2.7 h at the measured 23 battles/min). NOT RUN -- no battle, server
or GUI was started. Both knobs remain default 0.0.
TR_RAM_FLOOR_ENERGY (0.0 = off): at/below this self energy we start no NEW
shot, holding back the reserve for a final ram exchange. Justified by the only
energy gain in the game being +3*power per bullet hit LANDED, so not firing
denies the enemy its only refill. Blocks only NEW shots (gunHeat already gates
committed ones) and is bypassed while ramming.
TR_RAM_ENEMY_ENERGY (0.0 = off): last-scanned enemy energy <= this -> ram
mode. Enemy energy IS observable (ScannedBotEvent.energy, schemas.nim:306),
1-8 ticks stale. This is the shipped finisher with its energy tolerance
promoted to a knob, keeping the self>enemy surplus guard because RAM_DAMAGE
0.6 applies to BOTH bots on every contact tick.
Open-loop measurement (measure_ramfloor_energy, 8149 recordings / 29871
rounds / 33.8M ticks): 'both low' is COMMON (10.4% of ticks below 20, 15.1%
below 25) but neither side goes low first (enemy 52.7% / us 47.3%), and the
owner's literal trigger - enemy so low it cannot fire (energy <= 1.95) - is
only 2.5% of ticks, 1.1% while we are healthy.
Guards 136 -> 147 in test_tfil_commit_env.nim, all green. A/B PRE-REGISTERED
in docs/ram_floor_exhaustion_ab.md and NOT RUN.
The owner: choose the tile pool not only from the heat point but from the
geometric position too. Heat stays the hard filter; the draw over the
survivors is re-weighted by turn and/or distance. Three weighting forms
(soft softmax / top-K third / rejection band), one env name carrying both
axes. Default = off = today's uniform draw, byte-for-byte (golden parity).
WHAT DIFFERS FROM j9 (TR_TFIL_TURN_BIAS, a live null): that was a tiebreak
weight among the non-empty safe set only. This runs on the WHOLE pool the
draw already runs on, including the 2 promoted least-hot tiles the ~65%
forced picks choose from.
OFFLINE (8 fixtures x 3 seeds, no java): headline 'tile actually reached at
tta' 4.5% -> 16.9% at TR_TFIL_GEO_MODE=both-soft TR_TFIL_GEO_TAU=45, with
no diversity collapse (distinct tiles 220 -> 219, normalised entropy
0.87 -> 0.86, top-tile share 7.0% -> 8.6%). perpE — the perpendicular rate
in the FORCED population — does not move in ANY arm: that pool is 2 tiles
ranked by heat alone and the only lever is a coin flip.
Also registers j150's TR_TFIL_DIAG / TR_TFIL_DANGER_THRESHOLD in
env_report + knownEnvNames + .env.example (test_env_report was red) and
documents DANGER_THRESHOLD as quantisation-limited: heat comes in 5s, so
the effective steps are 10/15/20 and 10-14 admits zero extra tiles.
No battle, no server, no A/B run.
Measures, on the recorded fixtures and with NO counterfactual replay: how long
until a safe tile appears at a forced pick, whether the enemy had just fired,
whether our own tile is already hot, and all three split by distance. Registers
nothing and changes no default: the knob TR_TFIL_HOLD_WHEN_TRAPPED and its
implementation live in the concurrently edited working tree.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Measures the owner's two complaints per pick (turn angle, heat on the path
vs at the destination, time-to-arrive, and whether the destination is hot on
the recorded true future when we would arrive), split by empty vs non-empty
safe set. Result: the path IS scored (hard filter + least-hot fallback), but
there was NO time term at all -- 65% of picks outran the 15-tick commitment
and the chosen tile was reached 6.5% of the time. The bound refuses a
candidate we cannot reach in TR_TFIL_ARRIVE_TICKS (default 0 = off = today).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Measures WHERE the lava picker loses tiles, per pick:
reachable hull -> CoolestLevels=2 distinct-value filter -> path heat
filter -> draw set -> chosen. TR_TFIL_DIAG (default off) fills
TfilLoss*; TR_TFIL_DANGER_THRESHOLD (default 10.0, the shipped const)
makes the cutoff sweepable offline. No decision logic changed: the guard
test proves the diag-on move stream is byte-for-byte the diag-off one.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
MEASURED LIVE (common_libs/tests/measure_fire_ghost_lag.py, 4 sessions, 1777
matched ghost spawns, both movers): the server dispatches a turn's fire AFTER
our go() for that same turn, so a turn-T shot's energy drop first reaches our
scan at turn T+1 — and a bullet takes its FIRST step during the turn it is
fired, so the true bullet is already one whole bullet step (11-20 px) downrange.
Both movers place the ghost at the SCANNED enemy position (where the bullet was
born), so the whole ghost trajectory is the true one shifted one turn later and
the arrival deadline is a full tick late.
MEASURED: detection lag +1 tick on 100% of 1777 matched spawns; ghost-vs-
observer displacement 19.06 px mean / 22.00 p90 (tfil) and 16.08 / 21.81
(strafe); arrival-deadline error 0.99 / 0.77 ticks. NOT a rendering artefact:
the draw/advance order is correct (advanceBullets -> detectFires -> build).
THE FIX: TR_FIRE_LAG (int, default 0 = today byte-for-byte) in the shared
fire_tracker, applied by both movers at spawn: x = origin + dir*speed*lag.
The deadline needs no separate change — both movers derive it from the ghost's
own position, so a correct position gives a correct deadline.
WITH IT: displacement 19.06 -> 5.37 px mean (the residue is the enemy's own
<=8 px scan staleness) and the deadline error 0.99 -> 0.06 ticks.
Guards: test_tfil_commit_env 77 -> 87 checks (default golden parity, exact
n-step back-date, deadline shortens by exactly lag, junk/negative degrade to 0,
reaped exactly one tick earlier); test_env_report + test_env_dotenv green.
TR_FIRE_LAG registered in env_report + knownEnvNames + .env.example +
docs/env_reference.md. Live A/B pre-registered in docs/movement_campaign.md
(Batch 8) with its MDE stated up front; arms tools/ab/arms_fire_lag.txt.
TR_FIRE_DIAG gains a per-round ROUND line (the tick->getTurn anchor) and a
per-spawn SPAWN line (the ghost's drawn position).
The picker scored candidates on pathMaxHeat alone and then drew uniformly
among the survivors, so a mirror-side tile was as likely as a straight-ahead
one. Added a continuous turn cost as a DRAW WEIGHT applied only after the
hard heat filter:
w = max(1, round(1 + TR_TFIL_TURN_BIAS * (1 - max(0,|turn| - REF)/180)))
- TR_TFIL_TURN_BIAS (default 0) is the odds ratio straight-ahead vs 180 deg;
TR_TFIL_TURN_REF_DEG (default 45) is where the penalty starts. Both
default-off-effect: the default-path golden in test_tfil_commit_env.nim is
unchanged and still passes.
- Turn cost is NEVER folded into the heat score. The filter stays hard.
- The draw stays random (j51 measured an argmin worse); every weight is
floored at 1, so the pool can never be emptied and bias 0 is exactly the
shipped uniform draw.
- |turn| now travels on the ScoredTile, and the commit log gained turn /
minturn / promote so a caller can measure the regret of the draw.
Guards: 51 -> 66 checks (an absurd 99:1 bias never rescues an over-threshold
tile; mean |turn|, draw regret, >90 and mirror-side shares all fall; path
heat does not rise). env_report + .env.example updated.
The owner's live-GUI report was correct on all four counts, and all four are
one bug: the commitment is cancelled by our own tile-boundary crossing
(96.1% of picks, 3793/3946, mean hold 5.06 ticks) while the bot is still
accelerating, and the picker is an unconstrained uniform draw over every
safe tile, so the new target can land in the mirror direction at |speed| < 4.
New knobs, all env-gated and default = today's behaviour (byte-for-byte
default parity guard re-run and green, 51 checks):
TR_TFIL_COMMIT_ARRIVAL hold the committed tile until we are ON it; the
tick knob becomes a MINIMUM dwell. 0 = shipped.
TR_TFIL_COMMIT_MARGIN leave only if the best alternative is at least
this much cooler on the same pathMaxHeat scale.
0 = shipped.
TR_TFIL_NOREV_SPEED while |speed| is below this, a mid-flight switch
may not take a tile >90 deg off the travel
direction. 0 = shipped. norevPool() never returns
an empty pool: with every candidate behind us it
takes the least-bad turn.
Offline gate (recorded DrussGT fixture, 20026 ticks): mean hold 4.1 -> 24.0
ticks, abandoned-before-arrival 92.8% -> 40.5%, committed tile actually
reached 3.3% -> 17.2%, opposite-direction slow mid-flight switches 394 -> 64
(-84%). 'TR_TFIL_TILE_REPLAN=off' alone - what cc11ede's arm B already tried -
only gets the hold to 13.6, which is why that A/B could not find this.
strafe is untouched: it imports only heatDecay/bulletMagScale/Pillar*, none
of which this touches. TR_MOVEMENT default stays strafe. Registered in
env_report.nim + knownEnvNames() + .env.example. Arms pre-registered in
docs/movement_campaign.md and tools/ab/arms_tfil_commit.txt.
Remove guns/bitbrain_net.nim (+README), test_bitbrain_net.nim,
measure_bitbrain_scaling.nim, rack id 17 and all of its plumbing in
selector.nim / ModularBot.nim / env_report.nim, the TR_BITBRAIN_NET switch
and the NEW-NETWORK TR_BITBRAIN_* knobs, and the BitBrainNet arm of
run_prediction_quality.nim.
With id 17 gone there is nothing to disambiguate, so the legacy namespace
becomes the ONLY one: TR_RACK_BITBRAIN always selects id 16 LEADGAIN and
every TR_BITBRAIN_<X> in the frozen 14-suffix alias set always means
TR_LEADGAIN_<X>. The alias layer and its [depr] line stay.
KEPT: the common_libs/bitbrain/ SBC library (learned_surfer imports
bitbrain/sbc), lead_gain at id 16 with env TR_LEADGAIN_* and log tag [lg],
and the c9b6753 crash fix (NumRackGuns widths + test_rack_stat_width).
Tombstone: docs/bitbrain_campaign.md ## RETIRED and one cross-reference line
in docs/gun_campaign.md. Shipped defaults unchanged: clean env -> rack
active 1v1 = PATTERN, movement default strafe.
REGRESSION: the live bot stopped firing entirely and moved degenerately
whenever a rack admitted rack id 17 (the ADE+SBC BITBRAIN gun added in
e9302bc).
ROOT CAUSE: ModularBot.nim declared the per-gun accounting arrays
(gunRealShots / gunRealHits / gunRealShotsByMode / gunRealHitsByMode /
gunSelectionCount) as array[17, int] — the rack size BEFORE id 17 existed.
The instant the selector picked gun 17, the accounting indexed one past
the end:
* debug build -> IndexDefect out of run(): the bot stops, 0 shots;
* -d:release (shipped) -> silent out-of-bounds write onto the adjacent
lastPowerLogKey: string header, so the bot kept moving but never
fired and never reported a shot.
Measured with the owner's exact out/.env, 1v1 SittingDuck:
0dc5552 (pre-rename): BitBrain(id16) selected 356+157 ticks,
realShots 24+9, realHits 23+9, ModularBot wins 180/360
HEAD (e9302bc..) : selected 0, vShots 0, realShots 0, realHits 0
debug : IndexDefect on the first tick that selects id 17
-d:release : same 0/0/0, bot scores 22 and dies
FIX: derive every gun-indexed width from the rack instead of a literal.
gun_harness/selector exports NumRackGuns* = len(RackGunNames) (18);
ModularBot uses it for the five accounting arrays, the per-round reset
loops, the gun_stats.jsonl dump loop, the table and
initTracker(). Shipped defaults unchanged: clean env is still the
onlyPattern rack and movement is still strafe.
GUARD: common_libs/tests/test_rack_stat_width.nim (24 checks) — rack
table shape, a source scan proving no gun-indexed width/loop bound is
narrower than NumRackGuns, an in-process accounting replay that would
have overflowed array[17], and the legacy-namespace checks (id 16 via
TR_RACK_BITBRAIN, id 17 never admitted while legacy). It reports 7
failures on the pre-fix ModularBot.nim and passes after. Optional
--live section proves a rack admitting only the newest id fires and
lands hits.
Parity: test_env_report 25, test_rack_membership 49, test_lead_gain_
registration 13, test_lead_gain_legacy 24, test_bitbrain_net 44,
test_gun_harness 39, test_tfil_commit_env 30, test_bitbrain 56,
test_tm_pattern_registration 20 — all unchanged, 0 failures.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
TR_RACK_BITBRAIN=both is what the owner's live .env carries, and with the new
ADE+SBC gun registered at id 17 under the SAME rack name that value was also
landing on id 17 - so a gun that was not enabled (TR_BITBRAIN_NET unset) was
admitted into the rack and its placeholder predictions were pushed into the
shared VirtualTracker ring, which shifts every other gun's learning order.
While the namespace is LEGACY, loadRackMembership now skips id 17's
TR_RACK_BITBRAIN entirely, so that value addresses ONLY the gun it always
addressed (LEADGAIN, id 16). ModularBot additionally gates admission on
BitbrainNetGun.gunAdmitted(), and test_bitbrain_net pins the truth table: over
6 (rack, switch) settings there is NO configuration that admits the gun while
leaving it disabled.
Guards: test_env_report 25, test_rack_membership 49 (was 48; the revert
one-liner now sets TR_BITBRAIN_NET=1 and one truth-table check was added),
test_tm_pattern_registration 20, test_bitbrain 56, test_gun_harness 39,
test_tfil_commit_env 30, test_lead_gain_registration 13,
test_lead_gain_legacy 24, test_bitbrain_net 44.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The BITBRAIN name was sitting on a gun with no network in it. This is the gun
that actually runs the algorithm: an ADE layer (thresholded random projections
with ONLINE threshold adaptation) feeding the SBC head from
common_libs/bitbrain/, with the counted+decay mode available.
common_libs/guns/bitbrain_net.nim the gun
rack name BITBRAIN, rack id 17 (rack 17 -> 18 guns), both new guns default OFF
admitted by TR_RACK_BITBRAIN=both AND TR_BITBRAIN_NET=1 (the switch that also
disowns LEADGAIN's legacy TR_BITBRAIN_* aliases)
OUTPUT: a fine-grained aim CORRECTION on top of Pattern - the probability-
weighted mean of the nClasses class centres under inferProb - not a direct aim
point from the argmax. That is the shape docs/bitbrain_gate.md measured, and
Pattern is already a strong predictor, so the net's job is the signed residual.
Below TR_BITBRAIN_MINOBS the shift is exactly 0 and Pattern is returned
unchanged.
INPUT: a CONFIGURED set of FEATURE BLOCKS (TR_BITBRAIN_FEATURES=name:W), each
block's width == its resolution, laid out as a thermometer code over 0/255 slots
(so an ADE synapse 'matches' when its polarity agrees with the slot and a random
ADE fires iff its w synapses all match, rate 2^-w). Default is 52 slots over 9
blocks. NO long temporal window, per docs/state_window_gate.md: the only history
is a 12-tick ring feeding three rate/turn quantities.
Every knob env-configurable: _INPUT (width), _NCLASSES, _NADES, _WIDTHS
(clause widths), _FEATURES, _SPAN, _MODE, _DECAY_EVERY, _DECAY_SHIFT,
_MINOBS, _ADAPT_EVERY, _TARGET, _NETSEED, _NETLOG, _NET_RESET_ON_TARGET.
MEASURED SCALING (measure_bitbrain_scaling.nim, 3 recorded runs, 37412 ticks,
-d:release, one predict per power bin per tick, timed region = predicts only):
RAM 1.59 MB default (98.6% SBC tensors); linear in nClasses, QUADRATIC in
nAde, FLAT in input width; counted/bitset = 7.30x on RAM, ~1x on time.
ms/tick 2.70 default = 21% of the 13.16 ms budget; 64 classes busts it (149%),
nAde 512 uses 74%, nAde 64 uses 2%.
CAPACITY vs ACCURACY: over a 100x RAM range the offline mean |err| moves
17.254 -> 17.115 deg around Pattern's 16.964, and the sign flips along the
nClasses axis, so it is noise, not a trend. The corrector is consistently
slightly WORSE than Pattern. The ceiling is the STATE, not the classifier.
VETO-CAPABLE OFFLINE CHECK ONLY (docs/offline_harness_trust.md), never
presented as a live win.
ENGAGEMENT is proven, not assumed: test_bitbrain_net.nim (42 checks) shows 0
bytes before first use, different inputs -> different class outputs, a learn
raises SBC occupancy, bitset learn idempotent while counted learn is monotone,
threshold adaptation runs, and the global RNG is untouched.
Parity: shipped rack still onlyPattern, shipped movement still strafe. Guards:
test_env_report 25, test_rack_membership 48, test_tm_pattern_registration 20,
test_lead_gain_registration 13, test_lead_gain_legacy 24, test_bitbrain 56,
test_gun_harness 39, test_tfil_commit_env 30, test_bitbrain_net 42.
Clean archive build: [SuccessX].
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The gun at rack id 16 learned a multiplier for Pattern's lead, separately
per range band. It was called BITBRAIN and shipped a TR_BITBRAIN_* prefix,
which is why the name read as a neural network it no longer contains.
guns/bitbrain_gun.nim -> guns/lead_gain.nim (rack id 16 UNCHANGED)
RackGunNames[16] BITBRAIN -> LEADGAIN
TR_BITBRAIN_* knobs -> TR_LEADGAIN_*
[bb] log line -> [lg]
BACKWARD COMPATIBILITY is mandatory: the live .env carries
TR_RACK_BITBRAIN=both, TR_BITBRAIN_GAINS, TR_BITBRAIN_MEM=decay and
TR_BITBRAIN_LOG=1, and those must keep behaving identically. The new ADE+SBC
gun (next commit) claims the BITBRAIN name and the TR_BITBRAIN_* prefix, so
the namespace is disambiguated by ONE deterministic switch, TR_BITBRAIN_NET
(default 0):
TR_BITBRAIN_NET unset/0 -> LEGACY: the 14 frozen legacy suffixes are aliases
for TR_LEADGAIN_*, and TR_RACK_BITBRAIN still
selects rack id 16. One [depr] line on stderr
names the new spelling of each honoured knob.
TR_BITBRAIN_NET = 1 -> the TR_BITBRAIN_* names belong to the new gun.
The legacy suffix set and the new gun's knob set are DISJOINT, so no name is
ever claimed twice; the new name always wins over its alias.
Parity: shipped rack is still onlyPattern, shipped movement is still strafe.
Guards unchanged: test_env_report 25, test_rack_membership 48,
test_tm_pattern_registration 20, test_lead_gain_registration 13 (was
test_bitbrain_registration), test_bitbrain 56, test_gun_harness 39,
test_tfil_commit_env 30. New: test_lead_gain_legacy 24.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A `#` preceded by whitespace and outside quotes now ends the value, so
`TR_DEBUG_DRAW=0 # hides the grid` resolves to `0` instead of the
whole tail. Values that are still not plain tokens (whitespace, `#`, an
unclosed quote) get one `[dotenv] WARNING` line naming file, key, raw value
and the fact that the reader falls back to its DEFAULT, instead of being
applied silently. Guard test 29 -> 47 checks.
The repo's first multi-opponent gun measurement. Adds tools/ab/gauntlet_run.sh
(per-opponent A/B over the legacy roster, subject = frozen ModularBot),
tools/ab/gauntlet_analyze.py (paired per-opponent deltas, cross-opponent sign
test, style split, MDE) and the arm/opponent fixtures.
Result: BitBrain does NOT generalize beyond DrussGT. 32 opponents x 2 arms x
3 runs x 5 rounds = 192 battles / 960 rounds, 0 failed, 0 retries: damage/run
214.5 (pattern) vs 210.9 (bb), sign-flip p=0.53; round wins 237/480 vs 239/480,
p=0.91. Sign test: bb better on 13/32 opponents (damage). The DrussGT-only
penalty does not carry. The owner's 'killer vs regular movers' sub-claim is not
supported: regular bucket +1.3 dmg/run vs dodgers -0.2 (MW p=0.85), and the
measured movement predictability does not correlate with the delta.
Adds common_libs/tests/measure_melee_bitbrain_ab.nim (+ .sh driver, .py analyzer,
committed per-run fixtures) and docs/melee_bitbrain_ab.md.
Experiment: 4-bot Free-For-All (ModularBot + WaveSurfer + PatternMover +
RandomMover), 4 arms x 16 runs x 7 rounds, frozen ModularBot from git archive
HEAD (commit 0f5cfe3, binary 11bba27), shipped tfil movement in every run.
Arms differ only in the gun rack: pattern (shipped), bb_round, bb_ret, bb_learn.
Result: NOT DETECTABLE. Score (server round score = damage + survival bonus)
differs by -63..+33 pts (perm p=0.16-0.71) against an MDE of 151 (~5.1%).
Every arm finishes rank 1. Round wins hint BitBrain's way (112/112 and 111/112
vs 109/112) but p=0.225 (MW 0.080), half the 0.40-win MDE.
Liveness proven: rack boot lines flip (rack active melee = PATTERN / BITBRAIN),
every run faced 3 distinct targets and ~66-69 target changes, and the bb arms
logged one [bb-reset] reason=target_change per switch. The melee premise was
exercised; the fast adaptation bought no measurable score edge at this sample.
4 arms x 15 runs x 7 rounds (60 battles, 0 failed) vs real DrussGT on the
shipped TFIL default, frozen at ed25ce2. bb_id (gain 1.0 identity) is
statistically indistinguishable from shipped Pattern -> plumbing validity
check passes. No BitBrain arm beats TMHorizon or Pattern: bb_learn (the config
the owner likely ran) is the worst arm (276 dmg/run, 39/105 wins), the only
comparison at alpha=0.05 is Pattern beating it on damage. Learned gains
(>=1.0, gated >=300px) over-lead and lose 1.61pp of hit rate at 300-450px.
MDE 29.5 dmg/run, 1.235 wins/run; a 6-4-sized effect needs ~39 runs/arm.
Task j112. Two changes to the TR_MOVEMENT=strafe engine, both OFF the shipped
tfil path; the binary default is still tfil.
CURVED WINGS: the candidate set was the straight 1-D line through the bot, which
a bounded segment always terminates at a wall. It is now an adaptive parabola
with the vertex on the bot:
point(y) = bot + yhat*y + xhat*kappa(y)*y^2
xhat is the unit vector away from the nearest wall(s) (summed inward normals, so
a corner yields the diagonal). kappa grows as the wall approaches and saturates
at TR_STRAFE_KAPPA; every wing point is clamped inside TR_STRAFE_WALL_SAFE, so
the wing FLATTENS and runs parallel to the wall instead of touching it. In open
space kappa == 0 and the wing is exactly the old straight line. The wing chord
at the reach tilts the heading band toward the interior by atan(kappa*reach)
(capped by TR_STRAFE_WING_MAX); the body still only turns slowly to follow that
tangent, never to face the target, and reversals are still setForward sign flips.
GUARANTEED ESCAPE: with every candidate over threshold the old fallback minimised
pathMaxHeat, whose gradient points AT the wall (the shortest path has the least
wall exposure), so the least-hot tile was the adjacent one and led further along
the wall. Near a wall the picker now ranks by the DESTINATION (farthest from the
wall, then coolest tile) and commands the sign whose velocity has a positive
component along the wall-away normal. That sign is re-asserted EVERY tick, so
speed*heading . away >= 0 while escape is active: the clearance cannot fall.
mode=escape reaches the [strafe] log. A mild wall-margin bias
(TR_STRAFE_WALL_BIAS) prefers higher-clearance tiles when near a wall.
GUI/log: the curved wings are drawn as an orange polyline (candidates follow the
curve), a white ray + ESCAPE label marks the escape, and the [strafe] line now
carries wall=<dist> kappa=<..> mode=<pick|fallback|escape|radial>.
Gates (offline, kinematic replay of the DrussGT fixtures; see
common_libs/tests/measure_strafe_wings.nim, plus the reused j108/j111 gates):
wall occupancy (within 54 px) falls 25.6->4.3 / 18.3->4.1 / 23.8->4.3 / 27.8->4.7
percent and the longest continuous wall run 191->31 / 49->25 / 208->47 / 246->27
ticks; corner-region occupancy 4.7->0.0 percent with the longest corner run
71->7. The escape sweep (3520 start x heading x enemy runs, 110k escape ticks)
shows ZERO per-tick guarantee violations and a worst corner run of 21 ticks.
Open-space parity is bit-identical (kappa == 0), reversals are still sign flips
(0 non-sign commands), and mean turn/speed are unchanged (OFF 4.42 deg/tick,
31.3 percent no-turn vs ON 4.45 / 30.7; reversal-interval entropy 5.309 -> 5.311
bits). The fixtures are OPEN-LOOP, so these are veto-capable checks, not a live
win claim.
Task j111. Two changes to the TR_MOVEMENT=strafe engine, both OFF the shipped
tfil path; the binary default is still tfil.
RANGE CONTROL (a hypothesis under test, no default changed elsewhere):
the body is still pinned ~perpendicular to the threat, but the line is tilted
by the range error: lineAngle = threat + 90 + appliedTilt, with the tilt zero
inside +/-TR_STRAFE_RANGE_TOL around TR_STRAFE_RANGE (200 px, chosen because it
is exactly TR_POWER_FAR_DIST) and clamped to +/-TR_STRAFE_TILT_MAX. A tilt alone
cannot change range (the picker chooses both ends at random), so the picker also
PREFERS the end that reduces |distance - target| with a probability that grows
with |tilt|; both ends stay possible. The tilt sign is aligned to the ENEMY
bearing, since is the bullet direction (roughly its opposite) when a
bullet is in flight. Knobs: TR_STRAFE_RANGE (200), TR_STRAFE_RANGE_TOL (25),
TR_STRAFE_TILT_MAX (15), TR_STRAFE_TILT_GAIN (0.10), all registered in
env_report.nim (emit + knownEnvNames). NOT claimed to be better: j107 measured
that drifting 25-30 px closer made damage/run and wins WORSE.
CORNER STALL (a real defect): a line whose in-arena candidate set was empty set
targetValid=false and kept driving on the last sign, so the bot could oscillate
inside a corner tile forever. Three defenses: (1) a deterministic corner guard
projects the outward component off the line whenever BOTH ends are outside, so
the line becomes wall-parallel and a candidate always exists; (2) a degenerate
line (<=1 candidate) falls back to a radial search for the coolest in-arena
tile and commits the sign; (3) a commanded move with no displacement for
StuckFlipTicks (5) ticks flips the sign. Both warnings now reach the [strafe]
log.
GUI/log: the tilt is drawn as the existing strafe line (it is lineForward), plus
a green/red ray toward the enemy (length = |distance-target|) and white text
d=.. tgt=.. tilt=..; a red disc marks a stuck tick. The existing overlays and
the j110 heat grid are unchanged.
Gates (offline, kinematic replay of the DrussGT fixtures; see
common_libs/tests/measure_strafe_range_stall.nim): on the j110 field all four
corners that were 100% confined inside 72 px / 22.6 px max before now escape
(<=2.6% confined, 209-741 px); the achieved |distance-200| falls on 3 of 4
fixtures (mean -16% to -30%); mean |turnRate| and the 8.00 px/tick speed are
essentially unchanged (no-turn property survives). Reversal-interval entropy
falls 5.85 -> 5.31 bits (still above TFIL's 5.09): the range bias costs some
reversal randomness while closing.
Three defects the owner hit as "no heat tiles anymore" under TR_MOVEMENT=strafe.
1. The strafe overlay drew ONLY the tiles on its strafe line, so the computed
heat field was essentially invisible. It now draws the WHOLE field exactly as
TFIL does (every non-zero tile, yellow->orange->red ramp by field max, integer
value label) behind the same debugGraphics flag, with TR_STRAFE_HEAT_GRID=0 to
hide it. The strafe overlays draw on top, unchanged.
2. STRAFE carried the SHIPPED bullet constants (core 10 / aura 5), so a bullet's
own heat sat exactly ON PathDangerThreshold (10.0) and a bullet was never
dangerous on its own in this mover; it only ever bit through its corridor.
Defaults are now the retune's 20/10, exposed as TR_STRAFE_BULLET_CORE /
TR_STRAFE_BULLET_AURA.
3. The ring mover's header documented CorridorHeat 5.0 / WallHotness 10.0 while
the code has always been 10.0 / 15.0. A job read the comment and handed out
sub-threshold heat values, which emptied the field. The comment now states the
real values and their actual behaviour; no code values changed.
Also sets strafe's heat defaults to the retune shape (bullet 20/10, corridor 10,
wall 15/5, pillar 0), documented with the reason.
Gate A re-run (j110, offline DrussGT fixture, measure_strafe_gates.nim):
corrected DEFAULT : 24.6% of picks with ZERO safe tile, mean 11.17 safe
j108 shipped field: 63.4% / 3.70 (reproduced exactly)
j108 ring retune : 8.1% / 18.41 (reproduced exactly)
bullet isolated : 11.4% / 17.07
The corrected default beats the shipped field but is WORSE than j108's retune
row: the bullet retune alone costs 8.1 -> 11.4, the corridor/wall retune accounts
for the rest. That is the deliberate price of making a bullet dangerous.
Guards green: test_env_report 24 PASS, test_tfil_commit_env 30 PASS (shipped TFIL
default untouched, byte-for-byte), test_tfil_ring_weights 24 PASS. The three new
knobs are registered in the boot env report so the tree-scan guard stays clean.
New engine movements/strafe.nim, selected by TR_MOVEMENT=strafe (default stays
tfil, byte-identical — test_tfil_commit_env.nim's 30 checks still pass).
Design (the owner's):
- AXIS = incoming bullet's direction when a bullet is in flight, else the
perpendicular of the enemy bearing. The body heading is kept inside a band
(TR_STRAFE_BAND, default 20 deg) around the perpendicular LINE; it turns only
when outside the band, and never turns to face a movement target.
- Candidate tiles on the perpendicular line through our position, both forward
and backward, within TR_STRAFE_REACH px, with a perpendicular jitter of
+/- TR_STRAFE_SPREAD tiles. A tile is acceptable when its path max heat is
<= PathDangerThreshold, the SAME safety rule TFIL uses.
- Move by SIGN only: setForward(+/-MaxSpeed>). Dwell is re-picked after a random
number of ticks in [TR_STRAFE_DWELL_MIN, TR_STRAFE_DWELL_MAX], on arrival, or
on a serious threat spike.
- Heat machinery is REUSED from the shipped mover, not re-implemented: the
exported heatDecay()/bulletMagScale() (j105 time-indexed model) and the
PillarHotness/PillarRadiance globals (j106 pillar-free default). The heat
shape is overridable via TR_STRAFE_CORRIDOR_HEAT/WALL_HOTNESS/WALL_RADIANCE
(defaults = the shipped TFIL field).
- GUI overlay: strafe line, threat axis, candidate tiles (safe/unsafe), chosen
target, sign-coloured movement ray, and the heading band.
Gates (offline, recorded DrussGT fixture, 20026 ticks):
- A TILE AVAILABILITY: shipped heat field -> a safe tile exists on only 36.6%
of picks (63.4% fall back to the least-hot tile); the ring retune
(corridor 5, wall 10/5) raises it to 91.9%.
- B PREDICTABILITY: reversal-interval entropy 5.84 bits vs TFIL 5.09; direction
entropy 1.00 both; long-lag autocorrelation ~0 for both (no periodic
component). Fewer reversals (710 vs 1453) and more full-speed ticks.
measurements: common_libs/tests/measure_strafe_gates.nim
Also registers TR_STRAFE_* in the boot env report (ModularBot_garage/src/
env_report.nim) and wires the engine into ModularBot.nim (hold -> strafe,
ram trigger -> rammer).
Runs the pre-registered A/B for the two movement changes in HEAD: the
time-indexed bullet heat (TR_TFIL_HEAT_TIME, fca8993) and the removal of the
invented virtual centre pillar (d0750ab). One frozen binary from HEAD vs real
DrussGT: 6 arms x 10 runs x 7 rounds = 60 battles, 420 rounds, 0 failed.
Judged on damage/run and ROUND WINS only (hit rate and hits-taken are context):
hit rate would have inverted the verdict again - tau3 has the best pooled hit
rate of all arms (11.56%) and the fewest round wins (20/70).
RESULT (vs the reconstructed pre-change mover "old"):
heat-time HURTS. tau3/tau5/tau9 lose 1.3-1.7 wins/run (p=0.0010-0.0125) and
deal 22-38 less damage/run (p=0.004-0.047); tau15 is a wash on wins (p=0.64)
and 22 damage/run lower (p=0.046). Nothing improves either metric.
pillar removal does nothing measurable. old vs pillaoff: +5.7 damage/run
(p=0.71), +0.5 wins/run (35 vs 30, p=0.43), 30.8 MORE damage taken/run
without the pillar (p=0.040). The mechanism check proves the knob works
(centre-box occupancy 0.09% -> 2.37%, p<0.0001; range 469 -> 443 px,
p=0.0002), so this is a real behaviour change that buys nothing. At n=10 the
pillar contrast is inside the MDE (33 damage/run, 1.2 wins/run), so this is
not a proven regression.
Flags that the shipped default (pillar removed) should be reverted to the
TR_TFIL_PILLAR_ON behaviour; heat-time stays off.
Adds tools/ab/arms_heat_pillar.txt and tools/ab/ab_mechanism.py (per-tick
mechanism check: central-box occupancy, range distribution, live enemy-bullet
proximity) plus the captured summary/report fixtures.
Adds a per-sample intrinsic-confidence field (GunPrediction.confidence,
threaded through FeedbackEvent/VirtualBullet, populated by Pattern, DecayGF,
KNN, GuessFactor, Tsetlin, TMHorizon) and an offline recorder + analyzer that
reproduce the paper's Figure 2 per gun and its Eq-8 composite.
Measured on 3 held-out tr-bridge DrussGT battles (33k ticks, ~133k samples/gun):
- FAITHFUL: DecayGF (rho +0.133), KNN (+0.090), Pattern (+0.064, weak).
- GuessFactor is ANTI-faithful (rho -0.067); Tsetlin c_max is useless (0.001).
- No pair of guns specialises complementarily: the same gun dominates both
high-confidence slices in every pair.
- Eq-8 alpha-normalised confidence-weighted composite: 18.41% vs Pattern
20.45% (McNemar p=3.1e-126). Faithful-only variant 18.68%, still loses.
Shuffle control passes weakly (composite > shuffle, p=4e-14) so ~0.7pp of
competence is real but ~2pp short. Offline veto: design is dead.
See docs/tmcomposites_gate.md.
The default mover painted a 30/10 radiance blob on the arena centre even
though the arena has NO physical pillar there, creating a 4x4 tile
(144x144 px) exclusion zone over open centre floor. Set
PillarHotness/PillarRadiance to 0/0 in the shipped default (matching the
ring variant) and add TR_TFIL_PILLAR_ON=1 to restore the old 30/10 field
for A/B without a rebuild; registered in env_report.
Because the shipped default legitimately changed, the default-path parity
golden (fixtures/tfil_commit_default.golden) was regenerated from the NEW
default, with an explicit 'deliberate default change' note in the test so
a future failure is treated as a real regression.
Also register the three env reads job j102 added in common_libs/bitbrain
(TR_BITBRAIN_MODE / _DECAY_EVERY / _DECAY_SHIFT), which the env-report
guard was failing on.
Verification: test_env_report all green; test_tfil_commit_env 30/30.
Make danger a function of time-to-arrival instead of flat distance. Bullet
core/aura/corridor heat becomes magnitude(power) * decay(dt), dt = along/speed:
* decay(dt) = exp(-dt/tau) is a function of TIME; a fixed tau projects a
pixel reach of speed*tau, so fast/weak bullets get a longer slope and slow
ones a shorter one — derived from speed = 20 - 3*power, not hand-tuned.
tau = TR_TFIL_HEAT_TAU.
* magnitude(power) scales the near-end heat with power from DAMAGE
(calcBulletDamage = 4p, linear in p; SCORE_PER_BULLET_DAMAGE = 1.0). Hit
probability is FLAT across power (docs/env_reference.md), so risk does not
justify power scaling — the cost of the hit does. Floored at 1.0 so a weak
bullet's near end is never less dangerous than the flat model.
Gain = TR_TFIL_HEAT_POWER_GAIN.
Every source is already f(dt), so the time-indexed planner (evaluate a cell at
the tick the bot would ARRIVE, i.e. heatDecay(dt - arrivalDelay)) is a one-line
change. It is intentionally NOT implemented here.
Default path is byte-identical: with TR_TFIL_HEAT_TIME unset both factors are
exactly 1.0 (IEEE x*1.0 is exact), and the committed golden replay in
common_libs/tests/test_tfil_commit_env.nim (20,026 ticks) still passes
byte-for-byte against the pre-change mover. The debug corridor outline is also
drawn only to the model's reach when enabled, so the GUI shows the shortening.
Offline field measurement (common_libs/tests/measure_tfil_heat_time.nim,
46,054 fixture ticks, tau=9/gain=1): corridor reach drops from 443px
wall-to-wall to 143px mean (32% retained); fraction of tiles > 10 goes
0.61 -> 0.57; largest contiguous safe region 118 -> 140 tiles; mean
distance-to-nearest-safe-tile 49 -> 42px. Saturation stays high because wall
radiance + pillar alone are 44% of tiles over threshold and are untouched.
Registers the three knobs in env_report (report + known-name set).
Re-runs the SBC coincidence premise as a cheap veto test on the 70-battle
live-vs-DrussGT corpus (/tmp/tfil_ab2). Defines the wave-relative state
(lat/vlat/toa/room/turn, 5/7.9/10 bits at Q=2/3/4), quantises the miss offset
at the bullet's arrival into 7 bins, and sweeps window length K in
{1,4,8,16,32,48} with an interpolated suffix-backoff model under a BY-BATTLE
70/30 split (3 seeds).
Result: NO. On the pre-fire frame the window is worse than the single
fire-tick state at every K/Q/A (e.g. K=8 costs +0.35..+0.46 bits). On the
during-flight frame the entire apparent gain is the later decision tick, not
the window; the single state alone drops 2.70 -> 1.31 bits as K goes 1 -> 32.
The shuffle-order control confirms recency matters but the windows do not:
by Q=4/K=8 they average ~1 observation and never recur. The single state
survives as a strong predictor (log-loss 2.346 vs 2.698 majority; bin
accuracy 0.409 vs 0.235).
Gate only: no gun, no live-win claim.
Adds an smCounted storage mode alongside the default smBitset. Each
(i,j,class) cell becomes a saturating uint8 counter; learn increments it and
a global fractional decay (c -= c shr decayShift every decayEvery learns)
makes forgetting possible. infer sums raw counters; new inferProb sums the
per-cell posterior P(class|cell) (scale-free, recommended readout).
Bitset path is the default and byte-for-byte unchanged: test_bitbrain 56/56
(was 32), and test_bitbrain_mnist reproduces 97.210% corrected / 96.540%
bug-compatible exactly.
Counted mode configurable at runtime (TR_BITBRAIN_MODE / TR_BITBRAIN_DECAY_*)
and compile time (-d:bitbrainDecay*). Measured: forgetting (86.2% vs 48.9% on
a permuted-label stream), probabilities (rare-class balanced 0.998 vs 0.500),
and the stationary cost (counted hurts MNIST; see docs/bitbrain_counted_sbc.md).
Harness: common_libs/tests/measure_counted_sbc.nim
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.
2 arms x 15 runs x 7 rounds, one frozen binary from HEAD a82c864, real DrussGT,
server-side events sidecar. Shipped rack is onlyPattern, so control=Pattern-only
and headon=HeadOn-only (TR_RACK_PATTERN=off TR_RACK_HEADON=both).
arm dmg/run dmgtk/run round wins shots/run
control 279 211 48/105 785
headon 14 228 0/105 580
Round wins and dmg/run both separate at p<0.0001 (MC permutation, se 0.0000),
~7x the damage MDE (35.8). Per range band (pooled, 15 runs):
300-450: Pattern 12.3% (4590 shots) vs HeadOn 0.6% (3701) p<0.0001, MDE 2.0pp
450+ : Pattern 9.2% (6671) vs HeadOn 0.4% (4177) p<0.0001, MDE 1.1pp
HeadOn loses EVERY long-range band by 20-23x, so the whole-battle loss is not a
close-range artefact.
The offline ruler (prediction_quality_results.txt) predicted the opposite: HeadOn
meanAbs 14.61 vs Pattern 17.53 at 300-450 and 12.33 vs 16.19 at 450+, hitProxy
.105/.104 and .098/.077 (+27%). That is an open-loop replay of a FIXED enemy
track, so it cannot see that a different bullet makes the surfer dodge
differently; live, the static gun does not lead at all.
TR_PATTERN_RAD_SCALE arms were skipped: applyRadial scales aim DISTANCE along an
unchanged bearing, so it cannot express 'less lead' (bearing is what firing uses).
HeadOn confirmed to ignore bulletSpeed (head_on.nim:9), liveness OK 15/15.
Adds the range-band analyzer tools/ab/ab_range_bands.py (reuses the lead-capture
Run alignment) and the captured fixtures. Does not touch bitbrain_gun.nim /
bitbrain_campaign.md (job-100).
New harness (common_libs/gun_harness/prediction_quality.nim +
common_libs/tests/run_prediction_quality.nim): per-gun single-tick aim error in
degrees against the true continuous interception point on the recorded
live-vs-real-DrussGT corpus (/tmp/tfil_ab2/out, 70 runs, 899607 ticks), per
range band, with the hit-probability proxy mean(|err|<=atan(18/range)).
Validated: recorded hits separate from misses 13.34x px (reference 11.59x),
perfect-oracle max |err| = 0, correct ordering on synthetic ground truth, two
full runs byte-identical. Fixed a wrap180 bug (Nim float mod keeps the dividend
sign) that inflated the negative error tail.
Bar (mean|err| deg [hitProxy] at 450+): Pattern 16.19 [0.077], naive-linear
22.86 [0.054], TMHorizon 16.20 [0.076], BitBrain 16.20 [0.077], static HeadOn
12.33 [0.098], oracle 0 [1.0]. Lead-gain sweep on Pattern is a dead end (1.0
wins every band). Naive-linear applies ~1.8x Pattern's lead but carries no more
lead information (corr 0.178 vs 0.165) and is strictly worse. Ledger:
docs/bitbrain_campaign.md. All verdicts remain live-only.
4 arms x 7 runs vs real DrussGT. mix alternates the two guns 476 times/7 runs
(liveness OK) but our bullets are no more varied (power sd / aim-offset sd flat)
and DrussGT's dodge quality is unchanged (miss/tick mix-pat +0.03, p=0.66; MDE
3.8%). mix wins 24/49 = the 49% baseline; the user's 6/10 has P=0.353 at 49%.
New tools/ab/ab_dodge_analyze.py splits the validated per-shot dodge instrument
by arm and adds gun-switch/power/bearing liveness; fixtures committed.