diff --git a/common_libs/tests/measure_tfil_heat_field.nim b/common_libs/tests/measure_tfil_heat_field.nim new file mode 100644 index 0000000..989b1ad --- /dev/null +++ b/common_libs/tests/measure_tfil_heat_field.nim @@ -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..= 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()