## 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..= 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.. 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..= 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.. 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.. 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.. 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.. 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()