0b74b01c4b
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>
119 lines
5.4 KiB
Nim
119 lines
5.4 KiB
Nim
## OFFLINE — j150. WHERE DOES THE LAVA PICKER LOSE ITS TILES?
|
|
##
|
|
## The owner's report: "the bot chooses only between a poor number of tiles while
|
|
## there are a lot of them available but not considered". This ruler drives the
|
|
## REAL `TFILModule.computeMove` (with `TR_TFIL_DIAG=1`) over recorded
|
|
## DrussGT fixtures and reads the per-pick loss histogram the mover counts:
|
|
##
|
|
## reachable hull -> CoolestLevels(=2) distinct-lava filter
|
|
## -> pathMaxHeat <= PathDangerThreshold -> draw set -> CHOSEN
|
|
##
|
|
## No battle, no Java, no server, no behaviour change: every knob it moves is
|
|
## default-off or default-identical.
|
|
##
|
|
## Run:
|
|
## nim c -r --path:common_libs --nimcache:/tmp/nc_j150 \
|
|
## common_libs/tests/measure_tfil_picker_loss.nim [fixture.jsonl ...]
|
|
|
|
import std/[os, strformat, math, algorithm, json, sets, sequtils]
|
|
import std/strutils except fromHex # `fromHex` would clash with color.fromHex
|
|
import gun_harness/offline_range
|
|
# Private-field access: include (do NOT import) the shipped mover.
|
|
include movements/the_floor_is_lava
|
|
|
|
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()
|
|
|
|
proc replay(path: string, threshold: float): TfilLossStats =
|
|
putEnv("TR_TFIL_DIAG", "1")
|
|
putEnv("TR_TFIL_DANGER_THRESHOLD", $threshold)
|
|
loadTfilCommitEnv()
|
|
TfilLoss = TfilLossStats()
|
|
let fx = loadFixture(path)
|
|
let starts = loadRoundStarts(path)
|
|
var m = initTFIL()
|
|
for si in 0..<fx.states.len:
|
|
if si == 0 or si in starts: m.resetRound()
|
|
discard m.computeMove(fx.states[si])
|
|
result = TfilLoss
|
|
|
|
proc mean(x: seq[float]): float =
|
|
if x.len == 0: return 0.0
|
|
var s = 0.0
|
|
for v in x: s += v
|
|
s / x.len.float
|
|
|
|
proc pctStr(x: float): string = &"{100.0 * x:.1f}%"
|
|
|
|
proc f2(x: float): string = &"{x:.2f}"
|
|
|
|
const SafeBuckets = ["0", "1", "2-3", "4-7", "8-15", "16-31", "32-63", "64+"]
|
|
|
|
proc heatTally(x: seq[float]): seq[(float, int)] =
|
|
## distinct heat values -> how many rejected tiles carried it, most common
|
|
## first. Lava is a sum of quantised terms, so this shows the real steps.
|
|
var counts: seq[(float, int)]
|
|
for v in x:
|
|
var i = 0
|
|
while i < counts.len and counts[i][0] != v: inc i
|
|
if i < counts.len: inc counts[i][1]
|
|
else: counts.add (v, 1)
|
|
result = counts
|
|
result.sort(proc (a, b: (float, int)): int = cmp(b[1], a[1]))
|
|
|
|
proc report(path: string) =
|
|
let s = replay(path, 10.0)
|
|
echo &"\n\u2550\u2550\u2550 {path}"
|
|
if s.picks == 0:
|
|
echo " no picks"; return
|
|
let n = s.picks.float
|
|
echo &" picks = {s.picks} (replayed ticks, one recorded battle, seed fixed)"
|
|
echo &" stage mean tiles"
|
|
echo &" 1 reachable-hull tiles {s.sReach.float/n:>12.2f}"
|
|
echo &" 2 .. after CoolestLevels=2 filter {s.sCool.float/n:>12.2f}"
|
|
echo &" 3 .. after path heat filter (pre-prom) {s.sSafe.float/n:>12.2f}"
|
|
echo &" 4 .. draw set (what it chooses among) {s.sCand.float/n:>12.2f}"
|
|
echo &" LOST at the 2-levels filter {(1.0 - s.sCool.float/s.sReach.float)*100:>11.1f}%"
|
|
echo &" LOST at the heat filter {(1.0 - s.sSafe.float/max(1.0,s.sCool.float))*100:>11.1f}%"
|
|
echo &" LOST at blocked-tile / no-rev {(1.0 - s.sCand.float/max(1.0,s.sSafe.float))*100:>11.1f}%"
|
|
echo &" picks with an EMPTY safe set (<2 at stage 3) = {s.emptySafe} ({pctStr(s.emptySafe.float/n)})"
|
|
echo " safe-set size distribution (stage 3):"
|
|
for i in 0..<SafeBuckets.len:
|
|
if s.safeHist[i] > 0:
|
|
echo &" {SafeBuckets[i].alignLeft(6)} {s.safeHist[i]:>7} ({pctStr(s.safeHist[i].float/n)})"
|
|
|
|
# heat of the tiles the filter dropped
|
|
echo &" heat of the {s.rejectedHeat.len} REJECTED tiles (mean {f2(mean(s.rejectedHeat))}), most common first:"
|
|
for (v, c) in heatTally(s.rejectedHeat)[0 ..< min(8, s.rejectedHeat.len)]:
|
|
echo &" heat {f2(v):>7} {c:>7} ({pctStr(c.float/s.rejectedHeat.len.float)})"
|
|
echo &" heat of ADMITTED tiles mean {f2(mean(s.admittedHeat))} (n={s.admittedHeat.len}); " &
|
|
&"of CHOSEN mean {f2(mean(s.chosenHeat))} (n={s.chosenHeat.len})"
|
|
|
|
# \u2550\u2550 threshold sweep
|
|
echo " threshold mean draw set % empty safe newly admitted (n, mean heat) mean heat chosen"
|
|
var baseAdmitted = replay(path, 10.0).admittedHeat.len
|
|
for thr in [10.0, 14.0, 18.0]:
|
|
let a = replay(path, thr)
|
|
let newly = a.admittedHeat.filterIt(it > 10.0)
|
|
let newTxt = $newly.len & ", " & f2(mean(newly))
|
|
echo &" {thr:>9.0f} {a.sCand.float / a.picks.float:>13.2f} " &
|
|
&"{pctStr(a.emptySafe.float / a.picks.float):>12} {newTxt:>30} {f2(mean(a.chosenHeat)):>16}"
|
|
echo &" (admitted at threshold 10: {baseAdmitted} tiles)"
|
|
|
|
# ── driver ───────────────────────────────────────────────────────────────────
|
|
let args = commandLineParams()
|
|
let fixtures = if args.len > 0: args
|
|
else: @[currentSourcePath().parentDir.parentDir.parentDir /
|
|
"tools" / "fixtures" / "tr_drussgt_vs_modularbot.jsonl"]
|
|
for f in fixtures:
|
|
if fileExists(f): report(f)
|
|
else: echo "skip (missing): ", f
|