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.
This commit is contained in:
2026-09-27 13:27:02 +02:00
parent 4a1f3e1c88
commit fe77056459
6 changed files with 20411 additions and 6 deletions
File diff suppressed because it is too large Load Diff
+130
View File
@@ -42,6 +42,10 @@ import std/strutils except fromHex # `fromHex` would clash with color.fromHex
import gun_harness/gun_interface
# Private-field access: include (do NOT import) the shipped mover.
include movements/the_floor_is_lava
# j165: the TFIL-RING fork's arrival commitment. `tfil_ring_replay` includes
# the ring mover (for its PRIVATE commitTarget/commitTicks) and re-exports it,
# so this is the only ring import the guard needs.
import tfil_ring_replay
const repoRoot = currentSourcePath().parentDir.parentDir.parentDir
const fixtureRel = "tr_drussgt_vs_modularbot.jsonl"
@@ -1479,6 +1483,130 @@ proc testJ154() =
check "j154: clearing the knob restores today's behaviour exactly",
TfilHoldMaxTicks == 0
# ── j165: the TFIL-RING arrival commitment (TR_TFIL_RING_*, default OFF) ─────
# The same two mechanisms ported from `the_floor_is_lava.nim` (j144) onto
# RING-SPECIFIC env names, so the two forks never share a namespace by
# accident. What must hold, and nothing more:
# 1. DEFAULT PARITY: with both knobs unset the ring mover is byte-for-byte
# the PRE-CHANGE ring mover over the whole fixture replay.
# 2. the arrival commitment ENGAGES: the committed target is actually
# REACHED far more often, and there are strictly fewer mid-flight
# re-targets than the shipped fixed 5-tick dwell.
# 3. the no-reversal pool is SPEED-GATED: the stream may only diverge from
# the unarmed build on a tick whose |selfSpeed| is below the gate, and the
# pool itself can never be emptied.
when declared(TfilRingCommitArrival):
proc ringRunStats(): tuple[picks, reached: int] =
## Count picks over the replay and how many of them REPLACED a target the bot
## had actually stood on (< 18px, the same arrival radius the mover uses).
## Under a fixed dwell this is rare: the target is replaced mid-flight.
randomize(Seed)
var m = initTFILRing()
let states = loadStates()
let starts = loadRoundStarts()
var prev = (x: 0.0, y: 0.0)
var hadPick = false
for i in 0..<states.len:
if i == 0 or i in starts:
m.resetRound()
hadPick = false
let ws = states[i]
let before = ringPicks(m)
discard m.computeMove(ws)
if ringPicks(m) != before:
inc result.picks
if hadPick and
sqrt((ws.selfX - prev.x)^2 + (ws.selfY - prev.y)^2) < RingArriveRadius:
inc result.reached
prev = ringTarget(m)
hadPick = true
proc testJ165() =
doAssert fileExists(RingGoldenPath), "missing golden: " & RingGoldenPath
let recs = replayRing() # both knobs unset; the shipped fire detector, as
# the golden was generated
var golden: seq[string]
for rawLine in lines(RingGoldenPath):
if rawLine.startsWith("#"): continue
let line = rawLine.strip()
if line.len > 0: golden.add line
check "j165: ring golden covers the whole fixture (>= 15000 ticks)",
golden.len >= 15000
check "j165: the unset replay covers the same number of ticks",
recs.len == golden.len
var firstDiff = -1
for i in 0..<min(recs.len, golden.len):
if ringRecLine(recs[i]) != golden[i]:
firstDiff = i
break
check "j165: BOTH KNOBS UNSET IS BYTE-FOR-BYTE THE PRE-CHANGE RING MOVER " &
"(speed/turnRate/target/commitTicks) over " & $recs.len & " ticks",
firstDiff < 0
if firstDiff >= 0:
echo " first divergence at tick index ", firstDiff, ": got [",
ringRecLine(recs[firstDiff]), "] want [", golden[firstDiff], "]"
delEnv("TR_TFIL_RING_COMMIT_ARRIVAL")
delEnv("TR_TFIL_RING_NOREV_SPEED")
loadTfilRingCommitEnv()
check "j165: both knobs DEFAULT OFF with the env deleted",
(not TfilRingCommitArrival) and TfilRingNoRevSpeed == 0.0
putEnv("TR_TFIL_COMMIT_ARRIVAL", "1") # tfil's names must NOT leak across
putEnv("TR_TFIL_NOREV_SPEED", "9")
loadTfilRingCommitEnv()
check "j165: the env names are RING-SPECIFIC — tfil's " &
"TR_TFIL_COMMIT_ARRIVAL / TR_TFIL_NOREV_SPEED leave ring untouched",
(not TfilRingCommitArrival) and TfilRingNoRevSpeed == 0.0
delEnv("TR_TFIL_COMMIT_ARRIVAL")
delEnv("TR_TFIL_NOREV_SPEED")
# 2. the arrival commitment engages
let off = ringRunStats()
TfilRingCommitArrival = true
let on = ringRunStats()
TfilRingCommitArrival = false
check "j165: ARRIVAL ENGAGES — a committed target is actually REACHED on " &
$on.reached & "/" & $on.picks & " picks, vs " & $off.reached & "/" &
$off.picks & " on the shipped fixed 5-tick dwell",
on.picks > 0 and (on.reached.float / on.picks.float) >
(off.reached.float / off.picks.float)
check "j165: ... and it holds instead of re-targeting mid-flight: " &
$on.picks & " picks vs " & $off.picks & " on the same replay",
on.picks < off.picks
# 3. the no-reversal pool is speed-gated, and can never be emptied
check "j165: norevPool with the gate off returns EVERY candidate",
norevPool(@[10.0, 120.0, -170.0], 0.0) == @[0, 1, 2]
check "j165: norevPool armed keeps only the non-reversing candidates",
norevPool(@[10.0, 120.0, -170.0], 4.0) == @[0]
check "j165: norevPool never empties — all-behind falls back to the least bad",
norevPool(@[170.0, 150.0, 179.0], 4.0) == @[1] and
norevPool(@[170.0, 179.0], 4.0).len > 0
# Engine gate: the streams can only DIVERGE at a gated pick. (Divergence
# then persists for many ticks — a different target steers differently — so
# "every diverging tick is slow" is the wrong claim; "the FIRST divergence
# is a slow-tick pick" is the right one.)
TfilRingNoRevSpeed = 4.0
let armed = replayRing()
TfilRingNoRevSpeed = 0.0
var nDiv = 0
var firstArmed = -1
for i in 0..<min(recs.len, armed.len):
if ringRecLine(recs[i]) != ringRecLine(armed[i]):
inc nDiv
if firstArmed < 0: firstArmed = i
let states = loadRingStates()
check "j165: the no-reversal treatment APPLIES (gate 4.0 changes " & $nDiv &
" of " & $recs.len & " ticks — an A/B whose treatment never fires is worthless)",
nDiv > 0
check "j165: ... and it is SPEED-GATED: the first divergence (tick " &
$firstArmed & ") is a PICK made at |selfSpeed| = " &
$(if firstArmed >= 0: abs(states[firstArmed].selfSpeed) else: -1.0) &
" < 4.0",
firstArmed >= 0 and armed[firstArmed].picked and
abs(states[firstArmed].selfSpeed) < 4.0
# ── driver ───────────────────────────────────────────────────────────────────
testDefaultParity()
@@ -1494,6 +1622,8 @@ when declared(loadTfilCommitEnv):
testJ150()
testJ154()
testJ153()
when declared(TfilRingCommitArrival):
testJ165()
if failures > 0:
echo "\n", failures, " check(s) FAILED"
+109
View File
@@ -0,0 +1,109 @@
## Fixture replay for the TFIL-RING mover (`movements/the_floor_is_lava_ring.nim`).
##
## It `include`s the mover (not `import`s it) so the replay can read the
## private `commitTarget` / `commitTicks` — the same reason
## `test_tfil_commit_env.nim` includes `the_floor_is_lava.nim`. Living in its
## OWN module keeps those privates in this module's scope, so a file that
## includes BOTH movers still compiles (there is no name clash between them:
## this one only sees ring's).
##
## `--path:common_libs` relative to the repo root.
##
## GOLDEN GENERATION (j165 default parity). Compile this file with the golden
## flag against the PRE-CHANGE ring, e.g. from a `git show HEAD:...` tree:
##
## TFIL_RING_GOLDEN_OUT=<path> \
## nim c -r --path:. -d:tfilRingGenGolden common_libs/tests/tfil_ring_replay.nim
##
## Nothing here references a j165 symbol, so the SAME file generates the golden
## on the pre-change mover and checks it on the post-change one. Regenerating
## the golden from the new code would defeat the check — only do that after a
## DELIBERATE change to the ring defaults.
import std/[os, json, random, math]
import std/strutils except fromHex
import gun_harness/gun_interface
include movements/the_floor_is_lava_ring
const
Seed = 20250923 ## same seed as the tfil replay: comparable arms
ArenaW = 800.0
ArenaH = 600.0
fixtureRel = "tr_drussgt_vs_modularbot.jsonl"
const RingGoldenPath* = currentSourcePath().parentDir / "fixtures" /
"tfil_ring_commit_default.golden"
type RingTickRec* = object
spd, trn: float ## the emitted MoveCommand
tx, ty: float ## where we are steering to
ct: int ## ticks left on the commitment
picked*: bool ## this tick made a NEW pick (not in the golden)
# Thin accessors for the mover's PRIVATE per-round state. The guard needs them
# to measure what the commitment did; the mover's own API stays unchanged.
proc ringPicks*(m: TFILRingModule): int = m.picks
proc ringTarget*(m: TFILRingModule): tuple[x, y: float] = m.commitTarget
proc ringRecLine*(r: RingTickRec): string =
$r.spd & " " & $r.trn & " " & $r.tx & " " & $r.ty & " " & $r.ct
proc loadRingStates*(): seq[WorldState] =
let path = currentSourcePath().parentDir.parentDir.parentDir / "tools" /
"fixtures" / fixtureRel
for rawLine in lines(path):
let line = rawLine.strip()
if line.len == 0: continue
let n = parseJson(line)
if n.hasKey("meta") or n.hasKey("end"): continue
let ex = n["ex"].getFloat()
let ey = n["ey"].getFloat()
result.add WorldState(
enemyX: ex, enemyY: ey,
enemyHeading: n["eh"].getFloat(), enemySpeed: n["es"].getFloat(),
enemyEnergy: n["ee"].getFloat(),
selfX: n["sx"].getFloat(), selfY: n["sy"].getFloat(),
selfHeading: n["sh"].getFloat(), selfSpeed: n["ss"].getFloat(),
selfEnergy: n["se"].getFloat(),
arenaWidth: ArenaW, arenaHeight: ArenaH,
tick: n["tick"].getInt(),
enemies: @[EnemyInfo(id: 1, x: ex, y: ey,
heading: n["eh"].getFloat(), speed: n["es"].getFloat(),
energy: n["ee"].getFloat())])
proc loadRingStarts*(): seq[int] =
let side = currentSourcePath().parentDir.parentDir.parentDir / "tools" /
"fixtures" / "drussgt_meta" / (fixtureRel & ".rounds.json")
if not fileExists(side): return
for r in parseFile(side)["rounds"]:
result.add r["startTick"].getInt()
proc replayRing*(): seq[RingTickRec] =
## Drive the REAL ring `computeMove` over the recorded WorldState stream with a
## fixed seed, touching no env knob. With every j165 knob unset this is the
## pre-change code path exactly.
randomize(Seed)
var m = initTFILRing()
let states = loadRingStates()
let starts = loadRingStarts()
for i in 0..<states.len:
if i == 0 or i in starts: m.resetRound()
let before = m.picks
let cmd = m.computeMove(states[i])
result.add RingTickRec(spd: cmd.speed, trn: cmd.turnRate,
tx: m.commitTarget.x, ty: m.commitTarget.y,
ct: m.commitTicks, picked: m.picks != before)
when isMainModule and defined(tfilRingGenGolden):
block:
let recs = replayRing()
let outPath = getEnv("TFIL_RING_GOLDEN_OUT", RingGoldenPath)
var g = "# TFIL-RING default-path parity golden (j165).\n"
g.add "# Generated from the PRE-CHANGE ring mover (`git show HEAD:...`) with\n"
g.add "# every j165 knob UNSET, over the whole\n"
g.add "# tools/fixtures/tr_drussgt_vs_modularbot.jsonl replay.\n"
g.add "# Format: speed turnRate targetX targetY commitTicks\n"
for r in recs: g.add ringRecLine(r) & "\n"
createDir(outPath.parentDir)
writeFile(outPath, g)
echo "wrote ", outPath, " (", recs.len, " ticks)"