8 Commits

Author SHA1 Message Date
SirStone 940fa44631 docs(env_reference): the two j165 ring knobs were MISSING entirely
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.
2026-09-27 14:00:26 +02:00
SirStone 0df7763765 merge j160-ramfloor: the energy-reserve FIRING FLOOR + ENEMY-EXHAUSTION ram trigger (default off)
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
2026-09-27 14:00:06 +02:00
SirStone 07866c99bd merge j165-ring-commit: port tfil's arrival commitment to tfil_ring (default off)
TR_TFIL_RING_COMMIT_ARRIVAL (default off) and TR_TFIL_RING_NOREV_SPEED
(default 0.0), plus the tfil_ring replay ruler and its default-parity golden
fixture. Offline parity is byte-for-byte over 20,026 ticks with the knobs unset.
Never live-tested: the first live test of the_floor_is_lava_ring.nim under these
knobs should be a real experiment, not a formality.
2026-09-27 13:55:22 +02:00
SirStone 51bfa57067 j163: RESULT - the firing floor is a clean negative, and the offline energy corpus missed the live game by 200x
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.
2026-09-27 13:55:19 +02:00
SirStone fe77056459 j165: port tfil's arrival commitment to tfil_ring, default off
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.
2026-09-27 13:27:02 +02:00
SirStone 6cfb1698f1 j163: PRE-REGISTER the 2-arm firing-floor A/B (A floor=0 vs B floor=5) before any battle 2026-09-27 12:59:51 +02:00
SirStone 64e23e29d1 j162: measure whether the bot ever runs out of energy (offline, no battles)
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.
2026-09-27 12:58:14 +02:00
SirStone 23bce2dad5 j160 (default-off): the energy-reserve FIRING FLOOR + ENEMY-EXHAUSTION ram trigger
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.
2026-09-27 12:46:46 +02:00
13 changed files with 21427 additions and 10 deletions
+4
View File
@@ -98,12 +98,16 @@ TR_RAM_PLAN=off # the change-of-plan trigger (enemy outguns us while
TR_RAM_PLAN_DIST=250.0 # px; max range at which the plan trigger may fire
TR_RAM_PLAN_MARGIN=20.0 # energy advantage the plan trigger needs
TR_RAM_PLAN_HITRATE=0.05 # pooled virtual hit rate below which the gun duel counts as failing
TR_RAM_FLOOR_ENERGY=0.0 # j160 firing floor: at/below this self energy stop firing (0 = off)
TR_RAM_ENEMY_ENERGY=0.0 # j160 exhaustion: last-scanned enemy energy <= this -> ram (0 = off)
# ── movement internals: tfil (the floor-is-lava field) ──────────────────────
TR_TFIL_RANGE_LO=100.0 # px; lower edge of the range band the ring mover prefers
TR_TFIL_RANGE_HI=200.0 # px; upper edge of that band
TR_TFIL_RANGE_TEMP=0.4 # sharpness of the ring mover's weighted random draw
TR_TFIL_RANGE_K=60.0 # px; how fast the weight falls off outside the band
TR_TFIL_RING_COMMIT_ARRIVAL=off # tfil_ring only: on = hold the dodge tile until we are ON it (not a fixed dwell)
TR_TFIL_RING_NOREV_SPEED=0.0 # tfil_ring only; px/tick; below this, a mid-flight switch may not turn the bot around
TR_TFIL_CORRIDOR_HEAT=10.0 # lava painted per corridor-overlapping tile
TR_TFIL_CORRIDOR_TICKS=0.0 # corridor length in ticks: 0 = to the wall (shipped); N>0 = min(to wall, bullet speed * N)
TR_TFIL_WALL_HOTNESS=15.0 # peak heat painted on tiles next to a wall
+6 -1
View File
@@ -1459,9 +1459,14 @@ method run*(bot: ModularBot) =
let distPx = hypot(pred.x - getX(), pred.y - getY())
if shouldFire(gunDir, aimTarget, gunHeat, distPx):
# j160 FIRING FLOOR: at/below TR_RAM_FLOOR_ENERGY self energy we hold
# the reserve for the ram instead of spending it on a shot. Off by
# default (`RamFloorEnergy = 0.0`), and `ramming` (the exhaustion
# trigger) wins the conflict, so an engaged ram never starves itself.
let floorBlocks = fireFloorBlocks(RamFloorEnergy, getEnergy(), shouldRam)
# Enqueue the selected gun so onBulletFired can stamp the server's bulletId.
# getEnergy() > power mirrors the server's "bot.energy <= firepower" reject.
if setFire(power) and getEnergy() > power:
if not floorBlocks and setFire(power) and getEnergy() > power:
bot.pendingFires.add(PendingShot(
gunId: selectedGun,
angleErr: abs(normDelta),
+10
View File
@@ -309,6 +309,12 @@ proc printEffectiveValues(ctx: EnvReportContext) =
emit("TR_TFIL_RANGE_HI", $RangeHi, sourceOf("TR_TFIL_RANGE_HI"))
emit("TR_TFIL_RANGE_TEMP", $RangeTemp, sourceOf("TR_TFIL_RANGE_TEMP"))
emit("TR_TFIL_RANGE_K", $RangeK, sourceOf("TR_TFIL_RANGE_K"))
# j165: the ring fork's own arrival commitment (default off). RING-SPECIFIC
# names, so they can never be confused with the tfil mover's TR_TFIL_* pair.
emit("TR_TFIL_RING_COMMIT_ARRIVAL", onOff(TfilRingCommitArrival),
sourceOf("TR_TFIL_RING_COMMIT_ARRIVAL"))
emit("TR_TFIL_RING_NOREV_SPEED", $TfilRingNoRevSpeed,
sourceOf("TR_TFIL_RING_NOREV_SPEED"))
emit("TR_TFIL_CORRIDOR_HEAT", $the_floor_is_lava_ring.CorridorHeat,
sourceOf("TR_TFIL_CORRIDOR_HEAT"))
emit("TR_TFIL_CORRIDOR_TICKS", $the_floor_is_lava.TfilCorridorTicks,
@@ -410,6 +416,8 @@ proc printEffectiveValues(ctx: EnvReportContext) =
emit("TR_RAM_PLAN_MARGIN", $RamPlanMargin, sourceOf("TR_RAM_PLAN_MARGIN"))
emit("TR_RAM_PLAN_HITRATE", $RamPlanHitRate, sourceOf("TR_RAM_PLAN_HITRATE"))
emit("TR_RAM_LOG", onOff(RamLog), sourceOfPresence("TR_RAM_LOG"))
emit("TR_RAM_FLOOR_ENERGY", $RamFloorEnergy, sourceOf("TR_RAM_FLOOR_ENERGY"))
emit("TR_RAM_ENEMY_ENERGY", $RamEnemyEnergy, sourceOf("TR_RAM_ENEMY_ENERGY"))
# ── the horizon TM gun ────────────────────────────────────────────────────
# `resetLearning`/`targetChanged` resolve the lazily-read fields at round
@@ -661,7 +669,9 @@ proc knownEnvNames*(): seq[string] =
"TR_RAM_OPPORTUNITY", "TR_RAM_OPP_DIST", "TR_RAM_OPP_MARGIN",
"TR_RAM_ABORT_DMG", "TR_RAM_PLAN", "TR_RAM_PLAN_DIST",
"TR_RAM_PLAN_MARGIN", "TR_RAM_PLAN_HITRATE", "TR_RAM_LOG",
"TR_RAM_FLOOR_ENERGY", "TR_RAM_ENEMY_ENERGY",
"TR_TFIL_RANGE_LO", "TR_TFIL_RANGE_HI", "TR_TFIL_RANGE_TEMP",
"TR_TFIL_RING_COMMIT_ARRIVAL", "TR_TFIL_RING_NOREV_SPEED",
"TR_TFIL_RANGE_K", "TR_TFIL_CORRIDOR_HEAT", "TR_TFIL_WALL_HOTNESS",
"TR_TFIL_CORRIDOR_TICKS", "TR_TFIL_ARRIVE_TICKS",
"TR_TFIL_HOLD_WHEN_TRAPPED", "TR_TFIL_HOLD_MAX_TICKS",
+52 -2
View File
@@ -46,7 +46,29 @@
## TR_RAM_PLAN_MARGIN default 20.0 change-of-plan energy advantage
## TR_RAM_PLAN_HITRATE default 0.05 selected gun's pooled virtual hit rate
## below which the gun duel counts as failing
## TR_RAM_LOG=1 emit one change-gated `[ram]` line
## ## TR_RAM_LOG=1 emit one change-gated `[ram]` line
## TR_RAM_FLOOR_ENERGY default 0.0 FIRING FLOOR (j160). At or below this
## self energy we stop firing to keep a
## ram reserve. 0 = off = today's behaviour.
## TR_RAM_ENEMY_ENERGY default 0.0 ENEMY-EXHAUSTION trigger (j160). The
## last-scanned enemy energy <= this ->
## ram mode. 0 = off.
##
## ── j160: the energy-reserve + exhaustion policy ───────────────────────────
## Energy NEVER regenerates and has no cap; the only gain in the whole game is
## `+3 * power` per bullet hit LANDED (server `rules.kt`). So not firing denies
## the enemy its only refill AND keeps our ram reserve intact — the two halves
## of the policy are the same bet.
##
## Floor sizing: one likely return hit (`bulletDamage(1.0)` = 4.0) plus two
## 0.1-power shots (0.1 each) is 4.2. The knob DEFAULT stays 0.0 so the default
## path is byte-identical; the operator sets 5-ish.
##
## The exhaustion trigger is the FINISHER with the energy tolerance promoted to
## an operator knob. It deliberately KEEPS the finisher's own
## `selfEnergy > enemyEnergy` surplus guard: `RAM_DAMAGE 0.6` is applied to BOTH
## bots on every contact tick, so a head-on contact is a symmetric bleed decided
## by who walks in with the surplus.
import std/[os, strutils]
@@ -95,10 +117,15 @@ let RamPlanDist* = getEnvFloat("TR_RAM_PLAN_DIST", DefaultRamPlanDist)
let RamPlanMargin* = getEnvFloat("TR_RAM_PLAN_MARGIN", DefaultRamPlanMargin)
let RamPlanHitRate* = getEnvFloat("TR_RAM_PLAN_HITRATE", DefaultRamPlanHitRate)
let RamLog* = existsEnv("TR_RAM_LOG")
## j160. 0.0 = off on BOTH knobs, which is the shipped behaviour.
let RamFloorEnergy* = getEnvFloat("TR_RAM_FLOOR_ENERGY", 0.0)
let RamEnemyEnergy* = getEnvFloat("TR_RAM_ENEMY_ENERGY", 0.0)
type
RamReason* = enum
rrNone ## no trigger fires
rrExhausted ## j160: last-scanned enemy energy <= TR_RAM_ENEMY_ENERGY
## and we hold the surplus (it is out of ammo, we are not)
rrFinisher ## enemy < 20 energy, we are healthier, dist < 300
rrOpportunity ## we clearly out-energise and are close enough to close
rrDesperation ## both nearly dead, short range
@@ -131,7 +158,8 @@ proc ramTrigger*(inp: RamInputs,
planEnabled = RamPlanEnabled,
planDist = RamPlanDist,
planMargin = RamPlanMargin,
planHitRate = RamPlanHitRate): RamReason =
planHitRate = RamPlanHitRate,
enemyEnergyTol = RamEnemyEnergy): RamReason =
## Pure trigger evaluation. Returns the FIRST matching reason in priority
## order, or `rrNone`. Cooldown/duration/abort are deliberately NOT here — the
## caller composes those, so this function has no state and is unit-testable.
@@ -143,6 +171,13 @@ proc ramTrigger*(inp: RamInputs,
## `desperation` and `finisher` are kept: they are rare, short-range, and the
## finisher is the only measured conversion. `plan` remains opt-in and off.
if inp.enemyEnergy <= 0.0: return rrNone
# j160 exhaustion trigger. Checked FIRST so the operator-set tolerance wins
# the label when it is set; it is the finisher's own shape (same surplus and
# distance guards) with the 20.0 energy tolerance promoted to a knob. With
# `enemyEnergyTol = 0.0` (the default) this arm can never fire.
if enemyEnergyTol > 0.0 and inp.enemyEnergy <= enemyEnergyTol and
inp.dist < RamFinisherDist and inp.selfEnergy > inp.enemyEnergy:
return rrExhausted
if inp.dist < RamFinisherDist and inp.enemyEnergy < RamFinisherEnergy and
inp.selfEnergy > inp.enemyEnergy:
return rrFinisher
@@ -158,9 +193,24 @@ proc ramTrigger*(inp: RamInputs,
return rrPlan
rrNone
proc fireFloorBlocks*(floor, selfEnergy: float, ramming = false): bool =
## j160 FIRING FLOOR. True when the reserve is thin enough that we must not
## commit a NEW shot. `floor = 0.0` (the default) disables the floor entirely
## and returns false for every input, so the default path is unchanged.
##
## `ramming` WINS over the floor: once ram mode is engaged the duel is over,
## so the reserve is being spent on the contact, not held for it. This is the
## same exemption `ramming` already gets in `applyPowerPolicy`.
##
## The floor blocks only NEW shots. A bullet already in the air (gun heat > 0)
## is untouched — `shouldFire` already gates on `gunHeat <= 0.0`, so there is
## no committed shot for the floor to suppress or cancel.
not ramming and floor > 0.0 and selfEnergy <= floor
proc reasonName*(r: RamReason): string =
case r
of rrNone: "none"
of rrExhausted: "exhausted"
of rrFinisher: "finisher"
of rrOpportunity: "opportunity"
of rrDesperation: "desperation"
@@ -49,6 +49,10 @@
## TR_TFIL_CORRIDOR_HEAT default 10.0 lava per corridor-overlapping tile
## TR_TFIL_WALL_HOTNESS default 15.0 peak wall radiance at a wall tile
## TR_MOVEMENT_LOG=1 log band/range-class changes (not/tick)
## TR_TFIL_RING_COMMIT_ARRIVAL default off hold the committed tile until we
## are ON it (port of tfil's j144 fix)
## TR_TFIL_RING_NOREV_SPEED default 0.0 px/tick; below this a mid-flight
## switch may not turn the bot around
## `TR_TFIL_RANGE_TEMP=0` calls plain `rand(candidates.high)` exactly as the
## original mover did, so the same binary can serve as the control arm.
##
@@ -141,6 +145,36 @@ proc loadTfilRingFireEnv*() =
TfilRingFireFix = getEnvBool("TR_FIRE_FIX", true)
loadTfilRingFireEnv()
## ── j165: the ARRIVAL commitment, ported from `the_floor_is_lava.nim` ────────
## Same BEHAVIOUR as tfil's `TR_TFIL_COMMIT_ARRIVAL` / `TR_TFIL_NOREV_SPEED`,
## RING-SPECIFIC env names so the two forks never share a namespace by accident.
## Both default OFF, so the default path stays byte-for-byte today's ring
## (proved over the 20026-tick fixture replay in `test_tfil_commit_env.nim`).
## TR_TFIL_RING_COMMIT_ARRIVAL 0/1 hold the committed tile until we are
## ON it, instead of dropping the
## commitment on a tile crossing
## TR_TFIL_RING_NOREV_SPEED float while |speed| is below this, a
## mid-flight switch to the OPPOSITE
## side is refused (0 = off)
const
RingArriveRadius* = 18.0 ## "we are on the committed tile" — the same 18px
## radius `the_floor_is_lava.nim` uses
RingHullTicks = 50 ## the reachable-hull planning horizon (the literal
## 50 already passed to `computeReachableHull`
## below). Past it the committed target is no longer
## guaranteed reachable: the stall escape.
var
TfilRingCommitArrival* = false
TfilRingNoRevSpeed* = 0.0
proc loadTfilRingCommitEnv*() =
## Read the j165 knobs. Called once at module init; the guard test calls it
## again after `putEnv` so the non-default arms run in one process.
TfilRingCommitArrival = getEnvBool("TR_TFIL_RING_COMMIT_ARRIVAL", false)
TfilRingNoRevSpeed = max(0.0, getEnvFloat("TR_TFIL_RING_NOREV_SPEED", 0.0))
loadTfilRingCommitEnv()
const
DefaultRangeLo = 100.0
DefaultRangeHi = 200.0
@@ -240,6 +274,10 @@ type
commitTarget: tuple[x, y: float] ## world coords of committed dodge point
commitTicks: int ## ticks remaining on commitment
commitLava: float ## lava at commit time (for spike detection)
commitAge: int ## j165: ticks since the current target
## was picked (0 = just picked)
picks: int ## j165: picks made this round; > 0 means
## a switch would be MID-FLIGHT
blockedTile: tuple[col, row: int; active: bool] ## excluded from next pick after danger replan
cachedHull: seq[tuple[x, y: float]]
cachedInsideTiles: seq[tuple[col, row: int]]
@@ -283,6 +321,8 @@ proc resetRound*(m: var TFILRingModule) =
m.bullets = @[]
m.fire.reset()
m.commitTicks = 0
m.commitAge = 0
m.picks = 0
m.cachedHull = @[]
m.cachedInsideTiles = @[]
m.blockedTile = (col: 0, row: 0, active: false)
@@ -462,6 +502,40 @@ proc computeReachableHull(x0, y0, heading0, speed0,
if cur == startIdx: break
hull
proc ringTileOffTravel*(m: TFILRingModule, col, row: int,
sx, sy, travelDeg: float): float =
## Signed angle in degrees from the travel direction to the tile centre,
## folded into (-180, 180]. Verbatim from `the_floor_is_lava.nim`'s
## `tileOffTravel`; the ring's `ScoredTile` carries no `turnDeg`, so the
## no-reversal pool computes the offsets itself.
let tx = m.marginX + (col.float + 0.5) * GridSize
let ty = m.marginY + (row.float + 0.5) * GridSize
result = arctan2(ty - sy, tx - sx) * 180.0 / PI - travelDeg
while result > 180.0: result -= 360.0
while result < -180.0: result += 360.0
proc norevPool*(offs: openArray[float], threshold: float): seq[int] =
## j165: which candidate tiles may a slow, mid-flight switch take? Verbatim
## from `the_floor_is_lava.nim`. `offs` are the signed angles (deg) from the
## travel direction to each candidate, `threshold` is the speed gate
## (px/tick). Returns the indices NOT more than 90 deg off — the bot does not
## have to turn around to reach them. If EVERY candidate is behind us the
## reversal is unavoidable, so the single LEAST-bad one is returned (a shallow
## turn, not a 180 deg flip): the result is NEVER empty, so the pick can never
## be starved. `threshold <= 0` = the knob is off and every candidate stays.
if offs.len == 0: return
if threshold <= 0.0:
for i in 0..<offs.len: result.add i
return
var keep: seq[int]
for i, a in offs:
if abs(a) <= 90.0: keep.add i
if keep.len > 0: return keep
var best = 0
for i, a in offs:
if abs(a) < abs(offs[best]): best = i
@[best]
proc computeMove*(m: var TFILRingModule, ws: WorldState): MoveCommand =
if m.cols == 0:
m.initGrid(ws.arenaWidth, ws.arenaHeight)
@@ -482,7 +556,13 @@ proc computeMove*(m: var TFILRingModule, ws: WorldState): MoveCommand =
m.fire.prevEnergySet(ei.id, ei.energy)
# Tile-change replan: catches gradual displacement that position threshold misses
if (not jumped) and (m.callCount > 0) and (m.commitTicks > 0):
# j165: with TR_TFIL_RING_COMMIT_ARRIVAL the SELF-tile crossing is exactly the
# event that must NOT cancel a commitment: crossing a boundary is the very
# motion the commitment commands, and at GridSize 36 / speed 8 it fires every
# ~5 ticks — which is precisely this fork's CommitTicks. Under the shipped
# default (arrival off) this is the original block verbatim.
if (not jumped) and (m.callCount > 0) and (m.commitTicks > 0) and
not TfilRingCommitArrival:
let curTileCol = clamp(int((ws.selfX - m.marginX) / GridSize), 0, m.cols - 1)
let curTileRow = clamp(int((ws.selfY - m.marginY) / GridSize), 0, m.rows - 1)
if curTileCol != m.lastTileCol or curTileRow != m.lastTileRow:
@@ -756,22 +836,50 @@ proc computeMove*(m: var TFILRingModule, ws: WorldState): MoveCommand =
safeTiles.add blockedTiles[i]
blockedTiles = blockedTiles[promote ..< blockedTiles.len]
# Commitment logic
# Commitment logic. With every j165 knob at its default (both off) this is the
# original three-way test, unchanged. j165 adds one way OUT of a commitment
# that is NOT a tile crossing (the block above is skipped when armed) and turns
# the tick counter into a MINIMUM dwell: the target is held until we are
# actually standing on it.
let atTarget = (ws.selfX - m.commitTarget.x)^2 + (ws.selfY - m.commitTarget.y)^2 <
RingArriveRadius * RingArriveRadius
if m.commitTicks > 0:
# Only allow danger replan after MinCommitTicks have elapsed
inc m.commitAge
# Only allow a replan after MinCommitTicks have elapsed
let ticksElapsed = CommitTicks - m.commitTicks
if ticksElapsed >= MinCommitTicks:
let (cc, cr) = m.tileAt(m.commitTarget.x, m.commitTarget.y)
let curLava = m.lavaAt(cc, cr)
var commitEnd = false
if curLava > m.commitLava + DangerReplanThreshold:
# Mark committed tile blocked so we don't re-pick it
# GENUINE DANGER: the committed tile got hot. Block it so we don't
# immediately re-pick it, and replan. This safety valve is deliberately
# independent of the arrival rule and is UNCHANGED by j165.
m.blockedTile = (col: cc, row: cr, active: true)
m.commitTicks = 0 # replan
else:
commitEnd = true
elif TfilRingCommitArrival:
if atTarget:
# Reached. Only now is a new target allowed.
commitEnd = true
elif m.commitAge >= RingHullTicks:
# Stall escape: past the planner's own reachability horizon the
# committed tile is no longer guaranteed reachable (rammed, boxed in).
commitEnd = true
if not commitEnd:
dec m.commitTicks
if m.commitTicks == 0 and TfilRingCommitArrival:
m.commitTicks = CommitTicks # minimum dwell reached: renew, don't abandon
else:
m.commitTicks = 0
else:
dec m.commitTicks
# j165: was the commitment we are about to replace still UNREACHED? A pick
# that replaces a target we had not yet got to is the owner's failure mode:
# the bot is still accelerating and the target flips under it. `picks == 0`
# means this is the first pick of the round, which is not a switch at all.
let midFlight = m.picks > 0 and not atTarget
if m.commitTicks == 0 and safeTiles.len > 0:
# Filter out the blocked tile from candidates
var candidates: seq[ScoredTile]
@@ -780,6 +888,22 @@ proc computeMove*(m: var TFILRingModule, ws: WorldState): MoveCommand =
continue
candidates.add t
if candidates.len == 0: candidates = safeTiles # all blocked → ignore block
# j165, no opposite-direction flip while still accelerating. Below the speed
# threshold the bot physically cannot complete a reversal before the bullet
# lands, so a mid-flight switch to the mirror side only destroys the dodge it
# already has. It is refused outright — and only for a MID-FLIGHT switch: if
# we are already standing on the committed tile (an arrival pick) the bot is
# free to go anywhere, and that is exactly the pick that must not be blocked.
if TfilRingNoRevSpeed > 0.0 and abs(ws.selfSpeed) < TfilRingNoRevSpeed and midFlight:
let travelDeg = if ws.selfSpeed < -0.01: ws.selfHeading + 180.0
else: ws.selfHeading
var offs: seq[float]
for t in candidates:
offs.add ringTileOffTravel(m, t.col, t.row, ws.selfX, ws.selfY, travelDeg)
let keep = norevPool(offs, TfilRingNoRevSpeed)
var narrowed: seq[ScoredTile]
for i in keep: narrowed.add candidates[i]
candidates = narrowed
# Safety is a HARD constraint: the weighting below only re-orders the draw
# AMONG `candidates`, which is exactly the pool the old `rand` picked from.
# It can never select a tile the unweighted code would have rejected
@@ -801,6 +925,8 @@ proc computeMove*(m: var TFILRingModule, ws: WorldState): MoveCommand =
y: m.marginY + (ct.row.float + 0.5) * GridSize)
m.commitTicks = CommitTicks
m.commitLava = m.lavaAt(ct.col, ct.row)
m.commitAge = 0
inc m.picks
m.blockedTile.active = false # clear after successful pick
# One concise log line on a range-class or band change (never per-tick).
File diff suppressed because it is too large Load Diff
+278
View File
@@ -0,0 +1,278 @@
#!/usr/bin/env python3
"""j162 DECISIVE measurement: does the bot ever actually run out of energy?
The firing floor (TR_RAM_FLOOR_ENERGY) only pays if the bot regularly creeps
down to a few energy and gets disabled. This answers that from the ALREADY
RECORDED closed-loop corpus, state only:
A) self energy AT DEATH (the reserve we actually held when the killing blow
landed) -- the floor's entire claim
B) how long we stay at energy <= 0 (isDisabled) before the round ends
C) recovery: how often self energy RISES tick-over-tick, and from what level
(the only refill in the game is +3*power per landed bullet hit, so a rise
is a landed hit -- this is "can we climb back out by shooting")
D) what a floor at {3,5,10,20} would cost: % ticks suppressed, run length, and
the heat-limited ceiling on how much energy it could possibly save
NO battle, NO server, NO counterfactual replay, NO damage estimate (the offline
harness scored 0/6 on closed-loop questions, docs/offline_harness_trust.md).
Usage: python3 common_libs/tests/measure_ram_exhaustion [glob-dir]
"""
import glob, json, os, statistics, sys
from array import array
from multiprocessing import Pool
ROOTS = sys.argv[1:] or ["/tmp"]
# Tank Royale gun heat: heat += 1 + power/5 and the gun cools 0.1/tick, so a
# power-p shot can be fired at most once per 10 + 2p ticks and costs p energy.
# The cost bracket is therefore p/(10+2p) energy per tick, from 0.0098 at the
# cheapest legal shot (0.1) to 0.1875 at the most expensive (3.0).
def per_tick(power):
return power / (10.0 + 2.0 * power)
def num(line, key):
i = line.find('"' + key + '":')
if i < 0:
return None
i += len(key) + 3
j = line.find(',', i)
if j < 0:
j = line.find('}', i)
try:
return float(line[i:j])
except ValueError:
return None
def load(path):
"""[(self, enemy)] per tick, with round boundaries from the round map."""
rows = []
with open(path) as fh:
for line in fh:
if '"tick"' not in line:
continue
t, se, ee = num(line, 'tick'), num(line, 'se'), num(line, 'ee')
if t is None or se is None or ee is None:
continue
rows.append((t, se, ee))
if not rows:
return []
rf = path.replace(".jsonl", ".jsonl.rounds.json")
bounds = []
if os.path.exists(rf):
try:
for r in json.load(open(rf))["rounds"]:
bounds.append((r["startTick"], r["startTick"] + r["count"]))
except Exception:
bounds = []
if not bounds:
# no map: a round is the span between RISES from depleted to full,
# never the first ticks of a round where both bots sit at 100.
starts = [0] + [i for i in range(1, len(rows))
if rows[i][1] >= 100 > rows[i - 1][1]]
bounds = [(starts[k], starts[k + 1] if k + 1 < len(starts) else len(rows))
for k in range(len(starts))]
rounds = []
for s, e in bounds:
r = [(se, ee) for t, se, ee in rows if s <= t < e]
if r:
rounds.append(r)
return rounds
def corpus():
files = []
for root in ROOTS:
for f in glob.glob(os.path.join(root, "**", "*.jsonl"), recursive=True):
if f.endswith(".events.jsonl"):
continue
try:
with open(f) as fh:
first = fh.readline()
except OSError:
continue
if '"closed_loop":true' not in first.replace(" ", ""):
continue
files.append(f)
out = []
for r in Pool(8).imap(load, sorted(files), chunksize=32):
out += r
return sorted(files), out
def pct(sorted_x, q):
if not sorted_x:
return 0.0
i = q * (len(sorted_x) - 1)
lo, hi = int(i), min(int(i) + 1, len(sorted_x) - 1)
return sorted_x[lo] + (sorted_x[hi] - sorted_x[lo]) * (i - lo)
def main():
files, rounds = corpus()
N = sum(len(r) for r in rounds)
print(f"recordings={len(files)} rounds={len(rounds)} ticks={N}\n")
# ---- A) how each round ends, and the reserve held at that moment --------
self_dead = enemy_dead = both_dead = alive_end = 0
last_alive = [] # self energy on the last tick we were alive
death_tick = [] # self energy on the tick we crossed 0 (can be < 0)
zero_runs = [] # ticks spent at self energy <= 0 before round end
over = [] # reserve that would have absorbed the killing blow
for r in rounds:
sd = ed = None
for i, (a, b) in enumerate(r):
if sd is None and a <= 0:
sd = i
if ed is None and b <= 0:
ed = i
if sd is not None and ed is not None:
break
if sd is None and ed is None:
alive_end += 1
continue
if sd is not None and ed is not None:
both_dead += 1
elif sd is not None:
self_dead += 1
else:
enemy_dead += 1
if sd is not None:
last_alive.append(r[sd - 1][0] if sd > 0 else r[0][0])
death_tick.append(r[sd][0])
over.append(-r[sd][0])
j = len(r)
while j > sd and r[j - 1][0] <= 0:
j -= 1
zero_runs.append(len(r) - j)
m = len(rounds)
print("=== A) how each round ends ===")
print(f" self reached energy<=0 : {self_dead:>6} rounds ({100*self_dead/m:5.1f}%)")
print(f" only the enemy did : {enemy_dead:>6} rounds ({100*enemy_dead/m:5.1f}%)")
print(f" both in the same round : {both_dead:>6} rounds ({100*both_dead/m:5.1f}%)")
print(f" neither (truncated) : {alive_end:>6} rounds ({100*alive_end/m:5.1f}%)")
print("\n=== B) SELF ENERGY AT DEATH (last value above 0 before the kill) ===")
s = sorted(last_alive)
if s:
print(f" n={len(s)} min {s[0]:.2f} p10 {pct(s,.10):.2f} median {pct(s,.5):.2f}"
f" mean {statistics.fmean(s):.2f} p90 {pct(s,.90):.2f} max {s[-1]:.2f}")
for t in (0, 1, 3, 5, 10, 20):
c = sum(1 for x in s if x <= t)
print(f" <= {t:>2} energy: {c:>6} ({100*c/len(s):5.1f}% of self deaths,"
f" {100*c/m:5.2f}% of all rounds)")
d = sorted(death_tick)
if d:
print(f" crossing value: median {pct(d,.5):.2f} p10 {pct(d,.10):.2f}"
f" p90 {pct(d,.90):.2f} (negative = overshoot of the killing hit)")
over = sorted(over)
print(" reserve that WOULD have survived the killing blow (overshoot):")
print(f" median {pct(over,.5):.2f} p75 {pct(over,.75):.2f}"
f" p90 {pct(over,.90):.2f} p99 {pct(over,.99):.2f} max {over[-1]:.2f}")
for F in (3, 5, 10, 20):
c = sum(1 for x in over if x < F)
print(f" a reserve of {F:>2} would have absorbed it in {c:>6} self deaths"
f" ({100*c/len(over):5.1f}%)")
print("\n=== C) time spent at energy<=0 (isDisabled) before the round ends ===")
z = sorted(zero_runs)
if z:
print(f" ticks disabled: median {pct(z,.5):.0f} p90 {pct(z,.9):.0f}"
f" max {z[-1]} total {sum(z)} of {N} ticks"
f" ({100*sum(z)/N:.4f}%)")
# ---- D) recovery: energy RISES tick-over-tick = a landed bullet hit -----
rises, pre = 0, []
pre_low = {20: 0, 10: 0, 5: 0, 3: 0}
tot_ticks = 0
for r in rounds:
for i in range(1, len(r)):
tot_ticks += 1
if r[i][0] - r[i - 1][0] > 0.01:
rises += 1
pre.append(r[i - 1][0])
for t in pre_low:
if r[i - 1][0] <= t:
pre_low[t] += 1
print("\n=== D) RECOVERY: self energy rises tick-over-tick (a landed hit) ===")
print(f" rising transitions: {rises} of {tot_ticks} tick-pairs"
f" ({100*rises/tot_ticks:.3f}%), i.e. ~{rises/len(rounds):.2f} per round")
p = sorted(pre)
if p:
print(f" self energy just BEFORE the rise: median {pct(p,.5):.2f}"
f" p10 {pct(p,.10):.2f} p90 {pct(p,.90):.2f}")
print(" climbs that started from a low reserve:")
for t in sorted(pre_low, reverse=True):
print(f" from <= {t:>2}: {pre_low[t]:>6} rises"
f" ({100*pre_low[t]/rises:5.2f}% of rises)")
# the decisive conditional: sitting low, do we climb back out or die?
# "death" counts the ONE tick that crosses 0. The long zero tails a few
# recordings hold afterwards are a recorder artefact, not a state lived in.
print("\n P(climb out | low) vs P(die | low), per tick spent at that level:")
death_idx = []
for r in rounds:
death_idx.append(next((i for i, (a, _) in enumerate(r) if a <= 0), -1))
for F in (3, 5, 10, 20):
at = rise = died = 0
for r, di in zip(rounds, death_idx):
for i, (a, _) in enumerate(r):
if a > F:
continue
at += 1
if i and r[i][0] - r[i - 1][0] > 0.01:
rise += 1
if i == di:
died += 1
if at:
print(f" energy <= {F:>2}: {at:>8} ticks | climb next tick"
f" {100*rise/at:6.3f}% | killed on this tick {100*died/at:6.3f}%"
f" -> dying is {died/max(1,rise):.1f}x more likely than recovering")
# ---- E) what the floor would cost ---------------------------------------
print("\n=== E) COST of TR_RAM_FLOOR_ENERGY: ticks where a new shot is blocked ===")
print(f"{'floor':>5} {'%ticks':>7} {'rounds':>7} {'med run':>8} {'p90 run':>8}"
f" {'max run':>8} {'energy saved, corpus (0.1..3.0 p)':>34}"
f" {'per med run @1.0p':>19}")
for F in (3, 5, 10, 20):
tot, hit, lens = 0, 0, []
for r in rounds:
cur, got = 0, False
for a, _ in r:
if a <= F:
cur += 1
tot += 1
got = True
elif cur:
lens.append(cur)
cur = 0
if cur:
lens.append(cur)
hit += 1 if got else 0
lens.sort()
# The gun may not fire more often than 1/(10*heat) ticks, so the floor
# can never save more than the suppressed ticks x power-per-shot x
# shots-per-tick. Report the bracket: 0.1 power (cheapest legal shot) to
# 3.0 power (most expensive legal shot).
med = pct(lens, .5) if lens else 0
lo, hi = tot * per_tick(0.1), tot * per_tick(3.0)
mid = med * per_tick(1.0)
print(f"{F:>5} {100*tot/N:>6.2f}% {hit:>7} {med:>8.0f} "
f"{pct(lens,.9) if lens else 0:>8.0f} {lens[-1] if lens else 0:>8}"
f" {lo:>7.0f} .. {hi:>7.0f} {mid:>6.2f}")
print(" energy saved over the WHOLE corpus, heat-limited: the 0.1..3.0 power")
print(" bracket, then the p=1.0 column = what one median suppressed RUN is worth")
print(" (1.0 power is the mode of the measured landed-hit histogram).")
print(" Median run lengths 34/53/89/139 ticks; one 1.0-power landed hit = 3.0.")
print()
print(" CAVEAT, measured: the recorded energy ledger closes EXACTLY on")
print(" start + landed-gains - damage = end (residual -0.00 over 35065 rounds),")
print(" i.e. these captures DO NOT charge the firepower cost. The cost column")
print(" is therefore computed from the game rules, not read off the data.")
if __name__ == "__main__":
main()
@@ -0,0 +1,57 @@
recordings=8149 rounds=35163 ticks=34461805
=== A) how each round ends ===
self reached energy<=0 : 21518 rounds ( 61.2%)
only the enemy did : 12906 rounds ( 36.7%)
both in the same round : 347 rounds ( 1.0%)
neither (truncated) : 392 rounds ( 1.1%)
=== B) SELF ENERGY AT DEATH (last value above 0 before the kill) ===
n=21865 min 0.00 p10 0.10 median 0.83 mean 2.50 p90 8.90 max 24.83
<= 0 energy: 0 ( 0.0% of self deaths, 0.00% of all rounds)
<= 1 energy: 12217 ( 55.9% of self deaths, 34.74% of all rounds)
<= 3 energy: 16793 ( 76.8% of self deaths, 47.76% of all rounds)
<= 5 energy: 18420 ( 84.2% of self deaths, 52.38% of all rounds)
<= 10 energy: 20290 ( 92.8% of self deaths, 57.70% of all rounds)
<= 20 energy: 21864 (100.0% of self deaths, 62.18% of all rounds)
crossing value: median -0.40 p10 -6.90 p90 0.00 (negative = overshoot of the killing hit)
reserve that WOULD have survived the killing blow (overshoot):
median 0.40 p75 2.00 p90 6.90 p99 15.00 max 19.50
a reserve of 3 would have absorbed it in 17301 self deaths ( 79.1%)
a reserve of 5 would have absorbed it in 18610 self deaths ( 85.1%)
a reserve of 10 would have absorbed it in 20690 self deaths ( 94.6%)
a reserve of 20 would have absorbed it in 21865 self deaths (100.0%)
=== C) time spent at energy<=0 (isDisabled) before the round ends ===
ticks disabled: median 1 p90 18 max 452 total 593030 of 34461805 ticks (1.7208%)
=== D) RECOVERY: self energy rises tick-over-tick (a landed hit) ===
rising transitions: 205754 of 34426642 tick-pairs (0.598%), i.e. ~5.85 per round
self energy just BEFORE the rise: median 45.24 p10 8.04 p90 89.00
climbs that started from a low reserve:
from <= 20: 52256 rises (25.40% of rises)
from <= 10: 25352 rises (12.32% of rises)
from <= 5: 12812 rises ( 6.23% of rises)
from <= 3: 8013 rises ( 3.89% of rises)
P(climb out | low) vs P(die | low), per tick spent at that level:
energy <= 3: 2633881 ticks | climb next tick 0.130% | killed on this tick 0.830% -> dying is 6.4x more likely than recovering
energy <= 5: 3299807 ticks | climb next tick 0.232% | killed on this tick 0.663% -> dying is 2.9x more likely than recovering
energy <= 10: 5002524 ticks | climb next tick 0.365% | killed on this tick 0.437% -> dying is 1.2x more likely than recovering
energy <= 20: 8526129 ticks | climb next tick 0.500% | killed on this tick 0.256% -> dying is 0.5x more likely than recovering
=== E) COST of TR_RAM_FLOOR_ENERGY: ticks where a new shot is blocked ===
floor %ticks rounds med run p90 run max run energy saved, corpus (0.1..3.0 p) per med run @1.0p
3 7.64% 23086 34 299 1104 25822 .. 493853 2.83
5 9.58% 23739 53 330 1104 32351 .. 618714 4.42
10 14.52% 25211 89 433 1141 49044 .. 937973 7.42
20 24.74% 27799 139 621 2059 83590 .. 1598649 11.58
energy saved over the WHOLE corpus, heat-limited: the 0.1..3.0 power
bracket, then the p=1.0 column = what one median suppressed RUN is worth
(1.0 power is the mode of the measured landed-hit histogram).
Median run lengths 34/53/89/139 ticks; one 1.0-power landed hit = 3.0.
CAVEAT, measured: the recorded energy ledger closes EXACTLY on
start + landed-gains - damage = end (residual -0.00 over 35065 rounds),
i.e. these captures DO NOT charge the firepower cost. The cost column
is therefore computed from the game rules, not read off the data.
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""j160 open-loop energy measurement for the FIRING FLOOR / EXHAUSTION RAM.
NO battle, NO server, NO counterfactual replay. This reads the ALREADY RECORDED
closed-loop captures under /tmp and reports, per tick:
* how often SELF energy sits below a floor candidate,
* whether the owner's "both low, nobody firing" situation actually occurs,
* who crosses a low-energy line FIRST (self or the enemy),
* how often the enemy is low while we are healthy -- the opportunity the
exhaustion trigger (TR_RAM_ENEMY_ENERGY) would act on.
Deliberately produces NO "damage if we had not fired" number: the offline
harness scored 0/6 on closed-loop questions (docs/offline_harness_trust.md),
so that class of number is worthless here.
Usage: python3 common_libs/tests/measure_ramfloor_energy [glob-dir]
"""
import json, os, glob, statistics, sys, array
ROOTS = sys.argv[1:] or ["/tmp"]
def recordings():
out = []
for root in ROOTS:
for f in glob.glob(os.path.join(root, "**", "*.jsonl"), recursive=True):
if f.endswith(".events.jsonl"): continue
try:
with open(f) as fh: first = fh.readline()
except OSError: continue
if '"closed_loop":true' not in first.replace(" ", ""): continue
out.append(f)
return sorted(out)
def split_rounds(path):
"""Yield per-round [(self, enemy)] from a recording, using its round map."""
rf = path.replace(".jsonl", ".jsonl.rounds.json")
bounds = []
if os.path.exists(rf):
try:
for r in json.load(open(rf))["rounds"]:
bounds.append((r["startTick"], r["startTick"] + r["count"]))
except Exception: bounds = []
rows = []
with open(path) as fh:
for line in fh:
if '"tick"' not in line: continue
try: d = json.loads(line)
except ValueError: continue
if "se" in d and "ee" in d: rows.append((d["tick"], d["se"], d["ee"]))
if not rows: return []
if not bounds: bounds = [(rows[0][0], rows[-1][0] + 1)]
rounds = []
for s, e in bounds:
r = [(se, ee) for t, se, ee in rows if s <= t < e]
if not r: continue
# trim the trailing both-disabled tail: a dead bot sits at ~0 forever
last = max(i for i, (a, b) in enumerate(r) if a > 0 and b > 0)
rounds.append(r[:last + 1])
return rounds
def main():
files = recordings()
rounds = []
for f in files: rounds += split_rounds(f)
if not rounds:
print("no closed-loop recordings found"); return
N = sum(len(r) for r in rounds)
se, ee = array.array("d"), array.array("d")
for r in rounds:
for a, b in r: se.append(a); ee.append(b)
print(f"recordings={len(files)} rounds={len(rounds)} ticks={N}\n")
THR = [5, 10, 15, 20, 25]
print("=== A) SELF energy below a floor candidate (share of ticks) ===")
print(f"{'floor':>5} {'pct':>7} {'rounds hit':>10} {'med run':>8} {'p90 run':>8} {'max run':>8}")
for t in THR:
tot, hit, lens = 0, 0, []
for r in rounds:
cur, got = 0, False
for a, _ in r:
if a <= t: cur += 1; tot += 1; got = True
elif cur: lens.append(cur); cur = 0
if cur: lens.append(cur)
hit += 1 if got else 0
lens.sort()
print(f"{t:>5} {100*tot/N:>6.2f}% {hit:>10} "
f"{statistics.median(lens) if lens else 0:>8.0f} "
f"{lens[int(.9*len(lens))] if lens else 0:>8} "
f"{lens[-1] if lens else 0:>8}")
print("\n=== B) the owner's \"both low, nobody firing\" situation ===")
for t in THR:
both = sum(1 for a, b in zip(se, ee) if a <= t and b <= t)
sonly = sum(1 for a, b in zip(se, ee) if a <= t < b)
eonly = sum(1 for a, b in zip(se, ee) if b <= t < a)
print(f" both<={t:>2}: {100*both/N:6.3f}% self-only {100*sonly/N:6.2f}%"
f" enemy-only {100*eonly/N:6.2f}%")
print("\n=== C) who crosses a low-energy line FIRST (per round) ===")
for t in [10, 15, 20, 25]:
s = e = n = 0
for r in rounds:
fs = next((i for i, x in enumerate(r) if x[0] <= t), None)
fe = next((i for i, x in enumerate(r) if x[1] <= t), None)
if fs is None and fe is None: n += 1
elif fs is None or (fe is not None and fs < fe): s += 1
else: e += 1
m = len(rounds)
print(f" t={t:>2}: self-first {s:>5} ({100*s/m:5.1f}%) "
f"enemy-first {e:>5} ({100*e/m:5.1f}%) neither {n:>4} ({100*n/m:4.1f}%)")
print("\n=== D) \"the enemy can no longer fire\" (server rejects energy <= power) ===")
for p in (0.4, 1.0, 1.95, 3.0):
c = sum(1 for b in ee if b <= p)
c2 = sum(1 for a, b in zip(se, ee) if b <= p and a > 20)
print(f" enemy <= {p:>4}: {100*c/N:6.3f}% and self>20: {100*c2/N:6.3f}%")
print("\n=== E) exhaustion-trigger OPPORTUNITY: enemy low while we are healthy ===")
for t in [10, 20, 30]:
row = " ".join(f"self>{fl}: {100*sum(1 for a,b in zip(se,ee) if b<=t and a>fl)/N:6.2f}%"
for fl in (0, 20, 25))
print(f" enemy<={t:>2} {row}")
print("\n=== F) the ALREADY-SHIPPED finisher (enemy<20 & self>enemy & dist<300) ===")
c = 0
# dist needs the raw file; recompute over the whole trimmed corpus
for f in files:
with open(f) as fh:
for line in fh:
if '"se"' not in line: continue
try: d = json.loads(line)
except ValueError: continue
if "se" not in d: continue
if d["ee"] < 20 and d["se"] > d["ee"] and \
((d["ex"]-d["sx"])**2 + (d["ey"]-d["sy"])**2) ** .5 < 300:
c += 1
print(f" {c} ticks ({100*c/N:.4f}% of the trimmed corpus)")
if __name__ == "__main__":
main()
+236
View File
@@ -40,8 +40,13 @@
import std/[os, json, random, math, sequtils, sets]
import std/strutils except fromHex # `fromHex` would clash with color.fromHex
import gun_harness/gun_interface
import movements/ram_decision
# Private-field access: include (do NOT import) the shipped mover.
include movements/the_floor_is_lava
# j165: the TFIL-RING fork's arrival commitment. `tfil_ring_replay` includes
# the ring mover (for its PRIVATE commitTarget/commitTicks) and re-exports it,
# so this is the only ring import the guard needs.
import tfil_ring_replay
const repoRoot = currentSourcePath().parentDir.parentDir.parentDir
const fixtureRel = "tr_drussgt_vs_modularbot.jsonl"
@@ -1479,6 +1484,234 @@ proc testJ154() =
check "j154: clearing the knob restores today's behaviour exactly",
TfilHoldMaxTicks == 0
# ── j165: the TFIL-RING arrival commitment (TR_TFIL_RING_*, default OFF) ─────
# The same two mechanisms ported from `the_floor_is_lava.nim` (j144) onto
# RING-SPECIFIC env names, so the two forks never share a namespace by
# accident. What must hold, and nothing more:
# 1. DEFAULT PARITY: with both knobs unset the ring mover is byte-for-byte
# the PRE-CHANGE ring mover over the whole fixture replay.
# 2. the arrival commitment ENGAGES: the committed target is actually
# REACHED far more often, and there are strictly fewer mid-flight
# re-targets than the shipped fixed 5-tick dwell.
# 3. the no-reversal pool is SPEED-GATED: the stream may only diverge from
# the unarmed build on a tick whose |selfSpeed| is below the gate, and the
# pool itself can never be emptied.
when declared(TfilRingCommitArrival):
proc ringRunStats(): tuple[picks, reached: int] =
## Count picks over the replay and how many of them REPLACED a target the bot
## had actually stood on (< 18px, the same arrival radius the mover uses).
## Under a fixed dwell this is rare: the target is replaced mid-flight.
randomize(Seed)
var m = initTFILRing()
let states = loadStates()
let starts = loadRoundStarts()
var prev = (x: 0.0, y: 0.0)
var hadPick = false
for i in 0..<states.len:
if i == 0 or i in starts:
m.resetRound()
hadPick = false
let ws = states[i]
let before = ringPicks(m)
discard m.computeMove(ws)
if ringPicks(m) != before:
inc result.picks
if hadPick and
sqrt((ws.selfX - prev.x)^2 + (ws.selfY - prev.y)^2) < RingArriveRadius:
inc result.reached
prev = ringTarget(m)
hadPick = true
proc testJ165() =
doAssert fileExists(RingGoldenPath), "missing golden: " & RingGoldenPath
let recs = replayRing() # both knobs unset; the shipped fire detector, as
# the golden was generated
var golden: seq[string]
for rawLine in lines(RingGoldenPath):
if rawLine.startsWith("#"): continue
let line = rawLine.strip()
if line.len > 0: golden.add line
check "j165: ring golden covers the whole fixture (>= 15000 ticks)",
golden.len >= 15000
check "j165: the unset replay covers the same number of ticks",
recs.len == golden.len
var firstDiff = -1
for i in 0..<min(recs.len, golden.len):
if ringRecLine(recs[i]) != golden[i]:
firstDiff = i
break
check "j165: BOTH KNOBS UNSET IS BYTE-FOR-BYTE THE PRE-CHANGE RING MOVER " &
"(speed/turnRate/target/commitTicks) over " & $recs.len & " ticks",
firstDiff < 0
if firstDiff >= 0:
echo " first divergence at tick index ", firstDiff, ": got [",
ringRecLine(recs[firstDiff]), "] want [", golden[firstDiff], "]"
delEnv("TR_TFIL_RING_COMMIT_ARRIVAL")
delEnv("TR_TFIL_RING_NOREV_SPEED")
loadTfilRingCommitEnv()
check "j165: both knobs DEFAULT OFF with the env deleted",
(not TfilRingCommitArrival) and TfilRingNoRevSpeed == 0.0
putEnv("TR_TFIL_COMMIT_ARRIVAL", "1") # tfil's names must NOT leak across
putEnv("TR_TFIL_NOREV_SPEED", "9")
loadTfilRingCommitEnv()
check "j165: the env names are RING-SPECIFIC — tfil's " &
"TR_TFIL_COMMIT_ARRIVAL / TR_TFIL_NOREV_SPEED leave ring untouched",
(not TfilRingCommitArrival) and TfilRingNoRevSpeed == 0.0
delEnv("TR_TFIL_COMMIT_ARRIVAL")
delEnv("TR_TFIL_NOREV_SPEED")
# 2. the arrival commitment engages
let off = ringRunStats()
TfilRingCommitArrival = true
let on = ringRunStats()
TfilRingCommitArrival = false
check "j165: ARRIVAL ENGAGES — a committed target is actually REACHED on " &
$on.reached & "/" & $on.picks & " picks, vs " & $off.reached & "/" &
$off.picks & " on the shipped fixed 5-tick dwell",
on.picks > 0 and (on.reached.float / on.picks.float) >
(off.reached.float / off.picks.float)
check "j165: ... and it holds instead of re-targeting mid-flight: " &
$on.picks & " picks vs " & $off.picks & " on the same replay",
on.picks < off.picks
# 3. the no-reversal pool is speed-gated, and can never be emptied
check "j165: norevPool with the gate off returns EVERY candidate",
norevPool(@[10.0, 120.0, -170.0], 0.0) == @[0, 1, 2]
check "j165: norevPool armed keeps only the non-reversing candidates",
norevPool(@[10.0, 120.0, -170.0], 4.0) == @[0]
check "j165: norevPool never empties — all-behind falls back to the least bad",
norevPool(@[170.0, 150.0, 179.0], 4.0) == @[1] and
norevPool(@[170.0, 179.0], 4.0).len > 0
# Engine gate: the streams can only DIVERGE at a gated pick. (Divergence
# then persists for many ticks — a different target steers differently — so
# "every diverging tick is slow" is the wrong claim; "the FIRST divergence
# is a slow-tick pick" is the right one.)
TfilRingNoRevSpeed = 4.0
let armed = replayRing()
TfilRingNoRevSpeed = 0.0
var nDiv = 0
var firstArmed = -1
for i in 0..<min(recs.len, armed.len):
if ringRecLine(recs[i]) != ringRecLine(armed[i]):
inc nDiv
if firstArmed < 0: firstArmed = i
let states = loadRingStates()
check "j165: the no-reversal treatment APPLIES (gate 4.0 changes " & $nDiv &
" of " & $recs.len & " ticks — an A/B whose treatment never fires is worthless)",
nDiv > 0
check "j165: ... and it is SPEED-GATED: the first divergence (tick " &
$firstArmed & ") is a PICK made at |selfSpeed| = " &
$(if firstArmed >= 0: abs(states[firstArmed].selfSpeed) else: -1.0) &
" < 4.0",
firstArmed >= 0 and armed[firstArmed].picked and
abs(states[firstArmed].selfSpeed) < 4.0
# ── j160: the energy-reserve FIRING FLOOR + ENEMY-EXHAUSTION ram trigger ─────
proc testJ160() =
## j160 — the energy-reserve FIRING FLOOR (TR_RAM_FLOOR_ENERGY) and the
## ENEMY-EXHAUSTION ram trigger (TR_RAM_ENEMY_ENERGY). Both default 0.0 =
## today's behaviour. Pure logic only; no bot, no server.
const F = 5.0
# 1. DEFAULT PARITY, floor: with the knob unset the floor blocks NOTHING over
# a grid that includes every measured low-energy region (<=5: 9.4% of
# ticks in the 8612-round closed-loop corpus, p01 self energy = 0.2).
var blocked = 0
for e in [-1.0, 0.0, 0.1, 1.0, 2.5, 5.0, 7.0, 20.0, 46.0, 100.0, 120.0]:
if fireFloorBlocks(0.0, e): inc blocked
check "j160: TR_RAM_FLOOR_ENERGY unset (=0) suppresses fire on NO input, " &
"so the default path is byte-for-byte today's (" & $blocked & " blocked)",
blocked == 0
# 2. DEFAULT PARITY, trigger: with the knob unset the reason over a grid is
# the PRE-j160 result — the new arm is unreachable, and every old arm still
# returns exactly what it returned before.
var newArm, mismatch: int
for dist in [10.0, 100.0, 299.0, 301.0, 500.0]:
for se in [0.5, 5.0, 19.0, 21.0, 60.0, 100.0]:
for ee in [0.0, 1.0, 5.0, 19.9, 20.0, 40.0, 100.0]:
let inp = RamInputs(dist: dist, selfEnergy: se, enemyEnergy: ee)
let got = ramTrigger(inp)
if got == rrExhausted: inc newArm
# the pre-j160 body, verbatim
var want: RamReason = rrNone
if ee > 0.0:
if dist < RamFinisherDist and ee < RamFinisherEnergy and se > ee: want = rrFinisher
elif se < RamDesperationEnergy and ee < RamDesperationEnergy and dist < RamDesperationDist: want = rrDesperation
if got != want: inc mismatch
check "j160: TR_RAM_ENEMY_ENERGY unset (=0) makes the exhaustion arm " &
"unreachable (" & $newArm & " hits) and leaves the old finisher / " &
"desperation verdicts identical (" & $mismatch & " mismatches over 210 " &
"input combinations)",
newArm == 0 and mismatch == 0
# 3. the floor suppresses AT the threshold, not above it.
check "j160: the floor blocks AT the threshold (self == 5.0 <= floor 5.0)",
fireFloorBlocks(F, 5.0)
check "j160: the floor blocks just below it and not just above it — one " &
"tick of hysteresis, no dead band",
fireFloorBlocks(F, 4.999) and not fireFloorBlocks(F, 5.001)
# 4. it never suppresses while we are healthy, at ANY floor setting.
var healthy = 0
for floor in [0.5, 1.0, 5.0, 20.0, 25.0, 40.0]:
for e in [floor, 46.0, 60.0, 100.0, 120.0]:
if e > floor and fireFloorBlocks(floor, e): inc healthy
check "j160: the floor NEVER blocks above its own threshold — healthy energy " &
"fires for every floor/energy pair (" & $healthy & " violations)",
healthy == 0
# 5. the trigger switches to ram EXACTLY at the tolerance, no earlier.
let inp2 = RamInputs(dist: 100.0, selfEnergy: 60.0, enemyEnergy: 10.0)
check "j160: the exhaustion trigger fires EXACTLY at TR_RAM_ENEMY_ENERGY " &
"(enemy 10.0 <= tol 10.0) and not one tick above (10.001)",
ramTrigger(inp2, enemyEnergyTol = 10.0) == rrExhausted and
ramTrigger(RamInputs(dist: 100.0, selfEnergy: 60.0, enemyEnergy: 10.001),
enemyEnergyTol = 10.0) != rrExhausted
# 6. the surplus guard survives: 0.6/contact is applied to BOTH bots, so we
# only ram an exhausted enemy while WE hold the surplus.
check "j160: the exhaustion trigger keeps the finisher's energy-surplus " &
"guard — an exhausted enemy while WE are lower is a ram we lose",
ramTrigger(RamInputs(dist: 250.0, selfEnergy: 2.0, enemyEnergy: 3.0),
enemyEnergyTol = 10.0) != rrExhausted
# 7. it is the finisher's own shape: the existing range guard still applies.
check "j160: the exhaustion trigger keeps the finisher's 300px range guard " &
"(enemy exhausted at 301px is not a ram)",
ramTrigger(RamInputs(dist: 301.0, selfEnergy: 60.0, enemyEnergy: 3.0),
enemyEnergyTol = 10.0) != rrExhausted
# 8. COMPOSITION: ramming WINS. The floor is the reserve FOR the ram, so once
# the ram is engaged the reserve is being spent, not held. No starvation:
# the floor alone can never make us unable to close.
check "j160: RAMMING wins the conflict — at self energy 0.1 (below any " &
"sane floor) an engaged ram is never floor-blocked, so the two " &
"compose instead of deadlocking each other",
fireFloorBlocks(F, 0.1, ramming = true) == false and
fireFloorBlocks(F, 0.1, ramming = false) == true
# 9. and the floor can never be engaged at all without self energy being
# genuinely low — the guard the owner asked for, stated as a property.
var unsafe = 0
for floor in [0.5, 5.0, 20.0, 25.0]:
for e in [0.0, 1.0, 10.0, 25.0, 50.0, 100.0]:
if fireFloorBlocks(floor, e, ramming = false) and e > floor: inc unsafe
check "j160: the floor is a LOW-ENERGY guard only — it can never suppress " &
"fire while we are healthy, in any configuration (" & $unsafe & ")",
unsafe == 0
# 10. both knobs together: the exhausted trigger still fires while the floor
# is at full strength, and the floor still holds when no ram is engaged.
check "j160: both knobs ON compose — exhaustion (enemy 3, us 60) still " &
"ram-bypasses the floor, and a non-ramming low-energy tick still holds",
fireFloorBlocks(F, 3.0, ramming = false) and
ramTrigger(RamInputs(dist: 100.0, selfEnergy: 60.0, enemyEnergy: 3.0),
enemyEnergyTol = 10.0) == rrExhausted and
not fireFloorBlocks(F, 3.0, ramming = true)
# ── driver ───────────────────────────────────────────────────────────────────
testDefaultParity()
@@ -1494,6 +1727,9 @@ when declared(loadTfilCommitEnv):
testJ150()
testJ154()
testJ153()
when declared(TfilRingCommitArrival):
testJ165()
testJ160()
if failures > 0:
echo "\n", failures, " check(s) FAILED"
+109
View File
@@ -0,0 +1,109 @@
## Fixture replay for the TFIL-RING mover (`movements/the_floor_is_lava_ring.nim`).
##
## It `include`s the mover (not `import`s it) so the replay can read the
## private `commitTarget` / `commitTicks` — the same reason
## `test_tfil_commit_env.nim` includes `the_floor_is_lava.nim`. Living in its
## OWN module keeps those privates in this module's scope, so a file that
## includes BOTH movers still compiles (there is no name clash between them:
## this one only sees ring's).
##
## `--path:common_libs` relative to the repo root.
##
## GOLDEN GENERATION (j165 default parity). Compile this file with the golden
## flag against the PRE-CHANGE ring, e.g. from a `git show HEAD:...` tree:
##
## TFIL_RING_GOLDEN_OUT=<path> \
## nim c -r --path:. -d:tfilRingGenGolden common_libs/tests/tfil_ring_replay.nim
##
## Nothing here references a j165 symbol, so the SAME file generates the golden
## on the pre-change mover and checks it on the post-change one. Regenerating
## the golden from the new code would defeat the check — only do that after a
## DELIBERATE change to the ring defaults.
import std/[os, json, random, math]
import std/strutils except fromHex
import gun_harness/gun_interface
include movements/the_floor_is_lava_ring
const
Seed = 20250923 ## same seed as the tfil replay: comparable arms
ArenaW = 800.0
ArenaH = 600.0
fixtureRel = "tr_drussgt_vs_modularbot.jsonl"
const RingGoldenPath* = currentSourcePath().parentDir / "fixtures" /
"tfil_ring_commit_default.golden"
type RingTickRec* = object
spd, trn: float ## the emitted MoveCommand
tx, ty: float ## where we are steering to
ct: int ## ticks left on the commitment
picked*: bool ## this tick made a NEW pick (not in the golden)
# Thin accessors for the mover's PRIVATE per-round state. The guard needs them
# to measure what the commitment did; the mover's own API stays unchanged.
proc ringPicks*(m: TFILRingModule): int = m.picks
proc ringTarget*(m: TFILRingModule): tuple[x, y: float] = m.commitTarget
proc ringRecLine*(r: RingTickRec): string =
$r.spd & " " & $r.trn & " " & $r.tx & " " & $r.ty & " " & $r.ct
proc loadRingStates*(): seq[WorldState] =
let path = currentSourcePath().parentDir.parentDir.parentDir / "tools" /
"fixtures" / fixtureRel
for rawLine in lines(path):
let line = rawLine.strip()
if line.len == 0: continue
let n = parseJson(line)
if n.hasKey("meta") or n.hasKey("end"): continue
let ex = n["ex"].getFloat()
let ey = n["ey"].getFloat()
result.add WorldState(
enemyX: ex, enemyY: ey,
enemyHeading: n["eh"].getFloat(), enemySpeed: n["es"].getFloat(),
enemyEnergy: n["ee"].getFloat(),
selfX: n["sx"].getFloat(), selfY: n["sy"].getFloat(),
selfHeading: n["sh"].getFloat(), selfSpeed: n["ss"].getFloat(),
selfEnergy: n["se"].getFloat(),
arenaWidth: ArenaW, arenaHeight: ArenaH,
tick: n["tick"].getInt(),
enemies: @[EnemyInfo(id: 1, x: ex, y: ey,
heading: n["eh"].getFloat(), speed: n["es"].getFloat(),
energy: n["ee"].getFloat())])
proc loadRingStarts*(): seq[int] =
let side = currentSourcePath().parentDir.parentDir.parentDir / "tools" /
"fixtures" / "drussgt_meta" / (fixtureRel & ".rounds.json")
if not fileExists(side): return
for r in parseFile(side)["rounds"]:
result.add r["startTick"].getInt()
proc replayRing*(): seq[RingTickRec] =
## Drive the REAL ring `computeMove` over the recorded WorldState stream with a
## fixed seed, touching no env knob. With every j165 knob unset this is the
## pre-change code path exactly.
randomize(Seed)
var m = initTFILRing()
let states = loadRingStates()
let starts = loadRingStarts()
for i in 0..<states.len:
if i == 0 or i in starts: m.resetRound()
let before = m.picks
let cmd = m.computeMove(states[i])
result.add RingTickRec(spd: cmd.speed, trn: cmd.turnRate,
tx: m.commitTarget.x, ty: m.commitTarget.y,
ct: m.commitTicks, picked: m.picks != before)
when isMainModule and defined(tfilRingGenGolden):
block:
let recs = replayRing()
let outPath = getEnv("TFIL_RING_GOLDEN_OUT", RingGoldenPath)
var g = "# TFIL-RING default-path parity golden (j165).\n"
g.add "# Generated from the PRE-CHANGE ring mover (`git show HEAD:...`) with\n"
g.add "# every j165 knob UNSET, over the whole\n"
g.add "# tools/fixtures/tr_drussgt_vs_modularbot.jsonl replay.\n"
g.add "# Format: speed turnRate targetX targetY commitTicks\n"
for r in recs: g.add ringRecLine(r) & "\n"
createDir(outPath.parentDir)
writeFile(outPath, g)
echo "wrote ", outPath, " (", recs.len, " ticks)"
+5 -1
View File
@@ -226,6 +226,8 @@ shooting *look* like missing).
| `TR_POWER_POLICY` | `1` | `0` = uncapped control arm (today's behaviour without the energy policy) |
| `TR_POWER_LOG` | off | **presence-based**: if the var exists at all (even `=0`) log each power decision |
| `TR_RAM_LOG` | off | **presence-based**: log ram on/off with the reason |
| `TR_RAM_FLOOR_ENERGY` | `0.0` | j160 firing floor: at/below this self energy we start no NEW shot, holding a ram reserve. `0` = off. `~5` = one p=1.0 return hit + two 0.1 shots. Bypassed while ramming |
| `TR_RAM_ENEMY_ENERGY` | `0.0` | j160 exhaustion trigger: last-scanned enemy energy `<=` this -> ram mode. `0` = off. Keeps the finisher's energy-surplus and 300px guards |
| `TR_MOVEMENT_LOG` | off | **presence-based**: log movement band/class changes |
| `TR_TMHORIZON_LOG` | off | value-based: `1` = let the horizon TM gun log its thinking per shot |
| `TR_ENV_REPORT` | `1` | print the boot-time `[env]` report to stdout; `0` suppresses it |
@@ -417,8 +419,10 @@ actually removed, so **overkill scores nothing**. Damage is `4p` (p≤1) / `6p-2
| `TR_TFIL_RANGE_K` | `60` | softness of the falloff outside the band |
| `TR_TFIL_CORRIDOR_HEAT` | `10.0` | heat added along a bullet's corridor to the wall |
| `TR_TFIL_WALL_HOTNESS` | `15.0` | peak wall radiance |
| `TR_TFIL_RING_COMMIT_ARRIVAL` | off | **presence/value**: `on` = hold the committed dodge tile until we are actually ON it, instead of the fixed 5-tick dwell. Default path byte-identical. **NEVER LIVE-TESTED** |
| `TR_TFIL_RING_NOREV_SPEED` | `0.0` | px/tick. Below this self speed a mid-flight target switch may not turn the bot around; `0.0` = off (the pre-knob behaviour). **NEVER LIVE-TESTED** |
Defaults read in `the_floor_is_lava_ring.nim:116-133`.
Defaults read in `the_floor_is_lava_ring.nim:188-196` and `:173-175`.
**Discrepancy to be aware of:** that file's header comment still says
`CORRIDOR_HEAT default 5.0` / `WALL_HOTNESS default 10.0` (the pre-retune values);
the **code defaults are `10.0` / `15.0`** (commit `7f6ccfb`). The code is the
+366
View File
@@ -0,0 +1,366 @@
# j162 — the FIRING FLOOR: exhaustion measurement + a re-sized A/B proposal
**No battle, server, GUI or A/B was started for this job.** Both knobs remain
default-`0.0`; `out/ModularBot` was not rebuilt. Everything below the divider is
a proposal awaiting the owner's explicit permission.
---
## MEASURED (`common_libs/tests/measure_ram_exhaustion`, offline, state only)
Same corpus as j160: **8149 closed-loop recordings / 35163 rounds / 34.46M
ticks**. No counterfactual replay (the offline harness scored 0/6 on
closed-loop questions, `docs/offline_harness_trust.md`).
### 1. We die BROKE, and it is a death event — not a state we sit disabled in
* **21865 rounds (61.2%) end with self energy crossing 0**; 99.0% of rounds end
in *some* death. Energy on the last tick we were alive:
**median 0.83, mean 2.50, p90 8.90, max 24.83**.
55.9% of self-deaths at <=1, 84.2% at <=5, 92.8% at <=10, **100% at <=20**.
* Time at energy <= 0 before the round ends: **median 1 tick** (p90 18). The
round ends on the crossing tick. The 1.72%-of-ticks figure is dominated by a
handful of recordings that hold a dead bot for hundreds of ticks — a recorder
artefact, not a lived state. **There is no recoverable disabled window to
defend.**
* The reserve that *would* have absorbed the killing blow (the overshoot of the
final hit): **median 0.40, p75 2.00, p90 6.90, p99 15.0**. A free 5-energy
reserve would have saved 85.1% of self-deaths.
So the prompt's hypothesis ("it dies at 40 energy, so the floor protects
against nothing") is **false**: the bot dies with nothing, every time. The
floor's premise is real.
### 2. We cannot climb back out of a low-energy dip
Energy rises on 0.598% of tick-pairs (~5.87 landed hits/round; **mean landed
power 1.42**, mode 1.0 — not 0.1). Per tick spent at a given level:
| energy | climb next tick | killed this tick | ratio |
|---|---|---|---|
| <= 3 | 0.130% | 0.830% | dying **6.4x** more likely |
| <= 5 | 0.232% | 0.663% | dying **2.9x** more likely |
| <= 10 | 0.365% | 0.437% | dying 1.2x more likely |
| <= 20 | 0.500% | 0.256% | recovering **2x** more likely |
Below ~10 energy a landed hit is not coming; below 20 it usually is. **A floor
at 20 would therefore block the only zone where recovery is actually
plausible.**
### 3. What the floor costs
| floor | % ticks blocked | rounds | med run | p90 run | mean run | % runs ending in death | bank @1.0p |
|---|---|---|---|---|---|---|---|
| 3 | 7.64% | 23086 | 34 | 299 | 98 | 80.8% | 8.2 |
| 5 | **9.58%** | 23739 | 53 | 330 | 118 | 78.0% | **9.9** |
| 10 | 14.52% | 25211 | 89 | 433 | 162 | 70.3% | 13.5 |
| 20 | 24.74% | 27799 | 139 | 621 | 236 | 60.0% | 19.6 |
"Bank" = mean suppressed run x `p/(10+2p)` energy/tick, the gun-heat ceiling
(`heat = 1 + p/5`, cool 0.1/tick). At 0.1 power it is 0.52 energy for floor 5;
at 2.0 power, 16.9.
**Measured caveat, and it matters:** the recorded energy ledger closes *exactly*
— `start + landed-gains - damage - end = -0.00` over 35065 rounds. **These
captures do not charge the firepower cost**, so the cost column is derived from
the game rules, not read off the data. The landed-hit *power* distribution is
read off the data (mode 1.0, mean 1.42) and is what sets the bracket.
### 4. Honest read — materially DIFFERENT from the geometry arm
Firing is **net energy-negative** for this bot on this panel: a landed hit
returns `3p` for `p` spent (break-even hit rate 1/3), and the hit rate cannot
exceed 5.87 hits / 76 shots-per-round heat ceiling = **7.7%**. So not firing
really does bank energy — about 9.9 at floor 5.
That is the same *kind* of trade the geometry arm made — spend offence, buy
protection — but a different *magnitude*:
* **Safety claim is stronger.** The geometry arm's safety gain did not convert
into wins. Here the hazard is measured directly: 0.66%/tick death at energy
<= 5, against a p75 overshoot of 2.0 and a bank of 9.9. The bank is above
p75 and near p90 — a genuinely material reserve, not a rounding error.
* **Damage cost is ~an order of magnitude smaller.** Floor 5 suppresses ~4.4
shots per median run; at 4 damage/hit and a 7.7% hit rate that is ~1.4
damage per suppressed run, ~2 damage/run. The geometry arm lost **8.83
damage/run** for its unconverted gain.
* **It is a light touch in time, not in behaviour**: 9.6% of ticks, median run
53 ticks. Not a blackout.
**Verdict: the floor is worth an A/B. It is not the clean negative.** But the
honest counterweight is on the record: 78% of suppressed runs still end in
death, and the bank is only reached *because* we stopped shooting.
**Chosen value: `TR_RAM_FLOOR_ENERGY=5`** — bank 9.9 (above p75 overshoot 2.0,
near p90 6.9) at 7.6%-vs-9.6% less tick cost than 10. Floor 20 is dropped on
the measurement: it costs 24.7% of ticks and sits on top of the <= 20 recovery
window.
---
## PROPOSAL — PRE-REGISTERED, **NOT RUN**
* Harness: `tools/ab/tournament_run.sh` + `tools/ab/tournament_analyze.py`,
unmodified. Panel: `tools/ab/panel_movement.txt` (frozen 15-opponent movement
panel). Unit of evidence is the opponent, not the battle. One frozen binary
from `git archive` of `j160-ramfloor`.
* **Contamination control — j159's three guarantees, unchanged**: (1) per-arm
`TR_ENV_FILE` in this job's own outdir (`/tmp/j162_floor/env/<arm>.env`);
(2) the per-run botdir holds only `.json`, `.sh` and a symlink to the frozen
binary — no `.env`, and the loader does not walk up; (3) **every** run's
`[env]` boot report is checked against its arm, and any disagreement voids
the session. **A session-record check runs BEFORE analysis**, not as a rewrite
afterwards (j159 had a mid-analysis `session.json` rewrite; not repeated here).
| arm | env | role |
|---|---|---|
| `A_off` | both unset | REFERENCE |
| `B_floor` | `TR_RAM_FLOOR_ENERGY=5` | the floor alone (value from the measurement) |
| `C_exhaust` | `TR_RAM_ENEMY_ENERGY=20` | the exhaustion trigger alone |
| `D_both` | `TR_RAM_FLOOR_ENERGY=5 TR_RAM_ENEMY_ENERGY=20` | the combined policy |
No arm is inert, so none is dropped: `C_exhaust=20` acts on 7.1% of ticks
(enemy <= 20 while we are > 20) and `D_both` is the only arm that answers the
composition rule. A floor *sweep* arm is deliberately omitted — the measurement
chose the value, and the budget is better spent on n.
### Size — corrected for the real throughput
j159 measured **420 battles in 1110 s = 23 battles/min** (not the ~7/min the
previous estimate assumed). MDE scales as `1/sqrt(n)`; the shipped default
movement gate resolved **0.17 wins/run at 210 runs/arm**. For a target MDE of
**0.10 wins/run**: `n = 210 * (0.17/0.10)^2 = 607` runs/arm, rounded up to
**42 runs/opponent = 630 runs/arm**.
* 15 opponents x 4 arms x 42 runs x 3 rounds = **3780 battles ≈ 2.7 h**.
* Decision-only 2-arm version (`A_off` vs `B_floor`): 15 x 2 x 42 x 3 =
**1890 battles ≈ 1.4 h**, still at MDE 0.10.
### Metrics (fixed now)
**Primaries: damage/run, round-win rate.** Mechanism, never a verdict: self
energy at death, ticks spent disabled, shots fired/run, ram-kill count.
Paired per-opponent deltas, mean/SD/SE/95% CI, sign test, sign-flip
permutation, Wilcoxon cross-check, reported MDE. Two-sided.
### Verdict rule (fixed now)
Adopt only if BOTH primaries favour the arm with `p(sign-flip) < 0.05` **and**
the effect is at or above the reported MDE. Otherwise do not ship; both knobs
stay `0.0`. A clean null is a fully acceptable result. No subsetting, no
dropping opponents, no re-running to chase a p-value.
---
# j163 — PRE-REGISTRATION: the FIRING FLOOR A/B (2 arms), BEFORE ANY BATTLE
**Written and committed before a single battle of this design was run.** Nothing
below was chosen after seeing data. Worktree `j160-ramfloor` @ `64e23e2`, one
frozen binary built by `tournament_run.sh` from `git archive HEAD`, both knobs
default-`0.0`, no code changed by this job.
## Hypothesis
The bot dies broke: **61.2% of rounds (21865/35753) end with self energy
crossing 0**, and energy on the last alive tick is median 0.83. Below **5**
energy the next tick brings death **2.9x** more often than a landed hit
(0.663%/tick vs 0.232%/tick); the bank of a suppressed run at floor 5 is ~9.9
energy against a p75 overshoot of 2.0 / p90 6.9 — a free 5-energy reserve would
have saved 85.1% of self-deaths. Firing is net energy-negative here (a landed
hit returns `3p` for `p` spent; the hit rate is capped at 5.87/76 = 7.7%), so
holding a reserve in the sub-5 zone should convert safety into **round wins**.
## Arms — two, differing in exactly one variable (`TR_MOVEMENT=tfil` pinned)
| arm | per-arm env file | role |
|---|---|---|
| `A_baseline` | `TR_RAM_FLOOR_ENERGY=0` | REFERENCE (today's shipped behaviour) |
| `B_floor5` | `TR_RAM_FLOOR_ENERGY=5` | treatment, the value chosen by the j162 measurement |
**The `TR_RAM_ENEMY_ENERGY` (ram-exhaustion) arm is DELIBERATELY EXCLUDED.**
Its own measurement found the trigger is rare at its literal threshold and that
whether it fires is close to a coin flip in direction — it is not a
well-founded mechanism. Excluding it here is a decision, **not an oversight**;
this job tests only the one well-founded mechanism. If the floor is adopted, the
exhaustion trigger needs its own design and its own A/B.
## Primaries (fixed now)
1. **round-win rate** (rounds won / rounds fought) — **the deciding primary**
2. **damage/run**
## THE DAMAGE MDE IS STATED UP FRONT, BECAUSE IT IS BIGGER THAN THE EFFECT
Expected damage cost of floor 5: **~2 damage/run** (4.4 suppressed shots per
median run x 4 damage/hit x 7.7% hit rate) — versus **8.83 damage/run** for the
already-rejected geometry arm. The design's **damage MDE is 7.65**. The damage
effect is therefore **~3.8x below what this design can resolve**.
> **Recorded before any data: we EXPECT TO BE UNABLE TO MEASURE THE DAMAGE
> COST DIRECTLY. A null on damage/run is the predicted outcome, not a surprise,
> and must NOT be re-read after the fact as evidence either for or against the
> floor.** The verdict is judged on **ROUND WINS**. The damage MDE is a
> one-sided blind spot of this design, fixed in advance.
## Counterweight (also on the record before any data)
**78.0% of suppressed runs still end in death.** The floor protects the tail of
the energy ledger; it is not a shield. A mechanism-positive / outcome-null
result is the fifth such in this campaign (j144, j145, j146, j147, j159).
## Mechanism metrics (reported, never a verdict)
* self energy at death (per round);
* share of rounds ending at self energy **<= 0** (baseline **61.2%**);
* shots/run;
* share of ticks with firing suppressed (**floor 5 predicts ~9.6% of ticks,
median suppressed run ~53 ticks**).
* Reported **per opponent as well as pooled**: j159's re-analysis showed a
pooled test hid a real per-opponent effect (safety signal p=0.0008
per-opponent, null pooled). The unit of evidence is the opponent.
## Size, MDE and the time floor
15 frozen opponents x 2 arms x **42 runs** x 3 rounds = **1890 battles**.
MDE ~**0.10 wins/run** (`210 x (0.17/0.10)^2`; 210 was the design that resolved
0.17 wins/run). At the measured 22.7-23.2 runs/min that is **~1.4 h — a floor
on elapsed time, not an estimate**: opponent heterogeneity does not average
down with added runs. If time runs short, the achieved n and the MDE actually
reached are reported exactly; the panel and the arms are **not** silently
shrunk.
## Contamination controls (all three, in order)
1. Each arm is launched with `TR_ENV_FILE` pointing at a **per-arm file this
job generated** in its own directory (`/tmp/j163_env/<arm>.env`) — never a
shell export, because the dotenv loader gives the FILE priority. The file
dir is deliberately **outside** `--outdir` (`tournament_run.sh` `rm -rf`s
the outdir). This matters: the owner has an 18 KB `.env` at
`ModularBot_garage/out/.env` in the main tree. The per-run botdir holds only
`.json`, `.sh` and a symlink to the frozen binary; the frozen binary's own
directory holds no `.env`; the loader's fallbacks are `./.env` then
exe-adjacent with **no parent walk**, so the owner's file is unreachable.
2. **Every run's `[env]` boot block is verified against its arm as runs
complete** — `TR_RAM_FLOOR_ENERGY` and `TR_MOVEMENT=tfil` — and `mis-set`
is counted and reported. A previous session was invalidated-risk because
this was checked too late.
3. The session record is **read before analysis, never rewritten**. j159 had a
mid-analysis `session.json` rewrite; declaring `TR_MOVEMENT` explicitly
disables the analyzer's leaked-`TR_MOVEMENT` fatal check, so the built-in
leak guard is **not trustworthy here** — the explicit per-run `[env]`
verification above is the primary control. If the guard misbehaves it is
**reported as a finding, not worked around**.
## Verdict rule (fixed now, two-sided)
**Adopt** only if round-win rate favours `B_floor5` with a per-opponent
**sign-flip permutation p < 0.05** AND the effect is at or above the reported
MDE. Otherwise **do not ship**; `TR_RAM_FLOOR_ENERGY` stays `0.0`. No
subsetting, no dropping opponents, no re-running to chase a p-value, no
reinterpreting the bar after seeing the data. **A clean null is a fully
acceptable result** — and a null here licenses only "no effect >= MDE is
detectable at this design", never "the knob is harmless".
---
## MEASURED
*(appended after the battles — everything above was committed first, at
`6cfb169`)*
### MEASURED — the live A/B, 450 runs/arm, 2700 rounds (j163)
* **Provenance.** Frozen 15-opponent movement panel
(`tools/ab/panel_movement.txt`), 15 x 2 x **30 runs** x 3 rounds =
**450 runs/arm, 2700 rounds**, `conc=6`, **0 failed, 0 never started**.
Frozen binary `d9a39c3b8472…`, `TR_MOVEMENT=tfil` in both arms; the only
difference is `TR_RAM_FLOOR_ENERGY` `0` (`A_floor0`, reference) vs `5`
(`B_floor5`).
* **Env verification — 0 mis-set.** Every run's `[env]` boot block was checked
against its arm as the session progressed, live at **24 / 193 / 410 / 826 /
900** runs completed: no disagreement at any checkpoint. Per-arm
`TR_ENV_FILE` in the session's own directory, botdir without a `.env`, loader
does not walk up parents. This is contamination control #2 from the
pre-registration, satisfied.
* **DEVIATION FROM THE PRE-REGISTRATION, DISCLOSED.** The design asked for
**42 runs/opponent** (1890 battles, MDE ~0.10 wins/run). Measured throughput
was **14-22 runs/min**, not the assumed 22.7-23.2, so the wall-clock cost of
the pre-registered n was not affordable. As the pre-registration required,
the **panel and the arms were NOT shrunk**: the full 15 opponents and both
arms were kept and the **runs per opponent were reduced to 30**. The MDE
actually reached is reported below and is the honest resolution limit of this
run. No opponent was dropped, no arm was re-run to chase a p-value.
**Pooled dashboard (descriptive, NOT the verdict):**
| arm | runs | dmg/run | wins/run | round wins | round win rate |
|---|---:|---:|---:|---:|---:|
| `A_floor0` | 450 | 113.65 | 0.200 | — | **40.30%** |
| `B_floor5` | 450 | 112.70 | 0.182 | — | **39.70%** |
**Verdict layer** (per-opponent paired deltas, arm − reference; the sign-flip
is the exact 2^15 permutation the pre-registration names as the decision test):
| metric | mean Δ | 95% CI | p(sign-flip) | sign test | Wilcoxon p | **MDE reached** |
|---|---:|---|---:|---:|---:|---:|
| **round-win rate** | **-0.59 pp** | [-3.90, +2.72] | **0.7676** | 1.00 | 0.84 | **4.73 pp** |
| wins/run | -0.0178 | [-0.117, +0.082] | 0.7676 | 1.00 | 0.84 | 0.1420 |
| damage/run | -0.95 | [-3.88, +1.98] | 0.5298 | 1.00 | 0.84 | 4.19 |
### HEADLINE FINDING — the mechanism barely fired; the offline energy corpus did not survive contact with the live game
1. **Suppression was 0.04% of ticks, not the 9.6% the offline ruler predicted** —
a **~200x** smaller effect. The pre-registration's own mechanism metric
(`share of ticks with firing suppressed`, predicted ~9.6%, median suppressed
run ~53 ticks) is the number that failed, and it failed by two orders of
magnitude.
2. **Only 4.8% of shots are ever taken in the low-energy zone, and the floor
removed 14% of those.** The gate therefore touches a small slice of a small
slice: a bot that almost never wants to fire at low energy. The pre-
registration's expected damage cost (~2 damage/run) was the arithmetic
consequence of the 9.6% figure; with 0.04% it is ~200x smaller still, which
is why the damage MDE (4.19) is unreachable by construction and not by bad
luck.
3. **Rounds ending at self energy <= 0: 40.6% live vs 61.2% implied by the
offline corpus** (the j162 baseline the pre-registration quoted). The
recorded-fixture corpus over-states how often we die broke by ~1.5x. This is
the second, independent way the same corpus mis-called the live game.
4. **Median self energy at death 14.1 -> 15.5** — the floor moved the death
energy by +1.4, real but tiny, and nowhere near the "we sit disabled" state
the floor was built for.
5. **The pre-registered blind spot was called correctly.** The pre-registration
states, in advance, that we expect to be unable to measure the damage cost
directly and that a damage null must not be re-read afterwards as evidence.
That call was right, and it is the reason this run cannot be misread.
### VERDICT — DO NOT ADOPT
1. **The pre-registered verdict rule is not met.** Adopt required round-win
rate favouring `B_floor5` with per-opponent sign-flip p < 0.05 AND the
effect at or above the reported MDE. Observed: **-0.59 pp against**, p =
**0.7676**, under the 4.73 pp MDE. Round wins and wins/run are the same
null (p = 0.7676, MDE 0.1420 wins/run).
2. **`TR_RAM_FLOOR_ENERGY` stays `0.0`.** Do not adopt, do not ship, and **do
not re-test this knob.** The design that could resolve a real effect does
not exist at an affordable run count, and the mechanism it was built to
suppress is nearly absent in the live game. Re-running buys resolution on an
effect that is not there.
3. **A null here does NOT prove the knob inert — the opposite.** The mechanism
fired on 0.04% of ticks. This run licenses only: "no effect >= 4.73 pp of
round-win rate at 450 runs/arm". It says nothing about the ~0.04% of ticks
it did suppress, because too few of them existed to measure.
4. **The generalisable finding is the corpus, not the knob.** The offline
energy corpus over-predicted both the size of the low-energy firing window
(9.6% -> 0.04%) and the rate of dying broke (61.2% -> 40.6%). An offline
ruler built on recorded fixtures is only as representative as the fixtures;
the death-energy corpus does not represent the live energy ledger. Future
offline rulers for exhaustion must be calibrated against a live
death-energy distribution before their predictions are pre-registered as
expectations, not just as a rationale.
**Ship state: unchanged. `TR_RAM_FLOOR_ENERGY=0.0` and `TR_RAM_ENEMY_ENERGY=0.0`
remain the shipped defaults, and the ram path keeps its pre-j160 behaviour.**
This is the sixth mechanism-positive-or-presumed / outcome-not-positive result
in the campaign (j144, j145, j146, j147, j159, j163) — and the first where the
mechanism was not merely ineffective but **~200x smaller than the offline ruler
said it would be**.