3 Commits

Author SHA1 Message Date
SirStone 7632aaba06 docs: record the aim-capture blind spot - aim_fire only logs shots that passed setFire
j178 flagged it and it is real: the capture cannot show WHY a shot did not
happen (gun still hot, turret not aligned). A missing aim_fire record is
ambiguous, not a refusal. Docs only, no code change. Adds the TR_CAPTURE_AIM
row + a 'Known limitations' note to docs/env_reference.md section 8, and a
two-line pointer next to the knob in .env.example.
2026-09-27 18:53:59 +02:00
SirStone 208f4a9092 j177: aim capture - log what the model BELIEVED, not what it did
j176 could not attribute the 11.9 deg aim error at 450+ px: the corpus had
no gun id and no bot-side belief, so staleness was an inverse (unidentifiable)
problem and a good gun was indistinguishable from a bad one. Both are cheap to
log and impossible to recover later.

New default-off knob TR_CAPTURE_AIM (presence-only). It appends TWO record
kinds to the EXISTING TR_RECORD_WORLDSTATE file:

  aim_scan - one per onScannedBot, written BEFORE the tracker update, so it is
    the pre-update belief by construction: tick, raw scanned values
    (ex,ey,eh,es,ee), our own state (sx,sy,sh,ss), the gun in force, the
    PREVIOUS belief (bx,by,bh,bs,blst), the scan parity age = tick - blst,
    and the radar-lock context (rlock, rdir, lbear, boff).
  aim_fire - one per real shot: gun, power, the aim angle handed to setFire,
    the turret angle and the signed turret error, gunHeat, the predicted
    intercept (ax,ay) and implied TOF, and the exact WorldState the predictor
    consumed (ex,ey,eh,es,ee,sx,sy) with the tick it came from (lst).

Row builders live in a new pure module src/aim_capture.nim - no bot API, no
env reads - so the offline guard test and the live bot go through the SAME
builders and a field the test proves present is a field the bot writes.
Per-tick world-state rows also gain a `gun` id. offline_range.nim skips
aim_* lines (they carry no `ex`), so the annotations are inert to the replay.
No aim model changed.

Knob registered in env_report.nim (context field, effective-value emit) and
knownEnvNames(); documented in .env.example. Defaults OFF, diagnostic only,
never live-tested.

Verification (no battle, no Java, no server, no GUI):
  - default parity: per-gun shots/hits over 20026 ticks of
    tr_drussgt_vs_modularbot.jsonl byte-for-byte identical to the golden
    generated from the PRE-CHANGE tree (git archive 55e92bc); the golden was
    regenerated from that pre-change tree and re-diffed, so it is not
    self-referential. Boot [env] block of the pre- and post-change binaries is
    identical except pid/cmdline/build line and the new knob's own line.
  - test_aim_capture: ALL PASS (every aim_scan/aim_fire key present, plus the
    annotation-inertness replay).
  - guards: test_tfil_commit_env 159/0, test_env_report 25/0,
    test_tfil_ring_weights 24/0, test_vbullet_draw 30/0.
  - .env.example round-trip (env_report via the j172 harness): 210 effective
    values + 70 [x]/[modules] lines, 0 diffs, 0 dropped keys, 0 warnings.
  - clean `git archive HEAD` + nim c -d:release: [SuccessX].
2026-09-27 18:44:24 +02:00
pi 55e92bc5e0 docs: the range-vs-approach ceiling - melee unavailable vs DrussGT, the 16 deg is a lead-model number, pre-multiply premise dead 2026-09-27 15:05:14 +02:00
9 changed files with 654 additions and 1 deletions
+21
View File
@@ -153,6 +153,9 @@
# TR_SURF_LOG one line per wave-surfing decision
# TR_FIRE_DIAG per-reading fire-detection tick/raw/correction
# TR_RECORD_WORLDSTATE dump every observed world state to JSONL
# TR_CAPTURE_AIM aim_scan/aim_fire records (gun id + bot belief).
# KNOWN LIMIT: only shots that PASSED setFire are
# recorded, so a gap is ambiguous - see docs/env_reference.md
# TR_RADAR_SCANLOG log every radar scan tick
# TR_RADAR_FORCE_SPIN force the old full-360 spin radar
# TR_TRACKER_PROBE dump the enemy-tracker internals
@@ -737,6 +740,24 @@ GUN_STATS_PATH=/tmp/gun_stats.jsonl # where the per-round gun stats are writte
GUN_SHOTLOG_PATH=/tmp/shot_log.jsonl # where the per-shot log is written
# ── measurement helpers (leave off unless you are measuring) ─────────────────
# WHAT: aim capture. Adds TWO record kinds to the TR_RECORD_WORLDSTATE file:
# aim_scan — one per radar scan: the raw reading, our own state, the gun
# that fired, the PREVIOUS tracker belief, and the scan parity
# (age = tick - previous lastSeenTick);
# aim_fire — one per real shot: gun, power, the aim angle, the turret error,
# gun heat, the predicted intercept/TOF, and the exact WorldState the
# predictor consumed (with the tick it came from). It also adds the `gun`
# id to the per-tick world-state rows.
# VALUES: presence-only, like the other keys in this block. Unset = off.
# STATUS: default-off, diagnostic only, never live-tested. j176 could not
# attribute the 11.9 deg aim error because the corpus had no gun id and no
# bot-side belief; this knob makes both a lookup instead of an inverse
# problem. No aim model changed with it.
# GOTCHA: it only writes when TR_RECORD_WORLDSTATE is on as well, and it makes
# the capture file bigger, not different: the extra lines are annotations and
# the offline replay skips them.
# TRY: TR_RECORD_WORLDSTATE=1 TR_CAPTURE_AIM=1 ./out/ModularBot
#TR_CAPTURE_AIM=1 # PRESENCE-only: aim_scan / aim_fire records (needs TR_RECORD_WORLDSTATE)
#TR_RECORD_WORLDSTATE=1 # PRESENCE-only: dump every observed world state
#TR_RADAR_SCANLOG=1 # PRESENCE-only: log every radar scan tick
#TR_TRACKER_PROBE=1 # PRESENCE-only: dump the enemy-tracker's internal state
+45
View File
@@ -44,6 +44,7 @@ import movement_harness/bullet_shadows
import targeting/enemy_tracker
import targeting/target_selector
import env_report
import aim_capture
import vbullet_draw
import geo_overlay
@@ -69,6 +70,13 @@ const ShotLog = true
## can enable recording for just the battle it spawns by exporting the env var.
let RecordWorldState* = existsEnv("TR_RECORD_WORLDSTATE")
const WorldStateRecordPath = "/tmp/worldstate_record.jsonl"
## j177 aim capture: with TR_CAPTURE_AIM set, append two extra record kinds to
## the SAME world-state file — `aim_scan` (one per radar scan: the raw reading,
## our own state, the GUN, the previous belief and the scan parity) and
## `aim_fire` (one per firing decision: the gun, the power, the aim angle, the
## turret error, and the exact WorldState the predictor consumed). Off by
## default, so a normal run writes byte-for-byte what it wrote before.
let CaptureAim* = existsEnv("TR_CAPTURE_AIM")
## Radar measurement switches (all RUNTIME, read once at process start):
## TR_RADAR_FORCE_SPIN=1 force the melee radar to the old stateless full
## spin (always 45 deg/tick). This reproduces the
@@ -477,6 +485,9 @@ proc recordWorldState(bot: ModularBot, ws: WorldState) =
"eid": tid,
}
if lst >= 0: row["lst"] = %lst
# j177: the gun in force when this state was built. Absent before j177,
# which made a per-gun decomposition of the aim error impossible.
if CaptureAim: row["gun"] = %bot.currentGun
try:
let f = open(WorldStateRecordPath, fmAppend)
f.writeLine($row)
@@ -616,6 +627,23 @@ proc recordRadarStats(bot: ModularBot) =
inc bot.arcWidthHist[min(11, int(width / 30.0))]
method onScannedBot*(bot: ModularBot, e: ScannedBotEvent) =
# j177 aim capture: the belief we are about to REPLACE, and the fire site's
# state, recorded BEFORE the update. Written first so the record is the
# pre-update belief by construction, not by argument.
if CaptureAim:
var rec = AimScan(tick: bot.tick, eid: e.scannedBotId,
ex: e.x, ey: e.y, eh: e.direction, es: e.speed, ee: e.energy,
sx: getX(), sy: getY(), sh: getDirection(), ss: getSpeed(),
gun: bot.currentGun,
rlock: bot.radarMode == 0, rdir: getRadarDirection(),
bx: 0.0, by: 0.0, blst: -1)
if bot.enemyTracker.enemies.contains(e.scannedBotId):
let prev = bot.enemyTracker.enemies[e.scannedBotId]
rec.bx = prev.x; rec.by = prev.y; rec.bh = prev.heading
rec.bs = prev.speed; rec.blst = prev.lastSeenTick
rec.lbear = bearing(rec.bx, rec.by, rec.sx, rec.sy)
rec.boff = (bearing(rec.ex, rec.ey, rec.sx, rec.sy) - rec.rdir) mod 360.0
appendLine(WorldStateRecordPath, scanRow(rec))
bot.enemyTracker.update(e.scannedBotId, e.x, e.y, e.direction, e.speed, e.energy, bot.tick)
bot.hasContact = true
if RadarScanLog and bot.radarMeleeActive:
@@ -1467,6 +1495,22 @@ method run*(bot: ModularBot) =
# Enqueue the selected gun so onBulletFired can stamp the server's bulletId.
# getEnergy() > power mirrors the server's "bot.energy <= firepower" reject.
if not floorBlocks and setFire(power) and getEnergy() > power:
# j177 aim capture: one `aim_fire` line per REAL shot, with the gun
# that fired and the exact WorldState the predictor consumed. This
# is the only place where `lst` is knowable, so it is the only place
# the scan parity of a firing decision can be recorded.
if CaptureAim:
let bspd = bulletSpeed(power)
appendLine(WorldStateRecordPath, fireRow(AimFire(
tick: bot.tick, eid: tid, gun: selectedGun, power: power,
aim: aimTarget, turret: gunDir, terr: normDelta, heat: gunHeat,
ax: pred.x, ay: pred.y, tof: (if bspd > 0: distPx / bspd else: 0.0),
ex: bot.lastState.enemyX, ey: bot.lastState.enemyY,
eh: bot.lastState.enemyHeading, es: bot.lastState.enemySpeed,
ee: bot.lastState.enemyEnergy,
sx: bot.lastState.selfX, sy: bot.lastState.selfY,
lst: (if tid >= 0 and bot.enemyTracker.enemies.contains(tid):
bot.enemyTracker.enemies[tid].lastSeenTick else: -1))))
bot.pendingFires.add(PendingShot(
gunId: selectedGun,
angleErr: abs(normDelta),
@@ -1558,6 +1602,7 @@ when isMainModule:
vBulletDebugGun: getEnv(VBulletDebugGunEnv, ""),
vBulletDebugMax: VBulletDebugMax,
recordWorldState: RecordWorldState,
captureAim: CaptureAim,
geoDebug: GeoDebugOn,
debugDraw: DebugDrawOn,
radarForceSpin: RadarForceSpin,
+129
View File
@@ -0,0 +1,129 @@
## j177 aim capture — the two records that make the aim error ATTRIBUTABLE.
##
## j176 could not answer "why is the aim 11.9 deg off at 450+ px" because the
## corpus has neither the bot's own belief (staleness was an INVERSE problem,
## unidentifiable) nor the gun id (a good gun's contribution was
## indistinguishable from a bad one's). Both are cheap to log and impossible
## to recover later. This module builds the two JSON records; ModularBot.nim
## calls it and the lines go into the EXISTING world-state capture
## (`TR_RECORD_WORLDSTATE` file), so the offline tooling sees one stream.
##
## It is deliberately PURE (no bot API, no env reads): the bot passes plain
## floats, the offline guard test passes the recorded fixture, and both go
## through the SAME row builders — so a field that the test proves present is
## a field the live bot writes.
##
## Key convention: `e*` = the enemy, `s*` = us, `b*` = the enemy's PREVIOUS
## belief in the tracker (before this scan's update), `lst` = the tick that
## state came from. `age = tick - blst` is the scan PARITY, recorded rather
## than inferred.
import std/[json, os, math]
const
ScanRecordKey* = "aim_scan" ## wrapper key, sibling of "meta"/"end"
FireRecordKey* = "aim_fire"
type
AimScan* = object
## One `onScannedBot` event, as the bot saw it BEFORE the update.
tick*: int
eid*: int
ex*, ey*: float ## raw scanned values
eh*, es*: float ## scanned heading (= direction) and speed
ee*: float
sx*, sy*: float ## our state at the scan (the FIRE SITE)
sh*, ss*: float
gun*: int ## bot.currentGun — the gun that fired, not the rack slot
bx*, by*: float ## previous belief, BEFORE this scan's update
bh*, bs*: float
blst*: int ## previous lastSeenTick; -1 = never scanned before
rlock*: bool ## radar lock engaged (false = the melee radar)
rdir*: float ## 36 deg scan window centre (radar heading)
lbear*: float ## bearing the lock is chasing (believed target)
boff*: float ## scanned bearing - rdir: where in the window it landed
AimFire* = object
## One firing decision, as the model computed it.
tick*: int
eid*: int
gun*: int ## bot.currentGun = the gun that fired
power*: float
aim*: float ## the raw angle handed to setFire/turret
turret*: float ## getGunDirection() at the command
terr*: float ## signed turret error (aim - turret)
heat*: float ## getGunHeat() BEFORE firing
ax*, ay*: float ## the intercept the gun predicted
tof*: float ## implied time of flight, ticks
ex*, ey*: float ## the WorldState the predictor CONSUMED
eh*, es*: float
ee*: float
sx*, sy*: float
lst*: int ## which tick that enemy state came from (scan parity)
proc bearing*(x, y, fx, fy: float): float =
arctan2(y - fy, x - fx).radToDeg
proc scanRow*(s: AimScan): JsonNode =
## The `aim_scan` line. Every field is unconditional: a capture that
## silently omits a field is worse than no capture.
result = newJObject()
result[ScanRecordKey] = newJObject()
let b = result[ScanRecordKey]
b["tick"] = %s.tick
b["eid"] = %s.eid
b["ex"] = %s.ex
b["ey"] = %s.ey
b["eh"] = %s.eh
b["es"] = %s.es
b["ee"] = %s.ee
b["sx"] = %s.sx
b["sy"] = %s.sy
b["sh"] = %s.sh
b["ss"] = %s.ss
b["gun"] = %s.gun
b["bx"] = %s.bx
b["by"] = %s.by
b["bh"] = %s.bh
b["bs"] = %s.bs
b["blst"] = %s.blst
b["age"] = %(if s.blst >= 0: s.tick - s.blst else: -1)
b["rlock"] = %s.rlock
b["rdir"] = %s.rdir
b["lbear"] = %s.lbear
b["boff"] = %s.boff
proc fireRow*(f: AimFire): JsonNode =
## The `aim_fire` line.
result = newJObject()
result[FireRecordKey] = newJObject()
let b = result[FireRecordKey]
b["tick"] = %f.tick
b["eid"] = %f.eid
b["gun"] = %f.gun
b["power"] = %f.power
b["aim"] = %f.aim
b["turret"] = %f.turret
b["terr"] = %f.terr
b["heat"] = %f.heat
b["ax"] = %f.ax
b["ay"] = %f.ay
b["tof"] = %f.tof
b["ex"] = %f.ex
b["ey"] = %f.ey
b["eh"] = %f.eh
b["es"] = %f.es
b["ee"] = %f.ee
b["sx"] = %f.sx
b["sy"] = %f.sy
b["lst"] = %f.lst
proc appendLine*(path: string, row: JsonNode) =
## Append one JSONL line, fully guarded: a full disk or a bad path must
## never take the bot down (same contract as the existing recorders).
try:
let f = open(path, fmAppend)
f.writeLine($row)
f.close()
except CatchableError:
discard
+4 -1
View File
@@ -52,6 +52,7 @@ type
vBulletDebugGun*: string
vBulletDebugMax*: int
recordWorldState*: bool
captureAim*: bool ## j177: aim_scan / aim_fire records, default off
geoDebug*: bool
debugDraw*: bool
radarForceSpin*: bool
@@ -245,6 +246,8 @@ proc printEffectiveValues(ctx: EnvReportContext) =
emit("TR_RESULT_LOG", onOff(ctx.resultLog), sourceOf("TR_RESULT_LOG"))
emit("TR_RECORD_WORLDSTATE", onOff(ctx.recordWorldState),
sourceOfPresence("TR_RECORD_WORLDSTATE"))
emit("TR_CAPTURE_AIM", onOff(ctx.captureAim),
sourceOfPresence("TR_CAPTURE_AIM"))
emit("TR_RADAR_FORCE_SPIN", onOff(ctx.radarForceSpin),
sourceOfPresence("TR_RADAR_FORCE_SPIN"))
emit("TR_RADAR_SCANLOG", onOff(ctx.radarScanLog),
@@ -657,7 +660,7 @@ proc knownEnvNames*(): seq[string] =
"GUN_SELECTOR_SHRINK", "GUN_SELECTOR_DWELL", "GUN_SELECTOR_MARGIN",
"GUN_SELECTOR_POINT_TIE", "GUN_SELECTOR_SEED",
"GUN_RACK_DISABLE", "GUN_STATS_PATH", "GUN_SHOTLOG_PATH",
"TR_MOVEMENT", "TR_MOVEMENT_LOG", "TR_RECORD_WORLDSTATE",
"TR_MOVEMENT", "TR_MOVEMENT_LOG", "TR_RECORD_WORLDSTATE", "TR_CAPTURE_AIM",
"TR_RADAR_FORCE_SPIN", "TR_RADAR_SCANLOG", "TR_RADAR_SCAN_LOG_PATH",
"TR_TRACKER_PROBE", "TR_TRACKER_PROBE_PATH", "TR_VBULLET_ADMIT_ONLY",
VBulletDebugEnv, VBulletDebugGunEnv, VBulletDebugMaxEnv,
@@ -23,6 +23,8 @@
## A trailing live end marker is also optional:
## {"end":{"enemy_died":<bool>,"ticks":<int>}}
## It lets the replay reproduce the live resolver's final-tick behaviour.
## `aim_scan` / `aim_fire` annotation lines (written only when
## TR_CAPTURE_AIM is set) carry no `ex` and are skipped.
##
## The replay never calls the gun selector, so it is RNG-free for every
## deterministic gun. Tsetlin is stochastic and is expected to differ.
@@ -196,6 +198,10 @@ proc loadFixture*(path: string): Fixture =
if node["end"].hasKey("enemy_died"):
result.enemyDied = node["end"]["enemy_died"].getBool()
continue
# j177: the recorder can also write `aim_scan` / `aim_fire` lines into the
# same file when TR_CAPTURE_AIM is set. They are annotations on ticks, not
# ticks, so they carry no `ex` and are skipped here.
if not node.hasKey("ex"): continue
result.states.add stateFromJson(node, arenaW, arenaH, enemyId)
result.lastSeen.add (if node.hasKey("lst"): node["lst"].getInt() else: -1)
result.enemyId = enemyId
+18
View File
@@ -0,0 +1,18 @@
# j177 gun-path default-parity golden.
# Generated from the PRE-CHANGE tree (`git archive 55e92bc`) with
# TR_CAPTURE_AIM unset, over the whole tr_drussgt_vs_modularbot.jsonl.
# Format: <gun> shots=<n> hits=<n>, one line per rack gun
HeadOn shots=400 hits=59
Linear shots=400 hits=47
Tsetlin shots=400 hits=74
Circular shots=400 hits=51
GuessFactor shots=400 hits=44
Pattern shots=400 hits=46
WallBounce shots=400 hits=55
Accel shots=400 hits=59
StopShot shots=400 hits=84
Displace shots=400 hits=46
AvgLead shots=400 hits=51
DecayGF shots=400 hits=47
KNN shots=400 hits=29
TMSelect shots=0 hits=0
+132
View File
@@ -0,0 +1,132 @@
## j177 guard: the aim capture actually contains every field it promises, and
## the default gun path is byte-for-byte unchanged.
##
## NO battle, NO Java, NO server, NO GUI.
## nim c -r --path:common_libs --path:ModularBot_garage/src \
## --nimcache:/tmp/nc_j177 common_libs/tests/test_aim_capture.nim
##
## Part 1 (the guard): with the capture ON, build one `aim_scan` and one
## `aim_fire` row from a real recorded tick of
## tools/fixtures/tr_drussgt_vs_modularbot.jsonl, through the SAME
## `aim_capture` row builders ModularBot.nim calls, and assert every required
## key is present and carries the value that was passed in. A capture that
## silently omits a field is worse than none.
##
## Part 2 (default parity): replay the whole recorded fixture through the real
## VirtualTracker + the guns with the capture OFF and compare the per-gun
## fitness report against `fixtures/aim_capture_gunpath.golden`. That golden
## was generated from the PRE-CHANGE tree (`git archive 55e92bc`) with the
## knob unset — regenerating it from this code would defeat the check.
## Part 2b: the same replay over a fixture that ALSO carries aim_scan/aim_fire
## lines must give the identical report, i.e. the annotations are inert.
import std/[json, os, strutils, sequtils, strformat]
import gun_harness/offline_range
import range_guns
import ../../ModularBot_garage/src/aim_capture
const
repoRoot = currentSourcePath().parentDir.parentDir.parentDir
fixtureRel = "tr_drussgt_vs_modularbot.jsonl"
fixture = repoRoot / "tools" / "fixtures" / fixtureRel
goldenPath = currentSourcePath().parentDir / "fixtures" / "aim_capture_gunpath.golden"
annotated = "/tmp/j177_annotated_fixture.jsonl"
ScanKeys = ["tick", "eid", "ex", "ey", "eh", "es", "ee", "sx", "sy", "sh", "ss",
"gun", "bx", "by", "bh", "bs", "blst", "age", "rlock", "rdir",
"lbear", "boff"]
FireKeys = ["tick", "eid", "gun", "power", "aim", "turret", "terr", "heat",
"ax", "ay", "tof", "ex", "ey", "eh", "es", "ee", "sx", "sy", "lst"]
var failures = 0
proc check(name: string, ok: bool) =
if ok: echo "PASS: ", name
else:
echo "FAIL: ", name
inc failures
proc replayReport(path: string): string =
## Per-gun shots/hits over the whole fixture, through the REAL tracker and
## the REAL guns, with the capture OFF.
let res = replayFixture(loadFixture(path), buildAllGunDrivers(seed = 1))
for r in res:
result.add r.name & " shots=" & $r.shots & " hits=" & $r.hits & "\n"
# ── Part 1: the records carry every field ────────────────────────────────────
proc fieldCheck() =
let states = loadFixture(fixture).states
check("fixture loaded", states.len > 1000)
let ws = states[900]
let scan = scanRow(AimScan(
tick: 900, eid: 7,
ex: ws.enemyX, ey: ws.enemyY, eh: ws.enemyHeading, es: ws.enemySpeed,
ee: ws.enemyEnergy, sx: ws.selfX, sy: ws.selfY, sh: ws.selfHeading,
ss: ws.selfSpeed, gun: 5,
bx: ws.enemyX - 8.0, by: ws.enemyY - 8.0, bh: ws.enemyHeading,
bs: ws.enemySpeed, blst: 896,
rlock: true, rdir: 42.5, lbear: bearing(ws.enemyX - 8, ws.enemyY - 8, ws.selfX, ws.selfY),
boff: -12.25))
let fire = fireRow(AimFire(
tick: 900, eid: 7, gun: 5, power: 1.6, aim: 88.25, turret: 74.0,
terr: 14.25, heat: 0.31, ax: ws.enemyX + 40.0, ay: ws.enemyY - 15.0,
tof: 12.5, ex: ws.enemyX, ey: ws.enemyY, eh: ws.enemyHeading,
es: ws.enemySpeed, ee: ws.enemyEnergy, sx: ws.selfX, sy: ws.selfY, lst: 897))
for k in ScanKeys:
check("aim_scan has " & k, scan[ScanRecordKey].hasKey(k))
for k in FireKeys:
check("aim_fire has " & k, fire[FireRecordKey].hasKey(k))
check("aim_scan carries the gun id", scan[ScanRecordKey]["gun"].getInt() == 5)
check("aim_fire carries the gun id", fire[FireRecordKey]["gun"].getInt() == 5)
check("aim_scan records the scan parity age",
scan[ScanRecordKey]["age"].getInt() == 4)
check("aim_fire records the source tick (parity)",
fire[FireRecordKey]["lst"].getInt() == 897)
check("aim_fire carries the aim angle", fire[FireRecordKey]["aim"].getFloat() == 88.25)
check("aim_fire carries the turret error", fire[FireRecordKey]["terr"].getFloat() == 14.25)
check("aim_fire carries the WorldState the model consumed",
fire[FireRecordKey]["ex"].getFloat() == ws.enemyX and
fire[FireRecordKey]["ey"].getFloat() == ws.enemyY)
echo "\n--- aim_scan record ---"
echo $scan
echo "--- aim_fire record ---"
echo $fire
echo ""
# ── Part 2: default gun-path parity ──────────────────────────────────────────
proc parityCheck() =
let clean = replayReport(fixture)
let ticks = loadFixture(fixture).states.len
if defined(aimCapGenGolden):
var g = "# j177 gun-path default-parity golden.\n"
g.add "# Generated from the PRE-CHANGE tree (`git archive 55e92bc`) with\n"
g.add "# TR_CAPTURE_AIM unset, over the whole " & fixtureRel & ".\n"
g.add "# Format: <gun> shots=<n> hits=<n>, one line per rack gun\n"
g.add clean
createDir(goldenPath.parentDir)
writeFile(goldenPath, g)
echo "wrote ", goldenPath, " (", ticks, " ticks)"
return
check("golden exists", fileExists(goldenPath))
if not fileExists(goldenPath): return
let g = lines(goldenPath).toSeq().filterIt(not it.startsWith("#")).join("\n").strip()
check("gun path byte-for-byte identical over " & $ticks & " ticks", g == clean.strip())
# 2b: the annotation lines must be inert for the replay.
let extra = @[
$scanRow(AimScan(tick: 0, eid: 1, ex: 1.0, ey: 2.0, eh: 3.0, es: 4.0, ee: 5.0,
sx: 6.0, sy: 7.0, sh: 8.0, ss: 9.0, gun: 3,
bx: 0.0, by: 0.0, blst: -1, rlock: true, rdir: 1.0, lbear: 2.0)),
$fireRow(AimFire(tick: 1, eid: 1, gun: 3, power: 1.5, aim: 1.0, turret: 2.0,
terr: 3.0, heat: 0.0, ax: 1.0, ay: 1.0, tof: 1.0,
ex: 1.0, ey: 1.0, eh: 1.0, es: 1.0, ee: 1.0,
sx: 1.0, sy: 1.0, lst: 0))]
writeFile(annotated, (lines(fixture).toSeq() & extra).join("\n"))
check("aim records do not perturb the replay", replayReport(annotated) == clean)
removeFile(annotated)
fieldCheck()
parityCheck()
echo (if failures == 0: "\nALL PASS" else: "\n" & $failures & " FAILURE(S)")
quit(if failures == 0: 0 else: 1)
+16
View File
@@ -616,6 +616,7 @@ Measured byte-identical on `bmPath`. Kept for experiments; leave at defaults.
| `TR_RADAR_SCAN_LOG_PATH` | `/tmp/radar_scan_log.jsonl` | where that goes |
| `TR_TRACKER_PROBE` | off | presence-based; per-tick enemy tracker vs server enemy count |
| `TR_TRACKER_PROBE_PATH` | `/tmp/tracker_probe.jsonl` | where that goes |
| `TR_CAPTURE_AIM` | off | presence-based; append `aim_scan` / `aim_fire` records — what the lead model BELIEVED (gun id, blst, age, boff, aim, turret, terr, heat, ax/ay, tof) to the same capture file as `TR_RECORD_WORLDSTATE` (needs `TR_RECORD_WORLDSTATE=1`) |
| `TR_VBULLET_DEBUG` | off | presence-based; overlay the virtual bullets in the GUI debug graphics (see below) |
| `TR_VBULLET_DEBUG_GUN` | selected gun | `all`/`*` for every gun, or a gun name (e.g. `Pattern`); unset = only the currently selected gun |
| `TR_VBULLET_DEBUG_MAX` | `32` | cap on bullets drawn per tick |
@@ -633,6 +634,21 @@ selector's training signal visible. Turn it on with:
- colour per gun is the SAME table as the turret (`vbullet_draw.gunColors`), with
a one-line legend in the top-left corner.
### Known limitations — `TR_CAPTURE_AIM` (j177)
**`aim_fire` only records shots that PASSED `setFire`.** The capture is written
from the bot's own fire call, so a shot the **server rejected or that the bot
never issued** produces no `aim_fire` record at all.
That is a real blind spot: from these records you can never answer *why* a shot
did not happen — e.g. the gun was still hot, or the turret was not yet aligned.
A missing `aim_fire` is ambiguous between "no target / didn't try" and "tried and
was refused". The `aim_scan` records carry the belief state (age, boff, turret,
heat) for every scan, so you can often *infer* the cause by looking at the scans
that precede the gap, but the capture does not state it. Read the gaps as
"no recorded shot", never as "the server blocked it". Closing this needs a
pre-`setFire` gate record, which is j177+ work, not present today.
The **adaptive-melee radar** has no env knobs. Its tuning lives in compile-time
constants in `radars/adaptive_melee_radar.nim:36-50`: `MaxRadarTurnRate=45`,
`FreshnessTicks=16`, `FreshStreakTicks=3`, `MarginDeg=20`,
+283
View File
@@ -0,0 +1,283 @@
# Range vs approach: melee is unavailable against DrussGT, so the gun at range is the only lever
**Date:** 2026-09-27 · **Job:** j167 · **Branch:** `research/lead-targeting`
**Evidence base:** j166 (`worktrees/j166-aim` @ `a5a49bd`), j165 (`fe77056`), j159
(`4a1f3e1`), j165/j151/j152/j154, j160/j163 (`0df7763` / `51bfa57`), j161
(`docs/ram_floor_exhaustion_ab.md:219`).
**Instrument for the new numbers below:** `worktrees/j167-ceiling/j167_probe.py`
(branch `j167-ceiling`) — pure replay of the recorded corpora
(`/tmp/tfil_ab2/out/`, 60 252 of our own scored shots over 140 battles; and
`/tmp/firelag_live2/`, 1 700 shots / 1 664 incoming bullets over 4 battles).
**No battle, A/B, server or GUI was run for this document.**
---
## 1. The ceiling
**Melee is structurally unavailable against DrussGT, and the exhaust/ram line is a
niche rather than a lever.** The evidence is two-sided and independent: (a) *our
mover's own ruler* — 94% of forced (no-safe-tile) picks happen at range > 300 u
and only 6-7% of picks reach the chosen tile at the estimated arrival time, with
the destination hot on arrival 35-42% of the time; and (b) *the j166 pursuit
probe* — 35 windows × 250 ticks of open-loop kinematics in which **every**
steering law is equal-or-worse than doing nothing clever:
| steering law (j166) | closing (u/tick) | contact % | TTI (ticks) |
|---|---:|---:|---:|
| current-position closing | 4.03 | 65.7 | 72.3 |
| body/barrel ray | 1.38 | 40.0 | 131.4 |
| velocity intercept (degenerate at equal speed) | — | 0 over 2 118 ticks | — |
| best case: lag-5 lead | 4.20 | 65.7 | 68.9 |
The root cause of the historical **0/59 proactive-ram** result
(`docs/ramming_negative_result.md`) is not a bad gate: **DrussGT never let the
distance drop.** Per-round minimum distance 152-338 u, median ~490 u, and
`frac(dist < 50) = 0.000` in all four recorded rounds. A pursuit that never gets
below 152 u cannot make contact, whatever the gate says. It is also not a
gun-side problem: **the server never transmits the enemy's gun direction**
(`ScannedBotEvent` = `energy, x, y, direction` where `direction` is the BODY
heading, plus `speed`; `TurnProcessor.kt:313-323`). There is no aim-based lead,
no aim-based dodge and no early warning available. Against DrussGT the
body-to-bullet angle has median **90.1 deg**, and the body ray passes within
10 deg of us on **0.0% of 1 794 ticks** — its gun is always on us, its body
never is.
**Recorded so the idea is not re-proposed:** the j166 lag-5 residue does improve
TTI (72.3 → 68.9) and **converts to contact 0% of the time**. A 4% TTI gain with
zero contact conversion is noise, not a lead.
### The honest remaining niches for exhaust/ram
1. **An opponent that closes on us.** Ram works whenever the other side comes to
us. Nothing here generalises away from that.
2. **A late-round exhaustion when they are already near.** The one conversion
ever recorded came from a *finisher* (enemy 16 → 1 energy), which is already
the default gate.
3. **Any 2v1+ mode**, where closing dynamics are not symmetric.
Against DrussGT specifically none of these will move the score, and
`TR_RAM_FLOOR_ENERGY` is under test in j163 — do not duplicate it.
---
## 2. What the ceiling implies
**If range is held, the only remaining lever is the gun at range, and the binding
numbers are the gun's, not the tile picker's.** The long-range hit rate is
**~9-10%** (Pattern live: 12.3% at 300-450 px, 9.2% at 450+; overall 10.5% —
`docs/headon_longrange_live.md`), the live hit half-window at 450 px is
**`atan(18/450) = 2.29°`** (`docs/gun_campaign.md:59`), and the measured arrival
aim error is **16.19° mean-abs at 450+** (`docs/bitbrain_campaign.md:107`,
`docs/headon_longrange_live.md:85`). 16.19° is **7× the window**. The tile picker
cannot close a 7× gap that sits downstream of the gun.
### New measurement — arrival aim error decomposed (j167, 23 275 shots at 450+ px)
Arrival aim error is defined non-circularly: the angle between the fired bearing
and the bearing to where the target *actually is* when the bullet arrives
(`tof = 20 - 3·power`, so the flight time comes from the power, not from the
shot's own geometry). It splits **exactly**, as signed angles, into
* **B, the model part** = the error the gun's own lead model leaves behind, and
* **C, manoeuvre** = the target's path curvature relative to the
constant-velocity extrapolation from the true state at fire time.
| band (px) | n | mean&#124;A&#124; | mean&#124;B&#124; (model) | mean&#124;C&#124; (manoeuvre) | sd(B) | sd(C) | corr(B,C) |
|---|---:|---:|---:|---:|---:|---:|---:|
| 0-100 | 24 767 | 83.36 | 101.56 | 49.38 | 124.5 | 75.9 | −0.65 |
| 300-450 | 11 372 | 13.07 | 21.77 | 11.00 | 26.0 | 13.0 | −0.87 |
| **450+** | **23 275** | **11.26** | **17.18** | **7.73** | **20.7** | **9.3** | **−0.85** |
At 450+ the model part's variance is **2.2× the manoeuvre part's**, and
`corr(B,C) = −0.85` means the two largely *cancel* — the net 11.26° is much
smaller than either part. **The 16° is a lead-model number, not a dodge number.**
Two supporting numbers: a naive constant-velocity extrapolation of a **2-tick-old**
position scores 8.45° mean-abs at 450+, and the time-of-flight implied by the
shot's own geometry (holding the current velocity) sits a **median 10 ticks short**
of the power-derived arrival tick (p10 −18, p90 +31) — i.e. the gun systematically
**under-leads in time**, consistent with `docs/lead_capture_by_range.md`
(capture 0.135 at 450+).
> **Do not read "a stale-CV model scores 8.45°" as "simplify the gun".** This is
> exactly the offline-ruler trap that killed HeadOn: the ruler said a no-lead gun
> was equal-or-better at 300+ and live it hit **20×/23× less**
> (`docs/headon_longrange_live.md`). The corpus is closed-loop — the target's
> manoeuvre is a *reaction to our own bullet* — so (B) and (C) are not separable
> here, and per `docs/offline_harness_trust.md` (j89: 0/6 on closed-loop) this
> instrument ranks per-gun single-tick prediction, it does not predict a live A/B.
### Cross-reference: what is still open in the gun docs
| doc | finding | status after this ceiling |
|---|---|---|
| `docs/gun_campaign.md:59` | hit half-window 2.29° at 450 px; measured signal 4.6-7.6° | **STILL OPEN and now the load-bearing number.** The decomposition says the gap is in the *model*, and the model is systematically 10 ticks short in time-of-flight. |
| `docs/gun_campaign.md:40-45` | lead amplitude is dead (1.0/1.5/2.0/3.0 all worse); radial knobs are bearing-invariant by construction | **CLOSED.** |
| `docs/gun_campaign.md:737-753` | `len6` +0.49 wins/run (p=0.039, n=15) did not replicate on n=33 | **CLOSED.** |
| `docs/bitbrain_campaign.md:189` | BitBrain / TMHorizon corrector adds no measurable aim (16.199 vs 16.193) | **CLOSED.** |
| `docs/bitbrain_campaign.md:107` | Pattern's own lead correlation with the required lead is 0.165 at 450+ | **STILL OPEN.** It is the same defect the decomposition names. |
| `docs/state_window_gate.md` | single wave-relative state at Q=4 predicts the miss bin at 0.4094 vs 0.2348 majority, but bins are 4.58-7.63° wide | **STILL OPEN, and now the best-placed surviving idea** — it is a *model* correction, which is where the error is. |
| `docs/gun_rack_analysis.md:423-455` | the 16-candidate rack ranking A/B found no winner; knobs added, all neutral | **CLOSED** (13 guns, `onlyPattern` shipped). |
---
## 3. Negative-results ledger — mechanisms closed by measurement
Do not re-litigate any row. The unit of evidence is the **opponent**.
| mechanism | knob / job | headline number | verdict |
|---|---|---|---|
| Geometry-weighted tile draw | `TR_TFIL_GEO_MODE/TAU`, j152 `38fbc6e`, A/B'd j159 `4a1f3e1` | **−8.83 damage/run, p=0.0061**; wins −0.05, p=0.46; +26.3 px mean distance on 15/15 opponents | **REJECTED.** Default off, stays off. |
| The bounded hold | `TR_TFIL_HOLD_MAX_TICKS`, j154 `2223ca6` | mechanism-positive, outcome-null (j146/j153) | **Default off.** No live win. |
| The proactive ram | `oldram` vs `base` gate `dist<200` | **p=0.69**, damage 279 vs 284, survival 17/49 vs 16/49; **0/59 opportunity→contact** | **CLOSED** (`docs/ramming_negative_result.md`). |
| The aim-based ram | j166 `a5a49bd` | body ray within 10° of us on **0.0% of 1 794 ticks**; body/barrel ray contact 40.0% vs 65.7% for doing nothing clever | **IMPOSSIBLE** — the server never sends gun direction (`TurnProcessor.kt:313-323`). |
| Arrival commitment (`tfil`) | j144 `d2005ab` | mechanism-positive, outcome-null | Default off. |
| Turn-cost tiebreak among safe tiles | j145 `39c90fd` | real but small mechanism, under-powered outcome null (300 battles, 5 arms) | Default off. |
| Field shape (safety) | j146 `de5d02b` | safe-set broken 63.5% → 30.4% offline; live null on damage and wins (375 battles, 5 arms) | **Default off.** |
| Corridor bound | j148 `5e213df` `TR_{TFIL,STRAFE}_CORRIDOR_TICKS` | never landed in a live A/B | Untested, not a candidate. |
| Ring arrival commitment | j165 `fe77056` `TR_TFIL_RING_COMMIT_ARRIVAL` | reach 0.24% → **3.05%**, picks 5 521 → 525, byte-for-byte default parity over 20 026 ticks, 148 guards | **Mechanism-positive, default off.** The strongest surviving movement mechanism. |
| Firing floor / enemy-exhaustion ram | j160 `23bce2d`, A/B'd j163 `51bfa57` | **clean negative**; the offline energy corpus missed the live game by 200× | **Under test in j163 — do not duplicate.** |
| Fire-detection lag | j147 `d21f7ce` `TR_FIRE_LAG` | displacement 19.06 → 5.37 px, deadline error 0.99 → 0.06 ticks; **live outcome-neutral**; ceiling ~10% of incoming damage (measured below) | **Default off, permanently.** |
| Hard arrival bound | j151 `a01141c` `TR_TFIL_ARRIVE_TICKS` | mechanism-positive, outcome-null | Default off. |
> **Methodological caution (j161), binding on everything above.** Pooled tests
> can hide real per-opponent effects: j159's safety signal was **p=0.0008
> per-opponent while the pooled test was null** (`docs/ram_floor_exhaustion_ab.md:219`).
> **Any future mechanism claim must report per-opponent mechanism metrics, not a
> pooled mean.** A pooled null is not evidence of absence; it is evidence that
> the heterogeneity was not averaged down.
---
## 4. Lead-time lever 1 — what a 2-tick-stale ghost really costs
`TR_FIRE_LAG` back-dates the bullet ghost (default 0). Energy-drop shot
detection lags **1.9 ticks mean**; median bullet flight is **19 ticks**
(`onHitByBullet` gives 82 hits / 7 421 ticks, one update per ~90 ticks).
**Measured on 55 750 incoming bullets** (`/tmp/tfil_ab2/out/`). For each bullet:
the time to closest approach of the target's recorded path to the bullet line
(**median 9 ticks**, p10 1, p90 39), and the minimum number of ticks of lead time
a max-speed hard-turn dodge needs to build 17 px of lateral displacement:
| minimum dodge lead time (ticks) | 0 | 1 | 2 | 3 | 4 | 5+ |
|---|---:|---:|---:|---:|---:|---:|
| share of incoming bullets | **57%** | 33% | 4% | 2% | 1% | 2% |
**57% of incoming bullets are already undodgeable at the instant they are fired**,
and only **~10%** (need ≥ 2 ticks) are in a regime where a 2-tick detection lag
can change anything. Applying the lag to the open-loop dodge model:
| ghost lag (ticks) | modelled hits | Δ vs perfect | share of all bullets whose hit/miss verdict flips |
|---|---:|---:|---:|
| 0 | 22 419 | — | — |
| **1.9 / 2** | **25 100** | **+2 681 (+12.0%)** | **10.18%** |
| 3 | 26 230 | +14.6% | 14.63% |
| 5 | 28 949 | +22.0% | 22.04% |
**Verdict: the 1.9-tick lag costs on the order of 10% more incoming hits** — at
the measured ~200 damage/run, roughly **20 damage/run**, an order of magnitude
below the movement A/B damage MDE. This is consistent with `TR_FIRE_LAG`'s already
measured live outcome-neutral result. The ghost is *wrong*, but wrongness at
10% of incoming damage cannot be turned into wins at this sample size.
**Recommendation: `TR_FIRE_LAG` stays off permanently.** It is a correctness fix
with a measured, bounded, sub-MDE payoff.
---
## 5. Lead-time lever 2 — the 16° decomposed, component by component
At 450+ px (23 275 shots), against the 11.26° net arrival error:
| component | measured | addressable? |
|---|---|---|
| **(a) enemy body-gun decoupling** | `\|gun dir − body heading\|` median **89.9°** (p10 25.9, p90 154.0, n=60 928). Extrapolating the target along its **gun** instead of its **body** would put the arrival bearing **79.5° median** wrong. | **Not present, and not addressable.** The intercept model uses the target's *recorded position and velocity*, both of which are the true body quantities and both exactly observed. Body-gun decoupling therefore contributes **exactly 0** to our arrival error. It is fatal for *aim-based* leading and threat warning (j166) and irrelevant to *position-based* leading. |
| **(b) our own leading model** | mean&#124;·&#124; **17.18°**, sd **20.7**; implied time-of-flight a **median 10 ticks short** of the power-derived arrival tick | **DOMINANT, and addressable.** This is ~2.2× the manoeuvre variance and it is the whole of the 16°. |
| **(c) target manoeuvre between scan and fire** | mean&#124;·&#124; **7.73°**, sd **9.3** | Small relative to (b), and **irreducible** — it is the dodger's own unpredictability, exactly the ~half of the under-lead `docs/lead_capture_by_range.md` attributes to a trivial predictor's own ceiling. |
| **(d) gun turn rate / time-to-fire** | the correct solution drifts a **median 0.416°/tick** (p90 5.45). The gun turns at 10°/tick, so a 17° correction takes **1.7 ticks ≈ 0.40°** of drift. | **Not binding.** Contributes ~**0.4°, i.e. ~3% of the 11.26° error.** The gun can always reach the answer; it aims at the wrong answer. |
**So the 16° is not (a), not (c) and not (d). It is (b) — the lead model's
time-of-flight, short by ~10 ticks.** Caveat, stated once and load-bearing: on a
closed-loop corpus (B) and (C) are not cleanly separable, since the target's
manoeuvre is a reaction to our own shot; the `corr(B,C) = −0.85` is exactly that
confound showing up. The *rank order* (b) ≫ (c) ≫ (d) > (a)=0 is robust to it
because (b) and (c) differ by 2.2× in variance and (d) is 3%.
---
## 6. The proposed lever: pre-multiply before learning — PREMISE DEAD
The design: aim error is largely a *product* (bearing-rate × time-of-flight), so
pre-multiply the two features and feed one small Tsetlin machine. **Measured on
the same corpus, the premise does not hold and the experiment should not be
built.** `y` = the required lead angle (current bearing → arrival bearing), i.e.
exactly the quantity the gun must predict; `b` = the observable 4-tick finite
difference of the bearing; `t = 20 − 3·power`.
| band (px) | n | corr(**b·t**, y) | corr(b+t, y) | R² additive [1,b,t] | R² product [1,b·t] | held-out side acc, additive | held-out side acc, product | held-out residual rms (deg) |
|---|---:|---:|---:|---:|---:|---:|---:|---:|
| 0-200 | 24 934 | −0.1020 | −0.1022 | 0.0105 | 0.0104 | 0.579 | 0.580 | 75.98 |
| 200-300 | 671 | 0.1190 | 0.0766 | 0.0147 | 0.0142 | 0.488 | 0.487 | 13.70 |
| 300-450 | 11 372 | **0.1501** | 0.0299 | 0.0256 | 0.0225 | 0.544 | 0.534 | 8.43 |
| **450+** | **23 275** | **0.2833** | 0.1052 | **0.0831** | 0.0803 | **0.603** | **0.601** | **6.68** |
| pooled | 60 252 | −0.1005 | −0.1009 | 0.0102 | 0.0101 | — | — | — |
*(side accuracy is 2-fold held-out and balanced; the TM record is ~0.47-0.49)*
Two things are true and the second kills the idea:
1. **As a single scalar, the product is much the better feature at range**:
`corr(b·t, y) = 0.283` vs `corr(b+t, y) = 0.105` at 450+ — 2.7× better, and
5× better at 300-450. So the *premise* ("the error is a product, not a sum")
is **confirmed as a statement about correlation**.
2. **But it buys nothing a weight-sum cannot already express.** The best linear
additive model on the same two features reaches **R² 0.0831 vs the product's
0.0803**, and the held-out balanced side accuracy is **0.603 (additive) vs
0.601 (product)** — a 0.002 difference, i.e. nothing. Pooled, the two are
identical (−0.1005 vs −0.1009; R² 0.0102 vs 0.0101). A TM with two input
features **already reconstructs the product term**; the multiplication is what
the network was doing anyway.
**Even the ceiling is out of reach.** The best held-out residual on the required
lead at 450+ is **6.68° rms**, against a live hit half-window of **2.29°** — a
2.9× shortfall. Pre-multiplying does not get a classifier to 2.29°; nothing in
this family does. **Do not build it.** The spec is recorded here so the idea is
closed on measurement rather than on taste.
*(Had it survived, the spec would have been: one TM, ONE input feature `b·t`
binarised on sign, plus the 4-bit horizon one-hot as today; offline gate =
held-out balanced side accuracy above 0.55 and residual rms below 3° at 450+;
live gate = wins/run with CI excluding 0 and sign-flip p<0.05 at 210 runs/arm,
damage not detectably down, MDE 0.17 wins/run. Predicted accuracy was 0.60 side
accuracy, which is a real signal against the 0.47-0.49 record — and still not
close enough to the window to convert.)*
---
## 7. The A/B queue, in priority order, with the MDE honestly restated
Throughput **22.7-23.2 runs/min**; movement gate resolved **0.17 wins/run at 210
runs/arm**; the `1/√n` extrapolation to 0.10 wins/run is **607 runs/arm ≈ 1.4 h —
a FLOOR on elapsed time, not an estimate**, because opponent heterogeneity does
not average down. A null at this sample size **only excludes a LARGE effect.**
(j163 additionally measured 14-22 runs/min, not 22.7-23.2, so even the floor is
optimistic.)
| # | experiment | what it tests | cost | a null would license |
|---|---|---|---|---|
| **1** | **The lead-model time-of-flight correction** (j167's (b)): re-derive the gun's arrival prediction so the implied flight is the power-derived tick, not 10 ticks short. | The one component that carries 2.2× the error variance at 450+, and the only open axis in `docs/gun_campaign.md` (lead *information*, not amplitude). | Offline gate first: arrival aim error at 450+ must fall below 11.26° mean-abs on held-out battles, ideally <8°; only then 2 arms × 15 opponents × 14 runs = 420 battles ≈ **0.3-0.4 h** wall. | Closing the single open gun axis. Nothing left in the gun. |
| 2 | `TR_TFIL_RING_COMMIT_ARRIVAL` (j165, default off) | Whether the largest surviving *movement* mechanism (reach 0.24% → 3.05%, picks 5 521 → 525, 148 guards) converts to wins. | 210 runs/arm ≈ **1.4 h floor**. | Retiring the whole ring/approach programme: if even a 12× reach gain is outcome-null, the ceiling argument is confirmed end to end. |
| 3 | `TR_FIRE_LAG` (tfil/strafe, default off) | Nothing worth testing — its ceiling is now measured at **~10% of incoming damage ≈ 20 dmg/run**, below the MDE. | Would be 1.4 h to learn nothing. | Nothing. **Skip it**; the measurement has already answered it. |
| 4 | `TR_TFIL_ARRIVE_TICKS` (j151, default off) | Whether a hard arrival bound converts now that the ring is rehabilitated. | 1.4 h. | Retiring it with j151's own null attached. |
| 5 | `TR_RAM_FLOOR_ENERGY` (j160, j163) | **Under test in j163. DO NOT DUPLICATE.** | — | — |
**Recommendation.** Run **only experiment 1**, and only after the *offline* gate
passes; if the offline gate does not move the 450+ arrival error below ~8°, run
nothing at all. Given five consecutive nulls or near-nulls (j144, j145, j146,
j147, j159) plus a clean negative in j163, spending 1.4 h of live time on
experiments 2-4 is not justified — those are mechanism-positive
mechanisms whose outcome nulls are already the standing record, and a null there
teaches nothing that the ledger does not already say.
**"The ceiling is real and we should stop spending on movement" is the answer.**
The remaining budget belongs to the gun's lead model, or it is not spent.