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
+237 -3
View File
@@ -70,6 +70,15 @@ var
const CommitTicks = 15 ## ticks to commit to a dodge point
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 CoolestLevels = 2 ## how many distinct lava values count as "cool"
const MaxTrackedBullets = 20 ## hard cap on tracked bullets
@@ -95,6 +104,33 @@ type
rrNone, rrInit, rrTileSelf, rrTileEnemy, rrDanger, rrExpiry,
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 =
case m
of ttrSelf: "self"
@@ -163,11 +199,47 @@ var
## 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
@@ -228,12 +300,18 @@ proc loadTfilCommitEnv*() =
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()
@@ -394,6 +472,12 @@ type
## candidate set of the last pick (j145:
## lets a caller measure the REGRET of the
## 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
## <= PathDangerThreshold) the last pick
## drew from (j146: the size of the set
@@ -428,6 +512,7 @@ proc resetRound*(m: var TFILModule) =
m.fire.reset()
m.commitTicks = 0
m.commitAge = 0
m.holdTicks = 0 ## j154: the bounded hold never survives a round
m.cachedHull = @[]
m.cachedInsideTiles = @[]
m.blockedTile = (col: 0, row: 0, active: false)
@@ -443,6 +528,7 @@ proc resetRound*(m: var TFILModule) =
m.picks = 0
m.lastPickPromoted = false
m.lastPickMinTurn = 0.0
m.lastHeld = false
m.lastPickSafe = 0
# ── 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
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) =
## Advance positions and reap bullets that are: passed us, out of bounds, or too old.
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 *
(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 =
if m.cols == 0:
m.initGrid(ws.arenaWidth, ws.arenaHeight)
@@ -978,6 +1156,7 @@ proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand =
var pickedInterval = 0
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 pickedHeld = false ## j153: the safe set was EMPTY -> hold this tick
# Hull + inside-tiles: only recompute on replan tick (commitTicks == 0)
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
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:
# 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
let needed = 2 - safeTiles.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.
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
var candidates: seq[ScoredTile]
for t in safeTiles:
@@ -1196,7 +1402,20 @@ proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand =
for i in keep: narrowed.add candidates[i]
candidates = narrowed
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
# that already passed the hard heat filter above:
# 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.commitAge = 0
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
# log-only: reversal test against the travel direction
@@ -1359,10 +1579,24 @@ proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand =
",\"reason\":\"" & reasonName(reason) & "\",\"rev\":" &
(if pickedRev: "1" else: "0") & ",\"mid\":" &
(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 & "}")
if pickedThisTick: m.replanReason = rrNone
m.lastHeld = pickedHeld
# ── 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 stepDy = m.commitTarget.y - ws.selfY
let dist2 = stepDx*stepDx + stepDy*stepDy