TFIL heat field: the safety model keeps us ~400px away, which is our worst range
Offline diagnostic driving the REAL TFILModule.computeMove over the committed
DrussGT fixtures (re-derived field matched the module's own m.lava bit-for-bit,
max diff 0.000e+00). Answers "is the heat map too hot, and are the corridors to
blame?" - the user's suspicion after watching a GUI run stay far away.
PRIMARY FIXTURE (tr_drussgt_vs_modularbot, 20,026 ticks / 15 rounds):
Field saturation
tiles == 0 22%
tiles > 0 78%
tiles > PathDangerThreshold(10) 61% (worst tick 91%)
median / p90 / max lava 20.06 / 44.53 / 77.51
> 10 with NO bullets at all 44% <- wall radiance + pillar alone
early/mid/late frac > 10 0.61 / 0.63 / 0.60 (saturated from tick 0, not degrading)
Safe pool - THIS IS THE KEY NUMBER
inside-hull tiles/tick 173.7
safeTiles/tick 15.19
ticks with ZERO tile passing the filter 58.5% (2-tile promote fallback used 59.0%)
ticks where the ring weighting is enabled (pool >= MinRingPool=4) 39.1%
ticks with >=1 safe tile in the 100-200px band 11.84%
MEAN DISTANCE TO THE CLOSEST SAFE TILE 397.8 px
ticks both pool>=4 AND band present ("band-weightable") 10.79%
So the safety filter leaves nothing safe near the target: the closest safe tile
averages 398px away. Our measured hit rate is 27.1% at 100-200px and ~5% at
450px, so TFIL's danger model structurally parks us at our worst range. This -
not only the env-var issue - is why the bot stays far away.
Per-source attribution (share of total lava / of the over-10 set)
wall 60.56% / 56.39% <- saturates the RAW field
corridor 25.43% / 21.35% <- blocks the BAND
pillar 7.44% / 5.06%
bullet_aura 2.22% / 1.47%
enemy_core 1.97% / 0.62%
bullet_core 1.17% / 0.33%
enemy_aura 1.21% / 0.95%
Note CorridorHeat=20 is TWICE PathDangerThreshold=10, so a single corridor can
poison a path on its own; WallHotness=30 with WallRadiance=10 puts the outer two
tile rings at/over threshold by themselves (38.6% of all tiles).
Counterfactuals (shipped constants NOT changed) - band-weightable ticks
corridor 20 (shipped) 10.79% band-safe 11.84% pool 15.19
corridor 10 16.47% pool 30.54
corridor 5 23.80% band-safe 24.21% pool 43.93
corridor 0 38.74% pool 62.14
wall 30->10 only pool 15.19 -> 24.80, band unchanged (12.31%)
corridor 5 + wall 10 26.45% band-safe 26.64% pool 85.04
Reachability - NOT the blocker
band inside the 50-tick reachable hull 64.94% of ticks
0-300px inside hull 90.34%
So the band is reachable 65% of the time but SAFE only 12%: the 53-point gap is
heat, not hull geometry.
VERDICT: heat saturation is the real blocker; the WALL is the largest raw-heat
source but the CORRIDORS are the band blocker (removing them multiplies
band-weightable ticks 3.6x, while taming walls leaves the band unchanged).
Even at corridor=0/wall=0 the band is weightable only 40% of ticks, so no
constant tweak fully unlocks the range weighting - the safe set against DrussGT
rarely reaches 100-200px at all. Recommended (NOT applied): CorridorHeat 20->5
and WallHotness 30->10, to be validated by a live A/B.
Adds common_libs/tests/measure_tfil_heat_field.nim (offline, no shipped file
touched; both movers byte-identical).
This commit is contained in:
@@ -0,0 +1,619 @@
|
||||
## OFFLINE DIAGNOSTIC — is the TFIL heat field saturated, and are the corridors
|
||||
## the cause? READ-ONLY. No live battles, no bot rebuild, no shipped-constant
|
||||
## changes.
|
||||
##
|
||||
## Method: drive the REAL `TFILModule.computeMove` over the committed DrussGT
|
||||
## fixtures (so the selector state — reachable hull, cached inside-tiles,
|
||||
## bullet tracking, commit gating — is byte-identical to the live mover), then
|
||||
## READ its private state via `include`. `include` (not `import`) is the only
|
||||
## way to see the private `lava`/`bullets`/`cachedInsideTiles` fields without
|
||||
## editing the shipped file.
|
||||
##
|
||||
## The field is independently re-derived by `buildField` (a faithful copy of
|
||||
## `computeMove`'s lava section, parameterised by corridor heat) and checked
|
||||
## element-wise against the module's own `m.lava`, so per-source attribution and
|
||||
## the CorridorHeat counterfactual are trustworthy.
|
||||
##
|
||||
## Run:
|
||||
## nim c -r common_libs/tests/measure_tfil_heat_field.nim [fixture.jsonl ...]
|
||||
## With no args it uses the two ModularBot-vs-DrussGT TR-bridge fixtures (the
|
||||
## only fixtures in which ModularBot is the shooter `s*`).
|
||||
|
||||
import std/[os, strformat, math, algorithm, json, sets, tables]
|
||||
import gun_harness/offline_range
|
||||
# Private-field access: include (do NOT import) the shipped mover.
|
||||
include movements/the_floor_is_lava
|
||||
|
||||
## The safety threshold is a const LOCAL to computeMove in the shipped file
|
||||
## (`PathDangerThreshold = 10.0`). Re-declared here for the pool reproduction.
|
||||
const SafeThreshold = 10.0
|
||||
|
||||
# ── source taxonomy ──────────────────────────────────────────────────────────
|
||||
type
|
||||
Source = enum
|
||||
srcBulletCore, srcBulletAura, srcCorridor, srcEnemyCore, srcEnemyAura,
|
||||
srcWall, srcPillar
|
||||
|
||||
const SourceNames: array[Source, string] = [
|
||||
"bullet_core", "bullet_aura", "corridor", "enemy_core", "enemy_aura",
|
||||
"wall", "pillar"
|
||||
]
|
||||
|
||||
type
|
||||
FieldResult = object
|
||||
lava: seq[float]
|
||||
src: array[Source, seq[float]]
|
||||
|
||||
# ── exact copy of computeMove's lava section, corridor heat parameterised ────
|
||||
proc buildField(m: TFILModule, ws: WorldState, corridorHeat: float,
|
||||
wallHotness = WallHotness): FieldResult =
|
||||
let n = m.cols * m.rows
|
||||
result.lava = newSeq[float](n)
|
||||
for s in Source: result.src[s] = newSeq[float](n)
|
||||
|
||||
# Bullet core / aura
|
||||
for b in m.bullets:
|
||||
let bx = b.x
|
||||
let by = b.y
|
||||
let (coreR, auraR) = bulletRadii(b.power)
|
||||
let colMin = max(0, int(floor((bx - auraR - m.marginX) / GridSize)))
|
||||
let colMax = min(m.cols-1, int(floor((bx + auraR - m.marginX) / GridSize)))
|
||||
let rowMin = max(0, int(floor((by - auraR - m.marginY) / GridSize)))
|
||||
let rowMax = min(m.rows-1, int(floor((by + auraR - m.marginY) / GridSize)))
|
||||
for row in rowMin..rowMax:
|
||||
for col in colMin..colMax:
|
||||
let idx = row * m.cols + col
|
||||
let x0 = m.marginX + col.float * GridSize
|
||||
let y0 = m.marginY + row.float * GridSize
|
||||
let nearX = clamp(bx, x0, x0 + GridSize)
|
||||
let nearY = clamp(by, y0, y0 + GridSize)
|
||||
let dx = nearX - bx
|
||||
let dy = nearY - by
|
||||
let d2 = dx*dx + dy*dy
|
||||
if d2 <= coreR * coreR:
|
||||
result.src[srcBulletCore][idx] += BulletCore
|
||||
result.lava[idx] += BulletCore
|
||||
elif d2 <= auraR * auraR:
|
||||
result.src[srcBulletAura][idx] += BulletAura
|
||||
result.lava[idx] += BulletAura
|
||||
|
||||
# Corridors — rotated rectangle from bullet position to the wall
|
||||
for b in m.bullets:
|
||||
let cg = corridorGeom(b, m.arenaWidth, m.arenaHeight)
|
||||
if cg.tMin == 0.0: continue
|
||||
let (_, auraR) = bulletRadii(b.power)
|
||||
let wx = cg.bx + cg.dx * cg.tMin
|
||||
let wy = cg.by + cg.dy * cg.tMin
|
||||
let c0x = cg.bx + cg.px * auraR; let c0y = cg.by + cg.py * auraR
|
||||
let c1x = cg.bx - cg.px * auraR; let c1y = cg.by - cg.py * auraR
|
||||
let c2x = wx - cg.px * auraR; let c2y = wy - cg.py * auraR
|
||||
let c3x = wx + cg.px * auraR; let c3y = wy + cg.py * auraR
|
||||
let xMin = min(min(c0x, c1x), min(c2x, c3x))
|
||||
let xMax = max(max(c0x, c1x), max(c2x, c3x))
|
||||
let yMin = min(min(c0y, c1y), min(c2y, c3y))
|
||||
let yMax = max(max(c0y, c1y), max(c2y, c3y))
|
||||
let colMin = max(0, int(floor((xMin - m.marginX) / GridSize)))
|
||||
let colMax = min(m.cols-1, int(floor((xMax - m.marginX) / GridSize)))
|
||||
let rowMin = max(0, int(floor((yMin - m.marginY) / GridSize)))
|
||||
let rowMax = min(m.rows-1, int(floor((yMax - m.marginY) / GridSize)))
|
||||
for row in rowMin..rowMax:
|
||||
for col in colMin..colMax:
|
||||
let idx = row * m.cols + col
|
||||
let cx = m.marginX + (col.float + 0.5) * GridSize
|
||||
let cy = m.marginY + (row.float + 0.5) * GridSize
|
||||
let relX = cx - cg.bx
|
||||
let relY = cy - cg.by
|
||||
let along = relX * cg.dx + relY * cg.dy
|
||||
let perp = relX * cg.px + relY * cg.py
|
||||
if along >= 0.0 and along <= cg.tMin and perp >= -auraR and perp <= auraR:
|
||||
result.src[srcCorridor][idx] += corridorHeat
|
||||
result.lava[idx] += corridorHeat
|
||||
|
||||
# Enemy core / aura
|
||||
for ei in ws.enemies:
|
||||
let ex = ei.x
|
||||
let ey = ei.y
|
||||
let colMin = max(0, int(floor((ex - EnemyAuraRadius - m.marginX) / GridSize)))
|
||||
let colMax = min(m.cols-1, int(floor((ex + EnemyAuraRadius - m.marginX) / GridSize)))
|
||||
let rowMin = max(0, int(floor((ey - EnemyAuraRadius - m.marginY) / GridSize)))
|
||||
let rowMax = min(m.rows-1, int(floor((ey + EnemyAuraRadius - m.marginY) / GridSize)))
|
||||
for row in rowMin..rowMax:
|
||||
for col in colMin..colMax:
|
||||
let idx = row * m.cols + col
|
||||
let x0 = m.marginX + col.float * GridSize
|
||||
let y0 = m.marginY + row.float * GridSize
|
||||
let nearX = clamp(ex, x0, x0 + GridSize)
|
||||
let nearY = clamp(ey, y0, y0 + GridSize)
|
||||
let dx = nearX - ex
|
||||
let dy = nearY - ey
|
||||
let d2 = dx*dx + dy*dy
|
||||
if d2 <= EnemyCoreRadius * EnemyCoreRadius:
|
||||
result.src[srcEnemyCore][idx] += EnemyCore
|
||||
result.lava[idx] += EnemyCore
|
||||
elif d2 <= EnemyAuraRadius * EnemyAuraRadius:
|
||||
result.src[srcEnemyAura][idx] += EnemyAura
|
||||
result.lava[idx] += EnemyAura
|
||||
|
||||
# Wall radiance
|
||||
for row in 0..<m.rows:
|
||||
for col in 0..<m.cols:
|
||||
let idx = row * m.cols + col
|
||||
let heat = max(0.0, wallHotness - col.float * WallRadiance) +
|
||||
max(0.0, wallHotness - (m.cols-1-col).float * WallRadiance) +
|
||||
max(0.0, wallHotness - row.float * WallRadiance) +
|
||||
max(0.0, wallHotness - (m.rows-1-row).float * WallRadiance)
|
||||
result.src[srcWall][idx] += heat
|
||||
result.lava[idx] += heat
|
||||
|
||||
# Pillar radiance
|
||||
let pc0 = if m.cols mod 2 == 1: m.cols div 2 else: m.cols div 2 - 1
|
||||
let pc1 = m.cols div 2
|
||||
let pr0 = if m.rows mod 2 == 1: m.rows div 2 else: m.rows div 2 - 1
|
||||
let pr1 = m.rows div 2
|
||||
for row in 0..<m.rows:
|
||||
for col in 0..<m.cols:
|
||||
let idx = row * m.cols + col
|
||||
var minDist = int.high
|
||||
for pcol in pc0..pc1:
|
||||
for prow in pr0..pr1:
|
||||
let d = max(abs(col - pcol), abs(row - prow))
|
||||
if d < minDist: minDist = d
|
||||
let heat = max(0.0, PillarHotness - minDist.float * PillarRadiance)
|
||||
result.src[srcPillar][idx] += heat
|
||||
result.lava[idx] += heat
|
||||
|
||||
# ── reproduce the selector's safe pool from an arbitrary lava field ──────────
|
||||
type
|
||||
TileXY = tuple[col, row: int]
|
||||
Scored = tuple[col, row: int; pathMaxHeat: float; dist: float]
|
||||
Pool = object
|
||||
insideTiles: int
|
||||
coolTiles: int
|
||||
safe: seq[Scored]
|
||||
safeBeforeFallback: int
|
||||
|
||||
proc poolFromLava(lava: seq[float], m: TFILModule, ws: WorldState): Pool =
|
||||
result.insideTiles = m.cachedInsideTiles.len
|
||||
var distinctVals: seq[float]
|
||||
for t in m.cachedInsideTiles:
|
||||
let v = lava[t.row * m.cols + t.col]
|
||||
var found = false
|
||||
for dv in distinctVals:
|
||||
if dv == v: found = true; break
|
||||
if not found: distinctVals.add v
|
||||
for i in 1..<distinctVals.len:
|
||||
let key = distinctVals[i]
|
||||
var j = i - 1
|
||||
while j >= 0 and distinctVals[j] > key:
|
||||
distinctVals[j + 1] = distinctVals[j]
|
||||
dec j
|
||||
distinctVals[j + 1] = key
|
||||
|
||||
var coolTiles: seq[TileXY]
|
||||
let numLevels = min(CoolestLevels, distinctVals.len)
|
||||
for t in m.cachedInsideTiles:
|
||||
let v = lava[t.row * m.cols + t.col]
|
||||
for li in 0..<numLevels:
|
||||
if v == distinctVals[li]:
|
||||
coolTiles.add (col: t.col, row: t.row)
|
||||
break
|
||||
result.coolTiles = coolTiles.len
|
||||
|
||||
const PathSampleStep = 18.0
|
||||
var scoredTiles: seq[Scored]
|
||||
for t in coolTiles:
|
||||
let tx = m.marginX + (t.col.float + 0.5) * GridSize
|
||||
let ty = m.marginY + (t.row.float + 0.5) * GridSize
|
||||
let ddx = tx - ws.selfX
|
||||
let ddy = ty - ws.selfY
|
||||
let lineDist = sqrt(ddx*ddx + ddy*ddy)
|
||||
var pathMaxHeat = 0.0
|
||||
if lineDist > 0.1:
|
||||
let steps = max(1, int(lineDist / PathSampleStep))
|
||||
for si in 0..steps:
|
||||
let frac = si.float / steps.float
|
||||
let sx = ws.selfX + ddx * frac
|
||||
let sy = ws.selfY + ddy * frac
|
||||
let (sc, sr) = m.tileAt(sx, sy)
|
||||
pathMaxHeat = max(pathMaxHeat, lava[sr * m.cols + sc])
|
||||
scoredTiles.add (col: t.col, row: t.row, pathMaxHeat: pathMaxHeat,
|
||||
dist: hypot(tx - ws.enemyX, ty - ws.enemyY))
|
||||
|
||||
for i in 1..<scoredTiles.len:
|
||||
let key = scoredTiles[i]
|
||||
var j = i - 1
|
||||
while j >= 0 and scoredTiles[j].pathMaxHeat > key.pathMaxHeat:
|
||||
scoredTiles[j + 1] = scoredTiles[j]
|
||||
dec j
|
||||
scoredTiles[j + 1] = key
|
||||
|
||||
var safe: seq[Scored]
|
||||
var blocked: seq[Scored]
|
||||
for t in scoredTiles:
|
||||
if t.pathMaxHeat <= SafeThreshold: safe.add t
|
||||
else: blocked.add t
|
||||
result.safeBeforeFallback = safe.len
|
||||
if safe.len < 2:
|
||||
let needed = 2 - safe.len
|
||||
let promote = min(needed, blocked.len)
|
||||
for i in 0..<promote:
|
||||
safe.add blocked[i]
|
||||
result.safe = safe
|
||||
|
||||
# ── round-boundary sidecar (read-only) ───────────────────────────────────────
|
||||
proc loadRoundStarts(fixturePath: string): HashSet[int] =
|
||||
result = initHashSet[int]()
|
||||
let side = currentSourcePath().parentDir.parentDir.parentDir /
|
||||
"tools" / "fixtures" / "drussgt_meta" /
|
||||
(extractFilename(fixturePath) & ".rounds.json")
|
||||
if not fileExists(side): return
|
||||
let root = parseFile(side)
|
||||
if not root.hasKey("rounds"): return
|
||||
for r in root["rounds"]:
|
||||
if r.hasKey("startTick"): result.incl r["startTick"].getInt()
|
||||
|
||||
# ── accumulators ─────────────────────────────────────────────────────────────
|
||||
type
|
||||
PhaseSat = object
|
||||
ticks: int
|
||||
sumFrac0, sumFracPos, sumFracOver: float
|
||||
sumSafePool, sumSafeBefore, sumBandHull, sumBandSafe, sumNear300Safe: float
|
||||
bandHullTicks, bandSafeTicks, near300SafeTicks: int
|
||||
sumMinSafeDist: float
|
||||
minSafeDistTicks: int
|
||||
|
||||
FileStats = object
|
||||
name: string
|
||||
ticks: int
|
||||
rounds: int
|
||||
# field saturation
|
||||
sumFrac0, sumFracPos, sumFracOver: float
|
||||
sumMedian, sumP90, sumMax, sumMean: float
|
||||
maxOver: float
|
||||
# pool
|
||||
sumSafePool, sumSafeBefore: float
|
||||
zeroSafeTicks, fallbackTicks, zeroBeforeFallbackTicks: int
|
||||
poolGE4Ticks, poolGE4BandTicks: int
|
||||
sumInsideTiles: float
|
||||
sumStructOver: float
|
||||
# band reachability
|
||||
bandHullTicks, bandSafeTicks, near300HullTicks, near300SafeTicks: int
|
||||
sumMinSafeDist: float
|
||||
minSafeDistTicks: int
|
||||
# bullets
|
||||
sumBullets: float
|
||||
ticksWithBullets: int
|
||||
# per-phase (early/mid/late)
|
||||
phase: array[3, PhaseSat]
|
||||
# per-source: total lava and over-threshold responsibility
|
||||
srcTotal: array[Source, float]
|
||||
srcOverResp: array[Source, int]
|
||||
overTilesTotal: int
|
||||
# counterfactual
|
||||
cfSafePool: array[4, float] # scales 1.0, 0.5, 0.25, 0.0
|
||||
cfBandHull: array[4, int]
|
||||
cfBandSafe: array[4, int]
|
||||
cfNear300Safe: array[4, int]
|
||||
cfGE4Band: array[4, int]
|
||||
cfTicks: int
|
||||
# combined corridor x wall counterfactual
|
||||
cfComboSafe: array[6, float]
|
||||
cfComboBand: array[6, int]
|
||||
cfComboNear: array[6, int]
|
||||
cfComboGE4Band: array[6, int]
|
||||
# identity check
|
||||
maxFieldDiff: float
|
||||
|
||||
const CfScales = [1.0, 0.5, 0.25, 0.0]
|
||||
|
||||
## (corridorHeat, wallHotness) combos for the combined counterfactual.
|
||||
const CfCombos = [
|
||||
(20.0, 30.0), # shipped
|
||||
(10.0, 30.0), # corridor only halved
|
||||
(20.0, 10.0), # wall tamed (max wall heat == threshold)
|
||||
(10.0, 10.0), # both halved / tamed
|
||||
( 5.0, 10.0), # corridor quartered + wall tamed
|
||||
( 0.0, 0.0), # no soft heat at all
|
||||
]
|
||||
|
||||
proc percentile(sortedVals: openArray[float], p: float): float =
|
||||
if sortedVals.len == 0: return 0.0
|
||||
let idx = clamp(int(p * (sortedVals.len - 1).float + 0.5), 0, sortedVals.high)
|
||||
sortedVals[idx]
|
||||
|
||||
proc analyseFile(path: string): FileStats =
|
||||
result.name = extractFilename(path)
|
||||
let fx = loadFixture(path)
|
||||
let starts = loadRoundStarts(path)
|
||||
# round membership per tick, for the early/mid/late split
|
||||
var roundOf = newSeq[int](fx.states.len)
|
||||
var roundStart = newSeq[int](fx.states.len)
|
||||
var roundCount = newSeq[int](fx.states.len)
|
||||
block:
|
||||
var curStart = 0
|
||||
var curCount = fx.states.len
|
||||
for i in 0..<fx.states.len:
|
||||
if starts.len > 0 and i in starts:
|
||||
curStart = i
|
||||
# find count from the sidecar is not tracked here; recompute boundaries
|
||||
roundOf[i] = curStart
|
||||
# build start->count map from sidecar
|
||||
var startToCount = initTable[int, int]()
|
||||
let side = currentSourcePath().parentDir.parentDir.parentDir /
|
||||
"tools" / "fixtures" / "drussgt_meta" /
|
||||
(extractFilename(path) & ".rounds.json")
|
||||
if fileExists(side):
|
||||
let root = parseFile(side)
|
||||
for r in root["rounds"]:
|
||||
startToCount[r["startTick"].getInt()] = r["count"].getInt()
|
||||
result.rounds = root["rounds"].len
|
||||
for i in 0..<fx.states.len:
|
||||
if i in starts:
|
||||
curStart = i
|
||||
curCount = startToCount.getOrDefault(i, fx.states.len - i)
|
||||
roundStart[i] = curStart
|
||||
roundCount[i] = curCount
|
||||
|
||||
var m = initTFIL()
|
||||
for si in 0..<fx.states.len:
|
||||
let ws = fx.states[si]
|
||||
if si == 0 or si in starts:
|
||||
m.resetRound()
|
||||
discard m.computeMove(ws)
|
||||
inc result.ticks
|
||||
|
||||
# ── identity: my re-derived field must equal the module's own ──────────
|
||||
let fr = buildField(m, ws, CorridorHeat)
|
||||
var diff = 0.0
|
||||
for i in 0..<m.lava.len:
|
||||
diff = max(diff, abs(fr.lava[i] - m.lava[i]))
|
||||
result.maxFieldDiff = max(result.maxFieldDiff, diff)
|
||||
|
||||
# ── 1. field saturation ────────────────────────────────────────────────
|
||||
var sortedVals = newSeq[float](m.lava.len)
|
||||
var frac0 = 0.0
|
||||
var fracPos = 0.0
|
||||
var fracOver = 0.0
|
||||
var total = 0.0
|
||||
var mx = 0.0
|
||||
for i in 0..<m.lava.len:
|
||||
let v = m.lava[i]
|
||||
sortedVals[i] = v
|
||||
total += v
|
||||
if v == 0.0: frac0 += 1.0
|
||||
elif v > 0.0: fracPos += 1.0
|
||||
if v > SafeThreshold: fracOver += 1.0
|
||||
if v > mx: mx = v
|
||||
let nf = m.lava.len.float
|
||||
frac0 /= nf; fracPos /= nf; fracOver /= nf
|
||||
sort(sortedVals)
|
||||
let med = percentile(sortedVals, 0.5)
|
||||
let p90 = percentile(sortedVals, 0.9)
|
||||
result.sumFrac0 += frac0
|
||||
result.sumFracPos += fracPos
|
||||
result.sumFracOver += fracOver
|
||||
result.sumMedian += med
|
||||
result.sumP90 += p90
|
||||
result.sumMax += mx
|
||||
result.sumMean += total / nf
|
||||
result.maxOver = max(result.maxOver, fracOver)
|
||||
|
||||
# ── 2. safe pool + the band distance distribution over it ─────────────
|
||||
let p1 = poolFromLava(m.lava, m, ws)
|
||||
result.sumSafePool += p1.safe.len.float
|
||||
result.sumSafeBefore += p1.safeBeforeFallback.float
|
||||
result.sumInsideTiles += p1.insideTiles.float
|
||||
if p1.safe.len == 0: inc result.zeroSafeTicks
|
||||
if p1.safeBeforeFallback < 2 and p1.safe.len >= 2: inc result.fallbackTicks
|
||||
if p1.safeBeforeFallback == 0: inc result.zeroBeforeFallbackTicks
|
||||
# The ring mover disables range shaping for pools < MinRingPool (4).
|
||||
if p1.safe.len >= 4:
|
||||
inc result.poolGE4Ticks
|
||||
block:
|
||||
var bs = 0
|
||||
for t in p1.safe:
|
||||
if t.dist >= 100.0 and t.dist <= 200.0: inc bs
|
||||
if bs > 0: inc result.poolGE4BandTicks
|
||||
|
||||
# structural floor: wall + pillar heat alone (no bullets / corridors)
|
||||
var structOver = 0.0
|
||||
for i in 0..<m.lava.len:
|
||||
if fr.src[srcWall][i] + fr.src[srcPillar][i] > SafeThreshold: structOver += 1.0
|
||||
result.sumStructOver += structOver / nf
|
||||
var minSafeDist = Inf
|
||||
var bandSafe = 0
|
||||
var near300Safe = 0
|
||||
for t in p1.safe:
|
||||
let d = t.dist
|
||||
if d < minSafeDist: minSafeDist = d
|
||||
if d >= 100.0 and d <= 200.0: inc bandSafe
|
||||
if d <= 300.0: inc near300Safe
|
||||
if bandSafe > 0: inc result.bandSafeTicks
|
||||
if near300Safe > 0: inc result.near300SafeTicks
|
||||
if p1.safe.len > 0:
|
||||
result.sumMinSafeDist += minSafeDist
|
||||
inc result.minSafeDistTicks
|
||||
|
||||
# ── 5. band inside the reachable hull ─────────────────────────────────
|
||||
var bandHull = 0
|
||||
var near300Hull = 0
|
||||
for t in m.cachedInsideTiles:
|
||||
let tx = m.marginX + (t.col.float + 0.5) * GridSize
|
||||
let ty = m.marginY + (t.row.float + 0.5) * GridSize
|
||||
let d = hypot(tx - ws.enemyX, ty - ws.enemyY)
|
||||
if d >= 100.0 and d <= 200.0: inc bandHull
|
||||
if d <= 300.0: inc near300Hull
|
||||
if bandHull > 0: inc result.bandHullTicks
|
||||
if near300Hull > 0: inc result.near300HullTicks
|
||||
|
||||
# ── 3. per-source attribution ──────────────────────────────────────────
|
||||
for s in Source:
|
||||
var st = 0.0
|
||||
for v in fr.src[s]: st += v
|
||||
result.srcTotal[s] += st
|
||||
for i in 0..<m.lava.len:
|
||||
if m.lava[i] > SafeThreshold:
|
||||
inc result.overTilesTotal
|
||||
for s in Source:
|
||||
if m.lava[i] - fr.src[s][i] <= SafeThreshold:
|
||||
inc result.srcOverResp[s]
|
||||
|
||||
# ── bullets ────────────────────────────────────────────────────────────
|
||||
result.sumBullets += m.bullets.len.float
|
||||
if m.bullets.len > 0: inc result.ticksWithBullets
|
||||
|
||||
# ── per phase ──────────────────────────────────────────────────────────
|
||||
let rc = max(1, roundCount[si])
|
||||
let pos = si - roundStart[si]
|
||||
let ph = min(2, int(pos.float * 3.0 / rc.float))
|
||||
inc result.phase[ph].ticks
|
||||
result.phase[ph].sumFrac0 += frac0
|
||||
result.phase[ph].sumFracPos += fracPos
|
||||
result.phase[ph].sumFracOver += fracOver
|
||||
result.phase[ph].sumSafePool += p1.safe.len.float
|
||||
result.phase[ph].sumSafeBefore += p1.safeBeforeFallback.float
|
||||
if bandSafe > 0: inc result.phase[ph].bandSafeTicks
|
||||
if near300Safe > 0: inc result.phase[ph].near300SafeTicks
|
||||
if p1.safe.len > 0:
|
||||
result.phase[ph].sumMinSafeDist += minSafeDist
|
||||
inc result.phase[ph].minSafeDistTicks
|
||||
|
||||
# ── 4. CorridorHeat counterfactual ─────────────────────────────────────
|
||||
for ci, scale in CfScales:
|
||||
let cf = buildField(m, ws, CorridorHeat * scale)
|
||||
let pc = poolFromLava(cf.lava, m, ws)
|
||||
result.cfSafePool[ci] += pc.safe.len.float
|
||||
var bHull = 0
|
||||
var bSafe = 0
|
||||
var n3Safe = 0
|
||||
for t in m.cachedInsideTiles:
|
||||
let tx = m.marginX + (t.col.float + 0.5) * GridSize
|
||||
let ty = m.marginY + (t.row.float + 0.5) * GridSize
|
||||
let d = hypot(tx - ws.enemyX, ty - ws.enemyY)
|
||||
if d >= 100.0 and d <= 200.0: inc bHull
|
||||
for t in pc.safe:
|
||||
if t.dist >= 100.0 and t.dist <= 200.0: inc bSafe
|
||||
if t.dist <= 300.0: inc n3Safe
|
||||
if bHull > 0: inc result.cfBandHull[ci]
|
||||
if bSafe > 0: inc result.cfBandSafe[ci]
|
||||
if n3Safe > 0: inc result.cfNear300Safe[ci]
|
||||
if pc.safe.len >= 4 and bSafe > 0: inc result.cfGE4Band[ci]
|
||||
inc result.cfTicks
|
||||
|
||||
# ── 4b. combined corridor x wall counterfactual ─────────────────────────
|
||||
for ci, combo in CfCombos:
|
||||
let cf2 = buildField(m, ws, combo[0], combo[1])
|
||||
let pf = poolFromLava(cf2.lava, m, ws)
|
||||
result.cfComboSafe[ci] += pf.safe.len.float
|
||||
var bSafe = 0
|
||||
var n3Safe = 0
|
||||
for t in pf.safe:
|
||||
if t.dist >= 100.0 and t.dist <= 200.0: inc bSafe
|
||||
if t.dist <= 300.0: inc n3Safe
|
||||
if bSafe > 0: inc result.cfComboBand[ci]
|
||||
if n3Safe > 0: inc result.cfComboNear[ci]
|
||||
if pf.safe.len >= 4 and bSafe > 0: inc result.cfComboGE4Band[ci]
|
||||
|
||||
# ── reporting ────────────────────────────────────────────────────────────────
|
||||
proc f2(x: float): string = &"{x:.2f}"
|
||||
|
||||
proc report(res: FileStats) =
|
||||
let n = res.ticks.float
|
||||
echo ""
|
||||
echo "═══════════════════════════════════════════════════════════════════════════"
|
||||
echo &"FILE {res.name} ticks={res.ticks} rounds={res.rounds}"
|
||||
echo &"field identity check: max |buildField(scale=1) - m.lava| = {res.maxFieldDiff:.3e}" &
|
||||
(if res.maxFieldDiff == 0.0: " (EXACT)" else: " <-- MISMATCH")
|
||||
echo "───────────────────────────────────────────────────────────────────────────"
|
||||
echo "1. FIELD SATURATION (per-tick mean over all tiles)"
|
||||
echo &" frac == 0 : {f2(res.sumFrac0/n)}"
|
||||
echo &" frac > 0 : {f2(res.sumFracPos/n)}"
|
||||
echo &" frac > 10 (thresh) : {f2(res.sumFracOver/n)} (max per-tick={f2(res.maxOver)})"
|
||||
echo &" median lava : {f2(res.sumMedian/n)}"
|
||||
echo &" p90 lava : {f2(res.sumP90/n)}"
|
||||
echo &" max lava : {f2(res.sumMax/n)}"
|
||||
echo &" mean lava : {f2(res.sumMean/n)}"
|
||||
echo &" STRUCTURAL (wall+pillar only, no bullets) frac>10: {f2(res.sumStructOver/n)}"
|
||||
echo &" bullets tracked/tick : {f2(res.sumBullets/n)} (ticks with >=1: {res.ticksWithBullets})"
|
||||
echo ""
|
||||
echo " over-threshold fraction by round phase (early/mid/late):"
|
||||
for ph in 0..2:
|
||||
let p = res.phase[ph]
|
||||
if p.ticks > 0:
|
||||
let pn = p.ticks.float
|
||||
echo &" phase {ph}: frac>10={f2(p.sumFracOver/pn)} frac==0={f2(p.sumFrac0/pn)}" &
|
||||
&" safePool={f2(p.sumSafePool/pn)} bandSafeFreq={f2(p.bandSafeTicks.float/pn)}" &
|
||||
&" near300SafeFreq={f2(p.near300SafeTicks.float/pn)}"
|
||||
echo ""
|
||||
echo "2. SAFE POOL (the `candidates`/`safeTiles` the selector sees)"
|
||||
echo &" mean inside-hull tiles/tick : {f2(res.sumInsideTiles/n)}"
|
||||
echo &" mean safeTiles/tick : {f2(res.sumSafePool/n)}"
|
||||
echo &" mean safe-before-fallback : {f2(res.sumSafeBefore/n)}"
|
||||
echo &" ticks with 1+ safe tile : {res.ticks - res.zeroSafeTicks}/{res.ticks}"
|
||||
echo &" ticks with 0 safe before : {res.zeroBeforeFallbackTicks}/{res.ticks} (fallback saves them)"
|
||||
echo &" ticks using promote-fallback: {res.fallbackTicks}/{res.ticks}"
|
||||
echo &" ticks with safePool >= 4 : {res.poolGE4Ticks}/{res.ticks} ({f2(res.poolGE4Ticks.float/n*100.0)}%) <- ring weighting can act"
|
||||
echo &" ticks with pool>=4 AND band : {res.poolGE4BandTicks}/{res.ticks} ({f2(res.poolGE4BandTicks.float/n*100.0)}%) <- band available to weight"
|
||||
echo " safe-tile distance-to-target reachability (per tick):"
|
||||
echo &" ticks with >=1 safe tile in 100-200px : {res.bandSafeTicks}/{res.ticks} ({f2(res.bandSafeTicks.float/n*100.0)}%)"
|
||||
echo &" ticks with >=1 safe tile in 0-300px : {res.near300SafeTicks}/{res.ticks} ({f2(res.near300SafeTicks.float/n*100.0)}%)"
|
||||
if res.minSafeDistTicks > 0:
|
||||
echo &" mean closest-safe-tile distance : {f2(res.sumMinSafeDist/res.minSafeDistTicks.float)} px"
|
||||
echo ""
|
||||
echo "5. BAND INSIDE THE REACHABLE HULL (50-tick hull; outside => never a candidate)"
|
||||
echo &" ticks with >=1 hull tile in 100-200px : {res.bandHullTicks}/{res.ticks} ({f2(res.bandHullTicks.float/n*100.0)}%)"
|
||||
echo &" ticks with >=1 hull tile in 0-300px : {res.near300HullTicks}/{res.ticks} ({f2(res.near300HullTicks.float/n*100.0)}%)"
|
||||
echo ""
|
||||
echo "3. PER-SOURCE ATTRIBUTION"
|
||||
var grandTotal = 0.0
|
||||
for s in Source: grandTotal += res.srcTotal[s]
|
||||
echo " source lava share over-thr resp share (resp tiles may overlap)"
|
||||
for s in Source:
|
||||
let share = if grandTotal > 0.0: res.srcTotal[s]/grandTotal*100.0 else: 0.0
|
||||
let resp = if res.overTilesTotal > 0: res.srcOverResp[s].float/res.overTilesTotal.float*100.0 else: 0.0
|
||||
echo &" {SourceNames[s]:<12} {share:>9.2f}% {resp:>19.2f}%"
|
||||
echo &" total over-threshold tile-samples: {res.overTilesTotal}"
|
||||
echo ""
|
||||
echo "4. CORRIDORHEAT COUNTERFACTUAL (shipped constant NOT changed)"
|
||||
echo " corridor scale heat safePool bandHullFreq bandSafeFreq near300SafeFreq"
|
||||
for ci, scale in CfScales:
|
||||
let tn = res.cfTicks.float
|
||||
echo &" {f2(scale):<16} {f2(CorridorHeat*scale):>6} {f2(res.cfSafePool[ci]/tn):>9}" &
|
||||
&" {f2(res.cfBandHull[ci].float/tn*100.0):>12}% {f2(res.cfBandSafe[ci].float/tn*100.0):>12}%" &
|
||||
&" {f2(res.cfNear300Safe[ci].float/tn*100.0):>15}%"
|
||||
echo " (band-weightable = pool>=4 AND band present)"
|
||||
for ci, scale in CfScales:
|
||||
let tn = res.cfTicks.float
|
||||
echo &" corridor scale {f2(scale):<5}: band-weightable {f2(res.cfGE4Band[ci].float/tn*100.0)}%"
|
||||
echo ""
|
||||
echo "4b. COMBINED corridor x wall counterfactual (safe pool / band reach)"
|
||||
echo " corridorHeat wallHotness safePool bandSafeFreq near300SafeFreq"
|
||||
for ci, combo in CfCombos:
|
||||
let tn = res.cfTicks.float
|
||||
echo &" {f2(combo[0]):>12} {f2(combo[1]):>11} {f2(res.cfComboSafe[ci]/tn):>9}" &
|
||||
&" {f2(res.cfComboBand[ci].float/tn*100.0):>12}% {f2(res.cfComboNear[ci].float/tn*100.0):>15}%"
|
||||
echo " band-weightable (pool>=4 AND band present):"
|
||||
for ci, combo in CfCombos:
|
||||
let tn = res.cfTicks.float
|
||||
echo &" corridorHeat {f2(combo[0]):>5} wallHotness {f2(combo[1]):>5}: {f2(res.cfComboGE4Band[ci].float/tn*100.0)}%"
|
||||
|
||||
proc main() =
|
||||
var files: seq[string]
|
||||
for i in 1..paramCount():
|
||||
files.add paramStr(i)
|
||||
if files.len == 0:
|
||||
let dir = currentSourcePath().parentDir.parentDir.parentDir / "tools" / "fixtures"
|
||||
files = @[
|
||||
dir / "tr_drussgt_vs_modularbot.jsonl",
|
||||
dir / "tr_drussgt_vs_modularbot_shield.jsonl",
|
||||
dir / "tr_drussgt_vs_spinbot.jsonl",
|
||||
dir / "tr_drussgt_vs_corners.jsonl",
|
||||
]
|
||||
for f in files:
|
||||
if not fileExists(f):
|
||||
stderr.writeLine("missing fixture: " & f)
|
||||
continue
|
||||
report(analyseFile(f))
|
||||
|
||||
when isMainModule:
|
||||
main()
|
||||
Reference in New Issue
Block a user