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.
This commit is contained in:
2026-09-27 10:22:24 +02:00
parent 94ffc63160
commit 38fbc6ecd1
5 changed files with 793 additions and 28 deletions
+5
View File
@@ -113,6 +113,11 @@ TR_TFIL_BULLET_AURA=5.0 # tfil only: lava for the bullet's aura ring tiles
TR_TFIL_TILE_REPLAN=self # self | enemy | off: when a dodge commitment is cancelled TR_TFIL_TILE_REPLAN=self # self | enemy | off: when a dodge commitment is cancelled
TR_TFIL_COMMIT_TICKS=15 # ticks to commit to a dodge point before replanning TR_TFIL_COMMIT_TICKS=15 # ticks to commit to a dodge point before replanning
TR_TFIL_ARRIVE_TICKS=0.0 # hard bound: never pick a tile farther than this (ticks at 8px/tick); 0 = off = today's draw TR_TFIL_ARRIVE_TICKS=0.0 # hard bound: never pick a tile farther than this (ticks at 8px/tick); 0 = off = today's draw
TR_TFIL_HOLD_WHEN_TRAPPED=off # on = when NO safe tile exists, hold position one tick instead of taking the 2 least-hot blocked tiles; off = today's fallback
TR_TFIL_DANGER_THRESHOLD=10.0 # hard heat filter on the path; lava is quantised to 5, so the effective steps are 10/15/20 and 10-14 admits exactly what 10 does
TR_TFIL_DIAG=off # on = fill the per-pick picker loss histogram (TfilLoss*); observability only
TR_TFIL_GEO_MODE=off # off | turn | dist | both, each with a `-soft` (default) / `-topk` / `-rej` suffix; shapes the DRAW over the heat-filtered pool
TR_TFIL_GEO_TAU=0.0 # deg; the geometric cost scale. 0 = off = today's uniform draw
TR_TFIL_NO_REV=off # on = never reverse direction inside a corridor TR_TFIL_NO_REV=off # on = never reverse direction inside a corridor
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) TR_TFIL_COMMIT_ARRIVAL=off # on = hold the dodge tile until we are ON it (not a fixed dwell)
+13
View File
@@ -315,6 +315,8 @@ proc printEffectiveValues(ctx: EnvReportContext) =
sourceOf("TR_TFIL_CORRIDOR_TICKS")) sourceOf("TR_TFIL_CORRIDOR_TICKS"))
emit("TR_TFIL_ARRIVE_TICKS", $the_floor_is_lava.TfilArriveTicks, emit("TR_TFIL_ARRIVE_TICKS", $the_floor_is_lava.TfilArriveTicks,
sourceOf("TR_TFIL_ARRIVE_TICKS")) 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_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,
@@ -333,6 +335,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)
@@ -652,6 +662,9 @@ proc knownEnvNames*(): seq[string] =
"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_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_CORRIDOR_TICKS", "TR_TFIL_ARRIVE_TICKS",
"TR_TFIL_HOLD_WHEN_TRAPPED",
"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",
+237 -3
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
@@ -95,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"
@@ -163,11 +199,47 @@ var
## the DEFAULT is today's `10.0`, so the default path is bit-identical. ## the DEFAULT is today's `10.0`, so the default path is bit-identical.
## TR_TFIL_DANGER_THRESHOLD default 10.0 ## TR_TFIL_DANGER_THRESHOLD default 10.0
TfilDangerThreshold* = DefaultDangerThreshold 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 ## j151: `TR_TFIL_ARRIVE_TICKS` — refuse a candidate we cannot REACH inside
## the commitment horizon (ticks = dist / MaxSpeed). Hard bound, not a ## the commitment horizon (ticks = dist / MaxSpeed). Hard bound, not a
## preference; empty pool => today's full pool, so it can never starve the ## preference; empty pool => today's full pool, so it can never starve the
## draw. 0 (default) = off = byte-for-byte today. ## draw. 0 (default) = off = byte-for-byte today.
TfilArriveTicks*: float = 0.0 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 ## 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. ## (how many tiles die at each picker stage). Pure counters, off by default.
TfilDiag*: bool = false TfilDiag*: bool = false
@@ -228,12 +300,18 @@ proc loadTfilCommitEnv*() =
TfilDangerThreshold = max(0.0, getEnvFloat("TR_TFIL_DANGER_THRESHOLD", TfilDangerThreshold = max(0.0, getEnvFloat("TR_TFIL_DANGER_THRESHOLD",
DefaultDangerThreshold)) DefaultDangerThreshold))
TfilDiag = getEnvBool("TR_TFIL_DIAG", false) 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 # 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 # 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 # 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 # 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. # 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)) 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() if not TfilDiag: TfilLoss = TfilLossStats()
loadTfilCommitEnv() loadTfilCommitEnv()
@@ -394,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
@@ -428,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)
@@ -443,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) ──────────────────
@@ -546,6 +632,28 @@ proc detectFires(m: var TFILModule, ws: WorldState) =
" eta=", sqrt((ws.selfX - b.x)^2 + (ws.selfY - b.y)^2) / sp " 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
@@ -725,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)
@@ -978,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]
@@ -1097,8 +1276,11 @@ proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand =
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 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)
@@ -1173,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:
@@ -1196,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,
@@ -1253,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
@@ -1359,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
+114 -5
View File
@@ -14,6 +14,11 @@
## safePre size of the safe set BEFORE the "keep 2" promotion ## safePre size of the safe set BEFORE the "keep 2" promotion
## hotAtTta the destination tile was OVER the threshold `tta` ticks ## hotAtTta the destination tile was OVER the threshold `tta` ticks
## later, on the recorded (true) future <- the feasibility test ## 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. ## No battle, no Java, no server, no behaviour change.
## ##
## Run: ## Run:
@@ -21,7 +26,7 @@
## common_libs/tests/measure_tfil_pick_defects.nim [fixture.jsonl ...] ## common_libs/tests/measure_tfil_pick_defects.nim [fixture.jsonl ...]
## Env it forwards: TR_TFIL_CORRIDOR_TICKS, TR_TFIL_DANGER_THRESHOLD, ... ## Env it forwards: TR_TFIL_CORRIDOR_TICKS, TR_TFIL_DANGER_THRESHOLD, ...
import std/[os, strformat, math, algorithm, json, sets, random, sequtils] import std/[os, strformat, math, algorithm, json, sets, random, sequtils, tables]
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/offline_range import gun_harness/offline_range
# Private-field access: include (do NOT import) the shipped mover. # Private-field access: include (do NOT import) the shipped mover.
@@ -36,6 +41,7 @@ type Pick = object
safePre, cand: int safePre, cand: int
hotAtTta: bool ## destination over threshold when we would arrive hotAtTta: bool ## destination over threshold when we would arrive
reached: bool ## we actually got within ArriveRadius by then reached: bool ## we actually got within ArriveRadius by then
col, row: int ## the chosen tile (diversity)
proc loadRoundStarts(path: string): HashSet[int] = proc loadRoundStarts(path: string): HashSet[int] =
result = initHashSet[int]() result = initHashSet[int]()
@@ -100,7 +106,8 @@ proc replay(path: string, seed: int): seq[Pick] =
destHeat: m.lavaAt(cc, cr), dist: d, tta: d / MaxSpeed, destHeat: m.lavaAt(cc, cr), dist: d, tta: d / MaxSpeed,
promoted: m.lastPickPromoted, promoted: m.lastPickPromoted,
safePre: safeSetSize(m, TfilDangerThreshold), safePre: safeSetSize(m, TfilDangerThreshold),
cand: m.lastPickSafe, hotAtTta: false, reached: false) 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) pending.add (col: cc, row: cr, at: mActive + int(d / MaxSpeed), idx: result.high)
# 2. the arrival probe on the recorded true future # 2. the arrival probe on the recorded true future
var keep: seq[tuple[col, row: int; at: int; idx: int]] var keep: seq[tuple[col, row: int; at: int; idx: int]]
@@ -122,6 +129,7 @@ proc mean(x: seq[float]): float =
proc pc(x: float): string = &"{100.0 * x:.1f}%" proc pc(x: float): string = &"{100.0 * x:.1f}%"
proc f1(x: float): string = &"{x:.1f}" proc f1(x: float): string = &"{x:.1f}"
proc f2(x: float): string = &"{x:.2f}"
proc report(label, path: string, picks: seq[Pick]) = proc report(label, path: string, picks: seq[Pick]) =
echo &"\n\u2550\u2550\u2550 {label} {extractFilename(path)}" echo &"\n\u2550\u2550\u2550 {label} {extractFilename(path)}"
@@ -158,8 +166,16 @@ proc report(label, path: string, picks: seq[Pick]) =
# ── driver ─────────────────────────────────────────────────────────────────── # ── driver ───────────────────────────────────────────────────────────────────
let args = commandLineParams() let args = commandLineParams()
let fixtures = if args.len > 0: args let detail = "--detail" in args
else: @["/tmp/firelag_live2/tfil_on/run1.jsonl", 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/tfil_off/run1.jsonl",
"/tmp/firelag_live2/strafe_on/run1.jsonl", "/tmp/firelag_live2/strafe_on/run1.jsonl",
"/tmp/firelag_live2/strafe_off/run1.jsonl", "/tmp/firelag_live2/strafe_off/run1.jsonl",
@@ -171,6 +187,99 @@ let fixtures = if args.len > 0: args
"tools" / "fixtures" / "tr_drussgt_vs_crazy.jsonl", "tools" / "fixtures" / "tr_drussgt_vs_crazy.jsonl",
currentSourcePath().parentDir.parentDir.parentDir / currentSourcePath().parentDir.parentDir.parentDir /
"tools" / "fixtures" / "tr_drussgt_vs_spinbot.jsonl"] "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] var total: seq[Pick]
for f in fixtures: for f in fixtures:
if not fileExists(f): if not fileExists(f):
@@ -178,5 +287,5 @@ for f in fixtures:
for seed in [7, 8, 9]: for seed in [7, 8, 9]:
let p = replay(f, seed) let p = replay(f, seed)
total.add p total.add p
if seed == 7: report("seed 7", f, p) # per-fixture detail, first seed only if seed == 7: report("seed 7", f, p)
report("ALL FIXTURES x 3 SEEDS", "", total) report("ALL FIXTURES x 3 SEEDS", "", total)
+405 -1
View File
@@ -37,7 +37,7 @@
## 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
# Private-field access: include (do NOT import) the shipped mover. # Private-field access: include (do NOT import) the shipped mover.
@@ -1078,6 +1078,407 @@ proc testJ151() =
check "j151: clearing the knob restores today's pick stream exactly", check "j151: clearing the knob restores today's pick stream exactly",
replayJ151(0.0).picks == off.picks 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
# ── driver ─────────────────────────────────────────────────────────────────── # ── driver ───────────────────────────────────────────────────────────────────
testDefaultParity() testDefaultParity()
@@ -1089,7 +1490,10 @@ when declared(loadTfilCommitEnv):
testJ146() testJ146()
testJ147() testJ147()
testJ151() testJ151()
testJ152()
testJ150() testJ150()
testJ154()
testJ153()
if failures > 0: if failures > 0:
echo "\n", failures, " check(s) FAILED" echo "\n", failures, " check(s) FAILED"