22 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
SirStone 486e2a69c6 docs(env): 6-block env reference + .env.example, every claim traceable
Rewrites docs/env_reference.md and ModularBot_garage/.env.example so a reader
can act on the file without re-deriving anything, and so every claim in it
can be checked.

WHAT
  177 knobs documented across a 6-block format:
  WHAT / VALUES / STATUS / GOTCHA / TRY. Values are the built-in defaults,
  so `.env.example` is behaviourally identical to a clean run. No default
  value changed anywhere; the only added key is TR_FIRE_LAG=0, which is real
  (fire_tracker.nim:164).

WHY (traceability)
  Every STATUS line now cites the job or commit behind the claim it makes.
  A documented default is only useful if you can tell whether it was
  verified or copied by hand; the citation makes that decidable without
  re-running the experiment.

  The presence-gated list was wrong: it claimed 7 knobs, the true number is
  10. Three knobs were also wrongly labelled presence-gated; they are
  value-based and are now documented as such.

  All 31 `# TRY:` example values were checked against the code that parses
  them, so no example is rejected when copied.

VERIFICATION
  Round-trip (j172 probe: printEnvReport clean vs .env.example applied
  through the repo's own env_dotenv loader, reports diffed): 0 mismatches,
  0 warnings, 0 dropped keys (177 in file, 177 seen). Re-run after this
  commit's comment edit, unchanged.
  test_tfil_commit_env 159 PASS / 0 FAIL
  test_env_report        25 PASS / 0 FAIL
  test_tfil_ring_weights 24 PASS / 0 FAIL (earlier in the series)

Also drops the stale "snapshot of commit 5e32ec1" pin from .env.example: a
pinned hash goes stale the moment the next commit lands, which makes the
"regenerate when a default changes" instruction worse than none. The line
now just says the values mirror current defaults.
2026-09-27 14:52:16 +02:00
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
SirStone 4a1f3e1c88 j159: live 2-arm A/B result — geometry draw weighting costs 8.8 damage/run, wins null; keep TR_TFIL_GEO_MODE=off 2026-09-27 12:44:38 +02:00
SirStone 7c5bc9ccbd j159: PRE-REGISTER the tfil geo A/B (geo draw weighting vs uniform) before any battle 2026-09-27 12:16:36 +02:00
SirStone 2223ca667e j154 (default-off): the DERIVED bounded hold TR_TFIL_HOLD_MAX_TICKS + panic release
Budget derived from the server source (tank-royale 0.35.5): calcGunHeat(p) =
1 + p/5, coolDown 0.1/tick, fire only at heat == 0, calcBulletDamage(3.0) = 16
-> two max-power shots are 16 ticks apart and 32 damage is the most the enemy
can land in that window (brute force over the 0.1 power grid: 8/11/16/24/32
ticks -> 16/18/32/32/48). 16 = CommitTicks + 1, the first window that admits
the enemy's second shot.

Knob defaults to 0 = today's behaviour byte-for-byte (checked over 20026
ticks). Hold is taken only on a replan tick with an empty safe set, at most N
ticks per streak, released the tick a safe tile exists, counter reset on the
pick. PANIC RELEASE: a tracked bullet whose closest approach is within its core
at t* in [0, min(N,16)] overrides the hold immediately. The gun is untouched.

Also fixes a stray '&' that stopped test_tfil_commit_env.nim from compiling at
all, and enforces the 'a hold never interrupts a live commitment' invariant
j153 documented but did not implement. Registers TR_TFIL_HOLD_MAX_TICKS in
env_report + knownEnvNames + .env.example.

No battle, no A/B run.
2026-09-27 10:23:46 +02:00
SirStone 38fbc6ecd1 j152 (default-off): geometry shapes the tile DRAW (TR_TFIL_GEO_MODE/TAU) + the offline sweep with the diversity cost
The owner: choose the tile pool not only from the heat point but from the
geometric position too. Heat stays the hard filter; the draw over the
survivors is re-weighted by turn and/or distance. Three weighting forms
(soft softmax / top-K third / rejection band), one env name carrying both
axes. Default = off = today's uniform draw, byte-for-byte (golden parity).

WHAT DIFFERS FROM j9 (TR_TFIL_TURN_BIAS, a live null): that was a tiebreak
weight among the non-empty safe set only. This runs on the WHOLE pool the
draw already runs on, including the 2 promoted least-hot tiles the ~65%
forced picks choose from.

OFFLINE (8 fixtures x 3 seeds, no java): headline 'tile actually reached at
tta' 4.5% -> 16.9% at TR_TFIL_GEO_MODE=both-soft TR_TFIL_GEO_TAU=45, with
no diversity collapse (distinct tiles 220 -> 219, normalised entropy
0.87 -> 0.86, top-tile share 7.0% -> 8.6%). perpE — the perpendicular rate
in the FORCED population — does not move in ANY arm: that pool is 2 tiles
ranked by heat alone and the only lever is a coin flip.

Also registers j150's TR_TFIL_DIAG / TR_TFIL_DANGER_THRESHOLD in
env_report + knownEnvNames + .env.example (test_env_report was red) and
documents DANGER_THRESHOLD as quantisation-limited: heat comes in 5s, so
the effective steps are 10/15/20 and 10-14 admits zero extra tiles.

No battle, no server, no A/B run.
2026-09-27 10:22:24 +02:00
SirStone 94ffc63160 j153: the open-loop ruler for "hold when trapped" + the A/B proposal (NOT run)
Measures, on the recorded fixtures and with NO counterfactual replay: how long
until a safe tile appears at a forced pick, whether the enemy had just fired,
whether our own tile is already hot, and all three split by distance. Registers
nothing and changes no default: the knob TR_TFIL_HOLD_WHEN_TRAPPED and its
implementation live in the concurrently edited working tree.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-27 10:16:53 +02:00
SirStone a01141c959 j151 (default-off): hard arrival bound TR_TFIL_ARRIVE_TICKS + the offline per-pick ruler that found it
Measures the owner's two complaints per pick (turn angle, heat on the path
vs at the destination, time-to-arrive, and whether the destination is hot on
the recorded true future when we would arrive), split by empty vs non-empty
safe set. Result: the path IS scored (hard filter + least-hot fallback), but
there was NO time term at all -- 65% of picks outran the 15-tick commitment
and the chosen tile was reached 6.5% of the time. The bound refuses a
candidate we cannot reach in TR_TFIL_ARRIVE_TICKS (default 0 = off = today).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-27 09:57:25 +02:00
SirStone 0b74b01c4b j150 (diag only): picker loss histogram + sweepable heat cutoff
Measures WHERE the lava picker loses tiles, per pick:
reachable hull -> CoolestLevels=2 distinct-value filter -> path heat
filter -> draw set -> chosen. TR_TFIL_DIAG (default off) fills
TfilLoss*; TR_TFIL_DANGER_THRESHOLD (default 10.0, the shipped const)
makes the cutoff sweepable offline. No decision logic changed: the guard
test proves the diag-on move stream is byte-for-byte the diag-off one.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-27 09:51:19 +02:00
SirStone 5e213df8d9 j148: bound the corridor by speed x ticks (TR_{TFIL,STRAFE}_CORRIDOR_TICKS, default 0 = to the wall); UNTESTED 2026-09-27 08:23:16 +02:00
SirStone 32ec040e90 j147 results: 180-battle live A/B on the frozen 15-opponent panel
Within-mover references (the only comparison that isolates the knob): tfil_lag1
-0.04 wins/run, strafe_lag1 +0.18 wins/run, both far under the reported MDEs
(0.33 / 0.41) -> NOT distinguishable, so TR_FIRE_LAG stays default 0. Incoming
hit rate also unmoved (+0.94 / +0.33 pp). The one significant result is the
mover (strafe_lag1 vs tfil_off +0.38 wins/run p=0.0386, hit rate -4.67pp
p=0.0074), which is the known strafe-over-tfil gap and exactly why the
within-mover reference was pre-registered.

Also a cosmetic no-op refactor of the two spawn sites (compute velX/velY once;
bit-identical, and it keeps the default-parity claim exact).
2026-09-26 23:20:36 +02:00
SirStone d21f7ce5f5 j147: the 1-tick fire-detection lag is OURS — measure it, then back-date it (TR_FIRE_LAG)
MEASURED LIVE (common_libs/tests/measure_fire_ghost_lag.py, 4 sessions, 1777
matched ghost spawns, both movers): the server dispatches a turn's fire AFTER
our go() for that same turn, so a turn-T shot's energy drop first reaches our
scan at turn T+1 — and a bullet takes its FIRST step during the turn it is
fired, so the true bullet is already one whole bullet step (11-20 px) downrange.
Both movers place the ghost at the SCANNED enemy position (where the bullet was
born), so the whole ghost trajectory is the true one shifted one turn later and
the arrival deadline is a full tick late.

MEASURED: detection lag +1 tick on 100% of 1777 matched spawns; ghost-vs-
observer displacement 19.06 px mean / 22.00 p90 (tfil) and 16.08 / 21.81
(strafe); arrival-deadline error 0.99 / 0.77 ticks. NOT a rendering artefact:
the draw/advance order is correct (advanceBullets -> detectFires -> build).

THE FIX: TR_FIRE_LAG (int, default 0 = today byte-for-byte) in the shared
fire_tracker, applied by both movers at spawn: x = origin + dir*speed*lag.
The deadline needs no separate change — both movers derive it from the ghost's
own position, so a correct position gives a correct deadline.
WITH IT: displacement 19.06 -> 5.37 px mean (the residue is the enemy's own
<=8 px scan staleness) and the deadline error 0.99 -> 0.06 ticks.

Guards: test_tfil_commit_env 77 -> 87 checks (default golden parity, exact
n-step back-date, deadline shortens by exactly lag, junk/negative degrade to 0,
reaped exactly one tick earlier); test_env_report + test_env_dotenv green.
TR_FIRE_LAG registered in env_report + knownEnvNames + .env.example +
docs/env_reference.md. Live A/B pre-registered in docs/movement_campaign.md
(Batch 8) with its MDE stated up front; arms tools/ab/arms_fire_lag.txt.
TR_FIRE_DIAG gains a per-round ROUND line (the tick->getTurn anchor) and a
per-spawn SPAWN line (the ghost's drawn position).
2026-09-26 23:08:57 +02:00
30 changed files with 25344 additions and 74 deletions
+552 -45
View File
@@ -10,19 +10,187 @@
# Every value below IS the built-in default, so running the bot with this file # Every value below IS the built-in default, so running the bot with this file
# is identical to a clean run with no file at all. Delete a line (or comment it # is identical to a clean run with no file at all. Delete a line (or comment it
# out with #) and that knob falls back to the built-in default. An inline # out with #) and that knob falls back to the built-in default. An inline
# `# comment` after a value is fine — the loader strips it. # `# comment` after a value is fine — the loader strips it (a `#` that follows
# a space starts the comment; a `#` glued to the value, like `x#y`, is data).
# #
# A few switches are PRESENCE-only (`existsEnv`): for those, OFF means the line # These values mirror the current defaults, not a frozen snapshot of one commit.
# is absent, so they are shown commented out. Writing `=0` would still turn them # Regenerate this file whenever a default changes, or it will start lying.
# ON.
# ═════════════════════════════════════════════════════════════════════════════
# 1. ONE EXPERIMENT, END TO END
# ═════════════════════════════════════════════════════════════════════════════
# #
# SNAPSHOT of the code at commit 5e32ec1. Regenerate this file whenever a default # Pick ONE knob. Here the example is TR_TFIL_ARRIVE_TICKS, but the shape is the
# changes, or it will start lying. # same for every knob in this file.
#
# # 1. write the arm. In ModularBot_garage/.env, change ONE line:
# # TR_TFIL_ARRIVE_TICKS=15.0
# # A per-run file is better than editing .env, because it is how you
# # GUARANTEE the arm: whatever else is in .env or in your shell, this
# # file is the one that is applied.
# cat > /tmp/arm_arrive15.env <<'EOF'
# TR_MOVEMENT=tfil
# TR_TFIL_ARRIVE_TICKS=15.0
# EOF
#
# # 2. RESTART THE BOT. Env is read ONCE, at boot (module init). Editing
# # .env while the bot runs changes nothing. There is no live reload.
# # (Two exceptions read lazily on first use: TR_PATTERN_RAD_* and a few
# # TR_TMHORIZON_* — do not rely on either.)
# cd ModularBot_garage && ./ModularBot.sh # or restart the GUI
#
# # 3. CONFIRM IT TOOK EFFECT, before you read a single result line.
# # `source: .env` = your file was applied. `source: default` = it was not.
# grep '^\[env\]' /tmp/modularbot_stdout.log | grep -E 'env file|ARRIVE_TICKS'
# # [env] env file: /tmp/arm_arrive15.env (source: TR_ENV_FILE)
# # [env] TR_TFIL_ARRIVE_TICKS = 15.0 (source: .env)
# # A value showing `(source: default)` means YOUR FILE NEVER REACHED THE BOT.
#
# # 4. point at the file instead of copying it into .env:
# TR_ENV_FILE=/tmp/arm_arrive15.env ./out/ModularBot
# ./out/ModularBot --env-file /tmp/arm_arrive15.env
# # This is what an A/B run does: one frozen binary, one env file per arm.
# # A file you ASKED for and that does not exist stops the bot with an error
# # (it never silently falls back); a missing default .env is silent.
#
# # 5. the module inventory, when you want to know what is on at all:
# grep '^\[modules\]' /tmp/modularbot_stdout.log
#
# ═════════════════════════════════════════════════════════════════════════════
# 2. SAFE TO EXPERIMENT WITH RIGHT NOW
# ═════════════════════════════════════════════════════════════════════════════
#
# The honest list is SHORT. After the recent campaign most experimental knobs
# are either never live-tested or already measured null/harmful, and this file
# says so on every one of them. These four are safe in the sense that they
# either cannot change a decision, or are the ones a measurement actually
# supports.
#
# TR_GEO_DEBUG=on Draw-only: the candidate-tile geometry overlay.
# Watch: the circle on the two tanks and each
# heading line. Good: you can SEE the tile the
# picker chose. Cannot change any decision.
# TR_VBULLET_DEBUG=1 Draw-only: each admitted gun's virtual bullets.
# TR_VBULLET_DEBUG_GUN=all
# Watch: travelled path, aim ring, miss vector.
# Good: you can see the signal the selector ranks
# on. Also draw-only. Needs a gun in the rack.
# TR_TFIL_DIAG=on Observability only, on the tfil mover. Fills the
# per-pick LOSS HISTOGRAM. Watch: the tfil pick log
# line. Good: the sReach/sCool/sSafe/sCand counts
# tell you where tiles are lost. Provably does not
# move a single command (guard-tested).
# TR_MOVEMENT=tfil The long-shipped mover, as an explicit override.
# Watch: nothing to compare against — it is the
# same engine you had before j119. Good: you are
# reproducing an older, documented behaviour. Only
# do this together with the tfil knobs below.
#
# Anything else on this list is a MEASUREMENT, not a free change: read its
# STATUS line before you type it.
#
# ═════════════════════════════════════════════════════════════════════════════
# 3. ALREADY REJECTED OR MEASURED NULL — WITH THE NUMBER
# ═════════════════════════════════════════════════════════════════════════════
#
# TR_TFIL_GEO_MODE=both-rej REJECTED live, 420 battles, 15-opponent panel.
# TR_TFIL_GEO_TAU=60 damage/run -8.83, p(sign-flip) = 0.0061,
# Wilcoxon p = 0.011. Round wins null.
# Docs: docs/tfil_geo_ab.md. DO NOT re-run it.
# TR_RAM_FLOOR_ENERGY=5 CLEAN NULL, 900 battles, 450 runs/arm.
# -0.018 wins/run, p(sign-flip) = 0.7676, under
# a 0.1420 wins/run MDE. Docs:
# docs/ram_floor_exhaustion_ab.md. DO NOT re-run
# it — the mechanism fired on 0.04% of ticks, so
# more runs buy resolution on an effect that is
# not there.
# TR_RAM_FLOOR_ENERGY=10/20 MEASURED COSTLY OFFLINE (24.7% of ticks blocked
# at 20) and the live zone they guard is almost
# empty: only 4.8% of shots are ever taken below
# 10 energy. Do not go above 5.
# TR_TMHORIZON_WINDOW=150 MEASURED HARMFUL live: 26.5% round wins vs 49.0%
# for the shipped rack, p = 0.036. Keep 0.
# TR_POWER_POLICY=0 MEASURED HARMFUL live: real hit rate 10.61% ->
# 7.88%, p = 0.0012. Keep it on.
# TR_TFIL_HEAT_TIME=1 MEASURED HARMFUL live at every tau tried
# (3/5/9/15): tau15 alone is -22 damage/run,
# p = 0.046. Keep it off.
# TR_MOVEMENT=tfil_ring MEASURED: round wins 16/49 -> 6/49, p = 0.012. A
# glass cannon — best live hit rate of anything
# measured, half the survival. Do not ship.
# TR_RACK_* (the full rack) MEASURED NEGATIVE VALUE: Pattern ALONE beats
# the full 13-gun rack, p = 0.0012. Adding guns
# costs rounds.
# TR_TFIL_TURN_BIAS=9 LIVE NULL: +0.15 wins/run, p(sign) = 0.244, under
# TR_TFIL_TURN_REF_DEG=0 a 0.30 MDE; 300 battles. Docs:
# docs/movement_campaign.md (j145).
# TR_RAM_OPPORTUNITY=on MEASURED not to convert: 0/59
# opportunity -> contact. The finisher ram is the
# only path that converts, and it is always on.
#
# READ THIS BEFORE YOU TRUST ANY "null" ABOVE. A null only excludes an
# effect at or above the MDE that run resolved. The 15-opponent panel at
# 14 runs/arm resolves ~0.17 wins/run and ~7.65 damage/run; the j163 run
# resolved 0.1420 wins/run. So "clean null" here means "no effect >= that
# size", NOT "no effect".
#
# ═════════════════════════════════════════════════════════════════════════════
# 4. PRESENCE-GATED KNOBS — FOR THESE, `NAME=0` TURNS THE FEATURE ON
# ═════════════════════════════════════════════════════════════════════════════
#
# grep -rn 'existsEnv' ModularBot_garage/src common_libs | grep -v /tests/
#
# The knobs below are read with `existsEnv`, not by value. OFF means THE LINE
# IS ABSENT. Writing `TR_POWER_LOG=0` does not disable the power log — it
# ENABLES it, because 0 is a perfectly good value for a knob nobody reads.
# That is why they are all shown COMMENTED OUT in this file: there is no
# "off" spelling for them, only absence. To disable one, DELETE its line.
#
# TR_POWER_LOG one line per power-decision CHANGE
# TR_RAM_LOG one line per ram start/stop, with the reason
# TR_MOVEMENT_LOG movement band / range-class changes
# TR_STRAFE_LOG one line per strafe tile pick
# 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
#
# The VALUE-based switches are the opposite: 0 / false / no / off really
# disable them, and anything else enables them. Those are TR_RESULT_LOG,
# TR_TMHORIZON_LOG, TR_TMHORIZON_ACCURVE, TR_TMHORIZON_RESET_ON_TARGET,
# TR_LEADGAIN_LOG, TR_LEARNED_LOG, TR_LEARNED_GLOBAL, TR_LEARNED_REAL_EVENTS,
# TR_POWER_POLICY, TR_POWER_FINISH_KILL, TR_RAM_OPPORTUNITY, TR_RAM_PLAN,
# TR_FIRE_FIX, TR_STRAFE_FIRE_FIX, TR_STRAFE_ESCAPE, TR_STRAFE_HEAT_GRID,
# TR_TFIL_HEAT_TIME, TR_TFIL_PILLAR_ON, TR_TFIL_DIAG, TR_TFIL_NO_REV,
# TR_TFIL_HOLD_WHEN_TRAPPED, TR_TFIL_COMMIT_ARRIVAL,
# TR_TFIL_RING_COMMIT_ARRIVAL, GUN_SELECTOR_POOL, GUN_VBULLET_ADMIT_ONLY,
# TR_VBULLET_DEBUG, TR_GEO_DEBUG, TR_DEBUG_DRAW, TR_ENV_REPORT.
# (TR_TFIL_DIAG / _NO_REV / _HOLD_WHEN_TRAPPED / _COMMIT_ARRIVAL /
# _RING_COMMIT_ARRIVAL are read by value in the source; the boot report
# labels them by presence, which only affects the "(source: ...)" line, never
# the value.)
# ── movement ───────────────────────────────────────────────────────────────── # ── movement ─────────────────────────────────────────────────────────────────
# Which dodging engine runs: tfil, tfil_ring, strafe, surf or learned. # Which dodging engine runs. VALUE: strafe (default) | tfil | tfil_ring |
# surf | learned. Any unrecognised value silently runs `tfil`, with no warning.
# WHAT: the engine that picks the dodge tile every tick.
# STATUS: DEFAULT = strafe, the measured champion — 300 fresh battles,
# +0.30 wins/run over tfil, 95% CI [+0.02, +0.58], sign-flip p = 0.045
# (docs/movement_campaign.md, "Fresh-data confirmation (gate v2)").
# tfil_ring is measured harmful (round wins 16/49 -> 6/49, p = 0.012).
# surf and learned are wired and measured, and neither beats strafe.
# GOTCHA: a typo does not warn. `TR_MOVEMENT=straf` runs tfil.
# TRY: TR_MOVEMENT=tfil -> the long-shipped engine; the `[env]` block then
# reads `move.effective = tfil`. Pairs with the TR_TFIL_* knobs below.
TR_MOVEMENT=strafe TR_MOVEMENT=strafe
# Whole-engine on/off switches. 0 removes an engine from the TR_MOVEMENT choices. # Whole-engine on/off switches. VALUE: on | 0/false/no/off. 0 removes an engine
# from the TR_MOVEMENT choices; the effective engine then falls back to the
# first still-enabled one, and tfil if all are off (there is no "no movement").
TR_MODULE_MOVE_TFIL=on # the long-shipped "floor is lava" engine TR_MODULE_MOVE_TFIL=on # the long-shipped "floor is lava" engine
TR_MODULE_MOVE_TFIL_RING=on # the same, re-weighted toward a target range TR_MODULE_MOVE_TFIL_RING=on # the same, re-weighted toward a target range
TR_MODULE_MOVE_STRAFE=on # perpendicular strafe with sign-flip reversals TR_MODULE_MOVE_STRAFE=on # perpendicular strafe with sign-flip reversals
@@ -30,7 +198,17 @@ TR_MODULE_MOVE_SURF=on # wave surfing, steered by the GuessFactor
TR_MODULE_MOVE_LEARNED=on # learned per-state danger field TR_MODULE_MOVE_LEARNED=on # learned per-state danger field
# ── gun rack: which guns the bot may choose (off | 1v1 | melee | both) ─────── # ── gun rack: which guns the bot may choose (off | 1v1 | melee | both) ───────
# The shipped rack is Pattern only. Turn a gun on with `both`. # WHAT: which guns the selector is allowed to fire. The shipped rack is
# PATTERN only; every other gun is `off`.
# VALUES: off | 1v1 | melee | both. Aliases: any/empty->both, single/lock->1v1,
# only1v1->1v1, multi/onlymelee->melee, none/disabled/disable->off.
# STATUS: MEASURED — the full 13-gun rack is WORSE than Pattern alone,
# p = 0.0012. Do not re-enable guns to "improve" the bot.
# GOTCHA: an UNRECOGNISED value warns on stderr and falls back to `both`, i.e.
# a typo ADDS the gun back into the rack. It never turns one off.
# TRY: TR_RACK_PATTERN=off -> nothing admitted in 1v1; the selector falls
# back to the full rack, so do not ship this. Real use is a PAIR:
# TR_RACK_PATTERN=off + TR_RACK_HEADON=both -> exactly one gun fires.
TR_RACK_PATTERN=both # the only admitted gun; both = usable in 1v1 and melee TR_RACK_PATTERN=both # the only admitted gun; both = usable in 1v1 and melee
TR_RACK_HEADON=off # aim straight at the target, no lead TR_RACK_HEADON=off # aim straight at the target, no lead
TR_RACK_LINEAR=off # constant-angle linear aim TR_RACK_LINEAR=off # constant-angle linear aim
@@ -48,16 +226,24 @@ TR_RACK_TMSELECT=off # Tsetlin machine used as the shot selector
TR_RACK_TMPATTERN=off # Tsetlin machine used as a pattern matcher TR_RACK_TMPATTERN=off # Tsetlin machine used as a pattern matcher
TR_RACK_TMHORIZON=off # horizon Tsetlin automata gun TR_RACK_TMHORIZON=off # horizon Tsetlin automata gun
TR_RACK_LEADGAIN=off # per-range-band learned lead-gain corrector TR_RACK_LEADGAIN=off # per-range-band learned lead-gain corrector
# Give every admitted gun a fixed share of the turns instead of ranking them, # Give every named gun a fixed share of the turns instead of ranking them.
# e.g. TR_RACK_SHARE=PATTERN:60%,HEADON:40%. Empty = the ranking selector. # e.g. TR_RACK_SHARE=PATTERN:60%,HEADON:40% (GUN:weight, comma separated,
# the % sign is optional). Empty = the ranking selector. GOTCHA: every gun you
# name must ALSO be admitted by its own TR_RACK_<GUN> line, or the share is
# refused with a loud `[gun_harness] ERROR` and the ranking selector is used
# instead. In the shipped rack that means PATTERN and nothing else.
TR_RACK_SHARE= TR_RACK_SHARE=
# Drop whole guns by rack id (comma separated, e.g. 16). Empty = keep them all. # Drop whole guns by rack id (comma separated, e.g. 16). Empty = keep them all.
# Ids: 0 HEADON 1 LINEAR 2 TSETLIN 3 CIRCULAR 4 GUESSFACTOR 5 PATTERN
# 6 WALLBOUNCE 7 ACCEL 8 STOPSHOT 9 DISPLACE 10 AVGLEAD 11 DECAYGF 12 KNN
# 13 TMSELECT 14 TMPATTERN 15 TMHORIZON 16 LEADGAIN. A disabled gun never even
# spawns a virtual bullet, so its fitness stays empty and it cannot be picked.
GUN_RACK_DISABLE= GUN_RACK_DISABLE=
# ── gun selector (which admitted gun fires this tick) ─────────────────────── # ── gun selector (which admitted gun fires this tick) ───────────────────────
GUN_VBULLET_METRIC=path # fitness measure: path (time-to-collision) or point GUN_VBULLET_METRIC=path # fitness measure: path (time-to-collision) or point
GUN_SELECTOR_MODE=relative # rank guns against the incumbent (absolute = vs a fixed bar) GUN_SELECTOR_MODE=relative # rank guns against the incumbent (absolute = vs a fixed bar)
GUN_SELECTOR_WINDOW=100 # ticks of virtual-bullet history behind the fitness GUN_SELECTOR_WINDOW=100 # ticks of virtual-bullet history behind the fitness (clamped 1..100)
GUN_SELECTOR_MINOBS=50 # observations a gun needs before it may compete GUN_SELECTOR_MINOBS=50 # observations a gun needs before it may compete
GUN_SELECTOR_TIE=0.2 # relative margin two guns must differ by to count as separated GUN_SELECTOR_TIE=0.2 # relative margin two guns must differ by to count as separated
GUN_SELECTOR_FLOOR=0.25 # fitness fraction of the peak below which a band is unsafe GUN_SELECTOR_FLOOR=0.25 # fitness fraction of the peak below which a band is unsafe
@@ -73,6 +259,8 @@ GUN_SELECTOR_SEED=
# ── power / energy policy ──────────────────────────────────────────────────── # ── power / energy policy ────────────────────────────────────────────────────
# Every rule below only ever CAPS power; the gun's own preference is the ceiling. # Every rule below only ever CAPS power; the gun's own preference is the ceiling.
# The measured case for keeping it on: TR_POWER_POLICY=0 drops the real hit rate
# from 10.61% to 7.88%, p = 0.0012.
TR_POWER_POLICY=on # 0 = no cap at all (the control arm) TR_POWER_POLICY=on # 0 = no cap at all (the control arm)
TR_POWER_FAR_DIST=200.0 # px; past this the enemy is in the bad-chances zone TR_POWER_FAR_DIST=200.0 # px; past this the enemy is in the bad-chances zone
TR_POWER_FAR_CAP=1.0 # cap applied past TR_POWER_FAR_DIST TR_POWER_FAR_CAP=1.0 # cap applied past TR_POWER_FAR_DIST
@@ -85,7 +273,7 @@ TR_POWER_ENERGY_MAX=3.0 # the cap at/above ENERGY_HI; 3.0 means effectively unc
TR_POWER_FINISH_KILL=on # cap to the smallest bullet that still kills a low-energy enemy TR_POWER_FINISH_KILL=on # cap to the smallest bullet that still kills a low-energy enemy
# ── radar ──────────────────────────────────────────────────────────────────── # ── radar ────────────────────────────────────────────────────────────────────
#TR_RADAR_FORCE_SPIN=1 # presence-only: force the old full 360 spin instead of 1v1 lock #TR_RADAR_FORCE_SPIN=1 # PRESENCE-only: force the old full 360 spin instead of 1v1 lock
TR_RADAR_SCAN_LOG_PATH=/tmp/radar_scan_log.jsonl # where the per-tick scan log is written TR_RADAR_SCAN_LOG_PATH=/tmp/radar_scan_log.jsonl # where the per-tick scan log is written
# ── ram ────────────────────────────────────────────────────────────────────── # ── ram ──────────────────────────────────────────────────────────────────────
@@ -98,30 +286,274 @@ 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_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_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_PLAN_HITRATE=0.05 # pooled virtual hit rate below which the gun duel counts as failing
# WHAT: at or below this much SELF energy we start no new shot, keeping a
# reserve for the ram. Units: energy points.
# VALUES: energy, 0.0 = off (today's behaviour). Any float parses.
# STATUS: =5 is a CLEAN NULL and must not be re-run: -0.018 wins/run,
# p(sign-flip) = 0.7676, under a 0.1420 wins/run MDE, 900 battles. The
# mechanism fired on 0.04% of ticks, ~200x less than the offline ruler
# predicted. Docs: docs/ram_floor_exhaustion_ab.md.
# GOTCHA: 0.0 genuinely disables it, but any POSITIVE value arms it, and the
# higher it goes the more of the low-energy zone it blocks (20 blocked 24.7%
# of all ticks offline).
# TRY: TR_RAM_FLOOR_ENERGY=5 -> already measured, do not re-run. If you
# want to see it at all, that is the only defensible value; anything
# higher is worse by the offline ruler and by the live null.
TR_RAM_FLOOR_ENERGY=0.0
# WHAT: the last-scanned enemy energy at or below this switches the ram decider
# into exhaustion mode (they are out of ammo, we are not). Units: energy.
# VALUES: energy, 0.0 = off. Any float parses.
# STATUS: DEFAULT-OFF, never live-tested. It is the second half of the j160 pair;
# the other half (FLOOR_ENERGY) measured null, so the pair is not a win.
# GOTCHA: 0.0 is the only safe "off". The always-on finisher (enemy < 20 energy
# and we are healthier, within 300 px) already covers most of this; setting
# this to 20 makes the two overlap.
# TRY: TR_RAM_ENEMY_ENERGY=10 -> ram once a scan shows them at <= 10.
# Watch: the `[ram] ON rrExhausted` line. Good: the reason field says
# `exhausted` rather than `finisher`. Needs TR_RAM_LOG=1 to see it.
TR_RAM_ENEMY_ENERGY=0.0
# ── movement internals: tfil (the floor-is-lava field) ────────────────────── # ── 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_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_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_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_RANGE_K=60.0 # px; how fast the weight falls off outside the band
# WHAT (tfil_ring only): hold the committed dodge tile until we are actually ON
# it, instead of the fixed dwell. VALUE: on | 0/off.
# STATUS: DEFAULT-OFF, never live-tested. Ported to the ring fork in j165; the
# tfil original (TR_TFIL_COMMIT_ARRIVAL) has a real but under-powered live
# result — see that line. Neither is a proven win.
# GOTCHA: the ring mover is NOT the default and is not shippable (round wins
# 16/49 -> 6/49, p = 0.012), so this only matters while you are measuring it.
# TRY: TR_MOVEMENT=tfil_ring + TR_TFIL_RING_COMMIT_ARRIVAL=1
# -> the `[env]` block reads TR_TFIL_RING_COMMIT_ARRIVAL = on.
TR_TFIL_RING_COMMIT_ARRIVAL=off
# WHAT (tfil_ring only): below this self speed (px/tick), a mid-flight switch
# may not turn the bot around. UNITS: px/tick (top speed is 8).
# STATUS: DEFAULT-OFF (0.0), never live-tested. The tfil original is in the
# j144 recommendation below.
# TRY: TR_TFIL_RING_NOREV_SPEED=4 -> 4 px/tick is half of top speed, the
# value j144 used on tfil. Accepts any float >= 0.
TR_TFIL_RING_NOREV_SPEED=0.0
TR_TFIL_CORRIDOR_HEAT=10.0 # lava painted per corridor-overlapping tile TR_TFIL_CORRIDOR_HEAT=10.0 # lava painted per corridor-overlapping tile
# WHAT: cap the bullet-danger corridor at `bullet speed x this many ticks`,
# instead of running it all the way to the arena wall. UNITS: ticks.
# VALUES: ticks, 0.0 = off (corridor reaches the wall, today's behaviour).
# Any float parses; negatives are treated as 0.
# STATUS: DEFAULT-OFF, never live-tested, and NOT recommended. Commit 5e213df
# (j148) shipped it with no measurement at all: no battle, no offline ruler.
# The neighbouring j146 field-shape sweep, which is the closest evidence,
# says halving the corridor restores a safe tile set offline (filter-broken
# 63.5% -> 30.4%) and is a LIVE NULL on outcome.
# GOTCHA: value knob - 0.0 genuinely disables it. It is NOT a presence knob.
# TRY: TR_TFIL_CORRIDOR_TICKS=20 -> a 20-tick look-ahead. Bullet speed is
# 20 - 3*power, so over the shipped power bins 1.0..3.0 that is
# 17.0..11.0 px/tick = 340..220 px of corridor, instead of the whole
# wall. Watch: in the debug overlay the corridor stops short of the
# wall. Only bind it to tfil; strafe has its own knob below.
TR_TFIL_CORRIDOR_TICKS=0.0
TR_TFIL_WALL_HOTNESS=15.0 # peak heat painted on tiles next to a wall TR_TFIL_WALL_HOTNESS=15.0 # peak heat painted on tiles next to a wall
TR_TFIL_WALL_RADIANCE=10.0 # how fast wall heat falls off with distance TR_TFIL_WALL_RADIANCE=10.0 # how fast wall heat falls off with distance
TR_TFIL_BULLET_CORE=10.0 # tfil only: lava per bullet-overlapping tile (== PathDangerThreshold, so a bullet is never hot on its own) # WHAT: how hot a bullet paints the tile it is sitting on. UNITS: lava points
TR_TFIL_BULLET_AURA=5.0 # tfil only: lava for the bullet's aura ring tiles # on the picker's heat scale.
TR_TFIL_TILE_REPLAN=self # self | enemy | off: when a dodge commitment is cancelled # VALUES: any float >= 0. 10.0 is exactly the danger threshold, so a bullet is
TR_TFIL_COMMIT_TICKS=15 # ticks to commit to a dodge point before replanning # never dangerous on its own; 20.0 puts one bullet's own tile over it.
TR_TFIL_NO_REV=off # on = never reverse direction inside a corridor # STATUS: live-tested ONLY as part of the j146 five-shape batch, 375 battles:
# core 10 -> 20 was a live NULL on both primaries, and the arm that combined
# it with no corridor/wall field LOST 13.45 damage/run, p(sign-flip) =
# 0.0095. The middle shape (corridor 10 / wall 15/5 / core 20 / aura 10) is
# also a null. Do not retune the shape on one axis.
# GOTCHA: changing CORE alone with the shipped corridor (10) and wall (15) is
# the one combination j146 did NOT isolate.
# TRY: TR_TFIL_BULLET_CORE=20 + TR_TFIL_BULLET_AURA=10 -> the j146
# "middle" bullet heat. Only meaningful as part of the whole middle
# shape; on its own it is a null at best.
TR_TFIL_BULLET_CORE=10.0
# WHAT: the heat painted on the bullet's AURA ring (the tiles around it), as
# opposed to the core tile. UNITS: lava points.
# VALUES: any float >= 0; 5.0 is half the core's 10.0.
# STATUS: live-tested only inside the j146 batch — null, see BULLET_CORE.
# TRY: TR_TFIL_BULLET_AURA=10 -> doubles the aura heat; pairs with
# TR_TFIL_BULLET_CORE=20 in the j146 "middle" shape.
TR_TFIL_BULLET_AURA=5.0
# WHAT: when a dodge commitment is cancelled, replan from whose state?
# self = today's behaviour. VALUE: self | enemy | off.
# STATUS: `self` is the shipped default; `off` is the pre-j144 behaviour and is
# exactly what an earlier A/B (cc11ede arm D) tried and could not measure.
# TRY: TR_TFIL_TILE_REPLAN=off -> the tile-crossing cancel stops firing.
# Accepts self, enemy, off, none, never, 0, false (case-insensitive);
# anything else falls back to self with no warning.
TR_TFIL_TILE_REPLAN=self
TR_TFIL_COMMIT_TICKS=15 # ticks to commit to a dodge point before replanning (min 1)
# WHAT: hold the committed dodge tile until we are actually ON it, instead of
# letting our own tile-boundary crossing cancel it. VALUE: on | 0/off.
# STATUS: LIVE, 600 battles in two blocks (j144). Mechanism is real and
# confirmed: incoming hit rate 18.07% -> 14.92%, sign-flip p = 0.0013, damage
# taken -24.57/run. Outcome is NOT distinguishable: +0.28 wins/run,
# p(sign) = 0.0574 against a 0.31 MDE. The ledger's answer for your own .env
# is YES to this knob, and NO to COMMIT_MARGIN.
# GOTCHA: only meaningful with TR_MOVEMENT=tfil; the default strafe mover has
# no tfil commitment. With TR_TFIL_COMMIT_ARRIVAL on, COMMIT_TICKS becomes a
# MINIMUM dwell, not a maximum.
# TRY: TR_MOVEMENT=tfil + TR_TFIL_COMMIT_ARRIVAL=1
# -> `[env] TR_TFIL_COMMIT_ARRIVAL = on (source: .env)`.
TR_TFIL_COMMIT_ARRIVAL=off
# WHAT: leave the committed tile only if the best alternative is at least this
# much COOLER on the same pathMaxHeat scale the picker uses. UNITS: lava
# points; 10.0 is exactly one PathDangerThreshold level.
# VALUES: any float >= 0; 0.0 = off = today's behaviour (any improvement ends
# the commitment).
# STATUS: LIVE, j144, 600 battles, as the `arrive_hyst` arm. It is the WEAKEST
# of the three j144 arms on both primaries (+0.19 wins/run, p = 0.092) and
# the ledger's explicit answer is: YES to COMMIT_ARRIVAL, NO to this.
# GOTCHA: it is a hysteresis, so it makes the bot commit harder; a large value
# with a busy field means it holds a tile that is no longer the best one.
# TRY: TR_TFIL_COMMIT_MARGIN=10 -> the j144 value. Already measured, and
# the answer was no. Use it only to isolate COMMIT_ARRIVAL, not as an
# improvement.
TR_TFIL_COMMIT_MARGIN=0.0
# WHAT: refuse a candidate tile we cannot REACH inside this many ticks. The
# bot's top speed is 8 px/tick, so 1 tick = 8 px. UNITS: ticks.
# VALUES: ticks, 0.0 = off = today's uniform draw over every safe tile.
# STATUS: DEFAULT-OFF, never live-tested. Found by the j151 offline ruler: 65%
# of tfil picks outran the 15-tick commitment and the chosen tile was reached
# only 6.5% of the time.
# GOTCHA: it is a HARD bound, not a preference. If every safe tile is out of
# range the pool empties and the code falls back to today's full pool, so it
# can never starve the draw — it can also silently do nothing.
# TRY: TR_TFIL_ARRIVE_TICKS=15 -> refuse anything farther than 15*8 = 120
# px. That is deliberately the same length as TR_TFIL_COMMIT_TICKS,
# i.e. "only pick a tile you can still reach while you hold it".
TR_TFIL_ARRIVE_TICKS=0.0
# WHAT: when the safe-tile set is EMPTY (no tile under the heat threshold),
# hold position for one tick instead of promoting the 2 least-hot blocked
# tiles. VALUE: on | 0/off.
# STATUS: DEFAULT-OFF, never live-tested. j153 wrote the proposal and the A/B
# design; it was NOT run (docs/tfil_hold_when_trapped_ab.md is titled
# "NOT RUN"). Four mechanism-positive / outcome-null results preceded it, so
# a null was always the likely answer.
# GOTCHA: it can never latch — the pick site only runs on a replan tick, so the
# next tick re-reads the field from scratch. The gun is untouched: a held
# tick still fires exactly like every other tick.
# TRY: TR_TFIL_HOLD_WHEN_TRAPPED=on -> the tfil pick log shows `hold`
# instead of `promote` on a trapped tick. Needs TR_TFIL_COMMIT_LOG set.
TR_TFIL_HOLD_WHEN_TRAPPED=off
# WHAT: the BOUNDED version of the knob above: while the safe set stays empty,
# hold for at most this many ticks per empty streak. UNITS: ticks (integer).
# VALUES: integer >= 0; 0 = off = today's promote-the-2 fallback.
# STATUS: DEFAULT-OFF, never live-tested (j154, no battle).
# GOTCHA: the budget is DERIVED, not guessed: the enemy fires two 3.0-power
# shots 16 ticks apart, so 16 is the first window that admits its second
# shot. The hold is released the tick a safe tile exists, the counter resets
# when one is taken, and a tracked bullet reaching us within min(N,16) ticks
# overrides the hold outright (panic release).
# TRY: TR_TFIL_HOLD_MAX_TICKS=16 -> the derived budget. Any integer parses;
# a junk value degrades to 0 (off).
TR_TFIL_HOLD_MAX_TICKS=0
# WHAT: the hard heat filter on a candidate's path. UNITS: lava points.
# VALUES: any float >= 0. Lava is QUANTISED to 5, so the only values that
# change anything are 10, 15 and 20: 10-14 admits exactly what 10 admits.
# STATUS: 10.0 is the shipped const; j150 made it sweepable so the offline
# ruler could move it. Never live-tested as a knob.
# GOTCHA: raising it does not make the bot braver, it makes the safe set
# smaller and more of the picks forced. j146's `nofield` arm is the warning:
# -13.45 damage/run, p = 0.0095.
# TRY: TR_TFIL_DANGER_THRESHOLD=15 -> one quantisation step stricter. The
# picker log then reports fewer safe candidates per pick.
TR_TFIL_DANGER_THRESHOLD=10.0
# WHAT: fill the per-pick LOSS HISTOGRAM (TfilLoss*): how many tiles die at
# each picker stage. VALUE: on | 0/off (read by value, not by presence).
# STATUS: DIAGNOSTIC ONLY, j150. Provably does not move a single move command
# (byte-for-byte, guard-tested). Never measured on outcome, by design.
# GOTCHA: nothing. It is the safest tfil knob in this file.
# TRY: TR_TFIL_DIAG=on -> the tfil pick log gains the per-stage counts.
TR_TFIL_DIAG=off
# WHAT: shape the tile DRAW over the heat-filtered pool by geometry (how far
# the tile sits from where we are already going) as well as by heat.
# VALUES: off | turn | dist | both, each optionally suffixed -soft (default),
# -topk or -rej. `distance`=dist, `rejection`=rej are also accepted.
# Anything unrecognised, and `off`, means OFF — today's uniform draw.
# The form is NOT printed by the boot report, only the dim.
# STATUS: DEFAULT-OFF. THE ONE LIVE-TESTED ARM IS `both-rej` + TAU=60 AND IT
# WAS REJECTED: -8.83 damage/run, p(sign-flip) = 0.0061, Wilcoxon p = 0.011,
# 420 battles, 15 opponents. Round wins null. Offline it did exactly what was
# predicted (arrivals 4.5% -> 29.4%) and that is WHY it is bad: the bot ends
# up 26 px further out on 15/15 opponents and deals less. See
# docs/tfil_geo_ab.md. DO NOT re-run both-rej.
# GOTCHA: this knob ALONE is inert — TR_TFIL_GEO_TAU=0.0 means the weighting is
# off whatever the mode says. And it needs TR_MOVEMENT=tfil.
# TRY: TR_TFIL_GEO_MODE=both-soft + TR_TFIL_GEO_TAU=45
# -> the j152 offline headline (arrivals 4.5% -> 16.9%, top-tile share
# only 7.0% -> 8.6%). Still never live-tested, and the family has one
# measured loss, so treat it as a hypothesis.
TR_TFIL_GEO_MODE=off
# WHAT: the geometric cost scale, in degrees. 0.0 = off, i.e. exactly today's
# uniform draw. UNITS: degrees. VALUES: any float >= 0.
# STATUS: never live-tested with a mode other than off. The one live arm used
# TAU=60 and lost (see GEO_MODE).
# GOTCHA: TAU is IGNORED unless GEO_MODE is not off. A big TAU with mode=off
# looks like it is doing something and is not.
# TRY: TR_TFIL_GEO_TAU=45 -> 45 degrees of turn cost. Pairs with
# TR_TFIL_GEO_MODE=both-soft; on its own it changes nothing.
TR_TFIL_GEO_TAU=0.0
# WHAT: inside a corridor, never pick a tile in the reverse direction. VALUE:
# on | 0/false/no/off (read by value, so `=off` really disables it).
# STATUS: never live-tested as a standalone arm. The j144/j145 arms relied on
# the NOREV_SPEED knob instead, which is stricter.
# GOTCHA: soft only — every weight is floored, so the pool can never empty; and
# it overlaps TR_TFIL_NOREV_SPEED, which is the knob that was actually run.
# TRY: TR_TFIL_NO_REV=on -> a 3:1 forward:rearward draw weight inside a
# corridor. Accepts on/1/true/yes to enable, 0/false/no/off to disable.
TR_TFIL_NO_REV=off
TR_TFIL_COMMIT_LOG= # path for the per-commit log; empty = no log TR_TFIL_COMMIT_LOG= # path for the per-commit log; empty = no log
TR_TFIL_COMMIT_ARRIVAL=off # on = hold the dodge tile until we are ON it (not a fixed dwell) # WHAT: while our own speed is below this, a mid-flight target switch may not
TR_TFIL_COMMIT_MARGIN=0.0 # lava an alternative tile must be cooler by before it wins the tile # take a tile more than 90 degrees off the travel direction. UNITS: px/tick
TR_TFIL_NOREV_SPEED=0.0 # px/tick; below this, a mid-flight switch may not turn the bot around # (top speed 8). VALUES: any float >= 0; 0.0 = off = shipped.
TR_TFIL_TURN_BIAS=0.0 # turn TIEBREAK odds ratio among SAFE tiles; 0 = uniform draw as today # STATUS: LIVE, j144/j145, 600 battles. Mechanism confirmed: slow opposite-way
TR_TFIL_TURN_REF_DEG=45.0 # deg; turn below which the tiebreak applies no penalty # mid-flight switches fell 394 -> 64 offline. Outcome not distinguishable on
# its own (+0.12 wins/run, p = 0.39); the ledger recommends 4 in your .env
# together with COMMIT_ARRIVAL, not alone.
# GOTCHA: the pool can never be emptied — with every candidate behind us it
# takes the least-bad turn. 0.0 genuinely disables it.
# TRY: TR_TFIL_NOREV_SPEED=4 -> 4 px/tick = half of top speed, the j144
# value. Watch: fewer >90 deg switches in the tfil pick log.
TR_TFIL_NOREV_SPEED=0.0
# WHAT: turn TIEBREAK odds ratio among the SAFE tiles: a straight-ahead safe
# tile is drawn `1 + bias` times as often as a 180-degree one.
# w = max(1, round(1 + BIAS * (1 - max(0,|turn| - REF)/180)))
# UNITS: dimensionless. VALUES: any float >= 0; 0.0 = uniform draw as today.
# STATUS: LIVE NULL, j145, 300 battles: +0.15 wins/run, p(sign) = 0.244, under
# a 0.30 MDE. Docs: docs/movement_campaign.md. A real but small mechanism
# with an under-powered outcome.
# GOTCHA: the turn cost is NEVER folded into the heat score — the filter stays
# hard, and every weight is floored at 1, so the pool can never empty.
# TRY: TR_TFIL_TURN_BIAS=9 + TR_TFIL_TURN_REF_DEG=0
# -> the j145 arm value (the knee of the offline bias curve: mean
# |turn| -11%, opposite picks -22%). Already measured null.
TR_TFIL_TURN_BIAS=0.0
# WHAT: the turn below which the tiebreak above applies NO penalty. UNITS:
# degrees. VALUES: any float >= 0; 45.0 = today's default.
# STATUS: inert unless TR_TFIL_TURN_BIAS > 0. j145 used 0 with bias 9.
# GOTCHA: on its own this knob does nothing at all.
# TRY: TR_TFIL_TURN_REF_DEG=0 -> penalise every turn, not just the sharp
# ones. Pairs with TR_TFIL_TURN_BIAS=9; alone it is a no-op.
TR_TFIL_TURN_REF_DEG=45.0
# WHAT: paint heat on the virtual centre pillar. VALUE: on | 0/off. The shipped
# field has NO pillar: PillarHotness/PillarRadiance are 0.0.
# STATUS: live-tested, and the OWNER OVERRULED the recommendation to turn it
# back on: `old` (pillar on) was best on damage/run (287) and round wins
# (35/70) but the contrast is INSIDE the MDE (33 damage/run, 1.22 wins/run
# at n=10) and damage taken was 30.8/run higher with the pillar off
# (p = 0.040, not corrected for multiple arms). Decision: pillar stays
# removed. Docs: docs/tfil_heat_pillar_ab.md.
# GOTCHA: this restores an INVENTED hazard with no physical object behind it.
# Reversing that decision needs its own pre-registered A/B.
# TRY: TR_TFIL_PILLAR_ON=1 -> the pre-change field (30/10), for a fair
# A/B against the shipped one. Needs TR_MOVEMENT=tfil.
TR_TFIL_PILLAR_ON=off
TR_TFIL_HEAT_TIME=off # on = index bullet heat by time (flat field when off) TR_TFIL_HEAT_TIME=off # on = index bullet heat by time (flat field when off)
TR_TFIL_HEAT_TAU=9.0 # ticks a tracked bullet's heat lives for TR_TFIL_HEAT_TAU=9.0 # ticks a tracked bullet's heat lives for
TR_TFIL_HEAT_POWER_GAIN=1.0 # scale of the heat a bullet paints, per firepower TR_TFIL_HEAT_POWER_GAIN=1.0 # scale of the heat a bullet paints, per firepower
TR_TFIL_PILLAR_ON=off # on = paint heat on the arena centre, which has no pillar
# ── movement internals: strafe ─────────────────────────────────────────────── # ── movement internals: strafe ───────────────────────────────────────────────
TR_STRAFE_BAND=20.0 # degrees the heading may sit off the perpendicular TR_STRAFE_BAND=20.0 # degrees the heading may sit off the perpendicular
@@ -140,12 +572,42 @@ TR_STRAFE_WALL_BIAS=0.35 # how strongly a tile farther from the wall is preferr
TR_STRAFE_WALL_SAFE=24.0 # px; a wing point never lands nearer than this to a wall TR_STRAFE_WALL_SAFE=24.0 # px; a wing point never lands nearer than this to a wall
TR_STRAFE_ESCAPE=on # the guaranteed wall escape when every candidate is hot TR_STRAFE_ESCAPE=on # the guaranteed wall escape when every candidate is hot
TR_STRAFE_FIRE_FIX=on # strafe's share of the shared TR_FIRE_FIX switch TR_STRAFE_FIRE_FIX=on # strafe's share of the shared TR_FIRE_FIX switch
TR_FIRE_FIX=on # 0 = the shipped previous-energy bullet detector # WHAT: the shared enemy-fire detector. VALUE: on | 0/off. One switch, read by
#TR_FIRE_DIAG=1 # presence-only: per-reading tick/raw/correction trace # every mover; each mover may AND it with its own (TR_STRAFE_FIRE_FIX).
# STATUS: j134 propagated it to all five movers; the per-mover catch table went
# 98.888% -> 100% of enemy fires on a 70-battle corpus.
# GOTCHA: TR_STRAFE_FIRE_FIX is an AND, so turning TR_FIRE_FIX off is enough;
# turning only TR_STRAFE_FIRE_FIX off does not disable strafe's detector.
# TRY: TR_FIRE_FIX=0 -> the shipped previous-energy detector everywhere
# (the control arm for any fire-detector A/B).
TR_FIRE_FIX=on
#TR_FIRE_DIAG=1 # PRESENCE-only: per-reading tick/raw/correction trace
# WHAT: back-date every detected enemy fire by this many ticks when the ghost
# is spawned. UNITS: ticks (integer, clamped at 0). 0 = shipped.
# STATUS: LIVE, j147, 180 battles. The 1-tick detection lag is OURS and was
# measured on 1777 matched ghost spawns (displacement 19.1 px mean on tfil,
# 16.1 on strafe; the arrival deadline the mover reads was 0.99 / 0.77 ticks
# late). With =1 it falls to 5.4 / 9.0 px and 0.06 ticks. The live OUTCOME is
# null on both movers (+2.08 / +3.77 damage/run, every p > 0.6), so it stays 0.
# GOTCHA: a junk or negative value degrades to 0, never a negative back-date.
# TRY: TR_FIRE_LAG=1 -> one bullet step at power 1.0 is 17 px; the ghost is
# born that far downrange. Watch the tfil/strafe pick log's arrival
# deadline, which is what the knob actually corrects.
TR_FIRE_LAG=0
TR_STRAFE_HEAT_GRID=on # draw the whole heat grid; 0 leaves only the chosen tile TR_STRAFE_HEAT_GRID=on # draw the whole heat grid; 0 leaves only the chosen tile
TR_STRAFE_BULLET_CORE=20.0 # strafe's own retune: lava per bullet-overlapping tile TR_STRAFE_BULLET_CORE=20.0 # strafe's own retune: lava per bullet-overlapping tile
TR_STRAFE_BULLET_AURA=10.0 # strafe's own retune: lava for the bullet aura ring TR_STRAFE_BULLET_AURA=10.0 # strafe's own retune: lava for the bullet aura ring
TR_STRAFE_CORRIDOR_HEAT=10.0 # strafe's own retune: lava per corridor tile TR_STRAFE_CORRIDOR_HEAT=10.0 # strafe's own retune: lava per corridor tile
# WHAT: the same corridor LENGTH bound as TR_TFIL_CORRIDOR_TICKS, for the
# strafe mover. UNITS: ticks. VALUES: ticks, 0.0 = to the wall (shipped).
# STATUS: DEFAULT-OFF, never live-tested (commit 5e213df, j148). Same standing
# as the tfil one: no battle, no offline ruler, not recommended.
# GOTCHA: this is the DEFAULT engine's knob. Setting only the tfil one does
# nothing at all, because the shipped movement is strafe.
# TRY: TR_STRAFE_CORRIDOR_TICKS=20 -> 20-tick look-ahead, 220..340 px over
# the shipped power bins, instead of the whole wall. Watch: the strafe
# heat grid's corridor stops before the wall.
TR_STRAFE_CORRIDOR_TICKS=0.0
TR_STRAFE_WALL_HOTNESS=15.0 # strafe's own retune: peak wall heat TR_STRAFE_WALL_HOTNESS=15.0 # strafe's own retune: peak wall heat
TR_STRAFE_WALL_RADIANCE=5.0 # strafe's own retune: wall heat falloff TR_STRAFE_WALL_RADIANCE=5.0 # strafe's own retune: wall heat falloff
@@ -154,7 +616,7 @@ TR_SURF_PREF_DIST=400.0 # px; the wave distance the mover tries to sit at
TR_SURF_DIST_BAND=50.0 # px dead band around it TR_SURF_DIST_BAND=50.0 # px dead band around it
TR_SURF_WALL_MARGIN=48.0 # px kept from the wall when picking a wave point TR_SURF_WALL_MARGIN=48.0 # px kept from the wall when picking a wave point
TR_SURF_RADIAL_FRAC=0.35 # how much of the remaining weight goes to the radial blend TR_SURF_RADIAL_FRAC=0.35 # how much of the remaining weight goes to the radial blend
#TR_SURF_LOG=1 # presence-only: one line per wave-surfing decision #TR_SURF_LOG=1 # PRESENCE-only: one line per wave-surfing decision
# ── movement internals: learned (per-state learned danger) ────────────────── # ── movement internals: learned (per-state learned danger) ──────────────────
TR_LEARNED_DECAY_EVERY=128 # learns between forgetting passes; 0 never forgets TR_LEARNED_DECAY_EVERY=128 # learns between forgetting passes; 0 never forgets
@@ -169,18 +631,20 @@ TR_LEARNED_WALL_MARGIN=48.0 # px kept from the wall
TR_LEARNED_GLOBAL=off # on = ignore the learned state (ablation arm) TR_LEARNED_GLOBAL=off # on = ignore the learned state (ablation arm)
TR_LEARNED_LABEL=histogram # histogram (default) or outcome: what a wave is labelled with TR_LEARNED_LABEL=histogram # histogram (default) or outcome: what a wave is labelled with
TR_LEARNED_REAL_EVENTS=off # on = resolve a wave on the real bullet event, not on energy TR_LEARNED_REAL_EVENTS=off # on = resolve a wave on the real bullet event, not on energy
#TR_LEARNED_LOG=1 # presence-only: one line per learned decision #TR_LEARNED_LOG=1 # VALUE-based (1/on/yes to enable, 0/off to disable)
# ── guns ───────────────────────────────────────────────────────────────────── # ── guns ─────────────────────────────────────────────────────────────────────
# Virtual bullets: the prediction the whole gun selector is built on. # Virtual bullets: the prediction the whole gun selector is built on.
TR_MODULE_VBULLETS=on # 0 = no gun predicts or spawns; the selector falls back to its floor gun TR_MODULE_VBULLETS=on # 0 = no gun predicts or spawns; the selector falls back to its floor gun
# TMH — the horizon Tsetlin automata gun. # TMH — the horizon Tsetlin automata gun. Inert unless TR_RACK_TMHORIZON=both.
TR_TMHORIZON_SHIFT=2.0 # degrees added to the aim; 0 disables the correction arm TR_TMHORIZON_SHIFT=2.0 # degrees added to the aim; 0 disables the correction arm
TR_TMHORIZON_BIG_MULT=1.5 # extra scale applied when the error magnitude is big TR_TMHORIZON_BIG_MULT=1.5 # extra scale applied when the error magnitude is big
TR_TMHORIZON_RESET_ON_TARGET=on # wipe the automata when the target changes TR_TMHORIZON_RESET_ON_TARGET=on # wipe the automata when the target changes
TR_TMHORIZON_NSTATES=64 # automata state count (the inertia it can hold) TR_TMHORIZON_NSTATES=64 # automata state count (the inertia it can hold)
TR_TMHORIZON_WINDOW=0 # samples kept in the sliding window; 0 keeps everything TR_TMHORIZON_WINDOW=0 # samples kept in the sliding window; 0 keeps everything
# e.g. TR_TMHORIZON_WINDOW=30 -> retrain on the 30 most recent samples only.
# MEASURED HARMFUL LIVE at 150: 26.5% round wins vs 49.0%, p = 0.036. Keep 0.
TR_TMHORIZON_RESET_DROP=0.0 # rolling accuracy drop, in points, that forces a retrain TR_TMHORIZON_RESET_DROP=0.0 # rolling accuracy drop, in points, that forces a retrain
TR_TMHORIZON_ACCURVE=off # log the accuracy curve even without the thinking log TR_TMHORIZON_ACCURVE=off # log the accuracy curve even without the thinking log
TR_TMHORIZON_RETRAIN_EVERY=50 # samples between full retrains in sliding mode TR_TMHORIZON_RETRAIN_EVERY=50 # samples between full retrains in sliding mode
@@ -193,7 +657,7 @@ TR_LEADGAIN_MIN_OBS=8 # samples a band needs before its gain is trusted
TR_LEADGAIN_DECAY=250 # samples between count-decay passes TR_LEADGAIN_DECAY=250 # samples between count-decay passes
TR_LEADGAIN_DECAY_FRAC=0.02 # fraction each decay pass takes off every count TR_LEADGAIN_DECAY_FRAC=0.02 # fraction each decay pass takes off every count
TR_LEADGAIN_RESET_ON_TARGET=on # wipe the learned gains when the target changes TR_LEADGAIN_RESET_ON_TARGET=on # wipe the learned gains when the target changes
TR_LEADGAIN_LOG=off # on = one line per gain change TR_LEADGAIN_LOG=off # on = one line per gain change (value-based: 0 disables)
# Kept only so a pre-rename .env does not warn. The gun does not read them. # Kept only so a pre-rename .env does not warn. The gun does not read them.
TR_LEADGAIN_N=32 # NO-OP: the old SBC geometry, no longer used TR_LEADGAIN_N=32 # NO-OP: the old SBC geometry, no longer used
TR_LEADGAIN_NADE=256 # NO-OP: the old ADE count, no longer used TR_LEADGAIN_NADE=256 # NO-OP: the old ADE count, no longer used
@@ -207,6 +671,10 @@ TR_LEADGAIN_SEED=20240921 # NO-OP: the old seed, no longer used
TR_PATTERN_LEN=10 # ticks of movement history used as the search key TR_PATTERN_LEN=10 # ticks of movement history used as the search key
TR_PATTERN_DEPTH=500 # how far back the history scan may reach TR_PATTERN_DEPTH=500 # how far back the history scan may reach
TR_PATTERN_RAD_OFFSET=0.0 # px added to the aim distance; negative aims short TR_PATTERN_RAD_OFFSET=0.0 # px added to the aim distance; negative aims short
# Both RAD_* knobs are read LAZILY, inside predict() — the one place a
# mid-run change can matter, and it still does not. The live aim is bearing
# only, so a purely radial offset is structurally invisible: MEASURED
# byte-identical on bmPath. Docs: docs/env_reference.md §7.
TR_PATTERN_RAD_SCALE=1.0 # multiplier on the whole aim distance TR_PATTERN_RAD_SCALE=1.0 # multiplier on the whole aim distance
# The SBC library (common_libs/bitbrain), not a gun knob. Registered so a config # The SBC library (common_libs/bitbrain), not a gun knob. Registered so a config
@@ -236,32 +704,71 @@ TR_BITBRAIN_DECAY_SHIFT=1 # forgetting strength, counted mode only; 0 disa
#TR_BITBRAIN_RESET_ON_TARGET=on # LEGACY: old name of TR_LEADGAIN_RESET_ON_TARGET #TR_BITBRAIN_RESET_ON_TARGET=on # LEGACY: old name of TR_LEADGAIN_RESET_ON_TARGET
# ── debug overlays (all on top of the gun; they never change a decision) ───── # ── debug overlays (all on top of the gun; they never change a decision) ─────
TR_DEBUG_DRAW=on # master switch for every mover's debugGraphics # WHAT: master switch for every mover's debugGraphics. VALUE: on | 0/off.
TR_GEO_DEBUG=off # draw the shared candidate-tile geometry overlay TR_DEBUG_DRAW=on
TR_VBULLET_DEBUG=off # draw each admitted gun's virtual-bullet paths # WHAT: draw the shared candidate-tile geometry overlay. VALUE: on | 0/off.
TR_VBULLET_DEBUG_GUN= # which gun the overlay draws: empty = the selected one # DRAW ONLY — it cannot change a decision, so it is the safest knob here.
TR_VBULLET_DEBUG_MAX=32 # max virtual bullets drawn per gun # GOTCHA: independent of TR_DEBUG_DRAW: the overlay is drawn either way, and
# TR_DEBUG_DRAW=0 is what suppresses the movers' own graphics.
# TRY: TR_GEO_DEBUG=on -> the geometry circles appear over the arena.
TR_GEO_DEBUG=off
# WHAT: draw each admitted gun's virtual-bullet paths. VALUE: 1/on/yes to
# enable, 0/off/no/false to disable, unset = off. DRAW ONLY.
# GOTCHA: needs a gun in the rack to be legible; the shipped rack admits
# Pattern only, so set TR_VBULLET_DEBUG_GUN too.
# TRY: TR_VBULLET_DEBUG=1 + TR_VBULLET_DEBUG_GUN=all
# -> travelled path, aim ring and miss vector for every admitted gun.
TR_VBULLET_DEBUG=off
# WHICH gun the overlay draws. VALUES: empty = the currently selected gun;
# `all` or `*` = every gun; otherwise a gun name, e.g. `Pattern`.
# TRY: TR_VBULLET_DEBUG_GUN=all -> every admitted gun at once.
TR_VBULLET_DEBUG_GUN=
TR_VBULLET_DEBUG_MAX=32 # max virtual bullets drawn per gun (clamped to >= 1)
TR_VBULLET_ADMIT_ONLY=on # on = a gun the rack does not admit is not even predicted TR_VBULLET_ADMIT_ONLY=on # on = a gun the rack does not admit is not even predicted
# ── logs (set the value to 1; presence alone turns some of them on) ────────── # ── logs (set the value to 1; PRESENCE alone turns these on) ─────────────────
TR_RESULT_LOG=on # one line per round result # WHAT: one line per round result. VALUE-based (unlike the block below): 0/off
#TR_POWER_LOG=1 # presence-only: one line per power decision # really disables it, which is why this one is written out uncommented.
#TR_RAM_LOG=1 # presence-only: one line per ram start/stop and why # TRY: TR_RESULT_LOG=off -> no [result] lines at all.
#TR_MOVEMENT_LOG=1 # presence-only: movement band/class changes TR_RESULT_LOG=on
#TR_STRAFE_LOG=1 # presence-only: one line per strafe tile pick #TR_POWER_LOG=1 # PRESENCE-only: one line per power decision (0 would ENABLE it)
#TR_TMHORIZON_LOG=1 # presence-only: the per-shot thinking of the TM horizon gun #TR_RAM_LOG=1 # PRESENCE-only: one line per ram start/stop and why
#TR_MOVEMENT_LOG=1 # PRESENCE-only: movement band/class changes
#TR_STRAFE_LOG=1 # PRESENCE-only: one line per strafe tile pick
#TR_TMHORIZON_LOG=1 # VALUE-based: the per-shot thinking of the TM horizon gun
GUN_STATS_PATH=/tmp/gun_stats.jsonl # where the per-round gun stats are written GUN_STATS_PATH=/tmp/gun_stats.jsonl # where the per-round gun stats are written
GUN_SHOTLOG_PATH=/tmp/shot_log.jsonl # where the per-shot log is written GUN_SHOTLOG_PATH=/tmp/shot_log.jsonl # where the per-shot log is written
# ── measurement helpers (leave off unless you are measuring) ───────────────── # ── measurement helpers (leave off unless you are measuring) ─────────────────
#TR_RECORD_WORLDSTATE=1 # presence-only: dump every observed world state # WHAT: aim capture. Adds TWO record kinds to the TR_RECORD_WORLDSTATE file:
#TR_RADAR_SCANLOG=1 # presence-only: log every radar scan tick # aim_scan — one per radar scan: the raw reading, our own state, the gun
#TR_TRACKER_PROBE=1 # presence-only: dump the enemy-tracker's internal state # 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
TR_TRACKER_PROBE_PATH=/tmp/tracker_probe.jsonl # where that dump is written TR_TRACKER_PROBE_PATH=/tmp/tracker_probe.jsonl # where that dump is written
# ── dotenv / boot report ───────────────────────────────────────────────────── # ── dotenv / boot report ─────────────────────────────────────────────────────
# Name of the env file to load. Must be set in the REAL environment, not in the # Name of the env file to load. Must be set in the REAL environment, not in the
# file it points at. Empty = use ./.env, else .env next to the binary. # file it points at. Empty = use ./.env, else .env next to the binary.
# GOTCHA: the line below sets it to the EMPTY string, which is the correct
# "use the default" spelling; putting a real path here would make this file
# load itself, recursively, at every start.
TR_ENV_FILE= TR_ENV_FILE=
# 1 = print the [env] report on startup (default). 0 = do not print it. # 1 = print the [env] report on startup (default). 0 = do not print it.
TR_ENV_REPORT=1 TR_ENV_REPORT=1
+56 -1
View File
@@ -44,6 +44,7 @@ import movement_harness/bullet_shadows
import targeting/enemy_tracker import targeting/enemy_tracker
import targeting/target_selector import targeting/target_selector
import env_report import env_report
import aim_capture
import vbullet_draw import vbullet_draw
import geo_overlay import geo_overlay
@@ -69,6 +70,13 @@ const ShotLog = true
## can enable recording for just the battle it spawns by exporting the env var. ## can enable recording for just the battle it spawns by exporting the env var.
let RecordWorldState* = existsEnv("TR_RECORD_WORLDSTATE") let RecordWorldState* = existsEnv("TR_RECORD_WORLDSTATE")
const WorldStateRecordPath = "/tmp/worldstate_record.jsonl" 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): ## Radar measurement switches (all RUNTIME, read once at process start):
## TR_RADAR_FORCE_SPIN=1 force the melee radar to the old stateless full ## TR_RADAR_FORCE_SPIN=1 force the melee radar to the old stateless full
## spin (always 45 deg/tick). This reproduces the ## spin (always 45 deg/tick). This reproduces the
@@ -477,6 +485,9 @@ proc recordWorldState(bot: ModularBot, ws: WorldState) =
"eid": tid, "eid": tid,
} }
if lst >= 0: row["lst"] = %lst 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: try:
let f = open(WorldStateRecordPath, fmAppend) let f = open(WorldStateRecordPath, fmAppend)
f.writeLine($row) f.writeLine($row)
@@ -616,6 +627,23 @@ proc recordRadarStats(bot: ModularBot) =
inc bot.arcWidthHist[min(11, int(width / 30.0))] inc bot.arcWidthHist[min(11, int(width / 30.0))]
method onScannedBot*(bot: ModularBot, e: ScannedBotEvent) = 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.enemyTracker.update(e.scannedBotId, e.x, e.y, e.direction, e.speed, e.energy, bot.tick)
bot.hasContact = true bot.hasContact = true
if RadarScanLog and bot.radarMeleeActive: if RadarScanLog and bot.radarMeleeActive:
@@ -878,6 +906,11 @@ method onRoundEnded*(bot: ModularBot, e: RoundEndedEventForBot) =
method onRoundStarted*(bot: ModularBot, e: RoundStartedEvent) = method onRoundStarted*(bot: ModularBot, e: RoundStartedEvent) =
bot.roundNumber = e.roundNumber bot.roundNumber = e.roundNumber
if FireDiag:
# j147 timeline anchor: the (bot.tick -> server getTurn) offset, once per
# round, so a recorded capture's event sidecar can be aligned to the bot's
# own tick stream without guessing.
echo "[firediag] ROUND round=", getRound(), " getTurn=", getTurn()
# Per-round outcome-log state (TR_RESULT_LOG). Reset every round so round 1 # Per-round outcome-log state (TR_RESULT_LOG). Reset every round so round 1
# reports and a target/enemy change mid-round cannot leak a stale flag. # reports and a target/enemy change mid-round cannot leak a stale flag.
bot.weDiedThisRound = false bot.weDiedThisRound = false
@@ -1454,9 +1487,30 @@ method run*(bot: ModularBot) =
let distPx = hypot(pred.x - getX(), pred.y - getY()) let distPx = hypot(pred.x - getX(), pred.y - getY())
if shouldFire(gunDir, aimTarget, gunHeat, distPx): 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. # Enqueue the selected gun so onBulletFired can stamp the server's bulletId.
# getEnergy() > power mirrors the server's "bot.energy <= firepower" reject. # 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:
# 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( bot.pendingFires.add(PendingShot(
gunId: selectedGun, gunId: selectedGun,
angleErr: abs(normDelta), angleErr: abs(normDelta),
@@ -1548,6 +1602,7 @@ when isMainModule:
vBulletDebugGun: getEnv(VBulletDebugGunEnv, ""), vBulletDebugGun: getEnv(VBulletDebugGunEnv, ""),
vBulletDebugMax: VBulletDebugMax, vBulletDebugMax: VBulletDebugMax,
recordWorldState: RecordWorldState, recordWorldState: RecordWorldState,
captureAim: CaptureAim,
geoDebug: GeoDebugOn, geoDebug: GeoDebugOn,
debugDraw: DebugDrawOn, debugDraw: DebugDrawOn,
radarForceSpin: RadarForceSpin, 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
+41 -1
View File
@@ -25,6 +25,7 @@ import module_switches
import gun_harness/virtual_bullets import gun_harness/virtual_bullets
import gun_harness/selector import gun_harness/selector
import movements/ram_decision import movements/ram_decision
import movement_harness/fire_tracker
import movements/the_floor_is_lava import movements/the_floor_is_lava
import movements/the_floor_is_lava_ring import movements/the_floor_is_lava_ring
import movements/strafe import movements/strafe
@@ -51,6 +52,7 @@ type
vBulletDebugGun*: string vBulletDebugGun*: string
vBulletDebugMax*: int vBulletDebugMax*: int
recordWorldState*: bool recordWorldState*: bool
captureAim*: bool ## j177: aim_scan / aim_fire records, default off
geoDebug*: bool geoDebug*: bool
debugDraw*: bool debugDraw*: bool
radarForceSpin*: bool radarForceSpin*: bool
@@ -244,6 +246,8 @@ proc printEffectiveValues(ctx: EnvReportContext) =
emit("TR_RESULT_LOG", onOff(ctx.resultLog), sourceOf("TR_RESULT_LOG")) emit("TR_RESULT_LOG", onOff(ctx.resultLog), sourceOf("TR_RESULT_LOG"))
emit("TR_RECORD_WORLDSTATE", onOff(ctx.recordWorldState), emit("TR_RECORD_WORLDSTATE", onOff(ctx.recordWorldState),
sourceOfPresence("TR_RECORD_WORLDSTATE")) sourceOfPresence("TR_RECORD_WORLDSTATE"))
emit("TR_CAPTURE_AIM", onOff(ctx.captureAim),
sourceOfPresence("TR_CAPTURE_AIM"))
emit("TR_RADAR_FORCE_SPIN", onOff(ctx.radarForceSpin), emit("TR_RADAR_FORCE_SPIN", onOff(ctx.radarForceSpin),
sourceOfPresence("TR_RADAR_FORCE_SPIN")) sourceOfPresence("TR_RADAR_FORCE_SPIN"))
emit("TR_RADAR_SCANLOG", onOff(ctx.radarScanLog), emit("TR_RADAR_SCANLOG", onOff(ctx.radarScanLog),
@@ -308,8 +312,22 @@ proc printEffectiveValues(ctx: EnvReportContext) =
emit("TR_TFIL_RANGE_HI", $RangeHi, sourceOf("TR_TFIL_RANGE_HI")) 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_TEMP", $RangeTemp, sourceOf("TR_TFIL_RANGE_TEMP"))
emit("TR_TFIL_RANGE_K", $RangeK, sourceOf("TR_TFIL_RANGE_K")) 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, emit("TR_TFIL_CORRIDOR_HEAT", $the_floor_is_lava_ring.CorridorHeat,
sourceOf("TR_TFIL_CORRIDOR_HEAT")) sourceOf("TR_TFIL_CORRIDOR_HEAT"))
emit("TR_TFIL_CORRIDOR_TICKS", $the_floor_is_lava.TfilCorridorTicks,
sourceOf("TR_TFIL_CORRIDOR_TICKS"))
emit("TR_TFIL_ARRIVE_TICKS", $the_floor_is_lava.TfilArriveTicks,
sourceOf("TR_TFIL_ARRIVE_TICKS"))
emit("TR_TFIL_HOLD_WHEN_TRAPPED", onOff(the_floor_is_lava.TfilHoldWhenTrapped),
sourceOfPresence("TR_TFIL_HOLD_WHEN_TRAPPED"))
emit("TR_TFIL_HOLD_MAX_TICKS", $the_floor_is_lava.TfilHoldMaxTicks,
sourceOf("TR_TFIL_HOLD_MAX_TICKS"))
emit("TR_TFIL_WALL_HOTNESS", $the_floor_is_lava_ring.WallHotness, emit("TR_TFIL_WALL_HOTNESS", $the_floor_is_lava_ring.WallHotness,
sourceOf("TR_TFIL_WALL_HOTNESS")) sourceOf("TR_TFIL_WALL_HOTNESS"))
emit("TR_TFIL_WALL_RADIANCE", $the_floor_is_lava.WallRadiance, emit("TR_TFIL_WALL_RADIANCE", $the_floor_is_lava.WallRadiance,
@@ -328,6 +346,14 @@ proc printEffectiveValues(ctx: EnvReportContext) =
emit("TR_TFIL_COMMIT_MARGIN", $TfilCommitMargin, emit("TR_TFIL_COMMIT_MARGIN", $TfilCommitMargin,
sourceOf("TR_TFIL_COMMIT_MARGIN")) sourceOf("TR_TFIL_COMMIT_MARGIN"))
emit("TR_TFIL_NOREV_SPEED", $TfilNoRevSpeed, sourceOf("TR_TFIL_NOREV_SPEED")) emit("TR_TFIL_NOREV_SPEED", $TfilNoRevSpeed, sourceOf("TR_TFIL_NOREV_SPEED"))
emit("TR_TFIL_DANGER_THRESHOLD", $the_floor_is_lava.TfilDangerThreshold,
sourceOf("TR_TFIL_DANGER_THRESHOLD"))
emit("TR_TFIL_DIAG", onOff(the_floor_is_lava.TfilDiag),
sourceOfPresence("TR_TFIL_DIAG"))
emit("TR_TFIL_GEO_MODE", $the_floor_is_lava.TfilGeoMode,
sourceOf("TR_TFIL_GEO_MODE"))
emit("TR_TFIL_GEO_TAU", $the_floor_is_lava.TfilGeoTau,
sourceOf("TR_TFIL_GEO_TAU"))
emit("TR_TFIL_TURN_BIAS", $TfilTurnBias, sourceOf("TR_TFIL_TURN_BIAS")) emit("TR_TFIL_TURN_BIAS", $TfilTurnBias, sourceOf("TR_TFIL_TURN_BIAS"))
emit("TR_TFIL_TURN_REF_DEG", $TfilTurnRefDeg, sourceOf("TR_TFIL_TURN_REF_DEG")) emit("TR_TFIL_TURN_REF_DEG", $TfilTurnRefDeg, sourceOf("TR_TFIL_TURN_REF_DEG"))
# time-indexed bullet heat (default off = shipped flat model) # time-indexed bullet heat (default off = shipped flat model)
@@ -362,6 +388,9 @@ proc printEffectiveValues(ctx: EnvReportContext) =
emit("TR_FIRE_FIX", onOff(TfilFireFix), sourceOf("TR_FIRE_FIX")) emit("TR_FIRE_FIX", onOff(TfilFireFix), sourceOf("TR_FIRE_FIX"))
# j134 TASK B diagnostic (off by default): per-reading tick/raw/correction trace. # j134 TASK B diagnostic (off by default): per-reading tick/raw/correction trace.
emit("TR_FIRE_DIAG", onOff(StrafeFireDiag), sourceOf("TR_FIRE_DIAG")) emit("TR_FIRE_DIAG", onOff(StrafeFireDiag), sourceOf("TR_FIRE_DIAG"))
# j147: the back-date (ticks) applied to every detected enemy fire at spawn.
# 0 = shipped (the ghost is born at the scanned enemy position).
emit("TR_FIRE_LAG", $FireLag, sourceOf("TR_FIRE_LAG"))
emit("TR_STRAFE_HEAT_GRID", onOff(StrafeHeatGrid), sourceOf("TR_STRAFE_HEAT_GRID")) emit("TR_STRAFE_HEAT_GRID", onOff(StrafeHeatGrid), sourceOf("TR_STRAFE_HEAT_GRID"))
# STRAFE's heat shape is its own RETUNE (bullet 20/10, corridor 10, wall # STRAFE's heat shape is its own RETUNE (bullet 20/10, corridor 10, wall
# 15/5), override-able per run so the shipped field can be A/B'd on one # 15/5), override-able per run so the shipped field can be A/B'd on one
@@ -369,6 +398,7 @@ proc printEffectiveValues(ctx: EnvReportContext) =
emit("TR_STRAFE_BULLET_CORE", $StrafeBulletCore, sourceOf("TR_STRAFE_BULLET_CORE")) emit("TR_STRAFE_BULLET_CORE", $StrafeBulletCore, sourceOf("TR_STRAFE_BULLET_CORE"))
emit("TR_STRAFE_BULLET_AURA", $StrafeBulletAura, sourceOf("TR_STRAFE_BULLET_AURA")) emit("TR_STRAFE_BULLET_AURA", $StrafeBulletAura, sourceOf("TR_STRAFE_BULLET_AURA"))
emit("TR_STRAFE_CORRIDOR_HEAT", $StrafeCorridorHeat, sourceOf("TR_STRAFE_CORRIDOR_HEAT")) emit("TR_STRAFE_CORRIDOR_HEAT", $StrafeCorridorHeat, sourceOf("TR_STRAFE_CORRIDOR_HEAT"))
emit("TR_STRAFE_CORRIDOR_TICKS", $StrafeCorridorTicks, sourceOf("TR_STRAFE_CORRIDOR_TICKS"))
emit("TR_STRAFE_WALL_HOTNESS", $StrafeWallHotness, sourceOf("TR_STRAFE_WALL_HOTNESS")) emit("TR_STRAFE_WALL_HOTNESS", $StrafeWallHotness, sourceOf("TR_STRAFE_WALL_HOTNESS"))
emit("TR_STRAFE_WALL_RADIANCE", $StrafeWallRadiance, sourceOf("TR_STRAFE_WALL_RADIANCE")) emit("TR_STRAFE_WALL_RADIANCE", $StrafeWallRadiance, sourceOf("TR_STRAFE_WALL_RADIANCE"))
@@ -389,6 +419,8 @@ proc printEffectiveValues(ctx: EnvReportContext) =
emit("TR_RAM_PLAN_MARGIN", $RamPlanMargin, sourceOf("TR_RAM_PLAN_MARGIN")) 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_PLAN_HITRATE", $RamPlanHitRate, sourceOf("TR_RAM_PLAN_HITRATE"))
emit("TR_RAM_LOG", onOff(RamLog), sourceOfPresence("TR_RAM_LOG")) 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 ──────────────────────────────────────────────────── # ── the horizon TM gun ────────────────────────────────────────────────────
# `resetLearning`/`targetChanged` resolve the lazily-read fields at round # `resetLearning`/`targetChanged` resolve the lazily-read fields at round
@@ -628,7 +660,7 @@ proc knownEnvNames*(): seq[string] =
"GUN_SELECTOR_SHRINK", "GUN_SELECTOR_DWELL", "GUN_SELECTOR_MARGIN", "GUN_SELECTOR_SHRINK", "GUN_SELECTOR_DWELL", "GUN_SELECTOR_MARGIN",
"GUN_SELECTOR_POINT_TIE", "GUN_SELECTOR_SEED", "GUN_SELECTOR_POINT_TIE", "GUN_SELECTOR_SEED",
"GUN_RACK_DISABLE", "GUN_STATS_PATH", "GUN_SHOTLOG_PATH", "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_RADAR_FORCE_SPIN", "TR_RADAR_SCANLOG", "TR_RADAR_SCAN_LOG_PATH",
"TR_TRACKER_PROBE", "TR_TRACKER_PROBE_PATH", "TR_VBULLET_ADMIT_ONLY", "TR_TRACKER_PROBE", "TR_TRACKER_PROBE_PATH", "TR_VBULLET_ADMIT_ONLY",
VBulletDebugEnv, VBulletDebugGunEnv, VBulletDebugMaxEnv, VBulletDebugEnv, VBulletDebugGunEnv, VBulletDebugMaxEnv,
@@ -640,8 +672,14 @@ proc knownEnvNames*(): seq[string] =
"TR_RAM_OPPORTUNITY", "TR_RAM_OPP_DIST", "TR_RAM_OPP_MARGIN", "TR_RAM_OPPORTUNITY", "TR_RAM_OPP_DIST", "TR_RAM_OPP_MARGIN",
"TR_RAM_ABORT_DMG", "TR_RAM_PLAN", "TR_RAM_PLAN_DIST", "TR_RAM_ABORT_DMG", "TR_RAM_PLAN", "TR_RAM_PLAN_DIST",
"TR_RAM_PLAN_MARGIN", "TR_RAM_PLAN_HITRATE", "TR_RAM_LOG", "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_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_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",
"TR_TFIL_DANGER_THRESHOLD", "TR_TFIL_DIAG",
"TR_TFIL_GEO_MODE", "TR_TFIL_GEO_TAU",
"TR_TFIL_WALL_RADIANCE", "TR_TFIL_BULLET_CORE", "TR_TFIL_BULLET_AURA", "TR_TFIL_WALL_RADIANCE", "TR_TFIL_BULLET_CORE", "TR_TFIL_BULLET_AURA",
"TR_TFIL_TILE_REPLAN", "TR_TFIL_COMMIT_TICKS", "TR_TFIL_NO_REV", "TR_TFIL_TILE_REPLAN", "TR_TFIL_COMMIT_TICKS", "TR_TFIL_NO_REV",
"TR_TFIL_COMMIT_LOG", "TR_TFIL_COMMIT_ARRIVAL", "TR_TFIL_COMMIT_MARGIN", "TR_TFIL_COMMIT_LOG", "TR_TFIL_COMMIT_ARRIVAL", "TR_TFIL_COMMIT_MARGIN",
@@ -657,8 +695,10 @@ proc knownEnvNames*(): seq[string] =
"TR_STRAFE_FIRE_FIX", "TR_STRAFE_FIRE_FIX",
"TR_FIRE_FIX", "TR_FIRE_FIX",
"TR_FIRE_DIAG", "TR_FIRE_DIAG",
"TR_FIRE_LAG",
"TR_STRAFE_HEAT_GRID", "TR_STRAFE_BULLET_CORE", "TR_STRAFE_BULLET_AURA", "TR_STRAFE_HEAT_GRID", "TR_STRAFE_BULLET_CORE", "TR_STRAFE_BULLET_AURA",
"TR_STRAFE_CORRIDOR_HEAT", "TR_STRAFE_WALL_HOTNESS", "TR_STRAFE_CORRIDOR_HEAT", "TR_STRAFE_WALL_HOTNESS",
"TR_STRAFE_CORRIDOR_TICKS",
"TR_STRAFE_WALL_RADIANCE", "TR_STRAFE_WALL_RADIANCE",
SurfPrefDistEnv, SurfDistBandEnv, SurfWallMarginEnv, SurfRadialFracEnv, SurfPrefDistEnv, SurfDistBandEnv, SurfWallMarginEnv, SurfRadialFracEnv,
SurfLogEnv, SurfLogEnv,
@@ -23,6 +23,8 @@
## A trailing live end marker is also optional: ## A trailing live end marker is also optional:
## {"end":{"enemy_died":<bool>,"ticks":<int>}} ## {"end":{"enemy_died":<bool>,"ticks":<int>}}
## It lets the replay reproduce the live resolver's final-tick behaviour. ## 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 ## The replay never calls the gun selector, so it is RNG-free for every
## deterministic gun. Tsetlin is stochastic and is expected to differ. ## 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"): if node["end"].hasKey("enemy_died"):
result.enemyDied = node["end"]["enemy_died"].getBool() result.enemyDied = node["end"]["enemy_died"].getBool()
continue 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.states.add stateFromJson(node, arenaW, arenaH, enemyId)
result.lastSeen.add (if node.hasKey("lst"): node["lst"].getInt() else: -1) result.lastSeen.add (if node.hasKey("lst"): node["lst"].getInt() else: -1)
result.enemyId = enemyId result.enemyId = enemyId
@@ -134,6 +134,38 @@ proc detect*(t: var FireTracker, id: int, energy: float,
elif drop >= lo and drop <= hi: elif drop >= lo and drop <= hi:
result = @[drop] result = @[drop]
import std/[math, os, strutils]
## ── j147: the DETECTION LAG back-date (`TR_FIRE_LAG`, default 0) ─────────────
## MEASURED LIVE (`common_libs/tests/measure_fire_ghost_lag.py`, 4 sessions,
## 1777 matched ghost spawns over both movers, `TR_FIRE_DIAG=1`): the server
## dispatches a turn's fire AFTER our `go()` for that same turn, so the energy
## drop of a turn-T shot first reaches our scan at turn T+1 (our bot tick T). A bullet
## takes its FIRST step during the turn it is fired, so by then the true bullet
## is already `speed` px (11..20 px, one whole bullet step) downrange and the
## arrival deadline is a full tick shorter than the ghost's. Both movers place
## the ghost at the SCANNED enemy position, i.e. exactly where the bullet was
## born: the whole ghost trajectory is the true one shifted one turn later.
## The per-tick `advanceBullets` then keeps it there for the bullet's whole life.
##
## The compensation is the inverse: at SPAWN, back-date the shot by `lag` ticks
## (`x = origin + dir * speed * lag`, `y = ...`). The arrival deadline needs no
## separate change — every mover derives it from the ghost's own position
## (`heatDecay(along / speed)`, the `dot < 0` reap), so a correct position gives a
## correct deadline.
##
## DEFAULT 0 = the shipped behaviour, byte for byte (`x` is only touched when
## `lag > 0`), so the default-parity guard stays green.
var FireLag*: int = 0
proc loadFireTrackerEnv*() =
## Read the shared fire knobs. Called once at module init; callable again
## after `putEnv` so a guard test can exercise the arms in one process.
let s = getEnv("TR_FIRE_LAG", "").strip()
FireLag = (try: max(0, parseInt(s)) except ValueError: 0)
loadFireTrackerEnv()
proc endScan*(t: var FireTracker) = proc endScan*(t: var FireTracker) =
## Call once after the per-enemy scan. Rotates the event corrections one ## Call once after the per-enemy scan. Rotates the event corrections one
## slot: the events noted since the previous `endScan` become the corrections ## slot: the events noted since the previous `endScan` become the corrections
+52 -2
View File
@@ -46,7 +46,29 @@
## TR_RAM_PLAN_MARGIN default 20.0 change-of-plan energy advantage ## 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 ## TR_RAM_PLAN_HITRATE default 0.05 selected gun's pooled virtual hit rate
## below which the gun duel counts as failing ## 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] 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 RamPlanMargin* = getEnvFloat("TR_RAM_PLAN_MARGIN", DefaultRamPlanMargin)
let RamPlanHitRate* = getEnvFloat("TR_RAM_PLAN_HITRATE", DefaultRamPlanHitRate) let RamPlanHitRate* = getEnvFloat("TR_RAM_PLAN_HITRATE", DefaultRamPlanHitRate)
let RamLog* = existsEnv("TR_RAM_LOG") 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 type
RamReason* = enum RamReason* = enum
rrNone ## no trigger fires 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 rrFinisher ## enemy < 20 energy, we are healthier, dist < 300
rrOpportunity ## we clearly out-energise and are close enough to close rrOpportunity ## we clearly out-energise and are close enough to close
rrDesperation ## both nearly dead, short range rrDesperation ## both nearly dead, short range
@@ -131,7 +158,8 @@ proc ramTrigger*(inp: RamInputs,
planEnabled = RamPlanEnabled, planEnabled = RamPlanEnabled,
planDist = RamPlanDist, planDist = RamPlanDist,
planMargin = RamPlanMargin, planMargin = RamPlanMargin,
planHitRate = RamPlanHitRate): RamReason = planHitRate = RamPlanHitRate,
enemyEnergyTol = RamEnemyEnergy): RamReason =
## Pure trigger evaluation. Returns the FIRST matching reason in priority ## Pure trigger evaluation. Returns the FIRST matching reason in priority
## order, or `rrNone`. Cooldown/duration/abort are deliberately NOT here — the ## order, or `rrNone`. Cooldown/duration/abort are deliberately NOT here — the
## caller composes those, so this function has no state and is unit-testable. ## 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 ## `desperation` and `finisher` are kept: they are rare, short-range, and the
## finisher is the only measured conversion. `plan` remains opt-in and off. ## finisher is the only measured conversion. `plan` remains opt-in and off.
if inp.enemyEnergy <= 0.0: return rrNone 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 if inp.dist < RamFinisherDist and inp.enemyEnergy < RamFinisherEnergy and
inp.selfEnergy > inp.enemyEnergy: inp.selfEnergy > inp.enemyEnergy:
return rrFinisher return rrFinisher
@@ -158,9 +193,24 @@ proc ramTrigger*(inp: RamInputs,
return rrPlan return rrPlan
rrNone 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 = proc reasonName*(r: RamReason): string =
case r case r
of rrNone: "none" of rrNone: "none"
of rrExhausted: "exhausted"
of rrFinisher: "finisher" of rrFinisher: "finisher"
of rrOpportunity: "opportunity" of rrOpportunity: "opportunity"
of rrDesperation: "desperation" of rrDesperation: "desperation"
+38 -3
View File
@@ -226,6 +226,13 @@ const CorridorHeatDefault = 10.0 ## corridor heat (== PathDangerThreshold)
const WallHotnessDefault = 15.0 ## wall radiance peak (retune) const WallHotnessDefault = 15.0 ## wall radiance peak (retune)
const WallRadianceDefault = 5.0 ## wall radiance falloff (retune) const WallRadianceDefault = 5.0 ## wall radiance falloff (retune)
## j148: the corridor LENGTH bound, `TR_STRAFE_CORRIDOR_TICKS`. The shipped
## corridor runs from the bullet to the ARENA WALL; a bullet only covers
## `speed * t` px in `t` ticks, so a fixed TIME window is the physical length.
## 0 (the default) = to the wall, byte-for-byte shipped.
const DefaultStrafeCorridorTicks = 0.0
var StrafeCorridorTicks* = DefaultStrafeCorridorTicks
## Heat shape is override-able so the shipped field and the retune can be ## Heat shape is override-able so the shipped field and the retune can be
## compared on one binary. The DEFAULTS are the retune (see the block above); ## compared on one binary. The DEFAULTS are the retune (see the block above);
## the knobs are strafe-specific, so the ring mover's own `TR_TFIL_*` names do ## the knobs are strafe-specific, so the ring mover's own `TR_TFIL_*` names do
@@ -374,6 +381,15 @@ proc loadStrafeHeatEnv*() =
StrafeCorridorHeat = getEnvFloat("TR_STRAFE_CORRIDOR_HEAT", CorridorHeatDefault) StrafeCorridorHeat = getEnvFloat("TR_STRAFE_CORRIDOR_HEAT", CorridorHeatDefault)
StrafeWallHotness = getEnvFloat("TR_STRAFE_WALL_HOTNESS", WallHotnessDefault) StrafeWallHotness = getEnvFloat("TR_STRAFE_WALL_HOTNESS", WallHotnessDefault)
StrafeWallRadiance = getEnvFloat("TR_STRAFE_WALL_RADIANCE", WallRadianceDefault) StrafeWallRadiance = getEnvFloat("TR_STRAFE_WALL_RADIANCE", WallRadianceDefault)
StrafeCorridorTicks = getEnvFloat("TR_STRAFE_CORRIDOR_TICKS", DefaultStrafeCorridorTicks)
proc strafeCorridorReach*(tWall, speed: float): float =
## The ONE place the strafe corridor length is decided (same rule as tfil's
## `corridorReach`, same physics: a bullet covers `speed * t` px in `t` ticks).
## 0 (default) = to the arena wall, byte-for-byte shipped; N > 0 = N ticks.
## Only the LENGTH changes: the heat inside the surviving corridor is today's
## (`heatDecay(along / speed)` untouched) — this is NOT the j119 time-heat model.
if StrafeCorridorTicks <= 0.0: tWall else: min(tWall, speed * StrafeCorridorTicks)
proc loadStrafeEnv*() = proc loadStrafeEnv*() =
## Read the strafe knobs. Called once at module init; callable again after ## Read the strafe knobs. Called once at module init; callable again after
@@ -577,11 +593,19 @@ proc spawnTrackedWave(m: var StrafeModule, ws: WorldState, ei: EnemyInfo,
let heading = arctan2(predY - ei.y, predX - ei.x) let heading = arctan2(predY - ei.y, predX - ei.x)
if m.bullets.len >= MaxTrackedBullets: if m.bullets.len >= MaxTrackedBullets:
m.bullets.del(0) m.bullets.del(0)
# j147: back-date the shot by the measured detection lag (`TR_FIRE_LAG`,
# default 0 = untouched). See `movement_harness/fire_tracker.nim`.
let vx = speed * cos(heading)
let vy = speed * sin(heading)
var gx = ei.x
var gy = ei.y
if FireLag > 0:
gx += vx * FireLag.float
gy += vy * FireLag.float
m.bullets.add TrackedBullet( m.bullets.add TrackedBullet(
originX: ei.x, originY: ei.y, originX: ei.x, originY: ei.y,
x: ei.x, y: ei.y, x: gx, y: gy,
velX: speed * cos(heading), velX: vx, velY: vy,
velY: speed * sin(heading),
power: power, alive: true, age: 0) power: power, alive: true, age: 0)
proc noteEnemyBulletHit*(m: var StrafeModule, power: float) = proc noteEnemyBulletHit*(m: var StrafeModule, power: float) =
@@ -616,6 +640,16 @@ proc detectFires(m: var StrafeModule, ws: WorldState) =
" dealt=", m.fire.dealtPending " dealt=", m.fire.dealtPending
for p in m.fire.detect(ei.id, ei.energy, 0.09, 3.01, StrafeFireFix): for p in m.fire.detect(ei.id, ei.energy, 0.09, 3.01, StrafeFireFix):
m.spawnTrackedWave(ws, ei, p) m.spawnTrackedWave(ws, ei, p)
if StrafeFireDiag and m.bullets.len > 0:
# Ghost-vs-observer probe: the tick we DETECTED the fire, our own
# position (the timeline anchor) and the ghost's DRAWN position.
let b = m.bullets[^1]
let sp = 20.0 - 3.0 * p
echo "[firediag] SPAWN tick=", ws.tick,
" sx=", ws.selfX, " sy=", ws.selfY,
" gx=", b.x, " gy=", b.y,
" p=", p,
" eta=", sqrt((ws.selfX - b.x)^2 + (ws.selfY - b.y)^2) / sp
m.fire.endScan() m.fire.endScan()
proc advanceBullets(m: var StrafeModule, selfX, selfY: float) = proc advanceBullets(m: var StrafeModule, selfX, selfY: float) =
@@ -682,6 +716,7 @@ proc buildHeat(m: var StrafeModule, ws: WorldState) =
elif dx < 0.0: tMin = min(tMin, (0.0 - b.x) / dx) elif dx < 0.0: tMin = min(tMin, (0.0 - b.x) / dx)
if dy > 0.0: tMin = min(tMin, (m.arenaHeight - b.y) / dy) if dy > 0.0: tMin = min(tMin, (m.arenaHeight - b.y) / dy)
elif dy < 0.0: tMin = min(tMin, (0.0 - b.y) / dy) elif dy < 0.0: tMin = min(tMin, (0.0 - b.y) / dy)
tMin = strafeCorridorReach(tMin, speed)
if tMin == 0.0: continue if tMin == 0.0: continue
let (_, auraR) = bulletRadii(b.power) let (_, auraR) = bulletRadii(b.power)
let bMag = bulletMagScale(b.power) let bMag = bulletMagScale(b.power)
+372 -9
View File
@@ -70,6 +70,15 @@ var
const CommitTicks = 15 ## ticks to commit to a dodge point const CommitTicks = 15 ## ticks to commit to a dodge point
const MinCommitTicks = 5 ## must commit for this many ticks before danger replan allowed const MinCommitTicks = 5 ## must commit for this many ticks before danger replan allowed
## j154: the DERIVED hold budget's panic horizon. Server `rules/math.kt`:
## calcGunHeat(p) = 1 + p/5, coolDown 0.1/tick, and a gun may only fire at
## heat == 0 (`core/GunEngine.kt:36`), so two MAX-power shots are 1.6/0.1 = 16
## ticks apart and a 3.0-power bullet is `calcBulletDamage(3) = 16`. 16 ticks
## is therefore the window in which the enemy can land AT MOST its next two
## shots (32 damage) on us — the whole exposure a hold can possibly buy. It is
## also the FIRST window that admits the enemy's second shot at all, so a
## hold shorter than this can never be surprised by a third bullet.
const HoldPanicTicks = 16.0
const DangerReplanThreshold = 25.0 ## replan on serious threats only (bullet core), not corridors/auras const DangerReplanThreshold = 25.0 ## replan on serious threats only (bullet core), not corridors/auras
const CoolestLevels = 2 ## how many distinct lava values count as "cool" const CoolestLevels = 2 ## how many distinct lava values count as "cool"
const MaxTrackedBullets = 20 ## hard cap on tracked bullets const MaxTrackedBullets = 20 ## hard cap on tracked bullets
@@ -85,6 +94,8 @@ const MaxTrackedBullets = 20 ## hard cap on tracked bullets
# #
# Every default below reproduces the shipped mover byte-for-byte; see the # Every default below reproduces the shipped mover byte-for-byte; see the
# default-path parity guard in `common_libs/tests/test_tfil_commit_env.nim`. # default-path parity guard in `common_libs/tests/test_tfil_commit_env.nim`.
const DefaultDangerThreshold = 10.0 ## today's `PathDangerThreshold`
type type
TfilTileReplan* = enum TfilTileReplan* = enum
ttrSelf, ttrOff, ttrEnemy ttrSelf, ttrOff, ttrEnemy
@@ -93,6 +104,33 @@ type
rrNone, rrInit, rrTileSelf, rrTileEnemy, rrDanger, rrExpiry, rrNone, rrInit, rrTileSelf, rrTileEnemy, rrDanger, rrExpiry,
rrArrival, rrHyst ## j144: the tile was REACHED / the alternative won by a margin rrArrival, rrHyst ## j144: the tile was REACHED / the alternative won by a margin
type
TfilGeoDim* = enum ## WHAT geometry the draw is shaped by
gdoOff = "off", gdoTurn = "turn", gdoDist = "dist", gdoBoth = "both"
TfilGeoShape* = enum ## HOW the shape is turned into a draw
gfSoft = "soft", gfTopK = "topk", gfRej = "rej"
proc parseGeo*(s: string): tuple[dim: TfilGeoDim, form: TfilGeoShape] =
## `"turn"`, `"dist"`, `"both"` | any of those + `"-soft"` (default) |
## `"-topk"` | `"-rej"`. Anything unrecognised, and `"off"`, is OFF = today's
## uniform draw. One env name, two axes: a `both-soft`/`both-topk` grid would
## need three names for the same three dials.
var a = s.strip().toLowerAscii()
var form = gfSoft
let dash = a.rfind('-')
if dash > 0:
case a[dash + 1 .. ^1]
of "topk": form = gfTopK; a = a[0 ..< dash]
of "rej", "rejection": form = gfRej; a = a[0 ..< dash]
of "soft": a = a[0 ..< dash] # the default form, spelled out
else: discard
result.form = form
result.dim = case a
of "turn": gdoTurn
of "dist", "distance": gdoDist
of "both": gdoBoth
else: gdoOff
proc tileReplanName*(m: TfilTileReplan): string = proc tileReplanName*(m: TfilTileReplan): string =
case m case m
of ttrSelf: "self" of ttrSelf: "self"
@@ -152,6 +190,78 @@ var
## j134: the shared fire-detection correction (`TR_FIRE_FIX`, default on). ## j134: the shared fire-detection correction (`TR_FIRE_FIX`, default on).
## Off = the shipped `prev - energy` detector byte-for-byte. ## Off = the shipped `prev - energy` detector byte-for-byte.
TfilFireFix*: bool = true TfilFireFix*: bool = true
## j134/j147: the env-gated live trace (`TR_FIRE_DIAG`) — one `SPAWN` line per
## detected enemy fire, for the ghost-vs-observer position probe. Observability
## only; off by default.
TfilFireDiag*: bool = false
## j150: the picker's hard heat cutoff was a proc-local `const`, so no offline
## sweep could move it. Same env-overridable-var pattern as the shape knobs;
## the DEFAULT is today's `10.0`, so the default path is bit-identical.
## TR_TFIL_DANGER_THRESHOLD default 10.0
TfilDangerThreshold* = DefaultDangerThreshold
## j152: `TR_TFIL_GEO_MODE` / `TR_TFIL_GEO_TAU` — GEOMETRY shapes the DRAW.
## Heat still gates the pool with the same hard filter; this only re-weights
## the survivors by how far the tile sits from where we are already going.
## TR_TFIL_GEO_MODE off | turn | dist | both [+ `-soft` | `-topk` | `-rej`]
## TR_TFIL_GEO_TAU deg, 0 = off (= today's uniform draw, exactly)
## WHAT IS DIFFERENT FROM j9 (`TR_TFIL_TURN_BIAS`, a live null): that was a
## tiebreak WEIGHT applied only among the non-empty safe set. This runs on the
## WHOLE pool the draw already runs on, so it also shapes the 2 promoted
## least-hot tiles the ~65% forced (safePre < 2) picks choose from.
TfilGeoMode*: TfilGeoDim = gdoOff
TfilGeoForm*: TfilGeoShape = gfSoft
TfilGeoTau*: float = 0.0
## j151: `TR_TFIL_ARRIVE_TICKS` — refuse a candidate we cannot REACH inside
## the commitment horizon (ticks = dist / MaxSpeed). Hard bound, not a
## preference; empty pool => today's full pool, so it can never starve the
## draw. 0 (default) = off = byte-for-byte today.
TfilArriveTicks*: float = 0.0
## j153: `TR_TFIL_HOLD_WHEN_TRAPPED` — when the SAFE set is EMPTY (zero tiles
## with `pathMaxHeat <= PathDangerThreshold` inside the reachable hull), STOP
## for this tick instead of promoting the 2 least-hot blocked tiles. The
## owner's rule: "if no tile is found to go, to not choose the less dangerous,
## but to stay still! the next tick probably the situation already changed".
## ONE tick only, never latched: the hold is taken at the pick site, and the
## pick site only runs when `commitTicks == 0`, so the very next tick
## re-evaluates the field from scratch. That is why there is no max-hold knob:
## a counter can only add a way to get stuck.
## Default false = byte-for-byte today's promote-the-2 behaviour.
TfilHoldWhenTrapped*: bool = false
## j154: `TR_TFIL_HOLD_MAX_TICKS` — the BOUNDED version of the j153 one-tick
## hold. While the safe tile set stays EMPTY the mover holds position for at
## most this many ticks (the enemy's own rate of fire bounds what waiting can
## buy: see `HoldPanicTicks`). Rules, all four of them load-bearing:
## * a safe tile exists -> release on the SAME tick, always
## * a tracked bullet reaches us within `min(N, 16)` ticks -> PANIC RELEASE,
## the hold is overridden and the normal promote-the-2 fallback resumes
## * the counter resets when a safe tile is taken, so the bound is per
## empty-streak, not per round
## * the gun is untouched: `computeMove` never fires, so a held tick still
## fires exactly as every other tick (verified in `test_tfil_commit_env`)
## Default 0 = OFF = byte-for-byte today's behaviour, j153 included.
TfilHoldMaxTicks*: int = 0
## j150: `TR_TFIL_DIAG` — fill `TfilLoss*` with the per-pick LOSS HISTOGRAM
## (how many tiles die at each picker stage). Pure counters, off by default.
TfilDiag*: bool = false
## The picker's loss histogram, one entry per PICK. Stage sizes, in picker
## order: tiles inside the reachable hull -> survivors of the `CoolestLevels`
## distinct-lava-value filter -> survivors of the `pathMaxHeat <= threshold`
## filter (counted BEFORE the "keep 2 anyway" promotion) -> what the draw
## actually runs on. Pure bookkeeping, read by the offline ruler.
type TfilLossStats* = object
picks*: int
sReach*: int ## inside the reachable hull
sCool*: int ## after CoolestLevels (= 2) distinct-value filter
sSafe*: int ## after the path heat filter, pre-promotion
sCand*: int ## what the draw ran on (post blocked-tile/no-rev)
emptySafe*: int ## picks that had to break the heat filter (sSafe < 2)
safeHist*: array[8, int] ## sSafe size buckets: 0,1,2-3,4-7,8-15,16-31,32-63,64+
rejectedHeat*: seq[float] ## pathMaxHeat of every tile the filter dropped
admittedHeat*: seq[float] ## pathMaxHeat of every tile that passed it
chosenHeat*: seq[float]
var TfilLoss*: TfilLossStats
proc getEnvInt(name: string, default: int): int = proc getEnvInt(name: string, default: int): int =
let s = getEnv(name, "") let s = getEnv(name, "")
@@ -186,6 +296,23 @@ proc loadTfilCommitEnv*() =
TfilTurnBias = max(0.0, getEnvFloat("TR_TFIL_TURN_BIAS", 0.0)) TfilTurnBias = max(0.0, getEnvFloat("TR_TFIL_TURN_BIAS", 0.0))
TfilTurnRefDeg = max(0.0, getEnvFloat("TR_TFIL_TURN_REF_DEG", 45.0)) TfilTurnRefDeg = max(0.0, getEnvFloat("TR_TFIL_TURN_REF_DEG", 45.0))
TfilFireFix = getEnvBool("TR_FIRE_FIX", true) TfilFireFix = getEnvBool("TR_FIRE_FIX", true)
TfilFireDiag = existsEnv("TR_FIRE_DIAG")
TfilDangerThreshold = max(0.0, getEnvFloat("TR_TFIL_DANGER_THRESHOLD",
DefaultDangerThreshold))
TfilDiag = getEnvBool("TR_TFIL_DIAG", false)
let (gd, gf) = parseGeo(getEnv("TR_TFIL_GEO_MODE", "off"))
TfilGeoMode = gd
TfilGeoForm = gf
TfilGeoTau = max(0.0, getEnvFloat("TR_TFIL_GEO_TAU", 0.0))
# j151: hard arrival bound. The draw is UNIFORM over every safe tile inside the
# 50-tick reachable hull, so a tile 47 ticks away had the same 1-in-52 chance
# as the adjacent one, while the target is only HELD for CommitTicks=15. The
# offline ruler (measure_tfil_pick_defects) measured 65% of picks beyond the
# 15-tick horizon and a 6.5% arrival rate. 0 = off = today's uniform draw.
TfilArriveTicks = max(0.0, getEnvFloat("TR_TFIL_ARRIVE_TICKS", 0.0))
TfilHoldWhenTrapped = getEnvBool("TR_TFIL_HOLD_WHEN_TRAPPED", false)
TfilHoldMaxTicks = max(0, getEnvInt("TR_TFIL_HOLD_MAX_TICKS", 0))
if not TfilDiag: TfilLoss = TfilLossStats()
loadTfilCommitEnv() loadTfilCommitEnv()
@@ -264,6 +391,31 @@ proc loadTfilHeatEnv*() =
loadTfilHeatEnv() loadTfilHeatEnv()
# ── Corridor LENGTH bound (TR_TFIL_CORRIDOR_TICKS, default 0 = to the wall) ──
#
# WHY: the shipped corridor is the rotated rectangle from the bullet to the
# ARENA WALL, so one distant bullet blankets a 40px-wide swath across the whole
# map. That is not physical: in `t` ticks a bullet covers `speed * t` px, and
# `speed = 20 - 3*power`, so a fast (low-power) bullet's reach is LONG and a
# slow one's is SHORT.
#
# WHAT: bound the corridor's LENGTH, nothing else — the heat inside the
# surviving corridor is EXACTLY today's (`heatDecay(along/speed)` is unchanged),
# so unlike the j119 time-indexed heat model this does NOT decay heat along the
# corridor; it only removes corridor that no bullet will reach.
var TfilCorridorTicks* = 0.0 ## 0 (default) = to the wall: byte-for-byte shipped
proc loadTfilCorridorEnv*() =
TfilCorridorTicks = getEnvFloat("TR_TFIL_CORRIDOR_TICKS", 0.0)
loadTfilCorridorEnv()
proc corridorReach*(tWall, speed: float): float =
## The ONE place the corridor length is decided, so the heat field and the
## drawn outline can never disagree. `tWall` = distance to the wall along the
## heading, `speed` = the ghost's own px/tick. 0 = to the wall (shipped).
if TfilCorridorTicks <= 0.0: tWall else: min(tWall, speed * TfilCorridorTicks)
proc heatDecay*(dt: float): float = proc heatDecay*(dt: float): float =
## Fraction of a bullet's heat still present `dt` ticks before it arrives. ## Fraction of a bullet's heat still present `dt` ticks before it arrives.
## Exactly 1.0 when the time model is off, so the default field is ## Exactly 1.0 when the time model is off, so the default field is
@@ -320,6 +472,12 @@ type
## candidate set of the last pick (j145: ## candidate set of the last pick (j145:
## lets a caller measure the REGRET of the ## lets a caller measure the REGRET of the
## draw instead of only the drawn value) ## draw instead of only the drawn value)
lastHeld: bool ## the last tick HELD position
## (j153: TR_TFIL_HOLD_WHEN_TRAPPED,
## the safe set was empty)
holdTicks: int ## j154: ticks HELD in the current
## empty-safe-set streak; reset to 0
## when a safe tile is taken
lastPickSafe: int ## how many SAFE tiles (pathMaxHeat lastPickSafe: int ## how many SAFE tiles (pathMaxHeat
## <= PathDangerThreshold) the last pick ## <= PathDangerThreshold) the last pick
## drew from (j146: the size of the set ## drew from (j146: the size of the set
@@ -354,6 +512,7 @@ proc resetRound*(m: var TFILModule) =
m.fire.reset() m.fire.reset()
m.commitTicks = 0 m.commitTicks = 0
m.commitAge = 0 m.commitAge = 0
m.holdTicks = 0 ## j154: the bounded hold never survives a round
m.cachedHull = @[] m.cachedHull = @[]
m.cachedInsideTiles = @[] m.cachedInsideTiles = @[]
m.blockedTile = (col: 0, row: 0, active: false) m.blockedTile = (col: 0, row: 0, active: false)
@@ -369,6 +528,7 @@ proc resetRound*(m: var TFILModule) =
m.picks = 0 m.picks = 0
m.lastPickPromoted = false m.lastPickPromoted = false
m.lastPickMinTurn = 0.0 m.lastPickMinTurn = 0.0
m.lastHeld = false
m.lastPickSafe = 0 m.lastPickSafe = 0
# ── Commit diagnostics (TR_TFIL_COMMIT_LOG, off by default) ────────────────── # ── Commit diagnostics (TR_TFIL_COMMIT_LOG, off by default) ──────────────────
@@ -426,11 +586,20 @@ proc spawnTrackedWave(m: var TFILModule, ws: WorldState, ei: EnemyInfo,
let heading = arctan2(predY - ei.y, predX - ei.x) let heading = arctan2(predY - ei.y, predX - ei.x)
if m.bullets.len >= MaxTrackedBullets: if m.bullets.len >= MaxTrackedBullets:
m.bullets.del(0) # ponytail: drop oldest; fine for 20-bullet cap m.bullets.del(0) # ponytail: drop oldest; fine for 20-bullet cap
# j147: back-date the shot by the measured detection lag (`TR_FIRE_LAG`,
# default 0 = untouched). See `movement_harness/fire_tracker.nim`.
let vx = speed * cos(heading)
let vy = speed * sin(heading)
var gx = ei.x
var gy = ei.y
if FireLag > 0:
gx += vx * FireLag.float
gy += vy * FireLag.float
m.bullets.add TrackedBullet( m.bullets.add TrackedBullet(
originX: ei.x, originY: ei.y, originX: ei.x, originY: ei.y,
x: ei.x, y: ei.y, x: gx, y: gy,
velX: speed * cos(heading), velX: vx,
velY: speed * sin(heading), velY: vy,
power: power, power: power,
alive: true, alive: true,
age: 0) age: 0)
@@ -451,8 +620,40 @@ proc detectFires(m: var TFILModule, ws: WorldState) =
for ei in ws.enemies: for ei in ws.enemies:
for p in m.fire.detect(ei.id, ei.energy, 0.09, 3.01, TfilFireFix): for p in m.fire.detect(ei.id, ei.energy, 0.09, 3.01, TfilFireFix):
m.spawnTrackedWave(ws, ei, p) m.spawnTrackedWave(ws, ei, p)
if TfilFireDiag and m.bullets.len > 0:
# Ghost-vs-observer probe: the tick we DETECTED the fire, our own
# position (the timeline anchor) and the ghost's DRAWN position.
let b = m.bullets[^1]
let sp = 20.0 - 3.0 * p
echo "[firediag] SPAWN tick=", ws.tick,
" sx=", ws.selfX, " sy=", ws.selfY,
" gx=", b.x, " gy=", b.y,
" p=", p,
" eta=", sqrt((ws.selfX - b.x)^2 + (ws.selfY - b.y)^2) / sp
m.fire.endScan() m.fire.endScan()
proc bulletPanic*(m: TFILModule, selfX, selfY, horizon: float): bool =
## PANIC RELEASE (j154). True when a TRACKED bullet's straight path comes
## within its own CORE of where WE are at any time in `[0, horizon]` ticks.
## Closest approach of a straight ray is `t* = ((self - b) . v) / |v|^2`; the
## `t* < 0` case is the `dot < 0` reap in `advanceBullets` (it is already past
## us) and `t* > horizon` is "not inside the window". The prediction is the
## tracked ghost's OWN position/velocity — the same model `pathMaxHeat` decays
## by and `advanceBullets` integrates — so there is no second arrival model in
## this file. Exported so the guard test can call it directly.
if horizon <= 0.0: return false
for b in m.bullets:
let v2 = b.velX * b.velX + b.velY * b.velY
if v2 < 1e-9: continue
let dx = selfX - b.x
let dy = selfY - b.y
let t = (dx * b.velX + dy * b.velY) / v2
if t < 0.0 or t > horizon: continue
let mx = dx - t * b.velX
let my = dy - t * b.velY
if sqrt(mx * mx + my * my) <= bulletRadii(b.power).core: return true
false
proc advanceBullets(m: var TFILModule, selfX, selfY: float) = proc advanceBullets(m: var TFILModule, selfX, selfY: float) =
## Advance positions and reap bullets that are: passed us, out of bounds, or too old. ## Advance positions and reap bullets that are: passed us, out of bounds, or too old.
var i = 0 var i = 0
@@ -482,6 +683,9 @@ type CorridorGeom = object
bx, by: float ## bullet origin bx, by: float ## bullet origin
proc corridorGeom(b: TrackedBullet, arenaWidth, arenaHeight: float): CorridorGeom = proc corridorGeom(b: TrackedBullet, arenaWidth, arenaHeight: float): CorridorGeom =
## `tMin` is the corridor LENGTH: the distance to the wall, bounded by
## `corridorReach` (TR_TFIL_CORRIDOR_TICKS). Unset (0) -> exactly the wall
## distance, so the shipped field and outline are byte-for-byte unchanged.
let speed = sqrt(b.velX * b.velX + b.velY * b.velY) let speed = sqrt(b.velX * b.velX + b.velY * b.velY)
if speed < 0.001: return if speed < 0.001: return
let dx = b.velX / speed let dx = b.velX / speed
@@ -491,6 +695,7 @@ proc corridorGeom(b: TrackedBullet, arenaWidth, arenaHeight: float): CorridorGeo
elif dx < 0.0: tMin = min(tMin, (0.0 - b.x) / dx) elif dx < 0.0: tMin = min(tMin, (0.0 - b.x) / dx)
if dy > 0.0: tMin = min(tMin, (arenaHeight - b.y) / dy) if dy > 0.0: tMin = min(tMin, (arenaHeight - b.y) / dy)
elif dy < 0.0: tMin = min(tMin, (0.0 - b.y) / dy) elif dy < 0.0: tMin = min(tMin, (0.0 - b.y) / dy)
tMin = corridorReach(tMin, speed)
CorridorGeom(dx: dx, dy: dy, px: -dy, py: dx, tMin: tMin, bx: b.x, by: b.y) CorridorGeom(dx: dx, dy: dy, px: -dy, py: dx, tMin: tMin, bx: b.x, by: b.y)
proc lavaAt(m: TFILModule, col, row: int): float = proc lavaAt(m: TFILModule, col, row: int): float =
@@ -628,6 +833,76 @@ proc turnWeights*(turns: openArray[float], bias, refDeg: float): seq[int] =
result.add max(1, int(round(1.0 + bias * result.add max(1, int(round(1.0 + bias *
(1.0 - max(0.0, t - refDeg) / 180.0)))) (1.0 - max(0.0, t - refDeg) / 180.0))))
# ── j152: the geometric DRAW ────────────────────────────────────────────────
# The owner: "choose tiles pool not only from the heat point but from a
# geometrically position too". Heat is already a HARD filter (unchanged); this is
# the second half — the distribution the draw samples from.
#
# WHY THIS IS NOT j9 AGAIN. j9 (`TR_TFIL_TURN_BIAS`) down-weighted the turn among
# the non-empty safe set and measured a live null. This runs on the pool the draw
# ALREADY runs on, which for ~65% of picks is the 2 promoted least-hot tiles that
# broke the heat filter — j9 could not see those at all.
const GeoDegPerTick = 12.0 ## distance cost, in "effective degrees": a 15-tick
## trip (the commitment horizon) costs the same as a
## 180 deg turn, so ONE tau knob means the same
## thing in `turn` and `dist` mode.
proc geoCosts*(turns, ttas: openArray[float], dim: TfilGeoDim): seq[float] =
## Per-candidate cost in effective degrees. Never filters: it only re-orders
## and re-weights tiles that already passed the heat filter.
for i in 0..<turns.len:
result.add (if dim in {gdoTurn, gdoBoth}: turns[i] else: 0.0) +
(if dim in {gdoDist, gdoBoth}: ttas[i] * GeoDegPerTick else: 0.0)
proc geoPick*(turns, ttas: openArray[float], dim: TfilGeoDim, form: TfilGeoShape,
tau: float): int =
## Draw index from `candidates` under the geometric weight. NEVER returns -1
## and NEVER returns an out-of-range index, so no arm can starve the pick.
let c = geoCosts(turns, ttas, dim)
var best = 0
for i in 1..<c.len:
if c[i] < c[best]: best = i
case form
of gfRej:
# Rejection sampling: a GEOMETRY-FREE acceptance test (no shape function at
# all) — uniform draw, redraw while the candidate costs more than `tau`.
# ponytail: 16 tries is a fixed budget; widen it if the band ever tightens
# enough that the fallback below starts dominating.
for _ in 0..<16:
let i = rand(c.high)
if c[i] <= tau: return i
return best # band too tight: take the best available, never starve
of gfTopK:
# Hard: keep the best THIRD, uniform inside. Collapses diversity by design —
# measured against the soft form before it could ever be a default.
let k = max(1, (c.len + 2) div 3)
var pool: seq[int]
var taken = newSeq[bool](c.len)
for _ in 0..<k:
var b = -1
for i in 0..<c.len:
if not taken[i] and (b < 0 or c[i] < c[b]): b = i
taken[b] = true
pool.add b
return pool[rand(pool.high)]
of gfSoft:
# w = exp(-cost / tau), NORMALISED so the best tile weighs exactly 1.0. The
# normalisation is what makes "all tiles tie" (and only that) degrade to the
# uniform draw, and makes starvation impossible.
var w: seq[float]
for x in c: w.add exp(-x / tau)
var wMax = 0.0
for x in w: wMax = max(wMax, x)
if wMax <= 0.0: return best
var total = 0.0
for x in w: total += x
let r = rand(total)
var acc = 0.0
for i, x in w:
acc += x
if r < acc: return i
return best
proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand = proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand =
if m.cols == 0: if m.cols == 0:
m.initGrid(ws.arenaWidth, ws.arenaHeight) m.initGrid(ws.arenaWidth, ws.arenaHeight)
@@ -881,6 +1156,7 @@ proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand =
var pickedInterval = 0 var pickedInterval = 0
var pickedTurn = 0.0 # log-only: |turn| to the tile that was chosen var pickedTurn = 0.0 # log-only: |turn| to the tile that was chosen
var pickedPromoted = false ## log-only: the pick had to break the heat filter var pickedPromoted = false ## log-only: the pick had to break the heat filter
var pickedHeld = false ## j153: the safe set was EMPTY -> hold this tick
# Hull + inside-tiles: only recompute on replan tick (commitTicks == 0) # Hull + inside-tiles: only recompute on replan tick (commitTicks == 0)
type TileRef = tuple[col, row: int] type TileRef = tuple[col, row: int]
@@ -931,11 +1207,13 @@ proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand =
# Score each cool tile by MAX lava on the straight-line path from bot. # Score each cool tile by MAX lava on the straight-line path from bot.
# A single hot tile on the path (corridor, bullet core, enemy aura) makes the whole path unsafe. # A single hot tile on the path (corridor, bullet core, enemy aura) makes the whole path unsafe.
const PathSampleStep = 18.0 # ~half a tile const PathSampleStep = 18.0 # ~half a tile
const PathDangerThreshold = 10.0 # max lava on path; above this = unsafe # j150: was `const 10.0`; now the env-overridable var whose DEFAULT is 10.0.
let PathDangerThreshold = TfilDangerThreshold # max lava on path; above = unsafe
# j145: `turnDeg` is the |heading change| from the direction we are ALREADY # j145: `turnDeg` is the |heading change| from the direction we are ALREADY
# travelling to the tile centre. It is carried on the candidate (never folded # travelling to the tile centre. It is carried on the candidate (never folded
# into `pathMaxHeat`) so the pick can bias among the safe tiles only. # into `pathMaxHeat`) so the pick can bias among the safe tiles only.
type ScoredTile = tuple[col, row: int; pathMaxHeat: float; turnDeg: float] type ScoredTile = tuple[col, row: int; pathMaxHeat: float; turnDeg: float;
arriveTicks: float]
proc pathMaxHeat(m: TFILModule, fx, fy, tx, ty: float): float = proc pathMaxHeat(m: TFILModule, fx, fy, tx, ty: float): float =
## MAX lava on the straight-line segment (fx,fy) -> (tx,ty), sampled every ## MAX lava on the straight-line segment (fx,fy) -> (tx,ty), sampled every
@@ -976,7 +1254,8 @@ proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand =
scoredTiles.add (col: t.col, row: t.row, scoredTiles.add (col: t.col, row: t.row,
pathMaxHeat: pathMaxHeat(m, ws.selfX, ws.selfY, tx, ty), pathMaxHeat: pathMaxHeat(m, ws.selfX, ws.selfY, tx, ty),
turnDeg: abs(tileOffTravel(m, t.col, t.row, ws.selfX, turnDeg: abs(tileOffTravel(m, t.col, t.row, ws.selfX,
ws.selfY, travelDeg))) ws.selfY, travelDeg)),
arriveTicks: sqrt((tx - ws.selfX)^2 + (ty - ws.selfY)^2) / MaxSpeed)
# Sort by pathMaxHeat ascending (insertion sort — small N) # Sort by pathMaxHeat ascending (insertion sort — small N)
for i in 1..<scoredTiles.len: for i in 1..<scoredTiles.len:
@@ -996,8 +1275,12 @@ proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand =
for t in scoredTiles: for t in scoredTiles:
if t.pathMaxHeat <= PathDangerThreshold: safeTiles.add t if t.pathMaxHeat <= PathDangerThreshold: safeTiles.add t
else: blockedTiles.add t else: blockedTiles.add t
let safePre = safeTiles.len # j150: the safe set BEFORE the "keep 2" promotion
let safeEmpty = safePre == 0
if safeTiles.len < 2: if safeTiles.len < 2:
# Fallback: promote the least-hot blocked tiles until we have 2 # Fallback: promote the least-hot blocked tiles until we have 2. j154 runs
# this EVEN when a hold is armed, so the panic release has a real fallback to
# fall back ON; the hold only ever DISCARDS the promotion, at the pick site.
# ponytail: O(n) scan on already-sorted seq — fine for small N # ponytail: O(n) scan on already-sorted seq — fine for small N
let needed = 2 - safeTiles.len let needed = 2 - safeTiles.len
let promote = min(needed, blockedTiles.len) let promote = min(needed, blockedTiles.len)
@@ -1006,6 +1289,15 @@ proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand =
m.lastPickPromoted = true m.lastPickPromoted = true
blockedTiles = blockedTiles[promote ..< blockedTiles.len] blockedTiles = blockedTiles[promote ..< blockedTiles.len]
# j151: the arrival bound, applied to the pool the draw runs on (hysteresis
# included) so every consumer sees the same set. It never empties the pool:
# if nothing is within the horizon, the full pool is used, exactly as today.
if TfilArriveTicks > 0.0:
var withinHorizon: seq[ScoredTile]
for t in safeTiles:
if t.arriveTicks <= TfilArriveTicks: withinHorizon.add t
if withinHorizon.len > 0: safeTiles = withinHorizon
# Commitment logic. With every j144 knob at its default (all off) this is the # Commitment logic. With every j144 knob at its default (all off) this is the
# original three-way test, unchanged. j144 adds two ways OUT of a commitment # original three-way test, unchanged. j144 adds two ways OUT of a commitment
# that are NOT a tile crossing, and turns the tick counter into a MINIMUM # that are NOT a tile crossing, and turns the tick counter into a MINIMUM
@@ -1063,7 +1355,31 @@ proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand =
# is the first pick of the round, which is not a switch at all. # is the first pick of the round, which is not a switch at all.
let midFlight = m.picks > 0 and not atTarget let midFlight = m.picks > 0 and not atTarget
if m.commitTicks == 0 and safeTiles.len > 0: # ── j153/j154: THE HOLD, decided here and nowhere else ─────────────────────
# The safe set was EMPTY, so there is nothing good to walk to. Today's answer
# is the promote-the-2 fallback above; the owner's answer is to stand still and
# let the field change. It is decided HERE, after the commitment block, so
# "are we on a replan tick?" is already answered — a hold replaces a REPLAN
# and can never interrupt a live commitment (j153's comment said that; its
# code did not enforce it, and a mid-commitment hold silently froze the bot).
var doPick = m.commitTicks == 0 and safeTiles.len > 0
if m.commitTicks == 0 and safeEmpty:
let budgeted = TfilHoldMaxTicks > 0
let hold =
if budgeted:
# The BOUNDED hold: at most N ticks per empty streak, released the tick
# a safe tile exists, and overridden outright by an inbound bullet.
m.holdTicks < TfilHoldMaxTicks and
not bulletPanic(m, ws.selfX, ws.selfY,
min(TfilHoldMaxTicks.float, HoldPanicTicks))
else:
TfilHoldWhenTrapped # j153's ONE-tick hold, unchanged
if hold:
pickedHeld = true
doPick = false
if budgeted: inc m.holdTicks
if doPick:
# Filter out the blocked tile from candidates # Filter out the blocked tile from candidates
var candidates: seq[ScoredTile] var candidates: seq[ScoredTile]
for t in safeTiles: for t in safeTiles:
@@ -1086,7 +1402,20 @@ proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand =
for i in keep: narrowed.add candidates[i] for i in keep: narrowed.add candidates[i]
candidates = narrowed candidates = narrowed
var chosen = 0 var chosen = 0
if (TfilNoRev or TfilTurnBias > 0.0) and candidates.len >= 2: # j152: geometry shapes the DRAW, on top of the heat filter (never instead
# of it). Off by default: with `TR_TFIL_GEO_MODE=off` this whole block is
# skipped and the draw below is byte-for-byte today's.
# ponytail: takes precedence over TfilNoRev/TfilTurnBias (both also default
# off) instead of composing weights; compose if two are ever armed at once.
let geoOn = TfilGeoMode != gdoOff and TfilGeoTau > 0.0
if geoOn and candidates.len >= 2:
var gturns: seq[float]
var gttas: seq[float]
for t in candidates:
gturns.add t.turnDeg
gttas.add t.arriveTicks
chosen = geoPick(gturns, gttas, TfilGeoMode, TfilGeoForm, TfilGeoTau)
elif (TfilNoRev or TfilTurnBias > 0.0) and candidates.len >= 2:
# Soft preferences — down-weight, never filter, and only ever among tiles # Soft preferences — down-weight, never filter, and only ever among tiles
# that already passed the hard heat filter above: # that already passed the hard heat filter above:
# arm C (TR_TFIL_NO_REV, off by default) — 3:1 forward vs rearward, # arm C (TR_TFIL_NO_REV, off by default) — 3:1 forward vs rearward,
@@ -1143,6 +1472,7 @@ proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand =
m.commitTicks = TfilCommitTicks m.commitTicks = TfilCommitTicks
m.commitAge = 0 m.commitAge = 0
m.commitLava = m.lavaAt(ct.col, ct.row) m.commitLava = m.lavaAt(ct.col, ct.row)
m.holdTicks = 0 # j154: a safe tile was taken -> the budget refills
m.blockedTile.active = false # clear after successful pick m.blockedTile.active = false # clear after successful pick
# log-only: reversal test against the travel direction # log-only: reversal test against the travel direction
@@ -1158,6 +1488,25 @@ proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand =
m.lastPickCall = m.callCount m.lastPickCall = m.callCount
inc m.picks inc m.picks
if TfilDiag: # j150: where the tiles died, one row per pick. No effect.
inc TfilLoss.picks
TfilLoss.sReach += insideTiles.len
TfilLoss.sCool += coolTiles.len
TfilLoss.sSafe += safePre
TfilLoss.sCand += candidates.len
if safePre < 2: inc TfilLoss.emptySafe
let b = (if safePre == 0: 0 elif safePre == 1: 1
elif safePre <= 3: 2 elif safePre <= 7: 3
elif safePre <= 15: 4 elif safePre <= 31: 5
elif safePre <= 63: 6 else: 7)
inc TfilLoss.safeHist[b]
# admitted/rejected by the FILTER itself, so the promoted (over-threshold)
# rescue tiles are not counted as safe.
for t in scoredTiles:
if t.pathMaxHeat <= PathDangerThreshold: TfilLoss.admittedHeat.add t.pathMaxHeat
else: TfilLoss.rejectedHeat.add t.pathMaxHeat
TfilLoss.chosenHeat.add ct.pathMaxHeat
if m.debugGraphics: if m.debugGraphics:
# Reachable hull perimeter (darker blue) # Reachable hull perimeter (darker blue)
if m.cachedHull.len >= 3: if m.cachedHull.len >= 3:
@@ -1230,10 +1579,24 @@ proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand =
",\"reason\":\"" & reasonName(reason) & "\",\"rev\":" & ",\"reason\":\"" & reasonName(reason) & "\",\"rev\":" &
(if pickedRev: "1" else: "0") & ",\"mid\":" & (if pickedRev: "1" else: "0") & ",\"mid\":" &
(if pickedMidFlight: "1" else: "0") & ",\"interval\":" & $pickedInterval & (if pickedMidFlight: "1" else: "0") & ",\"interval\":" & $pickedInterval &
",\"hold\":" & (if pickedHeld: "1" else: "0") &
",\"ht\":" & $m.holdTicks & ## j154: ticks held in the current streak
",\"picks\":" & $m.picks & "}") ",\"picks\":" & $m.picks & "}")
if pickedThisTick: m.replanReason = rrNone if pickedThisTick: m.replanReason = rrNone
m.lastHeld = pickedHeld
# ── Steering ───────────────────────────────────────────────────────────────── # ── Steering ─────────────────────────────────────────────────────────────────
# j153/j154: HOLD. `speed: 0.0` is how this module already says "stop" (the
# already-at-target case below returns the same command), so holding needs no
# new signal, and the hold path is byte-identical to the stop path the bot
# already emits every time it reaches its dodge tile. The gun is NOT in this
# module — computeMove never touches fire, and `ModularBot.nim` aims and
# fires from tracked state AFTER `go()` on every tick regardless of the speed
# it just commanded — so a held tick still fires exactly as before. Guarded in
# `test_tfil_commit_env.nim` (the fire detector still latches a wave on a
# held tick).
if pickedHeld:
return (speed: 0.0, turnRate: 0.0)
let stepDx = m.commitTarget.x - ws.selfX let stepDx = m.commitTarget.x - ws.selfX
let stepDy = m.commitTarget.y - ws.selfY let stepDy = m.commitTarget.y - ws.selfY
let dist2 = stepDx*stepDx + stepDy*stepDy let dist2 = stepDx*stepDx + stepDy*stepDy
@@ -49,6 +49,10 @@
## TR_TFIL_CORRIDOR_HEAT default 10.0 lava per corridor-overlapping tile ## 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_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_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 ## `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. ## 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) TfilRingFireFix = getEnvBool("TR_FIRE_FIX", true)
loadTfilRingFireEnv() 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 const
DefaultRangeLo = 100.0 DefaultRangeLo = 100.0
DefaultRangeHi = 200.0 DefaultRangeHi = 200.0
@@ -240,6 +274,10 @@ type
commitTarget: tuple[x, y: float] ## world coords of committed dodge point commitTarget: tuple[x, y: float] ## world coords of committed dodge point
commitTicks: int ## ticks remaining on commitment commitTicks: int ## ticks remaining on commitment
commitLava: float ## lava at commit time (for spike detection) 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 blockedTile: tuple[col, row: int; active: bool] ## excluded from next pick after danger replan
cachedHull: seq[tuple[x, y: float]] cachedHull: seq[tuple[x, y: float]]
cachedInsideTiles: seq[tuple[col, row: int]] cachedInsideTiles: seq[tuple[col, row: int]]
@@ -283,6 +321,8 @@ proc resetRound*(m: var TFILRingModule) =
m.bullets = @[] m.bullets = @[]
m.fire.reset() m.fire.reset()
m.commitTicks = 0 m.commitTicks = 0
m.commitAge = 0
m.picks = 0
m.cachedHull = @[] m.cachedHull = @[]
m.cachedInsideTiles = @[] m.cachedInsideTiles = @[]
m.blockedTile = (col: 0, row: 0, active: false) m.blockedTile = (col: 0, row: 0, active: false)
@@ -462,6 +502,40 @@ proc computeReachableHull(x0, y0, heading0, speed0,
if cur == startIdx: break if cur == startIdx: break
hull 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 = proc computeMove*(m: var TFILRingModule, ws: WorldState): MoveCommand =
if m.cols == 0: if m.cols == 0:
m.initGrid(ws.arenaWidth, ws.arenaHeight) 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) m.fire.prevEnergySet(ei.id, ei.energy)
# Tile-change replan: catches gradual displacement that position threshold misses # 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 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) let curTileRow = clamp(int((ws.selfY - m.marginY) / GridSize), 0, m.rows - 1)
if curTileCol != m.lastTileCol or curTileRow != m.lastTileRow: if curTileCol != m.lastTileCol or curTileRow != m.lastTileRow:
@@ -756,22 +836,50 @@ proc computeMove*(m: var TFILRingModule, ws: WorldState): MoveCommand =
safeTiles.add blockedTiles[i] safeTiles.add blockedTiles[i]
blockedTiles = blockedTiles[promote ..< blockedTiles.len] 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: 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 let ticksElapsed = CommitTicks - m.commitTicks
if ticksElapsed >= MinCommitTicks: if ticksElapsed >= MinCommitTicks:
let (cc, cr) = m.tileAt(m.commitTarget.x, m.commitTarget.y) let (cc, cr) = m.tileAt(m.commitTarget.x, m.commitTarget.y)
let curLava = m.lavaAt(cc, cr) let curLava = m.lavaAt(cc, cr)
var commitEnd = false
if curLava > m.commitLava + DangerReplanThreshold: 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.blockedTile = (col: cc, row: cr, active: true)
m.commitTicks = 0 # replan commitEnd = true
else: 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 dec m.commitTicks
if m.commitTicks == 0 and TfilRingCommitArrival:
m.commitTicks = CommitTicks # minimum dwell reached: renew, don't abandon
else:
m.commitTicks = 0
else: else:
dec m.commitTicks 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: if m.commitTicks == 0 and safeTiles.len > 0:
# Filter out the blocked tile from candidates # Filter out the blocked tile from candidates
var candidates: seq[ScoredTile] var candidates: seq[ScoredTile]
@@ -780,6 +888,22 @@ proc computeMove*(m: var TFILRingModule, ws: WorldState): MoveCommand =
continue continue
candidates.add t candidates.add t
if candidates.len == 0: candidates = safeTiles # all blocked → ignore block 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 # 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. # AMONG `candidates`, which is exactly the pool the old `rand` picked from.
# It can never select a tile the unweighted code would have rejected # 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) y: m.marginY + (ct.row.float + 0.5) * GridSize)
m.commitTicks = CommitTicks m.commitTicks = CommitTicks
m.commitLava = m.lavaAt(ct.col, ct.row) m.commitLava = m.lavaAt(ct.col, ct.row)
m.commitAge = 0
inc m.picks
m.blockedTile.active = false # clear after successful pick m.blockedTile.active = false # clear after successful pick
# One concise log line on a range-class or band change (never per-tick). # One concise log line on a range-class or band change (never per-tick).
+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
File diff suppressed because it is too large Load Diff
+164
View File
@@ -0,0 +1,164 @@
#!/usr/bin/env python3
"""Ghost-vs-observer probe (j147) — is the 1-tick aura lag a DECISION lag?
For every enemy fire in the recorded event sidecar we locate the ghost our
mover spawned for it (the `[firediag] SPAWN` line) and compare
* WHEN the ghost was spawned (its tick) vs WHEN the enemy fired, and
* WHERE the ghost was drawn vs WHERE the true bullet is at that instant.
Timeline anchoring is done in ABSOLUTE arena coordinates: the bot logs its own
position at the spawn tick, and the capture row carrying that position pins
`captureRow = botTick + k` (a per-round constant = the server's delivery
offset). With k known,
trueBullet(row) = fireOrigin + (row - fireRow) * v (v = 20 - 3*power)
ghost(row) = spawnGhostPos + (row - spawnRow) * v
so both the time lag and the pixel displacement are directly measurable.
"""
import json, math, re, sys, collections
SPAWN_RE = re.compile(
r"SPAWN tick=(\d+) sx=([-\d.eE+]+) sy=([-\d.eE+]+) gx=([-\d.eE+]+) "
r"gy=([-\d.eE+]+) p=([-\d.eE+]+) eta=([-\d.eE+]+)")
def load(arm_dir):
rows = [json.loads(l) for l in open(f"{arm_dir}/run1.jsonl") if '"tick"' in l]
ev = [json.loads(l) for l in open(f"{arm_dir}/run1.events.jsonl") if l.strip()]
rounds = json.load(open(f"{arm_dir}/run1.jsonl.rounds.json"))["rounds"]
spawns = []
for line in open(f"{arm_dir}/run1.bot.stdout.log"):
m = SPAWN_RE.search(line)
if m:
g = [float(x) for x in m.groups()]
spawns.append(dict(tick=int(g[0]), sx=g[1], sy=g[2], gx=g[3], gy=g[4],
p=g[5], eta=g[6]))
return rows, ev, rounds, spawns
def split_rounds(spawns):
"""The bot's tick restarts every round -> segment on a tick decrease."""
out, cur = [], []
for s in spawns:
if cur and s["tick"] <= cur[-1]["tick"]:
out.append(cur); cur = []
cur.append(s)
if cur:
out.append(cur)
return out
def analyse(arm_dir, verbose=True):
rows, ev, rounds, spawns = load(arm_dir)
pos = {r["tick"]: r for r in rows}
starts = {r["round"]: r["startTick"] for r in rounds}
counts = {r["round"]: r["count"] for r in rounds}
# who is the enemy, and which capture row does a fire turn correspond to
votes = collections.Counter()
for e in ev:
if e["type"] != "fire":
continue
gt = starts[e["round"]] + e["tick"]
for off in (-3, -2, -1, 0, 1):
d = pos.get(gt + off)
if not d:
continue
de = math.hypot(d["ex"] - e["x"], d["ey"] - e["y"])
ds = math.hypot(d["sx"] - e["x"], d["sy"] - e["y"])
votes[(e["owner"], "e" if de < ds else "s")] += 1
break
enemy_owner = max(votes.items(), key=lambda kv: kv[1])[0][0]
fires = [e for e in ev if e["type"] == "fire" and e["owner"] == enemy_owner]
by_round = collections.defaultdict(list)
for e in fires:
by_round[e["round"]].append(e)
recs, unmatched = [], 0
chunks = split_rounds(spawns)
# which capture row holds a given position (the bot's own, for the anchor)
where = collections.defaultdict(list)
for r in rows:
where[(round(r["sx"], 3), round(r["sy"], 3))].append(r["tick"])
for chunk in chunks:
for s in chunk:
# TIMELINE, no guessing. MEASURED LIVE (`[firediag] EV hit` lines vs
# the capture's event sidecar, exact matches): the sidecar's per-round
# `tick` IS the server getTurn and the bot runs with
# `getTurn = bot.tick + 1` (j134), so a ghost logged at bot tick `t`
# was placed during server turn `t + 1` of its round. The round is
# found from the bot's OWN logged position (unique per round).
hits = where.get((round(s["sx"], 3), round(s["sy"], 3)), ())
rnd = None
for hrow in hits:
for r_ in rounds:
if r_["startTick"] <= hrow < r_["startTick"] + r_["count"]:
rnd = r_["round"]
break
if rnd is not None:
break
if rnd is None:
unmatched += 1
continue
sturn = s["tick"] + 1
cands = []
for e in by_round[rnd]:
if abs(e["tick"] - sturn) > 2 or abs(e["power"] - s["p"]) > 1e-6:
continue
oerr = math.hypot(s["gx"] - e["x"], s["gy"] - e["y"])
if oerr < 32.0: # the scanned enemy IS the shooter
cands.append((abs(e["tick"] - sturn), oerr, e, e["tick"]))
if not cands:
unmatched += 1
continue
cands.sort()
_, oerr, e, eturn = cands[0]
sp = 20.0 - 3.0 * e["power"]
th = math.radians(e["dir"])
vx, vy = sp * math.cos(th), sp * math.sin(th)
d = sturn - eturn # +ve = ghost spawned LATE
# a bullet takes its FIRST step during the turn it is fired, so at
# the start of server turn `eturn + n` the true bullet sits at
# origin + n * v.
tx, ty = e["x"] + vx * d, e["y"] + vy * d
# arrival deadline: the mover's own eta (logged) vs the true remaining
# flight time to the TRUE bullet from the same reference point.
etaTrue = math.hypot(s["sx"] - tx, s["sy"] - ty) / sp
recs.append(dict(round=rnd, tick=s["tick"], lag=d, power=e["power"],
speed=sp, origin_err=oerr, eta=s["eta"],
err=math.hypot(s["gx"] - tx, s["gy"] - ty),
lagerr=abs(s["eta"] - etaTrue)))
if verbose:
print(f"\n=== {arm_dir}")
print(f"enemy owner id={enemy_owner} spawns={len(spawns)} matched={len(recs)}"
f" unmatched={unmatched}")
if not recs:
return None, []
print("DETECTION LAG (capture row of the spawn - capture row of the fire),"
" +ve = detected LATE:")
for lag, n in sorted(collections.Counter(r["lag"] for r in recs).items()):
print(f" lag={lag:+d} ticks : {n:4d} ({100.0*n/len(recs):5.1f}%)")
errs = sorted(r["err"] for r in recs)
n = len(errs)
print(f"GHOST-vs-TRUTH displacement px: mean={sum(errs)/n:.2f} median={errs[n//2]:.2f}"
f" p90={errs[int(0.9*n)]:.2f} max={errs[-1]:.2f}")
oe = sorted(r["origin_err"] for r in recs)
print(f" of which ghost ORIGIN vs fire origin (the scanned enemy position):"
f" mean={sum(oe)/n:.2f} median={oe[n//2]:.2f} max={oe[-1]:.2f}")
# the pure time part: lag * speed
pure = sorted(abs(r["lag"]) * r["speed"] for r in recs)
print(f"TIME part only (|lag| * speed): mean={sum(pure)/n:.2f} "
f"median={pure[n//2]:.2f} max={pure[-1]:.2f}")
le = sorted(r["lagerr"] for r in recs)
print(f"ARRIVAL-DEADLINE error (mover's eta - the true remaining flight),"
f" ticks: mean={sum(le)/n:.3f} median={le[n//2]:.3f}"
f" p90={le[int(0.9*n)]:.3f} max={le[-1]:.3f}")
return None, recs
if __name__ == "__main__":
for d in sys.argv[1:]:
analyse(d)
+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()
@@ -0,0 +1,244 @@
## OFFLINE — j153. THE OWNER'S "HOLD WHEN TRAPPED" — OPEN-LOOP DESCRIPTORS ONLY.
##
## The claim: "if no tile is found to go, to not choose the less dangerous, but
## to stay still! the next tick probably the situation already changed and we
## did not commit to any dangerous place."
##
## This ruler reports FOUR DESCRIPTORS of the RECORDED field around every forced
## pick (the pick the mover makes when the safe set is too small to draw from).
## They are all measured on the recorded trajectory, forward-looking in TIME but
## NOT counterfactual: nothing here replays "what damage would holding have
## cost". Per `docs/offline_harness_trust.md` that question is closed-loop and
## the replay ruler scored 0/6 on such questions, so it is NOT asked here.
##
## 1. WAIT WINDOW — at a forced pick, holding OUR POSITION fixed, how many
## ticks pass before ANY tile inside the reachable hull is safe
## (pathMaxHeat <= PathDangerThreshold, after the CoolestLevels=2 filter)?
## CAVEAT: the safe set depends on where we are, so this holds the pick
## position and replays the field — the recorded field still belongs to a
## bot that moved, so the window is an APPROXIMATION of the wait a holder
## would really see. It is an UPPER bound on the wait (a moving recorded
## bot stirs the field) and it is reported with the censoring share.
## 2. FRESH FIRE — ticks since the enemy's last confirmed shot (energy-drop
## detection, the same signal the mover itself uses).
## 3. OUR OWN TILE — is our current tile already over the threshold at the
## forced pick? (if yes, "stay" is not on the table at all)
## 4. DISTANCE — all of the above split by distance to the nearest enemy.
##
## No battle, no Java, no server, no behaviour change (the knob is off).
##
## Run:
## nim c -r --path:common_libs --nimcache:/tmp/nc_j153 \
## common_libs/tests/measure_tfil_hold_window.nim [fixture.jsonl ...]
import std/[os, strformat, math, algorithm, json, random, sequtils, sets]
import std/strutils except fromHex
import gun_harness/offline_range
include movements/the_floor_is_lava
const PathSampleStep = 18.0 ## the picker's own path sampling step
const MaxWait = 40 ## censor horizon, ticks
const ArenaW = 800.0
const ArenaH = 600.0
type Probe = object
idx: int ## fixture index of the forced pick
x, y, heading, speed: float ## OUR position at the pick (held fixed)
dist: float ## to the nearest enemy at the pick
ownHot: bool ## our own tile already over the threshold
emptySet: bool ## the safe set was EMPTY (0), not merely < 2
sinceFire: int ## ticks since the enemy's last confirmed shot
wait: int ## filled below: -1 = censored
resolved: bool
proc loadRoundStarts(path: string): HashSet[int] =
result = initHashSet[int]()
for side in [path & ".rounds.json",
currentSourcePath().parentDir.parentDir.parentDir /
"tools" / "fixtures" / "drussgt_meta" /
(extractFilename(path) & ".rounds.json")]:
if not fileExists(side): continue
let root = parseFile(side)
if not root.hasKey("rounds"): continue
for r in root["rounds"]:
if r.hasKey("startTick"): result.incl r["startTick"].getInt()
proc pathMax(m: TFILModule, fx, fy, tx, ty: float): float =
let ddx = tx - fx; let ddy = ty - fy
let d = sqrt(ddx*ddx + ddy*ddy)
if d <= 0.1: return 0.0
let steps = max(1, int(d / PathSampleStep))
var h = 0.0
for si in 0..steps:
let f = si.float / steps.float
let (sc, sr) = m.tileAt(fx + ddx * f, fy + ddy * f)
h = max(h, m.lavaAt(sc, sr))
h
proc coolestOf(m: TFILModule, tiles: seq[tuple[col, row: int]]):
seq[tuple[col, row: int]] =
## The picker's CoolestLevels=2 filter: only the coolest 2 DISTINCT lava
## values among the tiles count as candidates at all.
var vals: seq[float]
for t in tiles:
let v = m.lavaAt(t.col, t.row)
var found = false
for dv in vals:
if dv == v: found = true; break
if not found: vals.add v
for i in 1..<vals.len:
let key = vals[i]; var j = i - 1
while j >= 0 and vals[j] > key:
vals[j + 1] = vals[j]; dec j
vals[j + 1] = key
let lv = min(CoolestLevels, vals.len)
for t in tiles:
let v = m.lavaAt(t.col, t.row)
for li in 0..<lv:
if v == vals[li]:
result.add t
break
proc safeCount(m: TFILModule, tiles: seq[tuple[col, row: int]],
fx, fy, thr: float): int =
for t in coolestOf(m, tiles):
let tx = m.marginX + (t.col.float + 0.5) * GridSize
let ty = m.marginY + (t.row.float + 0.5) * GridSize
if pathMax(m, fx, fy, tx, ty) <= thr: inc result
proc safeTileExists(m: TFILModule, p: Probe, thr: float): bool =
## The picker's own definition, from OUR HELD POSITION: inside the reachable
## hull -> CoolestLevels=2 coolest distinct lava values -> path heat filter.
let hull = computeReachableHull(p.x, p.y, p.heading, p.speed,
ArenaW, ArenaH, HullTicks)
if hull.len < 3: return false
var inside: seq[tuple[col, row: int]]
for row in 0..<m.rows:
for col in 0..<m.cols:
let cx = m.marginX + (col.float + 0.5) * GridSize
let cy = m.marginY + (row.float + 0.5) * GridSize
if pointInHull(cx, cy, hull): inside.add (col, row)
safeCount(m, inside, p.x, p.y, thr) > 0
proc nearestEnemyDist(ws: WorldState): float =
result = Inf
for e in ws.enemies:
result = min(result, sqrt((e.x - ws.selfX)^2 + (e.y - ws.selfY)^2))
proc collect(fx: seq[WorldState], starts: HashSet[int], seed: int): seq[Probe] =
randomize(seed)
var m = initTFIL()
var lastPicks = 0
var lastBullets = 0
var sinceFire = 99
for i in 0..<fx.len:
if i == 0 or i in starts: m.resetRound()
discard m.computeMove(fx[i])
if m.bullets.len > lastBullets: sinceFire = 0 else: inc sinceFire
lastBullets = m.bullets.len
if m.picks == lastPicks: continue
lastPicks = m.picks
# a pick happened: was the safe set big enough to draw from?
let safePre = safeCount(m, m.cachedInsideTiles, fx[i].selfX, fx[i].selfY,
TfilDangerThreshold)
if safePre >= 2: continue # not a forced pick
let (cc, cr) = m.tileAt(fx[i].selfX, fx[i].selfY)
result.add Probe(idx: i, x: fx[i].selfX, y: fx[i].selfY,
heading: fx[i].selfHeading, speed: fx[i].selfSpeed,
dist: nearestEnemyDist(fx[i]),
emptySet: safePre == 0,
ownHot: m.lavaAt(cc, cr) > TfilDangerThreshold,
sinceFire: sinceFire, wait: -1)
proc fillWaits(fx: seq[WorldState], starts: HashSet[int], seed: int,
probes: var seq[Probe]) =
## Second linear pass, SAME trajectory: at tick j every still-open probe is
## advanced to k = j - idx and asked whether a safe tile exists at k.
randomize(seed)
var m = initTFIL()
for j in 0..<fx.len:
if j == 0 or j in starts: m.resetRound()
discard m.computeMove(fx[j])
for p in probes.mitems:
if p.resolved: continue
let k = j - p.idx
if k < 1: continue
if k > MaxWait:
p.resolved = true; p.wait = -1; continue
if safeTileExists(m, p, TfilDangerThreshold):
p.resolved = true; p.wait = k
proc mean(xs: seq[int]): float =
if xs.len == 0: return 0.0
var t = 0
for x in xs: t += x
t.float / xs.len.float
proc median(xs: seq[int]): int =
if xs.len == 0: return -1
let s = xs.sorted()
s[s.len div 2]
proc shareN(n, d: int): string =
if d == 0: return "n/a"
&"{100.0 * n.float / d.float:.1f}%"
proc share(n, d: int): string = shareN(n, d)
proc report(label: string, ps: seq[Probe]) =
echo &"\n\u2550\u2550\u2550 {label}"
if ps.len == 0: echo " no forced picks"; return
let waits = ps.filterIt(it.wait >= 0).mapIt(it.wait)
let cens = ps.filterIt(it.wait < 0)
echo &" forced picks: {ps.len} of which EMPTY safe set (0 tiles): " &
&"{shareN(ps.filterIt(it.emptySet).len, ps.len)}"
echo &" WAIT WINDOW (ticks until ANY safe tile exists, position held):"
echo &" median {median(waits)} mean {mean(waits):.1f}" &
&" resolved {waits.len}/{ps.len} censored(>{MaxWait}) {cens.len}"
for k in [1, 3, 5, 10]:
echo &" within {k:>2} tick(s): {share(waits.filterIt(it <= k).len, ps.len)}"
echo &" FRESH FIRE (ticks since the enemy's last confirmed shot):"
echo &" same tick {shareN(ps.filterIt(it.sinceFire == 0).len, ps.len)}" &
&" prev 1 tick {shareN(ps.filterIt(it.sinceFire <= 1).len, ps.len)}" &
&" prev 3 ticks {shareN(ps.filterIt(it.sinceFire <= 3).len, ps.len)}" &
&" >3 ticks {shareN(ps.filterIt(it.sinceFire > 3).len, ps.len)}"
echo &" OUR OWN TILE already over the threshold: " &
&"{shareN(ps.filterIt(it.ownHot).len, ps.len)}"
for (name, lo, hi) in [("close <150px", 0.0, 150.0), ("mid 150-300px", 150.0, 300.0),
("far >300px", 300.0, 1.0e9)]:
let g = ps.filterIt(it.dist >= lo and it.dist < hi)
if g.len == 0:
echo &" {name}: n/a"; continue
let w = g.filterIt(it.wait >= 0).mapIt(it.wait)
echo &" {name}: n={g.len} median wait {median(w)} " &
&"<=3t {share(w.filterIt(it <= 3).len, g.len)} <=10t {share(w.filterIt(it <= 10).len, g.len)}" &
&" fired<=1t ago {shareN(g.filterIt(it.sinceFire <= 1).len, g.len)}" &
&" own tile hot {shareN(g.filterIt(it.ownHot).len, g.len)}"
# ── driver ───────────────────────────────────────────────────────────────────
let args = commandLineParams()
let fixtures = if args.len > 0: args
else: @["/tmp/firelag_live2/tfil_on/run1.jsonl",
"/tmp/firelag_live2/tfil_off/run1.jsonl",
"/tmp/firelag_live2/strafe_on/run1.jsonl",
"/tmp/firelag_live2/strafe_off/run1.jsonl",
currentSourcePath().parentDir.parentDir.parentDir /
"tools" / "fixtures" / "tr_drussgt_vs_modularbot.jsonl",
currentSourcePath().parentDir.parentDir.parentDir /
"tools" / "fixtures" / "tr_drussgt_vs_corners.jsonl",
currentSourcePath().parentDir.parentDir.parentDir /
"tools" / "fixtures" / "tr_drussgt_vs_crazy.jsonl",
currentSourcePath().parentDir.parentDir.parentDir /
"tools" / "fixtures" / "tr_drussgt_vs_spinbot.jsonl"]
var total: seq[Probe]
for f in fixtures:
if not fileExists(f):
echo "skip (missing): ", f; continue
let fx = loadFixture(f).states
let starts = loadRoundStarts(f)
for seed in [7, 8, 9]:
var ps = collect(fx, starts, seed)
fillWaits(fx, starts, seed, ps)
total.add ps
if seed == 7: report(extractFilename(f), ps)
report("ALL FIXTURES x 3 SEEDS", total)
@@ -0,0 +1,291 @@
## OFFLINE — j151. THE OWNER'S TWO PAINTERS, PER PICK.
##
## "the bot choose a tile that is almost perpendicular to it, a tile that is not
## reachable in feasible time and 1 will put the bot in danger trying to go
## there 2 will not arrive there as a new location will drive it away."
##
## This ruler replays recorded fixtures through the REAL
## `TFILModule.computeMove` and records, for EVERY pick:
## turn° angle between the current heading and the chosen tile
## pathMax/Mean lava on the straight-line path bot -> chosen tile
## destHeat lava on the chosen tile itself
## tta dist / MaxSpeed, i.e. ticks to arrive at full speed
## promoted the pick had to break the heat filter (safePre < 2)
## safePre size of the safe set BEFORE the "keep 2" promotion
## hotAtTta the destination tile was OVER the threshold `tta` ticks
## later, on the recorded (true) future <- the feasibility test
## tile the chosen (col,row) <- j152: pick DIVERSITY
## j152: every row of the sweep table also reports the DIVERSITY cost (distinct
## tiles, entropy, top-tile share). j51 measured the randomness in this draw as
## LOAD-BEARING, so a geometry weight that improves the geometry numbers while
## collapsing the distribution is a regression, not a win.
## No battle, no Java, no server, no behaviour change.
##
## Run:
## nim c -r --path:common_libs --nimcache:/tmp/nc_j151 \
## common_libs/tests/measure_tfil_pick_defects.nim [fixture.jsonl ...]
## Env it forwards: TR_TFIL_CORRIDOR_TICKS, TR_TFIL_DANGER_THRESHOLD, ...
import std/[os, strformat, math, algorithm, json, sets, random, sequtils, tables]
import std/strutils except fromHex # `fromHex` would clash with color.fromHex
import gun_harness/offline_range
# Private-field access: include (do NOT import) the shipped mover.
include movements/the_floor_is_lava
const PathSampleStep = 18.0 # the picker's own sampling step
const PerpDeg = 60.0 ## the owner's "perpendicular"
type Pick = object
turn, pathMax, pathMean, destHeat, dist, tta: float
promoted: bool
safePre, cand: int
hotAtTta: bool ## destination over threshold when we would arrive
reached: bool ## we actually got within ArriveRadius by then
col, row: int ## the chosen tile (diversity)
proc loadRoundStarts(path: string): HashSet[int] =
result = initHashSet[int]()
for side in [path & ".rounds.json",
currentSourcePath().parentDir.parentDir.parentDir /
"tools" / "fixtures" / "drussgt_meta" /
(extractFilename(path) & ".rounds.json")]:
if not fileExists(side): continue
let root = parseFile(side)
if not root.hasKey("rounds"): continue
for r in root["rounds"]:
if r.hasKey("startTick"): result.incl r["startTick"].getInt()
proc pathHeat(m: TFILModule, fx, fy, tx, ty: float): tuple[max, mean: float] =
let ddx = tx - fx
let ddy = ty - fy
let lineDist = sqrt(ddx*ddx + ddy*ddy)
if lineDist <= 0.1: return (0.0, 0.0)
let steps = max(1, int(lineDist / PathSampleStep))
var h = 0.0
var s = 0.0
for si in 0..steps:
let frac = si.float / steps.float
let (sc, sr) = m.tileAt(fx + ddx * frac, fy + ddy * frac)
let v = m.lavaAt(sc, sr)
h = max(h, v)
s += v
(h, s / (steps + 1).float)
proc safeSetSize(m: TFILModule, thr: float): int =
## Replay of the picker's own hard filter over the tiles it considered, from
## the same lava snapshot the pick saw. No re-implementation of the choice.
for t in m.cachedInsideTiles:
let tx = m.marginX + (t.col.float + 0.5) * GridSize
let ty = m.marginY + (t.row.float + 0.5) * GridSize
if pathHeat(m, m.lastBotX, m.lastBotY, tx, ty).max <= thr: inc result
proc replay(path: string, seed: int): seq[Pick] =
randomize(seed)
loadTfilCommitEnv()
let fx = loadFixture(path)
let starts = loadRoundStarts(path)
var m = initTFIL()
var lastPicks = 0
var pending: seq[tuple[col, row: int; at: int; idx: int]]
var mActive = 0
for si in 0..<fx.states.len:
if si == 0 or si in starts: m.resetRound()
discard m.computeMove(fx.states[si])
inc mActive
# 1. a new pick happened this tick -> record the geometry
if m.picks != lastPicks:
lastPicks = m.picks
let (cc, cr) = m.tileAt(m.commitTarget.x, m.commitTarget.y)
let ang = arctan2(m.commitTarget.y - m.lastBotY, m.commitTarget.x - m.lastBotX) *
180.0 / PI - fx.states[si].selfHeading
var turn = abs(((ang + 180.0) mod 360.0) - 180.0)
if turn > 180.0: turn = 360.0 - turn
let d = sqrt((m.commitTarget.x - m.lastBotX)^2 + (m.commitTarget.y - m.lastBotY)^2)
let (pmx, pmean) = pathHeat(m, m.lastBotX, m.lastBotY, m.commitTarget.x, m.commitTarget.y)
result.add Pick(turn: turn, pathMax: pmx, pathMean: pmean,
destHeat: m.lavaAt(cc, cr), dist: d, tta: d / MaxSpeed,
promoted: m.lastPickPromoted,
safePre: safeSetSize(m, TfilDangerThreshold),
cand: m.lastPickSafe, hotAtTta: false, reached: false,
col: cc, row: cr)
pending.add (col: cc, row: cr, at: mActive + int(d / MaxSpeed), idx: result.high)
# 2. the arrival probe on the recorded true future
var keep: seq[tuple[col, row: int; at: int; idx: int]]
for p in pending:
if mActive < p.at:
keep.add p
else:
result[p.idx].hotAtTta = m.lavaAt(p.col, p.row) > TfilDangerThreshold
let px = m.marginX + (p.col.float + 0.5) * GridSize
let py = m.marginY + (p.row.float + 0.5) * GridSize
result[p.idx].reached = sqrt((m.lastBotX - px)^2 + (m.lastBotY - py)^2) < ArriveRadius
pending = keep
proc mean(x: seq[float]): float =
if x.len == 0: return 0.0
var s = 0.0
for v in x: s += v
s / x.len.float
proc pc(x: float): string = &"{100.0 * x:.1f}%"
proc f1(x: float): string = &"{x:.1f}"
proc f2(x: float): string = &"{x:.2f}"
proc report(label, path: string, picks: seq[Pick]) =
echo &"\n\u2550\u2550\u2550 {label} {extractFilename(path)}"
if picks.len == 0: echo " no picks"; return
let thr = TfilDangerThreshold
var groups = [("EMPTY safe set (promoted)", picks.filterIt(it.promoted)),
("non-empty safe set", picks.filterIt(not it.promoted))]
var allPerp, allHot, allFar, allBad = 0
for (name, g) in groups:
let perp = g.filterIt(it.turn > PerpDeg)
let hot = g.filterIt(it.pathMax > thr) # crosses a hot region
let far = g.filterIt(it.tta > CommitTicks.float) # cannot arrive in the commitment
let bad = g.filterIt(it.turn > PerpDeg and it.pathMax > thr)
let badFar = g.filterIt(it.turn > PerpDeg and it.tta > CommitTicks.float)
let futHot = g.filterIt(it.hotAtTta)
let reach = g.filterIt(it.reached)
echo &" {name}: {g.len} picks ({pc(g.len.float/picks.len.float)} of all)"
if g.len == 0: continue
echo &" PERPENDICULAR (>60\u00b0) {perp.len:>6} {pc(perp.len.float/g.len.float):>7}" &
&" mean pathMax {f1(mean(perp.mapIt(it.pathMax)))}"
echo &" path crosses HOT {hot.len:>6} {pc(hot.len.float/g.len.float):>7}" &
&" mean pathMax(all) {f1(mean(g.mapIt(it.pathMax)))} destHeat {f1(mean(g.mapIt(it.destHeat)))}"
echo &" tta > commit({CommitTicks}) {far.len:>6} {pc(far.len.float/g.len.float):>7}" &
&" mean tta {f1(mean(g.mapIt(it.tta)))} max {f1(g.mapIt(it.tta).max)}"
echo &" PERP + hot {bad.len:>6} | PERP + far {badFar.len:>6}"
echo &" dest HOT when we arrive {futHot.len:>6} {pc(futHot.len.float/g.len.float):>7}" &
&" | actually arrived {pc(reach.len.float/g.len.float):>7}"
echo &" mean turn {f1(mean(g.mapIt(it.turn)))}\u00b0 mean safePre {f1(mean(g.mapIt(it.safePre.float)))}" &
&" mean cand {f1(mean(g.mapIt(it.cand.float)))}"
allPerp += perp.len; allHot += hot.len; allFar += far.len
allBad += bad.len + badFar.len
echo &" ALL: perp {pc(allPerp.float/picks.len.float)} hot-path {pc(allHot.float/picks.len.float)}" &
&" far {pc(allFar.float/picks.len.float)} (perp&(hot|far)) {pc(allBad.float/picks.len.float)}"
# ── driver ───────────────────────────────────────────────────────────────────
let args = commandLineParams()
let detail = "--detail" in args
let fixtures: seq[string] =
block:
if detail:
var v: seq[string]
for a in args:
if not a.startsWith("--"): v.add a
v
else:
@["/tmp/firelag_live2/tfil_on/run1.jsonl",
"/tmp/firelag_live2/tfil_off/run1.jsonl",
"/tmp/firelag_live2/strafe_on/run1.jsonl",
"/tmp/firelag_live2/strafe_off/run1.jsonl",
currentSourcePath().parentDir.parentDir.parentDir /
"tools" / "fixtures" / "tr_drussgt_vs_modularbot.jsonl",
currentSourcePath().parentDir.parentDir.parentDir /
"tools" / "fixtures" / "tr_drussgt_vs_corners.jsonl",
currentSourcePath().parentDir.parentDir.parentDir /
"tools" / "fixtures" / "tr_drussgt_vs_crazy.jsonl",
currentSourcePath().parentDir.parentDir.parentDir /
"tools" / "fixtures" / "tr_drussgt_vs_spinbot.jsonl"]
# ── j152: the sweep, with the DIVERSITY cost on every row ────────────────────
## (label, TR_TFIL_GEO_MODE, TR_TFIL_GEO_TAU, TR_TFIL_ARRIVE_TICKS)
type Arm = tuple[label, mode, tau, arrive: string]
proc diversity(picks: seq[Pick]): tuple[distinctN, topShare, entBits, normEnt: float] =
## Shannon entropy (bits) of the CHOICE distribution over tiles. `normEnt` is
## H / log2(distinct): 1.0 = the arm spreads its picks over exactly as many
## tiles as the baseline, 0.0 = every pick is the same tile.
var counts: Table[(int, int), int]
for p in picks: counts[(p.col, p.row)] = counts.getOrDefault((p.col, p.row)) + 1
result.distinctN = counts.len.float
if picks.len == 0: return
var h = 0.0
var top = 0
for _, n in counts.pairs:
let q = n.float / picks.len.float
h -= q * log2(q)
top = max(top, n)
result.topShare = top.float / picks.len.float
result.entBits = h
result.normEnt = if result.distinctN > 1.0: h / log2(result.distinctN) else: 0.0
proc pctS(x, n: int): string =
if n == 0: return " n/a"
pc(x.float / n.float)
proc isPerp(p: Pick): bool = p.turn > PerpDeg
proc isPromoted(p: Pick): bool = p.promoted
proc isFar(p: Pick): bool = p.tta > 15.0
proc isReached(p: Pick): bool = p.reached
proc isHotAtTta(p: Pick): bool = p.hotAtTta
proc row(label: string, picks: seq[Pick]): string =
let n = picks.len
let emp = picks.filterIt(isPromoted(it))
let nes = picks.filterIt(not isPromoted(it))
let perp = picks.filterIt(isPerp(it)).len
let perpE = emp.filterIt(isPerp(it)).len
let perpN = nes.filterIt(isPerp(it)).len
let far = picks.filterIt(isFar(it)).len
let reach = picks.filterIt(isReached(it)).len
let hot = picks.filterIt(isHotAtTta(it)).len
let d = diversity(picks)
&"{label:<22} {pctS(perp, n):>7} {pctS(perpE, emp.len):>7} {pctS(perpN, nes.len):>7}" &
&" {pctS(far, n):>7} {f1(mean(picks.mapIt(it.tta))):>6}" &
&" {pctS(reach, n):>7} {pctS(hot, n):>7} {pctS(emp.len, n):>7}" &
&" {d.distinctN.int:>6} {f2(d.entBits):>6} {f2(d.normEnt):>6} {pc(d.topShare):>7}" &
&" {f1(mean(picks.mapIt(it.cand.float))):>5}"
proc header(): string =
result = "arm".align(22, ' ')
for (h, w) in [("perp", 7), ("perpE", 7), ("perpN", 7), ("far", 7), ("mtta", 6),
("REACH", 7), ("hotArr", 7), ("empty", 7), ("tiles", 6),
("Hbits", 6), ("H/", 6), ("top1", 7), ("cand", 5)]:
result &= " " & h.align(w, ' ')
# `perpE`/`perpN` = the perpendicular rate in the FORCED (empty safe set) and the
# non-empty populations; `REACH` = the headline (tile actually stood on at tta);
# `tiles`/`Hbits`/`H/`/`top1` = the diversity cost; `cand` = mean draw-set size.
let arms: seq[Arm] = @[
("BASELINE (off)", "off", "0", "0"),
("turn-soft tau90", "turn-soft", "90", "0"),
("turn-soft tau45", "turn-soft", "45", "0"),
("turn-soft tau20", "turn-soft", "20", "0"),
("turn-topk", "turn-topk", "45", "0"),
("turn-rej tau60", "turn-rej", "60", "0"),
("dist-soft tau90", "dist-soft", "90", "0"),
("dist-soft tau30", "dist-soft", "30", "0"),
("both-soft tau90", "both-soft", "90", "0"),
("both-soft tau45", "both-soft", "45", "0"),
("both-soft tau20", "both-soft", "20", "0"),
("both-topk", "both-topk", "45", "0"),
("both-rej tau60", "both-rej", "60", "0"),
# j151 interaction: a soft distance preference vs the HARD arrival bound.
("arrive15 (j151)", "off", "0", "15"),
("arrive15+both t45", "both-soft", "45", "15")]
echo "\n", header()
for a in arms:
putEnv("TR_TFIL_GEO_MODE", a.mode)
putEnv("TR_TFIL_GEO_TAU", a.tau)
putEnv("TR_TFIL_ARRIVE_TICKS", a.arrive)
var picks: seq[Pick]
for f in fixtures:
if not fileExists(f): continue
for seed in [7, 8, 9]: picks.add replay(f, seed)
echo row(a.label, picks)
# ── per-fixture detail (--detail only), for the BASELINE arm ────────────────
if detail:
putEnv("TR_TFIL_GEO_MODE", arms[0].mode)
putEnv("TR_TFIL_GEO_TAU", arms[0].tau)
putEnv("TR_TFIL_ARRIVE_TICKS", arms[0].arrive)
var total: seq[Pick]
for f in fixtures:
if not fileExists(f):
echo "skip (missing): ", f; continue
for seed in [7, 8, 9]:
let p = replay(f, seed)
total.add p
if seed == 7: report("seed 7", f, p)
report("ALL FIXTURES x 3 SEEDS", "", total)
@@ -0,0 +1,118 @@
## OFFLINE — j150. WHERE DOES THE LAVA PICKER LOSE ITS TILES?
##
## The owner's report: "the bot chooses only between a poor number of tiles while
## there are a lot of them available but not considered". This ruler drives the
## REAL `TFILModule.computeMove` (with `TR_TFIL_DIAG=1`) over recorded
## DrussGT fixtures and reads the per-pick loss histogram the mover counts:
##
## reachable hull -> CoolestLevels(=2) distinct-lava filter
## -> pathMaxHeat <= PathDangerThreshold -> draw set -> CHOSEN
##
## No battle, no Java, no server, no behaviour change: every knob it moves is
## default-off or default-identical.
##
## Run:
## nim c -r --path:common_libs --nimcache:/tmp/nc_j150 \
## common_libs/tests/measure_tfil_picker_loss.nim [fixture.jsonl ...]
import std/[os, strformat, math, algorithm, json, sets, sequtils]
import std/strutils except fromHex # `fromHex` would clash with color.fromHex
import gun_harness/offline_range
# Private-field access: include (do NOT import) the shipped mover.
include movements/the_floor_is_lava
proc loadRoundStarts(fixturePath: string): HashSet[int] =
result = initHashSet[int]()
let side = currentSourcePath().parentDir.parentDir.parentDir /
"tools" / "fixtures" / "drussgt_meta" /
(extractFilename(fixturePath) & ".rounds.json")
if not fileExists(side): return
let root = parseFile(side)
if not root.hasKey("rounds"): return
for r in root["rounds"]:
if r.hasKey("startTick"): result.incl r["startTick"].getInt()
proc replay(path: string, threshold: float): TfilLossStats =
putEnv("TR_TFIL_DIAG", "1")
putEnv("TR_TFIL_DANGER_THRESHOLD", $threshold)
loadTfilCommitEnv()
TfilLoss = TfilLossStats()
let fx = loadFixture(path)
let starts = loadRoundStarts(path)
var m = initTFIL()
for si in 0..<fx.states.len:
if si == 0 or si in starts: m.resetRound()
discard m.computeMove(fx.states[si])
result = TfilLoss
proc mean(x: seq[float]): float =
if x.len == 0: return 0.0
var s = 0.0
for v in x: s += v
s / x.len.float
proc pctStr(x: float): string = &"{100.0 * x:.1f}%"
proc f2(x: float): string = &"{x:.2f}"
const SafeBuckets = ["0", "1", "2-3", "4-7", "8-15", "16-31", "32-63", "64+"]
proc heatTally(x: seq[float]): seq[(float, int)] =
## distinct heat values -> how many rejected tiles carried it, most common
## first. Lava is a sum of quantised terms, so this shows the real steps.
var counts: seq[(float, int)]
for v in x:
var i = 0
while i < counts.len and counts[i][0] != v: inc i
if i < counts.len: inc counts[i][1]
else: counts.add (v, 1)
result = counts
result.sort(proc (a, b: (float, int)): int = cmp(b[1], a[1]))
proc report(path: string) =
let s = replay(path, 10.0)
echo &"\n\u2550\u2550\u2550 {path}"
if s.picks == 0:
echo " no picks"; return
let n = s.picks.float
echo &" picks = {s.picks} (replayed ticks, one recorded battle, seed fixed)"
echo &" stage mean tiles"
echo &" 1 reachable-hull tiles {s.sReach.float/n:>12.2f}"
echo &" 2 .. after CoolestLevels=2 filter {s.sCool.float/n:>12.2f}"
echo &" 3 .. after path heat filter (pre-prom) {s.sSafe.float/n:>12.2f}"
echo &" 4 .. draw set (what it chooses among) {s.sCand.float/n:>12.2f}"
echo &" LOST at the 2-levels filter {(1.0 - s.sCool.float/s.sReach.float)*100:>11.1f}%"
echo &" LOST at the heat filter {(1.0 - s.sSafe.float/max(1.0,s.sCool.float))*100:>11.1f}%"
echo &" LOST at blocked-tile / no-rev {(1.0 - s.sCand.float/max(1.0,s.sSafe.float))*100:>11.1f}%"
echo &" picks with an EMPTY safe set (<2 at stage 3) = {s.emptySafe} ({pctStr(s.emptySafe.float/n)})"
echo " safe-set size distribution (stage 3):"
for i in 0..<SafeBuckets.len:
if s.safeHist[i] > 0:
echo &" {SafeBuckets[i].alignLeft(6)} {s.safeHist[i]:>7} ({pctStr(s.safeHist[i].float/n)})"
# heat of the tiles the filter dropped
echo &" heat of the {s.rejectedHeat.len} REJECTED tiles (mean {f2(mean(s.rejectedHeat))}), most common first:"
for (v, c) in heatTally(s.rejectedHeat)[0 ..< min(8, s.rejectedHeat.len)]:
echo &" heat {f2(v):>7} {c:>7} ({pctStr(c.float/s.rejectedHeat.len.float)})"
echo &" heat of ADMITTED tiles mean {f2(mean(s.admittedHeat))} (n={s.admittedHeat.len}); " &
&"of CHOSEN mean {f2(mean(s.chosenHeat))} (n={s.chosenHeat.len})"
# \u2550\u2550 threshold sweep
echo " threshold mean draw set % empty safe newly admitted (n, mean heat) mean heat chosen"
var baseAdmitted = replay(path, 10.0).admittedHeat.len
for thr in [10.0, 14.0, 18.0]:
let a = replay(path, thr)
let newly = a.admittedHeat.filterIt(it > 10.0)
let newTxt = $newly.len & ", " & f2(mean(newly))
echo &" {thr:>9.0f} {a.sCand.float / a.picks.float:>13.2f} " &
&"{pctStr(a.emptySafe.float / a.picks.float):>12} {newTxt:>30} {f2(mean(a.chosenHeat)):>16}"
echo &" (admitted at threshold 10: {baseAdmitted} tiles)"
# ── driver ───────────────────────────────────────────────────────────────────
let args = commandLineParams()
let fixtures = if args.len > 0: args
else: @[currentSourcePath().parentDir.parentDir.parentDir /
"tools" / "fixtures" / "tr_drussgt_vs_modularbot.jsonl"]
for f in fixtures:
if fileExists(f): report(f)
else: echo "skip (missing): ", f
+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)
+874 -1
View File
@@ -37,11 +37,16 @@
## A/B whose treatment did not apply is worthless) and that the soft ## A/B whose treatment did not apply is worthless) and that the soft
## no-reversal preference can never empty the candidate pool. ## no-reversal preference can never empty the candidate pool.
import std/[os, json, random, math, sequtils] import std/[os, json, random, math, sequtils, sets]
import std/strutils except fromHex # `fromHex` would clash with color.fromHex import std/strutils except fromHex # `fromHex` would clash with color.fromHex
import gun_harness/gun_interface import gun_harness/gun_interface
import movements/ram_decision
# Private-field access: include (do NOT import) the shipped mover. # Private-field access: include (do NOT import) the shipped mover.
include movements/the_floor_is_lava 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 repoRoot = currentSourcePath().parentDir.parentDir.parentDir
const fixtureRel = "tr_drussgt_vs_modularbot.jsonl" const fixtureRel = "tr_drussgt_vs_modularbot.jsonl"
@@ -848,6 +853,865 @@ when declared(loadTfilCommitEnv):
" mean path heat=", meanPathHeat(s).formatFloat(ffDecimal, 2), " mean path heat=", meanPathHeat(s).formatFloat(ffDecimal, 2),
" >90deg=", pct(s.bigTurn, s.picks) " >90deg=", pct(s.bigTurn, s.picks)
## j147: TR_FIRE_LAG back-dates the ghost by the MEASURED detection lag (1
## tick live: 1777/1777 matched spawns, `measure_fire_ghost_lag.py`). Default
## 0 must be byte-for-byte today's spawn, and lag=n must move the ghost exactly
## n bullet steps downrange — which is what shortens the arrival deadline,
## because every mover derives the deadline from the ghost's own position.
proc testJ147() =
let ws = WorldState(enemyX: 0.0, enemyY: 0.0, enemyHeading: 0.0,
enemySpeed: 0.0, enemyEnergy: 100.0,
selfX: 400.0, selfY: 320.0, selfHeading: 0.0,
selfSpeed: 0.0, selfEnergy: 100.0,
arenaWidth: ArenaW, arenaHeight: ArenaH, tick: 1,
enemies: @[])
let ei = EnemyInfo(id: 1, x: 200.0, y: 320.0, heading: 0.0,
speed: 0.0, energy: 100.0)
const Power = 1.0
let speed = 20.0 - 3.0 * Power # 17 px/tick
proc spawnGhost(): TrackedBullet =
var m = initTFIL()
discard m.computeMove(ws) # initGrid
m.spawnTrackedWave(ws, ei, Power)
m.bullets[^1]
# 1. default parity: unset -> 0, and the ghost is EXACTLY the scanned origin
delEnv("TR_FIRE_LAG")
loadFireTrackerEnv()
check "j147: TR_FIRE_LAG unset -> FireLag 0", FireLag == 0
let g0 = spawnGhost()
check "j147: default (lag 0) puts the ghost exactly on the scanned enemy",
g0.x == ei.x and g0.y == ei.y
# 2. lag 1 back-dates by EXACTLY one bullet step, on the ghost's own heading
putEnv("TR_FIRE_LAG", "1")
loadFireTrackerEnv()
let g1 = spawnGhost()
check "j147: TR_FIRE_LAG=1 places the ghost one bullet step downrange",
FireLag == 1 and
abs((g1.x - g0.x) - g1.velX) < 1e-9 and
abs((g1.y - g0.y) - g1.velY) < 1e-9
check "j147: ... and that step is the true bullet speed, not a scaled one",
abs(sqrt(g1.velX * g1.velX + g1.velY * g1.velY) - speed) < 1e-9
# 3. the ARRIVAL DEADLINE shortens by exactly `lag` ticks. The mover's own
# arrival proxy is dist(self, ghost) / speed (tfil `heatDecay(along/speed)`,
# the `dot < 0` reap); the true bullet is one step further along.
let etaGhost = sqrt((ws.selfX - g1.x)^2 + (ws.selfY - g1.y)^2) /
sqrt(g1.velX * g1.velX + g1.velY * g1.velY)
let etaTrue0 = sqrt((ws.selfX - g0.x)^2 + (ws.selfY - g0.y)^2) / speed
let etaTrue1 = sqrt((ws.selfX - (g0.x + g0.velX))^2 +
(ws.selfY - (g0.y + g0.velY))^2) / speed
check "j147: with lag=1 the mover's deadline equals the TRUE remaining " &
"flight (" & etaGhost.formatFloat(ffDecimal, 6) & " vs " &
etaTrue1.formatFloat(ffDecimal, 6) & "), the lag-0 deadline being " &
etaTrue0.formatFloat(ffDecimal, 6) & " — a full tick late",
abs(etaGhost - etaTrue1) < 1e-9 and
abs((etaTrue0 - etaTrue1) - 1.0) < 1e-9
# 4. lag n is n steps, and n=2 shortens the deadline by exactly 2
putEnv("TR_FIRE_LAG", "2")
loadFireTrackerEnv()
let g2 = spawnGhost()
check "j147: TR_FIRE_LAG=2 back-dates by two steps",
abs((g2.x - g0.x) - 2.0 * g0.velX) < 1e-9 and
abs((g2.y - g0.y) - 2.0 * g0.velY) < 1e-9
let etaTrue2 = sqrt((ws.selfX - (g0.x + 2.0 * g0.velX))^2 +
(ws.selfY - (g0.y + 2.0 * g0.velY))^2) / speed
check "j147: ... so the deadline shortens by exactly 2 ticks",
abs((etaTrue0 - etaTrue2) - 2.0) < 1e-9
# 5. a junk value falls back to 0, never to a negative/garbage back-date
putEnv("TR_FIRE_LAG", "junk")
loadFireTrackerEnv()
let gj = spawnGhost()
putEnv("TR_FIRE_LAG", "-4")
loadFireTrackerEnv()
let gn = spawnGhost()
check "j147: a junk / negative TR_FIRE_LAG degrades to the shipped lag 0",
FireLag == 0 and gj.x == ei.x and gn.x == ei.x
# 6. the ARRIVAL DEADLINE end-to-end: the ghost is reaped (`dot < 0`, the
# geometric arrival) exactly `lag` ticks earlier, because it is `lag`
# steps further along. This is the deadline the decision actually uses.
proc ticksToReap(): int =
var m = initTFIL()
randomize(Seed)
discard m.computeMove(ws)
m.spawnTrackedWave(ws, ei, Power)
for t in 1..80:
discard m.computeMove(ws)
if m.bullets.len == 0: return t
99
putEnv("TR_FIRE_LAG", "0")
loadFireTrackerEnv()
let reap0 = ticksToReap()
putEnv("TR_FIRE_LAG", "1")
loadFireTrackerEnv()
let reap1 = ticksToReap()
check "j147: the ghost arrives — and is reaped — exactly 1 tick earlier " &
"with the back-date (" & $reap0 & " -> " & $reap1 & " ticks)",
reap0 > 0 and reap1 > 0 and reap0 - reap1 == 1
# 7. restore the shipped default for every later check in this process
delEnv("TR_FIRE_LAG")
loadFireTrackerEnv()
check "j147: clearing the knob restores the shipped spawn exactly",
FireLag == 0 and spawnGhost().x == ei.x
# ── j150: the picker loss-histogram diag + the sweepable heat cutoff ──────────
#
# TR_TFIL_DIAG 0/1 default 0 — fill TfilLoss* only
# TR_TFIL_DANGER_THRESHOLD (float) default 10 — was a proc-local `const`
#
# Both must be default-off-effect: the whole point of the diag is to measure
# the shipped picker, not to change it.
proc testJ150() =
delEnv("TR_TFIL_DIAG"); delEnv("TR_TFIL_DANGER_THRESHOLD")
loadTfilCommitEnv()
check "j150: TR_TFIL_DIAG defaults OFF and TR_TFIL_DANGER_THRESHOLD defaults " &
"to today's 10.0", (not TfilDiag) and TfilDangerThreshold == 10.0
# 1. the diag is PURE: identical move stream with it on and off
let off = replay(loadStates(), loadRoundStarts())
putEnv("TR_TFIL_DIAG", "1")
loadTfilCommitEnv()
let on = replay(loadStates(), loadRoundStarts())
var diff = -1
if off.len != on.len: diff = min(off.len, on.len)
else:
for i in 0..<off.len:
if recLine(off[i]) != recLine(on[i]): diff = i; break
check "j150: with TR_TFIL_DIAG=1 the move stream is BYTE-FOR-BYTE the " &
"diag-off one over " & $off.len & " ticks — the counters are inert",
diff < 0
# 2. the histogram is populated and its stage chain is monotone
let st = TfilLoss # kept: step 4 clears the live counter
check "j150: the histogram counted picks (" & $st.picks & ") and every " &
"stage is non-increasing (reach >= cool-filter >= heat-filter)",
st.picks > 0 and st.sReach >= st.sCool and
st.sCool >= st.sSafe and st.sSafe <= st.sCand and
st.safeHist[0] <= st.picks
# 3. knob parsing, including the fallbacks
putEnv("TR_TFIL_DANGER_THRESHOLD", "18")
loadTfilCommitEnv()
check "j150: TR_TFIL_DANGER_THRESHOLD=18 is read", TfilDangerThreshold == 18.0
putEnv("TR_TFIL_DANGER_THRESHOLD", "junk")
loadTfilCommitEnv()
check "j150: a malformed value falls back to the DEFAULT 10.0",
TfilDangerThreshold == 10.0
putEnv("TR_TFIL_DANGER_THRESHOLD", "-4")
loadTfilCommitEnv()
check "j150: a negative value clamps to 0 (heat can never go backwards)",
TfilDangerThreshold == 0.0
# 4. restore the shipped default for every later check in this process
putEnv("TR_TFIL_DIAG", ""); putEnv("TR_TFIL_DANGER_THRESHOLD", "")
loadTfilCommitEnv()
check "j150: clearing the knobs restores 10.0 / diag off",
(not TfilDiag) and TfilDangerThreshold == 10.0 and TfilLoss.picks == 0
echo "\n j150 picker loss histogram (default build, offline fixture replay):"
echo " picks=", st.picks
let np = st.picks.float
echo " mean reachable hull tiles=", st.sReach.float / np
echo " mean after CoolestLevels=2 filter=", st.sCool.float / np
echo " mean after the heat filter (pre-promotion)=", st.sSafe.float / np
echo " mean draw set=", st.sCand.float / np
# ── j151: the ARRIVAL bound (TR_TFIL_ARRIVE_TICKS, default 0 = off) ─────────
type ArrStats = object
picks, beyond, starved: int ## starved = picks made with an empty pool
meanTta, meanPool: float
proc replayJ151(bound: float): ArrStats =
putEnv("TR_TFIL_ARRIVE_TICKS", $bound)
loadTfilCommitEnv()
let states = loadStates()
let starts = loadRoundStarts()
randomize(Seed)
var m = initTFIL()
var lastPicks = 0
for i in 0..<states.len:
if i == 0 or i in starts: m.resetRound()
discard m.computeMove(states[i])
if m.picks != lastPicks:
lastPicks = m.picks
let tta = sqrt((m.commitTarget.x - states[i].selfX)^2 +
(m.commitTarget.y - states[i].selfY)^2) / MaxSpeed
inc result.picks
if tta > TfilArriveTicks + 0.001: inc result.beyond
if m.lastPickSafe == 0: inc result.starved
result.meanTta += tta
result.meanPool += m.lastPickSafe.float
if result.picks > 0:
result.meanTta /= result.picks.float
result.meanPool /= result.picks.float
proc testJ151() =
# 8a. the shipped default is OFF — the golden parity check above is the proof
delEnv("TR_TFIL_ARRIVE_TICKS")
loadTfilCommitEnv()
check "j151: the arrival bound defaults to OFF (today's uniform draw over " &
"the whole 50-tick hull)", TfilArriveTicks == 0.0
let off = replayJ151(0.0) # today's behaviour, same seed
let on15 = replayJ151(15.0) # = CommitTicks: the horizon we hold a target for
delEnv("TR_TFIL_ARRIVE_TICKS")
loadTfilCommitEnv()
# the CEILING, stated: the bound is a filter on the SAFE set, so a tick whose
# every safe tile is past the horizon keeps the full pool (never starved) —
# those picks stay long, and the guard below measures exactly how many.
check "j151: picks past the 15-tick horizon collapse (" & $off.beyond & "/" &
$off.picks & " -> " & $on15.beyond & "/" & $on15.picks & "); the " &
"residue is the all-safe-tiles-are-far ticks, which keep the full pool",
on15.beyond < off.beyond div 2 and off.beyond > 0
check "j151: the mean time-to-arrive falls (" &
off.meanTta.formatFloat(ffDecimal, 1) & " -> " &
on15.meanTta.formatFloat(ffDecimal, 1) & " ticks) and the pool is " &
"not starved (mean safe tiles " &
on15.meanPool.formatFloat(ffDecimal, 1) & ", " & $on15.starved &
" empty pools)",
on15.meanTta < off.meanTta and on15.meanPool >= 1.0 and on15.starved == 0
check "j151: the bound is a filter, not a replacement — the pick COUNT is " &
"barely reduced (" & $off.picks & " -> " & $on15.picks & ")",
on15.picks.float > off.picks.float * 0.9
check "j151: clearing the knob restores today's pick stream exactly",
replayJ151(0.0).picks == off.picks
# ── j152: the GEOMETRIC DRAW (TR_TFIL_GEO_MODE / TR_TFIL_GEO_TAU, default off) ─
## Heat still gates the pool with the same hard filter; geometry only re-weights
## the survivors of that filter — INCLUDING the 2 promoted least-hot tiles the
## ~65% forced picks choose from, which is what j9 (`TR_TFIL_TURN_BIAS`) could
## not see. What must hold:
## 1. OFF by default and the OFF path is today's uniform draw byte-for-byte
## (the golden check #1 above runs with the knobs unset and is that proof;
## the last check here adds "off" == "unset" for the same seed).
## 2. NO STARVATION: a pool in which EVERY tile is perpendicular still returns
## a pick, in every form — the weight may never empty or index past the
## pool, whatever the TAU.
## 3. `off` and an all-ties pool both degenerate to the uniform draw.
## 4. The form is parsed, and junk stays off.
proc testJ152() =
delEnv("TR_TFIL_GEO_MODE"); delEnv("TR_TFIL_GEO_TAU")
loadTfilCommitEnv()
check "j152: both geometry knobs default to OFF (today's uniform draw)",
TfilGeoMode == gdoOff and TfilGeoTau == 0.0
check "j152: the mode string parses both axes (dim + form)",
parseGeo("both-rej") == (gdoBoth, gfRej) and
parseGeo("turn-topk") == (gdoTurn, gfTopK) and
parseGeo("dist-soft") == (gdoDist, gfSoft) and
parseGeo("turn") == (gdoTurn, gfSoft)
check "j152: junk and 'off' both parse to OFF, never to a live arm",
parseGeo("off").dim == gdoOff and parseGeo("sideways").dim == gdoOff
# 2. NO STARVATION: a pool where EVERY tile is 150 deg off the heading, at
# three different distances. No form may return an index outside the pool.
randomize(1)
let allPerpT = @[150.0, 150.0, 150.0]
let allPerpD = @[2.0, 30.0, 48.0]
for form in [gfSoft, gfTopK, gfRej]:
for tau in [1.0, 20.0, 5000.0]:
var seen: seq[int]
for _ in 0..<300:
seen.add geoPick(allPerpT, allPerpD, gdoTurn, form, tau)
# NO STARVATION = a pick always exists and is in range. It is NOT "every
# tile stays reachable": topk and rej are hard forms BY DESIGN and may
# legitimately return one tile forever when the whole pool is bad.
check "j152: no starvation — an all-perpendicular pool still returns " &
"an in-range pick (" & $form & ", tau " & $tau & ")",
seen.len == 300 and seen.allIt(it in 0..2)
# 3. every tile costs the same => every weight ties => the uniform draw
randomize(2)
var tieSeen: seq[int]
for _ in 0..<300:
tieSeen.add geoPick(@[40.0, 40.0, 40.0], @[10.0, 10.0, 10.0], gdoTurn, gfSoft, 45.0)
check "j152: an all-ties pool degenerates to the uniform draw (all 3 seen, " &
"none starved)", tieSeen.toHashSet().len == 3
var hitFar = 0
randomize(3)
for _ in 0..<400:
if geoPick(@[0.0, 180.0], @[1.0, 1.0], gdoTurn, gfSoft, 10.0) == 0: inc hitFar
check "j152: the soft form really tilts (a straight-ahead tile is drawn " &
">" & $hitFar & "/400 of the time with tau=10)", hitFar > 300
# 4. "off" == "unset" for the same seed: the shipped stream, unchanged.
let a = replayJ151(0.0)
delEnv("TR_TFIL_GEO_MODE"); delEnv("TR_TFIL_GEO_TAU")
loadTfilCommitEnv()
let b = replayJ151(0.0)
check "j152: geometry off reproduces the shipped draw exactly (same picks, " &
"same mean tta, same pool)", a.picks == b.picks and
a.meanTta == b.meanTta and a.meanPool == b.meanPool
# ── j153: HOLD WHEN TRAPPED (TR_TFIL_HOLD_WHEN_TRAPPED, default 0 = off) ────
## The owner's rule: "if no tile is found to go, to not choose the less
## dangerous, but to stay still! the next tick probably the situation already
## changed and we did not commit to any dangerous place."
## What must hold, and nothing more:
## 1. OFF by default, and the OFF stream is byte-for-byte today's (the golden
## check #1 above already proves the default path; this adds the explicit
## "unset == 0 == 1-off-by-parsing" arm).
## 2. ON + EMPTY safe set => no movement command for that tick.
## 3. The hold is ONE tick: it never latches, and a later safe tile IS taken
## (no stuck bot, no held-then-forever-silent).
## 4. Holding does not skip the rest of the tick: the bullet tracking the GUN
## and the lava field are updated exactly as on a non-held tick. (The gun
## itself lives in the bot loop, not in this module — computeMove never
## emits a fire command — so the real risk is a hold that `return`s too
## early and freezes the bullet tracker; that is what this checks.)
type HoldRec = object
call: int
spd, trn: float
held: bool
picked: bool
bullets: int ## tracked bullets after this tick (the fire tracker's)
proc replayJ153(hold: bool): seq[HoldRec] =
putEnv("TR_TFIL_HOLD_WHEN_TRAPPED", (if hold: "1" else: "0"))
loadTfilCommitEnv()
let states = loadStates()
let starts = loadRoundStarts()
randomize(Seed)
var m = initTFIL()
var lastPicks = 0
for i in 0..<states.len:
if i == 0 or i in starts:
m.resetRound()
lastPicks = 0 # resetRound zeroes `picks`: not a new pick
let cmd = m.computeMove(states[i])
result.add HoldRec(call: m.callCount, spd: cmd.speed, trn: cmd.turnRate, held: m.lastHeld,
picked: m.picks != lastPicks, bullets: m.bullets.len)
lastPicks = m.picks
delEnv("TR_TFIL_HOLD_WHEN_TRAPPED")
loadTfilCommitEnv()
proc testJ153() =
delEnv("TR_TFIL_HOLD_WHEN_TRAPPED")
loadTfilCommitEnv()
check "j153: TR_TFIL_HOLD_WHEN_TRAPPED defaults to OFF (today's " &
"promote-the-2-least-hot fallback)", not TfilHoldWhenTrapped
let off = replayJ153(false) # knob explicitly 0
let unset = replay(loadStates(), loadRoundStarts()) # knob never set
var diff = -1
if off.len != unset.len: diff = min(off.len, unset.len)
else:
for i in 0..<off.len:
if unset[i].spd != off[i].spd or unset[i].trn != off[i].trn or
unset[i].call != off[i].call:
diff = i; break
check "j153: with the knob unset the move stream is BYTE-FOR-BYTE the " &
"knob-0 one over " & $off.len & " ticks — the default is today's",
diff < 0 and off.len > 0
let on = replayJ153(true)
var held, heldMoved, heldPicked = 0
var nonHeldMoving = 0
for i in 0..<on.len:
if on[i].held:
inc held
if abs(on[i].spd) > 0.001: inc heldMoved
if on[i].picked: inc heldPicked
elif abs(on[i].spd) > 0.001: inc nonHeldMoving
check "j153: with the knob ON the safe set really is empty often enough to " &
"matter (" & $held & " held ticks of " & $on.len & ")",
held > on.len div 100
check "j153: a held tick emits NO movement (speed 0) and no pick " &
"(" & $heldMoved & " moving holds, " & $heldPicked & " held picks)",
held > 0 and heldMoved == 0 and heldPicked == 0
check "j153: the hold is not a freeze — " & $nonHeldMoving & " non-held " &
"ticks still move and the bot still picks",
nonHeldMoving > 0 and on.filterIt(it.picked).len > 0
# no latch: a held tick must be followed by movement again (within a couple of
# ticks), and a pick must still be taken somewhere after the holds.
# A HOLD is not a latch: the hold is decided at the pick site, and the pick
# site only runs when the commitment has expired, so every held tick is a FRESH
# evaluation of the field. Observable consequence: hold runs end, and the tick
# after a run is a moving tick again. (A latching implementation would show ONE
# run per round and ~0 resumptions.) A long run therefore means a long trap, not
# a stuck bot — that is why the run LENGTH is deliberately not asserted.
var runs = 0
var resumed = 0
var worst = 0
var run = 0
for i in 0..<on.len:
if on[i].held:
inc run
else:
if run > 0:
inc runs
worst = max(worst, run)
if abs(on[i].spd) > 0.001: inc resumed
run = 0
if run > 0:
inc runs
worst = max(worst, run)
var pickedAfter = 0
var sawHold = false
for r in on:
if r.held: sawHold = true
elif sawHold and r.picked: inc pickedAfter
check "j153: the hold is NOT a latch — " & $resumed & " of " & $runs &
" maximal hold runs resume moving on the very next tick (longest run " &
$worst & " ticks = a trap that lasts, not a stuck bot) and " &
$pickedAfter & " picks happen after a hold",
runs > 0 and resumed * 2 > runs and pickedAfter > 0
# the gun path: a held tick must leave the bullet tracker exactly where a
# non-held tick would. If the hold returned before the tracker update, the
# bullet counts would diverge from the first hold onwards.
var firstDiv = -1
for i in 0..<min(on.len, off.len):
if on[i].bullets != off[i].bullets:
if on[i].held or off[i].held: firstDiv = i
break
check "j153: holding does not freeze the fire/bullet bookkeeping the GUN " &
"reads (bullet counts identical on held vs non-held ticks)",
firstDiv < 0
# ── j154: the BOUNDED hold (TR_TFIL_HOLD_MAX_TICKS, default 0 = off) ────────
#
# The budget is DERIVED from the enemy's own rate of fire, not chosen. Server
# `rules/math.kt`: `calcGunHeat(p) = 1 + p/5`, `calcBulletDamage(3.0) = 16`;
# `core/GunEngine.kt`: the gun cools 0.1 per tick and may only fire at heat == 0.
# So two 3.0-power shots are 1.6/0.1 = 16 ticks apart, and a brute-force search
# over the 0.1 power quantisation says 32 damage is the most the enemy can land
# in any 16-tick window (8/11/16/24/32 ticks -> 16/18/32/32/48). 16 is also the
# FIRST window that admits the enemy's SECOND shot at all, so nothing shorter
# can be surprised by a third bullet.
#
# A fully hot field is painted with the virtual pillar at radiance 0
# (`max(0, hotness - d*0) = hotness` on every tile) — the one heat source that
# covers the whole reachable hull at once, so the safe set is provably empty.
proc testJ154() =
delEnv("TR_TFIL_HOLD_MAX_TICKS")
loadTfilCommitEnv()
check "j154: TR_TFIL_HOLD_MAX_TICKS defaults to 0 = today's behaviour exactly",
TfilHoldMaxTicks == 0 and not TfilHoldWhenTrapped
# 1. DEFAULT PARITY: an explicit 0 is indistinguishable from unset, over the
# whole fixture, tick for tick. (The golden above covers UNSET; this covers
# the explicit zero the owner would put in an arm.)
let unset = replay(loadStates(), loadRoundStarts())
putEnv("TR_TFIL_HOLD_MAX_TICKS", "0")
loadTfilCommitEnv()
let zero = replay(loadStates(), loadRoundStarts())
var diff = -1
if unset.len != zero.len: diff = min(unset.len, zero.len)
else:
for i in 0..<unset.len:
if recLine(unset[i]) != recLine(zero[i]): diff = i; break
check "j154: TR_TFIL_HOLD_MAX_TICKS=0 is BYTE-FOR-BYTE the unset build over " &
$unset.len & " ticks (default path unchanged)",
diff < 0 and unset.len > 0
# 2. knob parsing
putEnv("TR_TFIL_HOLD_MAX_TICKS", "16"); loadTfilCommitEnv()
check "j154: TR_TFIL_HOLD_MAX_TICKS=16 is read", TfilHoldMaxTicks == 16
putEnv("TR_TFIL_HOLD_MAX_TICKS", "junk"); loadTfilCommitEnv()
check "j154: a malformed value falls back to 0 (off)", TfilHoldMaxTicks == 0
putEnv("TR_TFIL_HOLD_MAX_TICKS", "-8"); loadTfilCommitEnv()
check "j154: a negative value clamps to 0", TfilHoldMaxTicks == 0
delEnv("TR_TFIL_HOLD_MAX_TICKS"); loadTfilCommitEnv()
const HoldN = 4 ## the budget under test; any small N exercises it
const Hot = 100.0 ## every tile at 100 >> PathDangerThreshold 10
let ws = WorldState(enemyX: 0.0, enemyY: 0.0, enemyHeading: 0.0,
enemySpeed: 0.0, enemyEnergy: 100.0,
selfX: 400.0, selfY: 300.0, selfHeading: 0.0,
selfSpeed: 8.0, selfEnergy: 100.0,
arenaWidth: ArenaW, arenaHeight: ArenaH, tick: 0,
enemies: @[])
## One tick. `hot` = the pillar heat (0 = a clean field). A non-nil `bullet`
## is installed as the tracked set, which is the ONLY way an inbound bullet
## ever exists here (the enemy is out of the arena in these worlds).
proc tick(m: var TFILModule, hot: float, t: int,
bullet: TrackedBullet = TrackedBullet(alive: false)): MoveCommand =
PillarHotness = hot
PillarRadiance = 0.0
var w = ws
w.tick = t
if bullet.alive: m.bullets = @[bullet]
result = m.computeMove(w)
## A module with the grid initialised and NO live commitment, so tick 0 of a
## scenario is a REPLAN tick (where, and only where, a hold may be taken).
proc fresh(): TFILModule =
PillarHotness = 0.0; PillarRadiance = 0.0
result = initTFIL()
randomize(Seed)
discard tick(result, 0.0, 0)
result.commitTicks = 0
result.picks = 0
result.commitTarget = (x: 400.0, y: 300.0)
type Rec = tuple[held: bool, ht: int, picked: bool]
## The held/pick pattern of a run, plus the counter at each tick.
proc run(m: var TFILModule, hot: seq[float]): seq[Rec] =
for t, h in hot:
let before = m.picks
discard tick(m, h, t + 1)
result.add (held: m.lastHeld, ht: m.holdTicks, picked: m.picks != before)
# A 1-tick commitment makes every tick a replan tick, so the scenario is a
# clean read of the hold rule alone (no commitment state leaking in).
putEnv("TR_TFIL_COMMIT_TICKS", "1")
putEnv("TR_TFIL_HOLD_MAX_TICKS", $HoldN)
loadTfilCommitEnv()
# 3. the BOUND: at most N consecutive held ticks, then the normal promote-the-2
# fallback takes over — the hold can never latch.
let hotAll = @[Hot, Hot, Hot, Hot, Hot, Hot, Hot, Hot, Hot, 0.0, 0.0,
Hot, Hot, Hot, Hot, Hot, Hot, 0.0, 0.0, 0.0]
var m = fresh()
let r = run(m, hotAll)
var firstPick = -1
for i, e in r:
if e.picked: firstPick = i; break
echo "\n j154 run (held/ht/picked per tick, index: value):"
for i, e in r:
echo " ", i, ": ", (if e.held: "H" else: "."), e.ht,
(if e.picked: " P" else: " ")
check "j154: with the safe set EMPTY the mover HOLDS (" & $HoldN &
" ticks) instead of promoting, and releases into a pick on tick " &
$(firstPick + 1) & " — the bound is N, not 'until a tile appears'",
firstPick == HoldN and r[0].held and r[HoldN - 1].held and
r[HoldN - 1].ht == HoldN and not r[HoldN].held and r[HoldN].picked
# 4. RELEASE THE MOMENT A SAFE TILE EXISTS: the field cools at index 10, and
# that very tick is a pick, not a hold — no tick of latency.
check "j154: the hold releases on the SAME tick a safe tile appears " &
"(index 10 cooled -> picked=" & $r[10].picked & ", held=" &
$r[10].held & ", counter=" & $r[10].ht & ")",
r[10].picked and not r[10].held and r[10].ht == 0
# ... and the budget REFILLS: a fresh empty streak holds a full N again,
# i.e. the bound is per streak and the counter is not cumulative.
let streak2 = r[11 .. ^1]
var held2 = 0
for e in streak2:
if e.held: inc held2
check "j154: the counter RESET when the safe tile was taken — the second " &
"empty streak holds a full N again (" & $held2 & " ticks), never the " &
"accumulated " & $r[10].ht & "+" & $r[11].ht,
streak2[0].held and held2 >= HoldN and r[11].ht == 1
# 5. the held command is the SAME stop the mover already emits at its target,
# and the gun path is untouched: on a held tick the fire detector still
# latches the enemy's wave (the bot aims and fires from tracked state after
# go(), on every tick, whatever speed it just commanded).
m = fresh()
PillarHotness = Hot; PillarRadiance = 0.0
var wFar = ws
wFar.enemies = @[EnemyInfo(id: 1, x: 760.0, y: 300.0, heading: 180.0,
speed: 0.0, energy: 100.0)]
wFar.tick = 1
discard m.computeMove(wFar) # enemy seen at 100.0 energy
m.commitTicks = 0 # armed: a replan tick, as above
wFar.tick = 2
wFar.enemies[0].energy = 98.5 # a 1.5 drop = a 1.5-power shot
let gunCmd = m.computeMove(wFar)
check "j154: the gun still fires while holding — the mover held (" &
$m.lastHeld & ") on the very tick the enemy fired, and the fire " &
"detector still latched the wave (" & $m.bullets.len & " tracked)",
m.lastHeld and m.bullets.len > 0
check "j154: the held command is the stop the mover already emits at its " &
"target (speed 0, turn 0) — no new signal, no movement side effect",
gunCmd.speed == 0.0 and gunCmd.turnRate == 0.0
# 6. PANIC RELEASE (required). A tracked bullet on a collision course, 9 ticks
# out, overrides the hold on the tick it exists. The same bullet offset
# laterally does NOT, so the release is specific, not "any bullet".
# The budget is the DERIVED 16 here, so the horizon is min(16, 16) = 16.
putEnv("TR_TFIL_HOLD_MAX_TICKS", "16")
loadTfilCommitEnv()
let inbound = TrackedBullet(originX: 570.0, originY: 300.0, x: 570.0, y: 300.0,
velX: -17.0, velY: 0.0, power: 1.0,
alive: true, age: 0)
let missing = TrackedBullet(originX: 570.0, originY: 500.0, x: 570.0,
y: 500.0, velX: -17.0, velY: 0.0, power: 1.0,
alive: true, age: 0)
m = fresh()
discard tick(m, Hot, 1, inbound)
check "j154: PANIC RELEASE — a tracked bullet 9 ticks from our position " &
"overrides the hold on the same tick (held=" & $m.lastHeld &
", picked=" & $(m.picks > 0) & ")",
(not m.lastHeld) and m.picks > 0 and m.holdTicks == 0
m = fresh()
let cmdMiss = tick(m, Hot, 1, missing)
check "j154: ... and it is SPECIFIC: the same bullet 200px off our line " &
"still holds (held=" & $m.lastHeld & "), so the release is an arrival " &
"test, not a bullet count",
m.lastHeld and m.picks == 0 and cmdMiss.speed == 0.0
proc withBullet(b: TrackedBullet): TFILModule =
result = initTFIL()
result.bullets = @[b]
check "j154: the panic horizon is the DERIVED budget min(N, 16) ticks — the " &
"arrival test fires inside it and not outside",
bulletPanic(initTFIL(), 400.0, 300.0, 16.0) == false and
bulletPanic(withBullet(inbound), 400.0, 300.0, 16.0) == true and
bulletPanic(withBullet(inbound), 400.0, 300.0, 4.0) == false
# 7. a hold NEVER interrupts a live commitment (j153's comment claimed that;
# j154's code enforces it). Take a pick, keep the field hot, and the mover
# must keep driving to its committed target.
putEnv("TR_TFIL_COMMIT_TICKS", "15")
loadTfilCommitEnv()
m = fresh()
discard tick(m, 0.0, 1) # a clean field first: that tick PICKS
let pickedFirst = m.picks > 0
let live = m.commitTicks
let cmdLive = tick(m, Hot, 2) # now the field goes fully hot
check "j154: a hold never interrupts a live commitment — with " & $live &
" ticks on the clock the mover keeps driving to its target (speed " &
$cmdLive.speed & "), it does not freeze",
pickedFirst and live > 0 and (not m.lastHeld) and cmdLive.speed != 0.0
putEnv("TR_TFIL_COMMIT_TICKS", "15")
delEnv("TR_TFIL_HOLD_MAX_TICKS")
loadTfilCommitEnv()
PillarHotness = 0.0; PillarRadiance = 0.0
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 ─────────────────────────────────────────────────────────────────── # ── driver ───────────────────────────────────────────────────────────────────
testDefaultParity() testDefaultParity()
@@ -857,6 +1721,15 @@ when declared(loadTfilCommitEnv):
testJ144() testJ144()
testJ145() testJ145()
testJ146() testJ146()
testJ147()
testJ151()
testJ152()
testJ150()
testJ154()
testJ153()
when declared(TfilRingCommitArrival):
testJ165()
testJ160()
if failures > 0: if failures > 0:
echo "\n", failures, " check(s) FAILED" 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)"
+137 -6
View File
@@ -47,13 +47,123 @@ misled people:**
warning at all**. warning at all**.
2. **Some flags are presence-based, not value-based.** They are read with 2. **Some flags are presence-based, not value-based.** They are read with
`existsEnv`, so **`TR_POWER_LOG=0` turns the log ON** (any value does). `existsEnv`, so **`TR_POWER_LOG=0` turns the log ON** (any value does).
Presence-based: `TR_POWER_LOG`, `TR_RAM_LOG`, `TR_MOVEMENT_LOG`, Value-based (`0`/`false`/`off` really disable): `TR_ENV_REPORT`,
`TR_RECORD_WORLDSTATE`, `TR_RADAR_FORCE_SPIN`, `TR_RADAR_SCANLOG`, `TR_TMHORIZON_LOG`, `TR_TMHORIZON_ACCURVE`,
`TR_TRACKER_PROBE`. Value-based (`0`/`false`/`off` really disable):
`TR_ENV_REPORT`, `TR_TMHORIZON_LOG`, `TR_TMHORIZON_ACCURVE`,
`TR_TMHORIZON_RESET_ON_TARGET`, `TR_POWER_POLICY`, `TR_POWER_FINISH_KILL`, `TR_TMHORIZON_RESET_ON_TARGET`, `TR_POWER_POLICY`, `TR_POWER_FINISH_KILL`,
`TR_RAM_OPPORTUNITY`, `TR_RAM_PLAN`, `GUN_SELECTOR_POOL`, `TR_RAM_OPPORTUNITY`, `TR_RAM_PLAN`, `GUN_SELECTOR_POOL`,
`TR_VBULLET_ADMIT_ONLY`. `TR_VBULLET_ADMIT_ONLY`, `TR_LEADGAIN_LOG`, `TR_LEARNED_LOG`,
`TR_LEARNED_GLOBAL`, `TR_LEARNED_REAL_EVENTS`, `TR_FIRE_FIX`,
`TR_STRAFE_FIRE_FIX`, `TR_STRAFE_ESCAPE`, `TR_STRAFE_HEAT_GRID`,
`TR_TFIL_HEAT_TIME`, `TR_TFIL_PILLAR_ON`, `TR_TFIL_DIAG`, `TR_TFIL_NO_REV`,
`TR_TFIL_HOLD_WHEN_TRAPPED`, `TR_TFIL_COMMIT_ARRIVAL`,
`TR_TFIL_RING_COMMIT_ARRIVAL`, `TR_VBULLET_DEBUG`, `TR_GEO_DEBUG`,
`TR_DEBUG_DRAW`, `TR_RESULT_LOG`.
### THE FULL PRESENCE-GATED LIST (j172, from `grep -rn existsEnv`)
The list above was **incomplete**: it was missing four knobs. The complete set,
from `grep -rn 'existsEnv' ModularBot_garage/src common_libs | grep -v /tests/`,
is:
| knob | read at | what it logs / does |
|---|---|---|
| `TR_POWER_LOG` | `ModularBot.nim:145` | one line per power-decision CHANGE |
| `TR_RAM_LOG` | `ram_decision.nim:119` | one line per ram start/stop + reason |
| `TR_MOVEMENT_LOG` | `the_floor_is_lava_ring.nim:192` | movement band / range-class changes |
| `TR_STRAFE_LOG` | `strafe.nim:402` | one line per strafe tile pick |
| `TR_SURF_LOG` | `wave_surfer.nim:102` | one line per wave-surfing decision |
| `TR_FIRE_DIAG` | `ModularBot.nim:137`, `the_floor_is_lava.nim:299`, `strafe.nim:415` | per-reading fire-detection tick/raw/correction |
| `TR_RECORD_WORLDSTATE` | `ModularBot.nim:70` | dump every observed world state |
| `TR_RADAR_SCANLOG` | `ModularBot.nim:80` | log every radar scan tick |
| `TR_RADAR_FORCE_SPIN` | `ModularBot.nim:79` | force the old full-360 spin radar |
| `TR_TRACKER_PROBE` | `ModularBot.nim:86` | dump the enemy-tracker internals |
**For these ten, `NAME=0` turns the feature ON.** OFF means the line is ABSENT.
That is why `.env.example` shows every one of them commented out: there is no
"off" spelling, only absence. To disable one, delete its line.
Two more are read with `existsEnv` but are *not* features — `TR_ENV_FILE` (an
empty value is the correct "use the default" spelling) and the loader's own
`existsEnv(e.key)` conflict check.
**And one label is misleading:** `env_report.nim` prints `TR_TFIL_DIAG`,
`TR_TFIL_HOLD_WHEN_TRAPPED`, `TR_TFIL_COMMIT_ARRIVAL` and
`TR_TFIL_RING_COMMIT_ARRIVAL` through `sourceOfPresence`, but all four are read
**by value** (`getEnvBool`) in the source. The value is always right; only the
`(source: ...)` label is affected. Do not read that label as "presence-gated".
---
## ONE EXPERIMENT, END TO END (mirrored from `.env.example`)
Pick ONE knob. Here it is `TR_TFIL_ARRIVE_TICKS`; the shape is the same for
every knob.
```sh
# 1. write the arm as its own file — that is how you GUARANTEE the arm, because
# nothing else can be applied on top of it
cat > /tmp/arm_arrive15.env <<'EOF'
TR_MOVEMENT=tfil
TR_TFIL_ARRIVE_TICKS=15.0
EOF
# 2. RESTART THE BOT. Env is read ONCE, at boot (module init). Editing .env
# while the bot runs changes nothing; there is no live reload.
cd ModularBot_garage && ./ModularBot.sh # or restart the GUI
# 3. CONFIRM IT TOOK EFFECT, before reading a single result line.
# `source: .env` = your file was applied. `source: default` = it was not.
grep '^\[env\]' /tmp/modularbot_stdout.log | grep -E 'env file|ARRIVE_TICKS'
# [env] env file: /tmp/arm_arrive15.env (source: TR_ENV_FILE)
# [env] TR_TFIL_ARRIVE_TICKS = 15.0 (source: .env)
# 4. point at the file instead of copying it into .env:
TR_ENV_FILE=/tmp/arm_arrive15.env ./out/ModularBot
./out/ModularBot --env-file /tmp/arm_arrive15.env
# A file you ASKED for and that does not exist stops the bot with an error; a
# missing default .env is silent. This is what an A/B run does: one frozen
# binary, one env file per arm.
# 5. what is switched on at all:
grep '^\[modules\]' /tmp/modularbot_stdout.log
```
## SAFE TO EXPERIMENT WITH RIGHT NOW
The honest list is SHORT: after the recent campaign most experimental knobs are
either never live-tested or already measured null/harmful, and `.env.example`
says so on every one of them. These four are safe in the sense that they either
cannot change a decision, or are the ones a measurement actually supports.
| knob | what changes | what to watch | a good result |
|---|---|---|---|
| `TR_GEO_DEBUG=on` | draw-only geometry overlay | the circles on the two tanks, each heading line | you can SEE the tile the picker chose; it cannot change a decision |
| `TR_VBULLET_DEBUG=1` + `TR_VBULLET_DEBUG_GUN=all` | draw-only: each admitted gun's virtual bullets | travelled path, aim ring, miss vector | you can see the signal the selector ranks on; also draw-only |
| `TR_TFIL_DIAG=on` | fills the per-pick loss histogram (tfil only) | the tfil pick log line | `sReach/sCool/sSafe/sCand` tell you where tiles are lost; provably moves no command |
| `TR_MOVEMENT=tfil` | runs the long-shipped mover | nothing to compare against | you are reproducing an older, documented behaviour; only do it together with the `TR_TFIL_*` knobs |
## ALREADY REJECTED OR MEASURED NULL — WITH THE NUMBER
Do not re-run these by accident.
| knob / arm | result | where |
|---|---|---|
| `TR_TFIL_GEO_MODE=both-rej` + `TR_TFIL_GEO_TAU=60` | **REJECTED** live, 420 battles, 15 opponents: damage/run **-8.83**, p(sign-flip) **0.0061**, Wilcoxon p 0.011. Round wins null. Offline it did what was predicted (arrivals 4.5% -> 29.4%) and that is why it is bad: +26 px distance on 15/15, less damage. | `docs/tfil_geo_ab.md` |
| `TR_RAM_FLOOR_ENERGY=5` | **CLEAN NULL**: **-0.018 wins/run**, p(sign-flip) **0.7676**, under a **0.1420 wins/run** MDE, 900 battles. Mechanism fired on 0.04% of ticks (~200x less than the offline ruler said). Do not re-test: more runs buy resolution on an effect that is not there. | `docs/ram_floor_exhaustion_ab.md` |
| `TR_RAM_FLOOR_ENERGY=10/20` | measured COSTLY offline (20 blocked 24.7% of all ticks) and the zone it guards is nearly empty: only 4.8% of shots are taken below 10 energy. | same |
| `TR_TMHORIZON_WINDOW=150` | **MEASURED HARMFUL** live: 26.5% round wins vs 49.0% for the shipped rack, p = 0.036. Keep 0. | env_reference "Measured verdicts" |
| `TR_POWER_POLICY=0` | **MEASURED HARMFUL** live: real hit rate 10.61% -> 7.88%, p = 0.0012. | same |
| `TR_TFIL_HEAT_TIME=1` | **MEASURED HARMFUL** live at every tau tried (3/5/9/15); tau15 alone is -22 damage/run, p = 0.046. | `docs/tfil_heat_pillar_ab.md` |
| `TR_MOVEMENT=tfil_ring` | **MEASURED**: round wins 16/49 -> 6/49, p = 0.012. Best live hit rate of anything measured, half the survival. | same / env_reference |
| the full `TR_RACK_*` rack | **MEASURED NEGATIVE VALUE**: Pattern ALONE beats the full 13-gun rack, p = 0.0012. Adding guns costs rounds. | `docs/gun_rack_analysis.md` |
| `TR_TFIL_TURN_BIAS=9` + `_TURN_REF_DEG=0` | **LIVE NULL**: +0.15 wins/run, p(sign) 0.244, under a 0.30 MDE, 300 battles. | `docs/movement_campaign.md` (j145) |
| `TR_RAM_OPPORTUNITY=on` | **MEASURED not to convert**: 0/59 opportunity -> contact. The finisher ram is the only path that converts, and it is always on. | env_reference |
| `TR_TFIL_PILLAR_ON=1` | live-tested, and the recommendation to revert to pillar-on was **OVERRULED by the owner**: the contrast is inside the MDE (33 damage/run, 1.22 wins/run at n=10). Pillar stays removed. | `docs/tfil_heat_pillar_ab.md` |
> **A null is only a null at the resolution that run reached.** The frozen
> 15-opponent panel at 14 runs/arm resolves ~0.17 wins/run and ~7.65 damage/run;
> the j163 run resolved 0.1420 wins/run. "Clean null" here means *no effect at or
> above that size* — not *no effect*.
--- ---
@@ -226,6 +336,8 @@ shooting *look* like missing).
| `TR_POWER_POLICY` | `1` | `0` = uncapped control arm (today's behaviour without the energy policy) | | `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_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_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_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_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 | | `TR_ENV_REPORT` | `1` | print the boot-time `[env]` report to stdout; `0` suppresses it |
@@ -268,6 +380,7 @@ name, with no new knob:
| `TR_RACK_<GUN>` = `both`/`1v1`/`melee` | that gun may be selected | `TR_RACK_<GUN>=off`: the gun is removed from the rack | | `TR_RACK_<GUN>` = `both`/`1v1`/`melee` | that gun may be selected | `TR_RACK_<GUN>=off`: the gun is removed from the rack |
| `TR_POWER_POLICY` | energy-aware power caps (default) | uncapped: the gun's own preferred power | | `TR_POWER_POLICY` | energy-aware power caps (default) | uncapped: the gun's own preferred power |
| `TR_FIRE_FIX` | the corrected enemy-fire detector (default) | the shipped `prev - energy` detector | | `TR_FIRE_FIX` | the corrected enemy-fire detector (default) | the shipped `prev - energy` detector |
| `TR_FIRE_LAG` = `<int>` | back-date every detected enemy fire by N ticks at spawn (0 = shipped; **1 = the measured live detection lag**, j147) | n/a — it is a value knob |
| `TR_RADAR_FORCE_SPIN` | force the old stateless full-spin melee radar (**off by default**) | the adaptive arc-narrowing radar (default) | | `TR_RADAR_FORCE_SPIN` | force the old stateless full-spin melee radar (**off by default**) | the adaptive arc-narrowing radar (default) |
| `TR_TFIL_HEAT_TIME` | time-indexed bullet heat (**off by default**) | flat, time-independent heat (default) | | `TR_TFIL_HEAT_TIME` | time-indexed bullet heat (**off by default**) | flat, time-independent heat (default) |
| `TR_VBULLET_DEBUG` | draw the virtual-bullet overlay (**off by default**) | nothing drawn | | `TR_VBULLET_DEBUG` | draw the virtual-bullet overlay (**off by default**) | nothing drawn |
@@ -416,8 +529,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_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_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_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 **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); `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 the **code defaults are `10.0` / `15.0`** (commit `7f6ccfb`). The code is the
@@ -501,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_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` | off | presence-based; per-tick enemy tracker vs server enemy count |
| `TR_TRACKER_PROBE_PATH` | `/tmp/tracker_probe.jsonl` | where that goes | | `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` | 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_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 | | `TR_VBULLET_DEBUG_MAX` | `32` | cap on bullets drawn per tick |
@@ -518,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 - colour per gun is the SAME table as the turret (`vbullet_draw.gunColors`), with
a one-line legend in the top-left corner. 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 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`, constants in `radars/adaptive_melee_radar.nim:36-50`: `MaxRadarTurnRate=45`,
`FreshnessTicks=16`, `FreshStreakTicks=3`, `MarginDeg=20`, `FreshnessTicks=16`, `FreshStreakTicks=3`, `MarginDeg=20`,
+215
View File
@@ -3454,3 +3454,218 @@ middle 30.4% · corr10 32.1% · bullets 65.0% · nofield 3.9%.
**The shipped default is untouched.** `TR_MOVEMENT=strafe` remains the default; **The shipped default is untouched.** `TR_MOVEMENT=strafe` remains the default;
`TR_TFIL_BULLET_CORE` / `TR_TFIL_BULLET_AURA` default to today's `10.0` / `5.0`, `TR_TFIL_BULLET_CORE` / `TR_TFIL_BULLET_AURA` default to today's `10.0` / `5.0`,
so `TR_MOVEMENT=tfil` still means today's tfil, byte-for-byte (guard check 1). so `TR_MOVEMENT=tfil` still means today's tfil, byte-for-byte (guard check 1).
# Batch 8 — the fire-detection lag (j147)
*Pre-registered BEFORE any battle of this batch was launched. No battle of this
batch existed when this section was written; the frozen binary for it is the
commit that adds `TR_FIRE_LAG` and the `TR_FIRE_DIAG` ghost-spawn trace.*
## The owner's report, and what was measured
*"i don't know if is the drawing only the arrives 1 tick later in the gui, but
the bullet auras looks like are all 1 tick-ish behind the real bullet!"*
The first job was to answer **drawing or decision**, not to fix anything. Three
measurements, in order, each one able to stop the next:
### 1. The corpus says the ENERGY DROP is on the fire's own row (lag 0)
`/tmp/tfil_ab2/out` (70 battles, `runN.jsonl` + `runN.events.jsonl`): for every
true fire event, the row at which the shooter's energy drop becomes visible is
`fireTick - 1` for **702/702** self fires in round 1 and 100% over the corpus —
i.e. in the recorded frame the drop and the shot are the SAME instant (a bullet
takes its first step during the turn it is fired, MEASURED: 1293/1293 `hitwall`
events have their first out-of-bounds bullet position at step
`hitwallTick - fireTick + 1`, which is only consistent with a first step inside
the firing turn). So the corpus alone cannot see a lag: it has no view of WHEN
our scan runs relative to the dispatch.
### 2. The corpus is NOT the bot's view, so the lag had to be measured LIVE
`common_libs/tests/measure_fire_ghost_lag.py`. The bot logs one
`[firediag] SPAWN tick=… sx=… sy=… gx=… gy=… p=… eta=…` line per detected fire
(the ghost's DRAWN position and our own position, the timeline anchor). The
capture supplies the true fire events (origin, direction, power) and the rounds.
The timeline is anchored without guessing: `[firediag] EV hit tick=… getTurn=…`
lines vs. the sidecar's own event turns match exactly, and give
`getTurn = bot.tick + 1` (j134, re-verified) — so a ghost logged at bot tick `t`
was placed during server turn `t + 1`.
| arm | matched spawns | detection lag | ghost-vs-observer px (mean / median / p90) | arrival-deadline error (ticks, mean / median) |
|---|---:|---|---:|---:|
| tfil, lag 0 | 413 | **+1 tick, 100%** | **19.06 / 19.16 / 22.00** | **0.987 / 0.991** |
| tfil, `TR_FIRE_LAG=1` | 446 | +1 tick, 100% | **5.37 / 5.65 / 8.96** | **0.063 / 0.051** |
| strafe, lag 0 | 497 | +1 tick (77.9%; the rest are duplicate/split waves of a fire already counted) | **16.08 / 18.23 / 21.81** | 0.771 / 0.944 |
| strafe, `TR_FIRE_LAG=1` | 466 | +1 tick, 100% | **6.01 / 5.91 / 9.80** | **0.065 / 0.051** |
**The answer to the owner: it is NOT only the drawing — the decision is late.**
The aura is displaced by exactly **one whole bullet step (11..20 px, 19.1 px
mean for tfil)**, in the direction of travel, and the arrival deadline the
mover reads is **a full tick late (0.99 ticks)**. The mechanism is measured, not
guessed: the server dispatches a turn's fire **after** our `go()` for that turn,
so the energy drop of a turn-`T` shot first reaches our scan at turn `T+1`; and
because a bullet takes its first step during the turn it is fired, the true
bullet is already one step downrange when we see it. Both movers place the ghost
at the SCANNED enemy position — where the bullet was *born* — and then advance
it once per tick, so the entire ghost trajectory is the true one shifted one
turn later, for the bullet's whole life.
**It is OURS.** The draw/advance order was checked and is correct (both movers
`advanceBullets()` -> `detectFires()` -> build the field, i.e. a ghost spawned
this tick is drawn at its age-0 position and every older ghost has been advanced
exactly once: build-then-advance, which is the correct direction; an
advance-then-build order would have shown the aura one tick AHEAD). With
`TR_FIRE_LAG=1` the ghosts land on the observer's bullet to within the enemy's
own scan staleness (5.4 px mean, max 8 px = the enemy's top speed), which is the
floor this design can reach: the origin is the enemy's *scanned* position, not
its fire-time position.
### The treatment
`TR_FIRE_LAG` (int, **default 0 = today's behaviour byte-for-byte**, `x` is only
touched when `lag > 0`), read once in the shared
`common_libs/movement_harness/fire_tracker.nim` and applied by BOTH movers at
spawn: `x = origin + dir * speed * lag`, `y = …`. The arrival deadline needs no
separate change — every mover derives it from the ghost's own position
(`heatDecay(along / speed)`, the `dot < 0` reap), so a correct position gives a
correct deadline. Guard: `test_tfil_commit_env.nim` 77 -> **87 checks**, all pass
(default parity on the golden replay, exact n-step back-date, deadline shortens
by exactly `lag`, junk/negative degrade to 0, the ghost is reaped exactly one
tick earlier).
## Arms (frozen, `tools/ab/arms_fire_lag.txt`)
| # | arm | mover | `TR_FIRE_LAG` | what it isolates |
|---|---|---|---|---|
| 1 | `tfil_off` | tfil | 0 (default) | **the reference** — today's tfil |
| 2 | `tfil_lag1` | tfil | 1 | the back-date, on tfil |
| 3 | `strafe_off` | strafe | 0 (default) | **the reference** — today's strafe |
| 4 | `strafe_lag1` | strafe | 1 | the back-date, on strafe |
Panel: the FROZEN 15-opponent `tools/ab/panel_movement.txt`. Harness:
`tools/ab/tournament_run.sh` + `tournament_analyze.py`.
## Pre-registered prediction, MDE and decision rule
* **MDE, stated up front.** The verdict layer is the paired per-opponent
difference over 15 opponents, exactly as batches 4-7. Batch 7 (5 runs/arm)
measured **MDE = 12.8 damage/run and 0.28 wins/run**; this batch runs **3
runs/arm**, so by `sqrt(5/3)` the MDE degrades to roughly **16 damage/run and
0.36 wins/run** — and the incoming-hit-rate MDE to roughly **1.9 points**.
**Any true effect smaller than that is invisible here by construction, and a
null will be recorded as "not distinguishable", never as "no effect".**
* **Prediction.** `tfil_lag1` > `tfil_off` and `strafe_lag1` > `strafe_off` on
damage/run and round wins, because the field the mover decides on is displaced
by a whole bullet step today and stops being after the fix. The **mechanism is
the incoming hit rate** (the dodge should survive strictly more), and the
offline gate already measured the mechanism geometrically (19.1 -> 5.4 px,
0.99 -> 0.06 ticks), so a mechanism-positive / outcome-null result is the
EXPECTED shape given the MDE, and is recorded as such — the same verdict
pattern as j144, j145 and j146.
* **Verdict rule (unchanged, not re-interpreted afterwards).** The cross-opponent
sign test p < 0.05 on one primary metric (damage/run or round wins) with the
other not down, SD/SE/95% CI/MDE reported.
* **Nothing separates -> nothing changes.** `TR_FIRE_LAG` stays default 0 and
the shipped movers are untouched. A mechanism-positive outcome-null does NOT
retract the geometric measurement, and does NOT change `TR_MOVEMENT=strafe`.
*(results appended below after the battles)*
### MEASURED — gate B: the guard (`test_tfil_commit_env.nim`, 77 -> 87 checks)
All 87 pass, including the byte-for-byte golden replay of the shipped mover with
`TR_FIRE_LAG` unset (check 1). The j147 ones:
* `TR_FIRE_LAG` unset -> `FireLag 0`, and the ghost lands EXACTLY on the scanned
enemy (`b.x == ei.x` bit for bit — the position is only touched when `lag > 0`).
* `=1` -> the ghost is exactly one bullet step (17 px at power 1.0) downrange on
its own heading; `=2` -> exactly two; the step length is the true
`20 - 3*power`, not a scaled one.
* **the arrival deadline**: the mover's eta equals the TRUE remaining flight
(10.764706 vs 10.764706) where the lag-0 eta was 11.764706 — a full tick late;
at `lag=2` the deadline shortens by exactly 2 ticks.
* a junk or negative value degrades to the shipped lag 0 (never a negative
back-date); clearing the knob restores the shipped spawn exactly.
* end to end: the ghost is reaped (`dot < 0`, the geometric arrival the mover
actually uses) **exactly one tick earlier** — 12 -> 11 ticks.
* `test_env_report` + `test_env_dotenv` green with `TR_FIRE_LAG` registered in
`env_report.nim` + `knownEnvNames()` + `.env.example` + `docs/env_reference.md`.
### MEASURED — gate C: the live A/B, 180 battles
> **Provenance.** Session `/tmp/ab/j147_firelag`, frozen binary `d21f7ce`
> (sha256 `29571d4d…`), panel `tools/ab/panel_movement.txt` (15 opponents,
> FROZEN), arms file `tools/ab/arms_fire_lag.txt` registered above BEFORE any of
> these battles ran. **4 arms x 15 opponents x 3 runs x 3 rounds = 180 battles,
> 0 failed, 0 never started, 473 s.** The MDEs the analyzer actually reported at
> 3 runs/arm: **12.2-15.1 damage/run, 0.33-0.55 wins/run, 1.4-2.9 hit-rate
> points** — the pre-registered estimate (~16 / ~0.36 / ~1.9) was right.
**Pooled dashboard (descriptive, NOT the verdict):**
| arm | runs | dmg/run | dmg taken/run | wins/run | round wins | win rate | incoming hit rate | mean distance |
|---|---:|---:|---:|---:|---:|---:|---:|---:|
| `tfil_off` | 45 | 109.7 | 187.8 | 1.24 | 56/135 | 41.5% | 16.91% | 394 |
| `tfil_lag1` | 45 | 111.8 | 192.9 | 1.20 | 54/135 | 40.0% | 17.45% | 396 |
| `strafe_off` | 45 | 106.3 | 160.1 | 1.44 | 65/135 | 48.1% | 12.84% | 434 |
| `strafe_lag1` | 45 | 110.1 | 159.0 | **1.62** | **73/135** | **54.1%** | 13.21% | 428 |
**Verdict layer, each mover against ITS OWN reference (the only comparison that
isolates the knob):**
| arm | metric | mean Δ | 95% CI | sign test | p(sign) | p(sign-flip) | Wilcoxon p | MDE |
|---|---|---:|---|---:|---:|---:|---:|---:|
| `tfil_lag1` vs `tfil_off` | damage | +2.08 | [-7.22, +11.38] | 6/15 | 0.6072 | 0.6375 | 0.9773 | 12.15 |
| `tfil_lag1` vs `tfil_off` | wins | -0.04 | [-0.29, +0.21] | 4/9 | 1 | 0.8516 | 0.5923 | 0.33 |
| `tfil_lag1` vs `tfil_off` | hit_rate | +0.94 | [-0.93, +2.81] | 10/15 | 0.3018 | 0.2984 | 0.222 | 2.45 |
| `strafe_lag1` vs `strafe_off` | damage | +3.77 | [-6.98, +14.53] | 9/15 | 0.6072 | 0.4598 | 0.6701 | 14.04 |
| `strafe_lag1` vs `strafe_off` | wins | +0.18 | [-0.13, +0.49] | 5/9 | 1 | 0.3359 | 0.1723 | 0.41 |
| `strafe_lag1` vs `strafe_off` | hit_rate | +0.33 | [-0.73, +1.39] | 9/15 | 0.6072 | 0.5403 | 0.5509 | 1.38 |
### VERDICT — plain
1. **Was it only the drawing? NO. The decision was late, by exactly one bullet
step, and the fix is now in.** Measured live on 1777 matched ghost spawns
across both movers: the detection lag is **+1 tick on 100%** of them, the
ghost-vs-observer displacement is **19.1 px mean / 22.0 p90** (tfil) and
**16.1 / 21.8** (strafe), and the arrival deadline the mover reads is
**0.99 / 0.77 ticks late**. With `TR_FIRE_LAG=1` the displacement is
**5.4 / 9.0 px** and the deadline error **0.06 ticks** — the residue is the
ENEMY's own scan staleness (<= 8 px, its top speed), which is the floor this
design can reach because the ghost's origin is the enemy's *scanned* position.
The draw/advance order was checked and is correct, so the GUI was faithfully
drawing a wrong field.
2. **The live OUTCOME is null, and that is recorded as "not distinguishable".**
`tfil_lag1` is -0.04 wins/run and `strafe_lag1` is +0.18 wins/run — both far
under the MDEs the analyzer reported (0.33 and 0.41). Nothing reaches the
pre-registered bar, so under the campaign's rule **nothing is changed**:
`TR_FIRE_LAG` stays **default 0** and both movers ship exactly as before. The
knob is there, measured and documented, for anyone who wants the arm.
3. **The live MECHANISM did not move either** — incoming hit rate +0.94 pp (tfil)
and +0.33 pp (strafe), neither significant. This is the fourth consecutive
movement job where a real, measured mechanism change does not show up as fewer
hits taken. Two readings, both worth keeping: the dodge is limited by the
1-tick-stale enemy POSITION and by the 8-px scan staleness of the ghost's
origin, not by a 19-px translation of a field whose core is 18 px and whose
corridor is 40 px wide; and at 3 runs/arm a real few-percent effect in hit
rate sits under the ~1.4-point MDE. **What the fix does buy, provably, is
the arrival deadline**: every mover's heat, corridor and reap are now timed
off the bullet's real position, which is the input the next arrival-commit /
time-indexed-heat work needs to be correct at all.
4. **The one significant live result in this batch is the MOVER, not the knob**:
`strafe_lag1` vs `tfil_off` is +0.38 wins/run (sign 10/12, p = 0.0386) with
the incoming hit rate **-4.67 pp (sign 2/15, p = 0.0074, sign-flip
p = 0.0007, Wilcoxon p = 0.0024)** and mean distance +34 px (14/15). That is
the known strafe-over-tfil gap reproducing itself, and it is exactly why the
pre-registration demanded the within-mover reference: read against `tfil_off`
alone, the knob looks like a winner it is not.
**j148 — corridor LENGTH bound (unmeasured).** `TR_TFIL_CORRIDOR_TICKS` and
`TR_STRAFE_CORRIDOR_TICKS` (both default `0`) bound the corridor — the rotated
rectangle from the ghost bullet along its heading — by `min(distance to the wall,
bulletSpeed * TICKS)`, so a fast (low-power) bullet's corridor is long and a
slow one's is short, instead of every bullet blanketing the arena to the wall.
`0` is exactly today's behaviour; only the LENGTH changes, the heat inside the
surviving corridor is untouched. **Untested** — no battle, no measurement, the
parity guard only says the default path is byte-for-byte unchanged.
+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**.
+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.
+209
View File
@@ -0,0 +1,209 @@
# j159 — PRE-REGISTRATION: does tile-geometry weighting in the tfil picker win?
**Written and committed BEFORE a single battle of this experiment ran.** No
result in the "MEASURED" section below existed when this section was written.
## The question
`TR_TFIL_GEO_MODE` / `TR_TFIL_GEO_TAU` (shipped default `off`, j152) shape the
**draw** over the safe tiles the tfil picker chooses from. The offline sweep on
recorded fixtures predicted a real geometric improvement —
| offline metric (`measure_tfil_pick_defects`) | `off` | `both-rej`, tau 60 |
|---|---:|---:|
| REACH / arrival within feasible time | 4.5% | 29.4% |
| hot on arrival (`hotAtTta`) | 31.0% | 24.0% |
| **top-1 tile share (pick DIVERSITY)** | **7.0%** | **10.6%** |
— and a **diversity cost**, because the geometry weight concentrates the draw.
The offline ruler replays the real picker on real fixtures; it says nothing
about whether a tile that is geometrically reachable and less hot actually wins
a round. That is what this run measures.
**Hypothesis H1.** Weighting the draw by tile geometry (`both-rej`, tau 60)
raises damage/run and round-win rate over the shipped uniform draw, because the
mover arrives at safe tiles instead of merely picking them.
**Direction is pre-registered as two-sided.** A regression is as interesting as
a win (the diversity cost makes one plausible) and re-deciding the direction
after seeing the data is exactly what this document exists to prevent.
## Arms — identical except the geo knob
Both arms: `TR_MOVEMENT=tfil`, everything else at the shipped defaults, one
frozen binary built once from `git archive HEAD` (session 2223ca6).
| arm | env | role |
|---|---|---|
| `A_off` | `TR_TFIL_GEO_MODE=off` (`TAU=0`) | **REFERENCE** — the shipped uniform draw |
| `B_geo` | `TR_TFIL_GEO_MODE=both-rej` `TR_TFIL_GEO_TAU=60` | treatment |
**Contamination control (the main risk).** The owner has `both-rej` in a personal
`.env` (`ModularBot_garage/out/.env`, a copy at `tr_bots/ModularBot_geo/.env`),
and this bot's dotenv loader gives the FILE priority over shell exports. If the
tournament's bot instances resolved that file, arm A would silently become arm B
and the whole run would be void. Three guarantees, all verifiable from the logs:
1. each arm is launched with `TR_ENV_FILE` pointing at a **per-arm file this
job generated** (`/tmp/j159_geo/env/A_off.env`, `/tmp/j159_geo/env/B_geo.env`)
in this job's own outdir, so the ONLY `.env` the loader can resolve is mine;
2. the tournament's per-run botdir (`$OUTDIR/.work/<opp>/<arm>/run<N>/bots/ModularBot`)
contains only `ModularBot.json`, `ModularBot.sh` and a symlink to the frozen
binary — **no `.env`**, and the loader's fallback is `./.env` then `.env` next
to the executable (it does not walk up parent directories), so the owner's file
is not reachable;
3. every single run's `[env]` boot report is checked for its intended
`TR_TFIL_GEO_MODE` / `TR_TFIL_GEO_TAU` before any number is read. **Any run
whose `[env]` disagrees with its arm invalidates the session** and the run is
reported as void rather than analysed.
## 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**, unchanged. Unit of evidence is the opponent, not the battle.
* 15 opponents x 2 arms x **14 runs** x 3 rounds = **420 battles**.
* Battles serialised: `--wait-arena 45`, one session at a time.
### Primary metrics (pre-registered, fixed)
1. **damage/run** (our damage dealt per run)
2. **round-win rate** (rounds won / rounds fought)
**Hit rate is NOT a primary metric** — it hid a survival regression once already.
### Secondary / mechanism (reported, never a verdict)
* incoming hit rate (the survival channel the mechanism actually runs through);
* damage taken/run;
* the offline geometric numbers above (REACH%, hot-on-arrival%, top-1 tile
share). The live battle logs do not contain the per-pick tile or the arrival
state, so the live run **cannot** re-measure them; that is stated in the
verdict rather than papered over. No new instrumentation is built for this.
### Statistical treatment
Same as every previous movement gate: per-opponent paired deltas (arm −
reference), mean delta, SD, SE, 95% CI, a sign test and a **sign-flip
permutation test** (exact when `2^n <= 2^20`, else Monte-Carlo), Wilcoxon as a
cross-check, plus the MDE the analyzer reports for the reference arm's n.
### MDE — stated up front, and it is LARGE
At **14 runs/arm** over the frozen 15-opponent panel this design resolves about
**0.28 wins/run** (and the corresponding damage/run MDE the analyzer prints).
A two-arm run is 420 battles, ~1 hour. Resolving **0.10 wins/run** would need
~2.2 h and ~2,900 battles — **which we are NOT doing.**
**Consequences, recorded before any data:**
* **A null is the likely outcome.** This would be the *fifth* consecutive
mechanism-positive / outcome-null result in this campaign (after j144, j145,
j146, j147).
* A null here **excludes only a LARGE effect** (>= ~0.28 wins/run). It does not
show the knob does nothing, and it does not retract the offline geometric
measurement.
* Because the offline sweep also measured a **diversity regression**
(top-1 tile share 7.0% -> 10.6%), a null combined with a confirmed diversity
cost is an argument **against** shipping, not for it.
### Verdict rule (fixed now, not re-read later)
* **Adopt** only if BOTH primaries move in B's favour with `p(sign-flip) < 0.05`
and the effect is at or above the reported MDE. One primary at p<0.05 with
the other not down is reported as a partial signal, not a win.
* Otherwise **do not ship**; the knob stays default `off`.
* The mechanism is 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)*
### MEASURED — the live A/B, 420 battles (j159)
* **Provenance.** Session `/tmp/ab/j159_geo`, commit `7c5bc9c`, frozen binary
sha256 `9f116e7a9eb9…`, panel `tools/ab/panel_movement.txt` (FROZEN, 15
opponents), 15 x 2 x **14 runs** x 3 rounds = **420 battles, 0 failed, 0 never
started, 1110 s**. Per-arm env files `/tmp/j159_geo/env/{A_off,B_geo}.env`.
* **Env verification.** All **420** runs carry their intended arm: 210/210
`A_off` show `TR_TFIL_GEO_MODE=off` / `TR_TFIL_GEO_TAU=0` (parsed `off`/`0.0`),
210/210 `B_geo` show `both-rej`/`60` (parsed `both`/`60.0`), every run reports
`env file: /tmp/j159_geo/env/<arm>.env (source: TR_ENV_FILE)` and
`move.effective = tfil`. No `.env` exists anywhere in the session dir, and the
loader's fallbacks are `./.env` then `.env` next to the executable — it does
not walk up parents — so the owner's file is unreachable. **0 runs mis-set.**
* **Record correction (no battle re-run).** The arms file declared only
`TR_ENV_FILE`, which the analyzer's liveness guard reads from `session.json`
and treats as an undeclared `TR_MOVEMENT` (fatal contamination). `session.json`
and a corrected arms file were rewritten to declare the effective env — the
original is kept as `/tmp/j159_geo/{session.json.orig,arms_tfil_geo.txt.orig}`.
The guard then re-verified all 420 declared values verbatim in the boot
reports: `liveness: 0 run(s) excluded (420 total)`.
**Pooled dashboard (descriptive, NOT the verdict):**
| arm | runs | dmg/run | dmg taken/run | wins/run | round wins | win rate | incoming hit rate | mean distance |
|---|---:|---:|---:|---:|---:|---:|---:|---:|
| `A_off` | 210 | 112.5 | 195.2 | 1.21 | 255/630 | 40.5% | 17.64% | 393 |
| `B_geo` | 210 | 103.7 | 188.0 | 1.16 | 244/630 | 38.7% | 17.42% | 419 |
**Verdict layer** (per-opponent paired deltas, arm − reference; sign-flip is
the exact 2^15 permutation the pre-registration names as the decision test):
| arm | metric | mean Δ | 95% CI | sign test | p(sign) | **p(sign-flip)** | Wilcoxon p | MDE |
|---|---|---:|---|---:|---:|---:|---:|---:|
| `B_geo` | **damage/run** | **-8.83** | [-14.69, -2.97] | 5/15 | 0.3018 | **0.006104** | 0.0115 | 7.65 |
| `B_geo` | round wins | -0.05 | [-0.18, +0.08] | 4/11 | 0.5488 | 0.4619 | 0.3496 | 0.17 |
| `B_geo` | damage taken | -7.24 | [-20.84, +6.36] | 8/15 | 1 | 0.2786 | 0.4777 | 17.77 |
| `B_geo` | incoming hit rate | -1.19 pp | [-3.88, +1.51] | 8/15 | 1 | 0.3962 | 0.5895 | 3.51 |
| `B_geo` | mean distance | **+26.3 px** | [+16.3, +36.4] | **15/15** | 6.1e-05 | 6.1e-05 | 0.0007 | 13.15 |
**Per-opponent damage/run** (the pattern/ram rows carry the loss): Coriantumr
-25.1, CassiusClay -24.2, SpinBot -26.2, WallAvoider -13.3, BlitzBat -11.2,
HawkOnFire -11.7, Diamond -10.9, TripHammer -9.0, YersiniaPestis -8.9,
Dookious -7.3 vs GresSuffurd +2.3, DiamondStealer +3.2, Ascendant +3.7,
DrussGT +0.1. Round wins: HawkOnFire +0.50 and WallAvoider +0.14 (both closer
opponents) against Coriantumr -0.57, TripHammer -0.21, YersiniaPestis -0.21.
**Mechanism, offline (the committed ruler, re-run unchanged for this doc):**
`measure_tfil_pick_defects` reproduces the pre-registered prediction exactly —
REACH **4.5% -> 29.4%**, hot-on-arrival **31.0% -> 24.0%**, and the diversity cost
**top-1 tile share 7.0% -> 10.6%** (normalised entropy 0.87 -> 0.85). The live
mean distance **+26 px on 15/15 opponents** is the same mechanism seen end to
end: the geometry weight prefers tiles that are far better to *arrive* in, and
the bot sits further out and deals **less** damage.
### VERDICT — DO NOT ADOPT
1. **H1 is rejected.** Round wins are flat (-0.05/run, p(sign-flip) = 0.46, under
the 0.17 MDE) and damage/run is **down 8.83** (p(sign-flip) = 0.0061, above
the 7.65 MDE, Wilcoxon p = 0.011). The pre-registration required BOTH
primaries up; one is down and significant by the test it named.
2. **The MDE, restated.** 14 runs/arm on the frozen 15-opponent panel resolves
**0.17 wins/run** and **7.65 damage/run** (better than the 0.28 pre-registered
estimate). So this run excludes a large *benefit*; it also positively measures
a small *harm* in damage. It says nothing about effects below those numbers.
3. **The mechanism moved exactly as predicted, and that is what makes it bad.**
REACH/arrival 4.5% -> 29.4% and hot-on-arrival 31.0% -> 24.0% are real and
reproducible, but they bought **+26 px of distance** and fewer damage points,
not survival: incoming hit rate moved -1.19 pp, a fifth of its own 3.51 MDE.
"Arrive at a safe tile" turned out to mean "arrive further away".
4. **Diversity worsened, as the offline sweep warned.** Top-1 tile share
7.0% -> 10.6%, and the live losses concentrate against the opponents that
punish a long-range mover (SpinBot -26.2 damage with a -14.5 pp hit-rate
shift, the pattern guns -9 to -25). Outcomes are null-to-negative AND
diversity is worse: that is the argument against shipping, exactly the
pre-registered case.
5. **A null on wins lets us claim only "no large win".** It does not show the
knob is inert, and it does not retract the geometric measurement — but the
geometry measurement is not an argument for shipping when the live
consequence of it is measurably less damage from measurably further away.
**Ship state: `TR_TFIL_GEO_MODE` stays default `off`. Nothing changes.** Not
adopted, not adopted default-off. This is the **fifth** mechanism-positive /
outcome-not-positive result in the movement campaign (j144, j145, j146, j147,
j159) — and the first one where the mechanism is *anti*-correlated with damage.
+139
View File
@@ -0,0 +1,139 @@
# j154 — the DERIVED hold budget for "hold when the safe set is empty"
**Status: implemented, DEFAULT OFF, no battle run.** `TR_TFIL_HOLD_MAX_TICKS`
(default `0` = today's behaviour byte-for-byte). The knob, the panic release and
the guards live in `common_libs/movements/the_floor_is_lava.nim` and
`common_libs/tests/test_tfil_commit_env.nim`.
## 1. The mechanics, re-verified against the server source
Source used: **the server Kotlin sources at `/home/davide/Projects/tank-royale`
(v0.35.5)**, not a cached doc. There is no `docs/energy_math.md` in this repo.
| Fact | Value | Source |
|---|---|---|
| gun heat added per shot | `1 + p/5` | `server/.../rules/math.kt:125` `calcGunHeat` |
| gun cooling | `0.1` / tick | `core/GunEngine.kt:108` `coolDownGun`, default `DEFAULT_GUN_COOLING_RATE = 0.1` (`lib/common/.../RuleDefaults.kt:25`) |
| may fire only at | `gunHeat == 0.0` (strict; the else-branch cools instead) | `core/GunEngine.kt:36` |
| bullet damage | `4p`, `+2(p-1)` above 1 → `6p-2` | `rules/math.kt:112-118` `calcBulletDamage` |
| firepower clamp | `0.1 … 3.0` | `rules/rules.kt:49,52` |
| round-start gun heat | `3.0` | `rules/rules.kt:22` `INITIAL_GUN_HEAT` |
Note: there is no `MaxGunHeat = 3.0` gate in this server — the fire gate is
`gunHeat == 0`, and `3.0` is only the *initial* heat a bot starts a round with
(30 idle ticks of cooldown). The 16-tick number below is unchanged by that
distinction, because it is derived from the heat ADD and the cooling rate.
## 2. The owner's frame: "the time between shooting 2 × 3.0-power bullets"
`heat(3.0) = 1 + 3/5 = 1.6`; `1.6 / 0.1` = **16 ticks** between two max-power
shots. `calcBulletDamage(3.0) = 6*3 - 2 = ` **16**, so two of them = **32**.
The *fastest* repeat is a `0.1`-power shot: `1.02` heat → **11 ticks**
(the 11th subtraction is what takes the residual 0.02 to 0), for
`4*0.1 = ` **0.4** damage. So "2 × 3.0-power" is a **DAMAGE** bound, not a
COUNT bound: the enemy can fire ~1.5× as often, but each of those shots is 40×
weaker. The right question is therefore "how much damage can land in N ticks",
not "how many bullets".
## 3. Max damage deliverable in N ticks (brute force over the power quantisation)
DP over the 0.1-step power grid (30 powers), the enemy free to mix powers
(it may interleave weak shots to shorten its own interval), first shot free at
t = 0:
| N (ticks) | max total damage | how |
|---|---|---|
| 8 | **16** | one 3.0 shot; the 0.1-power repeat needs 11 |
| 11 | **18** | 3.0 (16) at t=0, then 0.5-power (2) at t=11 |
| 16 | **32** | 3.0 at t=0 and t=16 — two max shots |
| 24 | **32** | same two; the next shot cannot land before t=32 |
| 32 | **48** | 3.0 at t=0, 16, 32 |
| 64 | **80** | five max shots (linear thereafter) |
The damage *rate* `(6p-2)/(10+2p)` is monotone increasing in `p` (0.036 dmg/tick
at 0.1, 0.33 at 1.0, 1.0 at 3.0), so no mix beats pure 3.0-power asymptotically;
mixing only wins at a window edge (N = 11 above), never by more than one weak
shot. **16 ticks is the exposure ceiling of a hold: 32 damage = 16 % of the
200 HP a bot carries.**
## 4. How 16 relates to the code
* `CommitTicks = 15` (`the_floor_is_lava.nim:71`). The derived budget is
**`CommitTicks + 1`**: a hold of 15 ticks admits ONE max-power shot (16
damage), 16 ticks admits the second (32). 16 is the first window in which the
enemy's *second* bullet can land at all, so it is the shortest budget that
cannot be surprised by a third. The two numbers agree by construction, which
is the point: the mover's existing commitment length and the enemy's rate of
fire are the same quantity here.
* j144 measured a **mean hold of 24.0 ticks** live. 24 sits between the 16- and
32-tick damage steps: it buys no extra protection (still 32) and is exposed to
the same two shots. A 16-tick cap is therefore a *tightening* of j144's
measured behaviour, not an extrapolation of it — hence the 24 arm in the A/B.
## 5. The knob
`TR_TFIL_HOLD_MAX_TICKS` (int, default `0` = off). When the safe tile set is
empty (~65 % of picks offline) and the mover is on a **replan** tick, it holds
position for at most N ticks per empty streak. Rules:
* a safe tile exists → release on the same tick (no latency);
* counter resets when a safe tile is taken, so the bound is per streak;
* the hold never interrupts a live commitment (it replaces a replan only) —
j153's comment claimed this and its code did not enforce it; j154 does;
* the **gun is untouched**: `computeMove` never emits fire, and `ModularBot`
aims and fires from tracked state after `go()` on every tick. Guarded: the
fire detector still latches the enemy's wave on a held tick.
### Panic release (required)
`bulletPanic(m, selfX, selfY, horizon)` — for each tracked bullet, closest
approach of its straight path is `t* = ((self-b)·v)/|v|²`; the hold is
overridden when `0 ≤ t* ≤ horizon` and the miss distance is within the
bullet's own core radius. Horizon = **`min(N, 16)` ticks**. It reuses the
tracked ghost's own position/velocity — the same model `pathMaxHeat` decays by
and `advanceBullets` integrates — so there is no second arrival model in the
file (`TR_TFIL_ARRIVE_TICKS` is a *tile-selection* filter, not a bullet-arrival
predictor). `min(N, 16)` because a bullet arriving after the budget expires
cannot hurt a hold that has already ended; 16 is the derived exposure window.
### Composition with the other knobs
* `TR_TFIL_COMMIT_ARRIVAL=1` — **wins**. A hold is only reachable on a replan
tick, so under arrival the bot is in a commitment and never holds. The
arrival commitment is a hold on a *destination*; the empty-set hold is a hold
with *no destination*. They never compete.
* `TR_TFIL_NOREV_SPEED=4` — no interaction: it filters mid-flight candidate
switches inside the pick block, which a hold short-circuits, and it cannot
force a pick during a hold.
* `TR_TFIL_ARRIVE_TICKS` — no interaction: it only filters a **non-empty** safe
set, and the hold only triggers when that set is empty.
## 6. Guards
`common_libs/tests/test_tfil_commit_env.nim`, `testJ154` (15 checks): default 0;
explicit `0` byte-for-byte the unset build over 20 026 ticks; knob parsing
(16 / junk / negative); the N-tick bound; same-tick release when a safe tile
appears; counter reset and budget refill; the gun still fires while holding; the
held command is the same stop as at-target; panic release; panic specificity
(a bullet 200 px off the line still holds); the horizon check; the hold never
interrupts a live commitment; clearing the knob.
Suite total: **136 PASS** (121 before j154, not the 99 quoted in the brief —
see the note below).
## 7. Proposed A/B — NOT RUN
Arms: `TR_TFIL_HOLD_MAX_TICKS` = 0 (control) / 16 / 24; `TR_MOVEMENT=tfil`
pinned; frozen 15-opponent panel; everything else at shipped defaults.
Primary metrics: **damage per run and round-win rate** (never hit rate).
Mechanism metrics: % picks held, incoming hit rate while holding, panic-release
frequency, distance-to-enemy at the moment of the hold.
MDE: ~0.28 wins/run at ~14 runs/arm (two arms ≈ 1 h); resolving ~0.10
wins/run needs ~2.2 h ≈ 2 900 battles for a three-arm design.
Expectation to state up front: four mechanism-positive / outcome-null results in
a row (j152 geometry, j151 arrival bound, j144 arrival commitment, j153 hold)
make a null the likely outcome, and this is the fifth candidate. 1 h can only
say "no large effect"; ~2.2 h is needed before "no small effect".
+90
View File
@@ -0,0 +1,90 @@
# j153 proposal (NOT RUN) — `TR_TFIL_HOLD_WHEN_TRAPPED`: hold when no safe tile exists
**Status: awaiting the owner's approval. No battle, no A/B arm, no tournament has
been started.** The knob exists, is registered, defaults to today's behaviour
byte-for-byte, and is guarded offline.
## The owner's claim
> "if no tile is found to go, to not choose the less dangerous, but to stay
> still! the next tick probably the situation already changed and we did not
> commit to any dangerous place."
Today, when the safe set (`pathMaxHeat <= PathDangerThreshold`, after the
`CoolestLevels = 2` filter) is too small to draw from, the picker **promotes the
2 least-hot blocked tiles** and moves to one of them. The proposal replaces that
with `speed 0.0` for that tick only.
## The knob
| env | default | meaning |
|---|---|---|
| `TR_TFIL_HOLD_WHEN_TRAPPED` | `0` (off) | `1` = hold when the safe set is **empty**; today = promote the 2 least-hot blocked tiles |
- **One tick, never latched.** The hold is taken at the pick site, and the pick
site only runs when `commitTicks == 0`, so the next tick re-evaluates the field
from scratch. There is deliberately **no max-hold knob** in j153: a counter can
only add a way to get stuck. (j154, in flight, adds
`TR_TFIL_HOLD_MAX_TICKS` = 0 = off as a *separate* default-off knob.)
- **It never interrupts a live commitment.** The hold replaces a *replan*, not a
commitment in progress: with `TR_TFIL_COMMIT_ARRIVAL=1` or
`TR_TFIL_NOREV_SPEED=4` armed the two do not fight, because the hold branch is
downstream of the commitment block and only runs at `commitTicks == 0`.
- **The gun keeps firing.** `computeMove` never emits a fire command (the gun
lives in the bot's `go()` loop), and the hold `return`s *after* the bullet
tracking, so the fire tracker's state on a held tick is bit-identical to a
non-held tick (guarded in `common_libs/tests/test_tfil_commit_env.nim`).
## What the offline harness can and cannot say
Per `docs/offline_harness_trust.md` the replay harness is trustworthy only for
per-gun single-tick prediction on a fixed enemy trajectory; it scored **0/6** on
closed-loop questions. "Hold vs move" is a **counterfactual closed-loop** question,
so this document contains **no** damage-taken comparison for holding. Only
open-loop descriptors of the recorded field are reported
(`common_libs/tests/measure_tfil_hold_window.nim`).
## Proposed A/B (needs approval)
```
TOURNAMENT_NIMCACHE=/tmp/nc_j153 \
tools/ab/tournament_run.sh \
--arms tools/ab/arms_hold_trapped.txt \
--panel tools/ab/panel_movement.txt \
--runs 14 --rounds 7 --conc 7 --wait-arena 45 \
--reference hold0 \
--outdir /tmp/ab/j153_hold
python3 tools/ab/tournament_analyze.py /tmp/ab/j153_hold --reference hold0
```
Arms file (frozen 15-opponent movement panel, 14 runs/arm, 7 rounds):
```
hold0 | | control = today's promote-the-2 fallback
hold1 | TR_TFIL_HOLD_WHEN_TRAPPED=1 | hold one tick when the safe set is empty
hold8 | TR_TFIL_HOLD_WHEN_TRAPPED=1 TR_TFIL_HOLD_MAX_TICKS=8 | bounded hold (only if j154 lands)
```
**Primary metrics: damage/run and round-win rate** (NOT hit rate — a movement
arm's value flows through the closed loop). **Mechanism metrics:** incoming hit
rate, % of picks held, mean distance-to-enemy at a hold, tick-share at speed 0.
**MDE, stated up front:** ~0.28 wins/run at 14 runs/arm; a two-arm session is
~1 h. Resolving ~0.10 wins/run needs ~2.2 h / ~2,900 battles. A 1 h two-arm run
can only reject effects at or above ~0.28 wins/run — anything smaller is a null
by construction, and must be reported as such.
**Prior, stated plainly:** four mechanism-positive / outcome-null results in a
row on this campaign. **A null is the most likely outcome.** A null with a
mechanism hit (holds fire, distance at hold is large) would mean: holding is
achievable and does not by itself buy rounds; ship nothing. A null *without* a
mechanism hit means the arm never bound and the A/B is void, not negative.
## Decision each duration supports
| duration | supports |
|---|---|
| 1 h (2 arms × 14 runs) | reject/accept only ≥0.28 wins/run. Mechanism check only. |
| 2.2 h (~2,900 battles, 3 arms) | resolve ~0.10 wins/run. Still not a small-effect test. |
| any null | no change to the shipped default. `TR_TFIL_HOLD_WHEN_TRAPPED` stays 0. |
+38
View File
@@ -0,0 +1,38 @@
# ─────────────────────────────────────────────────────────────────────────────
# arms_fire_lag.txt — j147: the ONE-TICK DETECTION LAG, four arms on the frozen
# movement panel (tools/ab/panel_movement.txt). Two movers (tfil, strafe) x
# {lag 0, lag 1}; the lag knob is the ONLY difference inside a mover pair.
#
# WHY. MEASURED LIVE (common_libs/tests/measure_fire_ghost_lag.py, 4 sessions,
# 1777 matched ghost spawns): the server dispatches a turn's fire AFTER our
# go() for that same turn, so the energy drop of a turn-T shot first reaches our
# scan at turn T+1 (our bot tick T). A bullet takes its FIRST step during the
# turn it is fired, so by then the true bullet is already `speed` px (11..20 px,
# one whole bullet step) downrange and the arrival deadline is a full tick short.
# Both movers place the ghost at the SCANNED enemy position, i.e. exactly where
# the bullet was born: the whole ghost trajectory is the true one shifted one
# turn later. The measured ghost-vs-observer displacement is 19.1 px mean /
# 22.0 p90 (tfil) and 16.1 / 21.8 (strafe), deadline error 0.99 / 0.77 ticks.
# With TR_FIRE_LAG=1 the displacement falls to 5.4 / 9.0 px (the residue is the
# ENEMY's own scan staleness, <= 8 px) and the deadline error to 0.06 ticks.
#
# `TR_FIRE_LAG` default 0 = today's behaviour byte for byte, so arm 1 and arm 3
# ARE the shipped movers.
#
# Pre-registered in docs/movement_campaign.md ("Batch 8 — the fire-detection
# lag") BEFORE any of these battles ran. References: `tfil_off`, `strafe_off`.
#
# Format: name | ENV=value ENV=value | label
# ─────────────────────────────────────────────────────────────────────────────
# 1. tfil as shipped (the reference for arm 2).
tfil_off | TR_MOVEMENT=tfil | today's tfil, the ghost is born at the scanned enemy (reference)
# 2. tfil with the measured lag back-dated at spawn.
tfil_lag1 | TR_MOVEMENT=tfil TR_FIRE_LAG=1 | tfil, every detected fire back-dated one bullet step
# 3. strafe as shipped (the reference for arm 4).
strafe_off | TR_MOVEMENT=strafe | today's strafe, the ghost is born at the scanned enemy (reference)
# 4. strafe with the measured lag back-dated at spawn.
strafe_lag1 | TR_MOVEMENT=strafe TR_FIRE_LAG=1 | strafe, every detected fire back-dated one bullet step