TR_TFIL_RING_COMMIT_ARRIVAL and TR_TFIL_RING_NOREV_SPEED were in .env.example
and in the boot env report but had zero mentions in this file - the trap this
document exists to prevent. Added to the movement table, and the 'defaults read
at' line pointer corrected from the stale 116-133 to the real 188-196 / 173-175
(j165 shifted them). Both are labelled NEVER LIVE-TESTED.
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
450 runs/arm x 2 arms (15 opponents x 30 runs x 3 rounds, conc=6, 0 failed,
0 never started, 0 env mis-set), frozen binary d9a39c3b8472, TR_MOVEMENT=tfil.
Round-win 40.30% -> 39.70% (-0.59 pp, CI -3.90..+2.72, sign-flip exact-2^15
p=0.7676, MDE 4.73 pp); wins/run 0.200 -> 0.182 (MDE 0.1420); damage/run
113.65 -> 112.70 (p=0.5298, MDE 4.19). Deviation disclosed: 30 runs/opponent
instead of 42 (throughput 14-22 runs/min vs 22.7-23.2 assumed); full 15-opponent
panel and both arms kept, runs reduced.
Headline is the mechanism failure: firing was suppressed on 0.04% of ticks,
not the 9.6% the offline ruler predicted (~200x smaller); only 4.8% of shots
happen in the low-energy zone and the floor removed 14% of those; rounds ending
at self energy <=0 were 40.6% live vs 61.2% implied by the offline corpus;
median self energy at death 14.1 -> 15.5. The pre-registered 'we cannot measure
the damage cost directly' call was correct.
DO NOT ADOPT. TR_RAM_FLOOR_ENERGY stays 0.0, do not re-test. The null does not
prove the knob inert - the mechanism barely fired.
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.
Budget derived from the server source (tank-royale 0.35.5): calcGunHeat(p) =
1 + p/5, coolDown 0.1/tick, fire only at heat == 0, calcBulletDamage(3.0) = 16
-> two max-power shots are 16 ticks apart and 32 damage is the most the enemy
can land in that window (brute force over the 0.1 power grid: 8/11/16/24/32
ticks -> 16/18/32/32/48). 16 = CommitTicks + 1, the first window that admits
the enemy's second shot.
Knob defaults to 0 = today's behaviour byte-for-byte (checked over 20026
ticks). Hold is taken only on a replan tick with an empty safe set, at most N
ticks per streak, released the tick a safe tile exists, counter reset on the
pick. PANIC RELEASE: a tracked bullet whose closest approach is within its core
at t* in [0, min(N,16)] overrides the hold immediately. The gun is untouched.
Also fixes a stray '&' that stopped test_tfil_commit_env.nim from compiling at
all, and enforces the 'a hold never interrupts a live commitment' invariant
j153 documented but did not implement. Registers TR_TFIL_HOLD_MAX_TICKS in
env_report + knownEnvNames + .env.example.
No battle, 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>
Within-mover references (the only comparison that isolates the knob): tfil_lag1
-0.04 wins/run, strafe_lag1 +0.18 wins/run, both far under the reported MDEs
(0.33 / 0.41) -> NOT distinguishable, so TR_FIRE_LAG stays default 0. Incoming
hit rate also unmoved (+0.94 / +0.33 pp). The one significant result is the
mover (strafe_lag1 vs tfil_off +0.38 wins/run p=0.0386, hit rate -4.67pp
p=0.0074), which is the known strafe-over-tfil gap and exactly why the
within-mover reference was pre-registered.
Also a cosmetic no-op refactor of the two spawn sites (compute velX/velY once;
bit-identical, and it keeps the default-parity claim exact).
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.
Pooled verdict on the pre-registered rule is NOT DISTINGUISHABLE: the primary
cross-opponent sign test on round wins is 11/14, p=0.05737 for 'arrive', over the
0.05 line. Block 1 alone passed (p=0.01294); block 2 alone did not (p=0.0654).
Every delta is positive in both blocks for all three arms and damage/run is UP
on all three, so this reads as an under-powered null at the MDE boundary
(MDE 0.31 wins/run, observed 0.28) - but the verdict layer is not
reinterpreted, exactly as gate v1 was not.
The MECHANISM is established cleanly and replicates in both blocks: incoming hit
rate 18.07% -> 14.92%, sign-flip p=0.0013, CI [-5.29, -1.53] pp, damage taken
-24.57/run. That is precisely the pathology the owner watched.
Recommended for his own .env: TR_TFIL_COMMIT_ARRIVAL=1 and
TR_TFIL_NOREV_SPEED=4; leave TR_TFIL_COMMIT_MARGIN at 0 (weakest arm in both
blocks). Shipped default untouched: TR_MOVEMENT=strafe, all three new knobs
default off.
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.
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.