diff --git a/ModularBot_garage/.env.example b/ModularBot_garage/.env.example index 485b65f..89d1fd1 100644 --- a/ModularBot_garage/.env.example +++ b/ModularBot_garage/.env.example @@ -98,6 +98,8 @@ 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 diff --git a/ModularBot_garage/src/ModularBot.nim b/ModularBot_garage/src/ModularBot.nim index cc06e23..676fcb6 100644 --- a/ModularBot_garage/src/ModularBot.nim +++ b/ModularBot_garage/src/ModularBot.nim @@ -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), diff --git a/ModularBot_garage/src/env_report.nim b/ModularBot_garage/src/env_report.nim index 7f35729..66c2870 100644 --- a/ModularBot_garage/src/env_report.nim +++ b/ModularBot_garage/src/env_report.nim @@ -410,6 +410,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,6 +663,7 @@ 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_RANGE_K", "TR_TFIL_CORRIDOR_HEAT", "TR_TFIL_WALL_HOTNESS", "TR_TFIL_CORRIDOR_TICKS", "TR_TFIL_ARRIVE_TICKS", diff --git a/common_libs/movements/ram_decision.nim b/common_libs/movements/ram_decision.nim index 4b6a003..3fbadc0 100644 --- a/common_libs/movements/ram_decision.nim +++ b/common_libs/movements/ram_decision.nim @@ -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" diff --git a/common_libs/tests/measure_ramfloor_energy b/common_libs/tests/measure_ramfloor_energy new file mode 100644 index 0000000..b0a071e --- /dev/null +++ b/common_libs/tests/measure_ramfloor_energy @@ -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() diff --git a/common_libs/tests/test_tfil_commit_env.nim b/common_libs/tests/test_tfil_commit_env.nim index 5808d86..359db24 100644 --- a/common_libs/tests/test_tfil_commit_env.nim +++ b/common_libs/tests/test_tfil_commit_env.nim @@ -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" diff --git a/docs/env_reference.md b/docs/env_reference.md index 95bdeef..b9eec3e 100644 --- a/docs/env_reference.md +++ b/docs/env_reference.md @@ -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 | diff --git a/docs/ram_floor_exhaustion_ab.md b/docs/ram_floor_exhaustion_ab.md new file mode 100644 index 0000000..f9c6a4e --- /dev/null +++ b/docs/ram_floor_exhaustion_ab.md @@ -0,0 +1,144 @@ +# j160 — PRE-REGISTRATION: the energy-reserve ram policy (floor + exhaustion) + +**Written and committed BEFORE any battle of this experiment ran. Nothing below +the divider has data in it, and none of it will unless the owner approves.** + +## What was built (both default-OFF, both default = today's behaviour) + +| knob | default | effect when set | +|---|---|---| +| `TR_RAM_FLOOR_ENERGY` | **0.0 = off** | at/below this self energy we do not start a NEW shot | +| `TR_RAM_ENEMY_ENERGY` | **0.0 = off** | last-scanned enemy energy <= this -> ram mode | + +Mechanics that justify the design (all read from source, not assumed): +energy has **no cap and no regeneration**; the only gain in the game is +`+3 * power` per bullet hit **landed** (`server/rules/rules.kt`). Not firing +therefore denies the enemy its only refill *and* preserves our ram reserve — +the floor and the exhaustion trigger are the same bet, not two ideas. + +`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 entered with the surplus. +The exhaustion trigger deliberately **keeps** the shipped finisher's +`selfEnergy > enemyEnergy` guard for that reason. + +**Composition when they conflict: RAMMING WINS.** Once ram mode is engaged the +duel is over and the reserve is being *spent*, not held, so the floor is +bypassed. The floor therefore can never deadlock the ram it exists to enable. +The floor blocks only NEW shots: a bullet already in the air has `gunHeat > 0` +and `shouldFire` already gates on `gunHeat <= 0.0`, so no committed shot is +suppressed or cancelled. + +Guards: `common_libs/tests/test_tfil_commit_env.nim` **136 -> 147 checks**, all +green. Eleven new checks cover default parity on both knobs (the exhaustion arm +is unreachable at 0.0 and the old finisher/desperation verdicts are unchanged +over 210 input combinations), the floor firing at exactly the threshold and not +one tick above it, the floor never blocking healthy energy in any configuration, +the trigger switching to ram exactly at the tolerance, and the composition. + +## The open-loop measurement (`common_libs/tests/measure_ramfloor_energy`) + +Recorded closed-loop corpus, **8149 recordings / 29871 rounds / 33.8M ticks**, +state only, no counterfactual replay (the offline harness scored 0/6 on +closed-loop questions, `docs/offline_harness_trust.md`). + +**The owner's premise ("both low, nobody firing") is TRUE and COMMON — but it is +not the situation he thinks it is.** Both bots below 25 energy happens on +**15.1% of ticks**; below 20, **10.4%**; below 10, **2.9%**. Self energy is +below 20 on **23.5%** of ticks with a median continuous run of **131 ticks**. + +**Neither side goes low first — it is a coin flip.** The enemy crosses 20 first +in 52.7% of rounds, we do in 47.3%. The premise that the *enemy* is the one +that runs dry is not supported; half the time we are the exhausted one, and +then there is nothing to ram *into* safely. + +**The specific trigger the owner described — "so low that it doesn't fire +anywhere more" — is rare.** The server rejects a shot when `energy <= power`, so +a p=1.95 opponent (DrussGT) stops firing below **2.0 energy**: **2.5% of ticks**, +and only **1.1%** while we are healthy. Below 3.0: 3.2%. An exhaustion tolerance +sized to the literal premise (`TR_RAM_ENEMY_ENERGY` ~2) therefore acts on about +1% of ticks. + +A looser tolerance is available and is what an A/B would actually sweep: +enemy <= 20 while we are above 20 is **7.1% of ticks**, enemy <= 30 is 13.2%. + +Gun-heat note: a 0.1-power shot adds 1.02 heat and the gun cools 0.1/tick, so a +floor suppresses roughly one shot per 11 ticks. This is a *lost opportunity +rate*, not a lost-damage estimate, and no counterfactual damage number is +produced here. + +## Arms + +Both: `TR_MOVEMENT` pinned, one frozen binary built once from `git archive` of +the j160 branch, per-arm `TR_ENV_FILE` in this job's own outdir, and every +run's `[env]` boot report checked against its arm before any number is read +(same contamination control as j159 — the owner's personal `.env` gives the +FILE priority and would silently void arm A). + +| arm | env | role | +|---|---|---| +| `A_off` | both knobs unset | **REFERENCE** — the shipped behaviour | +| `B_floor` | `TR_RAM_FLOOR_ENERGY=5` | the reserve half alone | +| `C_exhaust` | `TR_RAM_ENEMY_ENERGY=20` | the exhaustion half alone | +| `D_both` | `TR_RAM_FLOOR_ENERGY=5 TR_RAM_ENEMY_ENERGY=20` | the owner's full policy | + +Four arms, not two, because the two mechanisms are separable: the floor can +plausibly help on its own (it is the only part whose premise the data supports) +while the exhaustion trigger acts on 1-7% of ticks. A two-arm A+B run could not +tell a win from one half. + +## Design + +* Harness: `tools/ab/tournament_run.sh` + `tools/ab/tournament_analyze.py`, + unmodified. Panel: `tools/ab/panel_movement.txt`, the frozen 15-opponent + movement panel. Unit of evidence is the opponent, not the battle. +* 15 opponents x 4 arms x **10 runs** x 3 rounds = **600 battles**, serialised + (`--wait-arena 45`). ~1.5 h. + +### Primary metrics (pre-registered, fixed) + +1. **damage/run** +2. **round-win rate** + +### Mechanism metrics (reported, never a verdict) — specific to this one + +* **self energy at death** (the floor's entire claim is about the reserve we + hold when it matters); +* **ram-kill count** and ram-contact count (the exhaustion trigger's channel); +* shots fired/run (the floor's direct cost). + +### Statistical treatment + +Per-opponent paired deltas (arm − reference), mean, SD, SE, 95% CI, sign test, +sign-flip permutation test, Wilcoxon as a cross-check, plus the MDE the +analyzer prints. **Direction is pre-registered as two-sided**: a regression is +as interesting as a win, and the offline literature (`docs/ramming_negative_ +result.md`: proactive straight-line ramming converted 0/59) makes a regression +entirely plausible. + +### MDE — large, stated now + +At **10 runs/arm** the design resolves roughly **0.3 wins/run** and the +corresponding damage/run figure. Resolving 0.1 wins/run would need ~3x the +battles. **A null is the likely outcome — this would be the fifth consecutive +mechanism-positive / outcome-null result in this campaign.** + +Consequences recorded before any data: +* a null **excludes only a large effect**; it does not show the knobs do nothing; +* if `B_floor` is null and `C_exhaust` is null, the *pair* still leaves the + combined policy untested, and `D_both` is the arm that would be shipped. + +### Verdict rule (fixed now, not re-read later) + +* **Adopt** only if BOTH primaries move in the arm's favour with + `p(sign-flip) < 0.05` and the effect is at or above the reported MDE. +* Otherwise **do not ship**; the knobs stay default `off`. +* Mechanism numbers are reported as measured, with no vote in the verdict. +* No subsetting, no dropping opponents, no re-running to chase a p-value. A + clean null is a fully acceptable result. + +--- + +## MEASURED + +*(appended after the battles — everything above was committed first. Nothing +below exists yet: **no battle, server, GUI or A/B was started for this job**.)*