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.
This commit is contained in:
2026-09-27 12:46:46 +02:00
parent 7c5bc9ccbd
commit 23bce2dad5
8 changed files with 455 additions and 3 deletions
+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"
+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()
+105
View File
@@ -40,6 +40,7 @@
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
@@ -1479,6 +1480,109 @@ proc testJ154() =
check "j154: clearing the knob restores today's behaviour exactly",
TfilHoldMaxTicks == 0
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 +1598,7 @@ when declared(loadTfilCommitEnv):
testJ150()
testJ154()
testJ153()
testJ160()
if failures > 0:
echo "\n", failures, " check(s) FAILED"