From 0b74b01c4b31eb3818feee8c0f454939a239d9e8 Mon Sep 17 00:00:00 2001 From: Davide Cappellini Date: Sun, 27 Sep 2026 09:51:19 +0200 Subject: [PATCH] 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) --- common_libs/movements/the_floor_is_lava.nim | 82 +++++++++++- .../tests/measure_tfil_picker_loss.nim | 118 +++++++++++++++++ common_libs/tests/test_tfil_commit_env.nim | 122 ++++++++++++++++++ 3 files changed, 319 insertions(+), 3 deletions(-) create mode 100644 common_libs/tests/measure_tfil_picker_loss.nim diff --git a/common_libs/movements/the_floor_is_lava.nim b/common_libs/movements/the_floor_is_lava.nim index f82166f..092ba02 100644 --- a/common_libs/movements/the_floor_is_lava.nim +++ b/common_libs/movements/the_floor_is_lava.nim @@ -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.. 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: diff --git a/common_libs/tests/measure_tfil_picker_loss.nim b/common_libs/tests/measure_tfil_picker_loss.nim new file mode 100644 index 0000000..c6bc368 --- /dev/null +++ b/common_libs/tests/measure_tfil_picker_loss.nim @@ -0,0 +1,118 @@ +## 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.. 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.. 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 diff --git a/common_libs/tests/test_tfil_commit_env.nim b/common_libs/tests/test_tfil_commit_env.nim index 6ca3857..d069ab3 100644 --- a/common_libs/tests/test_tfil_commit_env.nim +++ b/common_libs/tests/test_tfil_commit_env.nim @@ -955,6 +955,126 @@ when declared(loadTfilCommitEnv): check "j147: clearing the knob restores the shipped spawn exactly", FireLag == 0 and spawnGhost().x == ei.x +# ── j150: the picker loss-histogram diag + the sweepable heat cutoff ────────── +# +# TR_TFIL_DIAG 0/1 default 0 — fill TfilLoss* only +# TR_TFIL_DANGER_THRESHOLD (float) default 10 — was a proc-local `const` +# +# Both must be default-off-effect: the whole point of the diag is to measure +# the shipped picker, not to change it. +proc testJ150() = + delEnv("TR_TFIL_DIAG"); delEnv("TR_TFIL_DANGER_THRESHOLD") + loadTfilCommitEnv() + check "j150: TR_TFIL_DIAG defaults OFF and TR_TFIL_DANGER_THRESHOLD defaults " & + "to today's 10.0", (not TfilDiag) and TfilDangerThreshold == 10.0 + + # 1. the diag is PURE: identical move stream with it on and off + let off = replay(loadStates(), loadRoundStarts()) + putEnv("TR_TFIL_DIAG", "1") + loadTfilCommitEnv() + let on = replay(loadStates(), loadRoundStarts()) + var diff = -1 + if off.len != on.len: diff = min(off.len, on.len) + else: + for i in 0..= cool-filter >= heat-filter)", + st.picks > 0 and st.sReach >= st.sCool and + st.sCool >= st.sSafe and st.sSafe <= st.sCand and + st.safeHist[0] <= st.picks + + # 3. knob parsing, including the fallbacks + putEnv("TR_TFIL_DANGER_THRESHOLD", "18") + loadTfilCommitEnv() + check "j150: TR_TFIL_DANGER_THRESHOLD=18 is read", TfilDangerThreshold == 18.0 + putEnv("TR_TFIL_DANGER_THRESHOLD", "junk") + loadTfilCommitEnv() + check "j150: a malformed value falls back to the DEFAULT 10.0", + TfilDangerThreshold == 10.0 + putEnv("TR_TFIL_DANGER_THRESHOLD", "-4") + loadTfilCommitEnv() + check "j150: a negative value clamps to 0 (heat can never go backwards)", + TfilDangerThreshold == 0.0 + + # 4. restore the shipped default for every later check in this process + putEnv("TR_TFIL_DIAG", ""); putEnv("TR_TFIL_DANGER_THRESHOLD", "") + loadTfilCommitEnv() + check "j150: clearing the knobs restores 10.0 / diag off", + (not TfilDiag) and TfilDangerThreshold == 10.0 and TfilLoss.picks == 0 + + echo "\n j150 picker loss histogram (default build, offline fixture replay):" + echo " picks=", st.picks + let np = st.picks.float + echo " mean reachable hull tiles=", st.sReach.float / np + echo " mean after CoolestLevels=2 filter=", st.sCool.float / np + echo " mean after the heat filter (pre-promotion)=", st.sSafe.float / np + echo " mean draw set=", st.sCand.float / np + +# ── j151: the ARRIVAL bound (TR_TFIL_ARRIVE_TICKS, default 0 = off) ───────── +type ArrStats = object + picks, beyond, starved: int ## starved = picks made with an empty pool + meanTta, meanPool: float + +proc replayJ151(bound: float): ArrStats = + putEnv("TR_TFIL_ARRIVE_TICKS", $bound) + loadTfilCommitEnv() + let states = loadStates() + let starts = loadRoundStarts() + randomize(Seed) + var m = initTFIL() + var lastPicks = 0 + for i in 0.. TfilArriveTicks + 0.001: inc result.beyond + if m.lastPickSafe == 0: inc result.starved + result.meanTta += tta + result.meanPool += m.lastPickSafe.float + if result.picks > 0: + result.meanTta /= result.picks.float + result.meanPool /= result.picks.float + +proc testJ151() = + # 8a. the shipped default is OFF — the golden parity check above is the proof + delEnv("TR_TFIL_ARRIVE_TICKS") + loadTfilCommitEnv() + check "j151: the arrival bound defaults to OFF (today's uniform draw over " & + "the whole 50-tick hull)", TfilArriveTicks == 0.0 + + let off = replayJ151(0.0) # today's behaviour, same seed + let on15 = replayJ151(15.0) # = CommitTicks: the horizon we hold a target for + delEnv("TR_TFIL_ARRIVE_TICKS") + loadTfilCommitEnv() + + check "j151: with the bound on (" & $on15.picks & " picks) NO target is " & + "farther than the horizon (" & $on15.beyond & " violations), while " & + "off the same fixture has " & $off.beyond & "/" & $off.picks, + on15.beyond == 0 and off.beyond > 0 + check "j151: the mean time-to-arrive falls (" & + off.meanTta.formatFloat(ffDecimal, 1) & " -> " & + on15.meanTta.formatFloat(ffDecimal, 1) & " ticks) and the pool is " & + "not starved (mean safe tiles " & + on15.meanPool.formatFloat(ffDecimal, 1) & ", " & $on15.starved & + " empty pools)", + on15.meanTta < off.meanTta and on15.meanPool >= 1.0 and on15.starved == 0 + check "j151: the bound is a filter, not a replacement — the pick COUNT is " & + "barely reduced (" & $off.picks & " -> " & $on15.picks & ")", + on15.picks.float > off.picks.float * 0.9 + check "j151: clearing the knob restores today's pick stream exactly", + replayJ151(0.0).picks == off.picks + # ── driver ─────────────────────────────────────────────────────────────────── testDefaultParity() @@ -965,6 +1085,8 @@ when declared(loadTfilCommitEnv): testJ145() testJ146() testJ147() + testJ151() + testJ150() if failures > 0: echo "\n", failures, " check(s) FAILED"