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>
This commit is contained in:
2026-09-27 09:51:19 +02:00
parent 5e213df8d9
commit 0b74b01c4b
3 changed files with 319 additions and 3 deletions
+79 -3
View File
@@ -85,6 +85,8 @@ const MaxTrackedBullets = 20 ## hard cap on tracked bullets
#
# 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`.
const DefaultDangerThreshold = 10.0 ## today's `PathDangerThreshold`
type
TfilTileReplan* = enum
ttrSelf, ttrOff, ttrEnemy
@@ -156,6 +158,38 @@ var
## 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
## 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
## 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 =
let s = getEnv(name, "")
@@ -191,6 +225,16 @@ proc loadTfilCommitEnv*() =
TfilTurnRefDeg = max(0.0, getEnvFloat("TR_TFIL_TURN_REF_DEG", 45.0))
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)
# 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))
if not TfilDiag: TfilLoss = TfilLossStats()
loadTfilCommitEnv()
@@ -984,11 +1028,13 @@ proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand =
# 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.
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
# 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.
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 =
## MAX lava on the straight-line segment (fx,fy) -> (tx,ty), sampled every
@@ -1029,7 +1075,8 @@ proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand =
scoredTiles.add (col: t.col, row: t.row,
pathMaxHeat: pathMaxHeat(m, ws.selfX, ws.selfY, tx, ty),
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)
for i in 1..<scoredTiles.len:
@@ -1049,6 +1096,7 @@ proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand =
for t in scoredTiles:
if t.pathMaxHeat <= PathDangerThreshold: safeTiles.add t
else: blockedTiles.add t
let safePre = safeTiles.len # j150: the safe set BEFORE the "keep 2" promotion
if safeTiles.len < 2:
# Fallback: promote the least-hot blocked tiles until we have 2
# ponytail: O(n) scan on already-sorted seq — fine for small N
@@ -1059,6 +1107,15 @@ proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand =
m.lastPickPromoted = true
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
# 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
@@ -1211,6 +1268,25 @@ proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand =
m.lastPickCall = m.callCount
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:
# Reachable hull perimeter (darker blue)
if m.cachedHull.len >= 3: