38fbc6ecd1
The owner: choose the tile pool not only from the heat point but from the geometric position too. Heat stays the hard filter; the draw over the survivors is re-weighted by turn and/or distance. Three weighting forms (soft softmax / top-K third / rejection band), one env name carrying both axes. Default = off = today's uniform draw, byte-for-byte (golden parity). WHAT DIFFERS FROM j9 (TR_TFIL_TURN_BIAS, a live null): that was a tiebreak weight among the non-empty safe set only. This runs on the WHOLE pool the draw already runs on, including the 2 promoted least-hot tiles the ~65% forced picks choose from. OFFLINE (8 fixtures x 3 seeds, no java): headline 'tile actually reached at tta' 4.5% -> 16.9% at TR_TFIL_GEO_MODE=both-soft TR_TFIL_GEO_TAU=45, with no diversity collapse (distinct tiles 220 -> 219, normalised entropy 0.87 -> 0.86, top-tile share 7.0% -> 8.6%). perpE — the perpendicular rate in the FORCED population — does not move in ANY arm: that pool is 2 tiles ranked by heat alone and the only lever is a coin flip. Also registers j150's TR_TFIL_DIAG / TR_TFIL_DANGER_THRESHOLD in env_report + knownEnvNames + .env.example (test_env_report was red) and documents DANGER_THRESHOLD as quantisation-limited: heat comes in 5s, so the effective steps are 10/15/20 and 10-14 admits zero extra tiles. No battle, no server, no A/B run.
292 lines
13 KiB
Nim
292 lines
13 KiB
Nim
## OFFLINE — j151. THE OWNER'S TWO PAINTERS, PER PICK.
|
|
##
|
|
## "the bot choose a tile that is almost perpendicular to it, a tile that is not
|
|
## reachable in feasible time and 1 will put the bot in danger trying to go
|
|
## there 2 will not arrive there as a new location will drive it away."
|
|
##
|
|
## This ruler replays recorded fixtures through the REAL
|
|
## `TFILModule.computeMove` and records, for EVERY pick:
|
|
## turn° angle between the current heading and the chosen tile
|
|
## pathMax/Mean lava on the straight-line path bot -> chosen tile
|
|
## destHeat lava on the chosen tile itself
|
|
## tta dist / MaxSpeed, i.e. ticks to arrive at full speed
|
|
## promoted the pick had to break the heat filter (safePre < 2)
|
|
## safePre size of the safe set BEFORE the "keep 2" promotion
|
|
## hotAtTta the destination tile was OVER the threshold `tta` ticks
|
|
## later, on the recorded (true) future <- the feasibility test
|
|
## tile the chosen (col,row) <- j152: pick DIVERSITY
|
|
## j152: every row of the sweep table also reports the DIVERSITY cost (distinct
|
|
## tiles, entropy, top-tile share). j51 measured the randomness in this draw as
|
|
## LOAD-BEARING, so a geometry weight that improves the geometry numbers while
|
|
## collapsing the distribution is a regression, not a win.
|
|
## No battle, no Java, no server, no behaviour change.
|
|
##
|
|
## Run:
|
|
## nim c -r --path:common_libs --nimcache:/tmp/nc_j151 \
|
|
## common_libs/tests/measure_tfil_pick_defects.nim [fixture.jsonl ...]
|
|
## Env it forwards: TR_TFIL_CORRIDOR_TICKS, TR_TFIL_DANGER_THRESHOLD, ...
|
|
|
|
import std/[os, strformat, math, algorithm, json, sets, random, sequtils, tables]
|
|
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
|
|
|
|
const PathSampleStep = 18.0 # the picker's own sampling step
|
|
const PerpDeg = 60.0 ## the owner's "perpendicular"
|
|
|
|
type Pick = object
|
|
turn, pathMax, pathMean, destHeat, dist, tta: float
|
|
promoted: bool
|
|
safePre, cand: int
|
|
hotAtTta: bool ## destination over threshold when we would arrive
|
|
reached: bool ## we actually got within ArriveRadius by then
|
|
col, row: int ## the chosen tile (diversity)
|
|
|
|
proc loadRoundStarts(path: string): HashSet[int] =
|
|
result = initHashSet[int]()
|
|
for side in [path & ".rounds.json",
|
|
currentSourcePath().parentDir.parentDir.parentDir /
|
|
"tools" / "fixtures" / "drussgt_meta" /
|
|
(extractFilename(path) & ".rounds.json")]:
|
|
if not fileExists(side): continue
|
|
let root = parseFile(side)
|
|
if not root.hasKey("rounds"): continue
|
|
for r in root["rounds"]:
|
|
if r.hasKey("startTick"): result.incl r["startTick"].getInt()
|
|
|
|
proc pathHeat(m: TFILModule, fx, fy, tx, ty: float): tuple[max, mean: float] =
|
|
let ddx = tx - fx
|
|
let ddy = ty - fy
|
|
let lineDist = sqrt(ddx*ddx + ddy*ddy)
|
|
if lineDist <= 0.1: return (0.0, 0.0)
|
|
let steps = max(1, int(lineDist / PathSampleStep))
|
|
var h = 0.0
|
|
var s = 0.0
|
|
for si in 0..steps:
|
|
let frac = si.float / steps.float
|
|
let (sc, sr) = m.tileAt(fx + ddx * frac, fy + ddy * frac)
|
|
let v = m.lavaAt(sc, sr)
|
|
h = max(h, v)
|
|
s += v
|
|
(h, s / (steps + 1).float)
|
|
|
|
proc safeSetSize(m: TFILModule, thr: float): int =
|
|
## Replay of the picker's own hard filter over the tiles it considered, from
|
|
## the same lava snapshot the pick saw. No re-implementation of the choice.
|
|
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
|
|
if pathHeat(m, m.lastBotX, m.lastBotY, tx, ty).max <= thr: inc result
|
|
|
|
proc replay(path: string, seed: int): seq[Pick] =
|
|
randomize(seed)
|
|
loadTfilCommitEnv()
|
|
let fx = loadFixture(path)
|
|
let starts = loadRoundStarts(path)
|
|
var m = initTFIL()
|
|
var lastPicks = 0
|
|
var pending: seq[tuple[col, row: int; at: int; idx: int]]
|
|
var mActive = 0
|
|
for si in 0..<fx.states.len:
|
|
if si == 0 or si in starts: m.resetRound()
|
|
discard m.computeMove(fx.states[si])
|
|
inc mActive
|
|
# 1. a new pick happened this tick -> record the geometry
|
|
if m.picks != lastPicks:
|
|
lastPicks = m.picks
|
|
let (cc, cr) = m.tileAt(m.commitTarget.x, m.commitTarget.y)
|
|
let ang = arctan2(m.commitTarget.y - m.lastBotY, m.commitTarget.x - m.lastBotX) *
|
|
180.0 / PI - fx.states[si].selfHeading
|
|
var turn = abs(((ang + 180.0) mod 360.0) - 180.0)
|
|
if turn > 180.0: turn = 360.0 - turn
|
|
let d = sqrt((m.commitTarget.x - m.lastBotX)^2 + (m.commitTarget.y - m.lastBotY)^2)
|
|
let (pmx, pmean) = pathHeat(m, m.lastBotX, m.lastBotY, m.commitTarget.x, m.commitTarget.y)
|
|
result.add Pick(turn: turn, pathMax: pmx, pathMean: pmean,
|
|
destHeat: m.lavaAt(cc, cr), dist: d, tta: d / MaxSpeed,
|
|
promoted: m.lastPickPromoted,
|
|
safePre: safeSetSize(m, TfilDangerThreshold),
|
|
cand: m.lastPickSafe, hotAtTta: false, reached: false,
|
|
col: cc, row: cr)
|
|
pending.add (col: cc, row: cr, at: mActive + int(d / MaxSpeed), idx: result.high)
|
|
# 2. the arrival probe on the recorded true future
|
|
var keep: seq[tuple[col, row: int; at: int; idx: int]]
|
|
for p in pending:
|
|
if mActive < p.at:
|
|
keep.add p
|
|
else:
|
|
result[p.idx].hotAtTta = m.lavaAt(p.col, p.row) > TfilDangerThreshold
|
|
let px = m.marginX + (p.col.float + 0.5) * GridSize
|
|
let py = m.marginY + (p.row.float + 0.5) * GridSize
|
|
result[p.idx].reached = sqrt((m.lastBotX - px)^2 + (m.lastBotY - py)^2) < ArriveRadius
|
|
pending = keep
|
|
|
|
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 pc(x: float): string = &"{100.0 * x:.1f}%"
|
|
proc f1(x: float): string = &"{x:.1f}"
|
|
proc f2(x: float): string = &"{x:.2f}"
|
|
|
|
proc report(label, path: string, picks: seq[Pick]) =
|
|
echo &"\n\u2550\u2550\u2550 {label} {extractFilename(path)}"
|
|
if picks.len == 0: echo " no picks"; return
|
|
let thr = TfilDangerThreshold
|
|
var groups = [("EMPTY safe set (promoted)", picks.filterIt(it.promoted)),
|
|
("non-empty safe set", picks.filterIt(not it.promoted))]
|
|
var allPerp, allHot, allFar, allBad = 0
|
|
for (name, g) in groups:
|
|
let perp = g.filterIt(it.turn > PerpDeg)
|
|
let hot = g.filterIt(it.pathMax > thr) # crosses a hot region
|
|
let far = g.filterIt(it.tta > CommitTicks.float) # cannot arrive in the commitment
|
|
let bad = g.filterIt(it.turn > PerpDeg and it.pathMax > thr)
|
|
let badFar = g.filterIt(it.turn > PerpDeg and it.tta > CommitTicks.float)
|
|
let futHot = g.filterIt(it.hotAtTta)
|
|
let reach = g.filterIt(it.reached)
|
|
echo &" {name}: {g.len} picks ({pc(g.len.float/picks.len.float)} of all)"
|
|
if g.len == 0: continue
|
|
echo &" PERPENDICULAR (>60\u00b0) {perp.len:>6} {pc(perp.len.float/g.len.float):>7}" &
|
|
&" mean pathMax {f1(mean(perp.mapIt(it.pathMax)))}"
|
|
echo &" path crosses HOT {hot.len:>6} {pc(hot.len.float/g.len.float):>7}" &
|
|
&" mean pathMax(all) {f1(mean(g.mapIt(it.pathMax)))} destHeat {f1(mean(g.mapIt(it.destHeat)))}"
|
|
echo &" tta > commit({CommitTicks}) {far.len:>6} {pc(far.len.float/g.len.float):>7}" &
|
|
&" mean tta {f1(mean(g.mapIt(it.tta)))} max {f1(g.mapIt(it.tta).max)}"
|
|
echo &" PERP + hot {bad.len:>6} | PERP + far {badFar.len:>6}"
|
|
echo &" dest HOT when we arrive {futHot.len:>6} {pc(futHot.len.float/g.len.float):>7}" &
|
|
&" | actually arrived {pc(reach.len.float/g.len.float):>7}"
|
|
echo &" mean turn {f1(mean(g.mapIt(it.turn)))}\u00b0 mean safePre {f1(mean(g.mapIt(it.safePre.float)))}" &
|
|
&" mean cand {f1(mean(g.mapIt(it.cand.float)))}"
|
|
allPerp += perp.len; allHot += hot.len; allFar += far.len
|
|
allBad += bad.len + badFar.len
|
|
echo &" ALL: perp {pc(allPerp.float/picks.len.float)} hot-path {pc(allHot.float/picks.len.float)}" &
|
|
&" far {pc(allFar.float/picks.len.float)} (perp&(hot|far)) {pc(allBad.float/picks.len.float)}"
|
|
|
|
# ── driver ───────────────────────────────────────────────────────────────────
|
|
let args = commandLineParams()
|
|
let detail = "--detail" in args
|
|
let fixtures: seq[string] =
|
|
block:
|
|
if detail:
|
|
var v: seq[string]
|
|
for a in args:
|
|
if not a.startsWith("--"): v.add a
|
|
v
|
|
else:
|
|
@["/tmp/firelag_live2/tfil_on/run1.jsonl",
|
|
"/tmp/firelag_live2/tfil_off/run1.jsonl",
|
|
"/tmp/firelag_live2/strafe_on/run1.jsonl",
|
|
"/tmp/firelag_live2/strafe_off/run1.jsonl",
|
|
currentSourcePath().parentDir.parentDir.parentDir /
|
|
"tools" / "fixtures" / "tr_drussgt_vs_modularbot.jsonl",
|
|
currentSourcePath().parentDir.parentDir.parentDir /
|
|
"tools" / "fixtures" / "tr_drussgt_vs_corners.jsonl",
|
|
currentSourcePath().parentDir.parentDir.parentDir /
|
|
"tools" / "fixtures" / "tr_drussgt_vs_crazy.jsonl",
|
|
currentSourcePath().parentDir.parentDir.parentDir /
|
|
"tools" / "fixtures" / "tr_drussgt_vs_spinbot.jsonl"]
|
|
|
|
# ── j152: the sweep, with the DIVERSITY cost on every row ────────────────────
|
|
## (label, TR_TFIL_GEO_MODE, TR_TFIL_GEO_TAU, TR_TFIL_ARRIVE_TICKS)
|
|
type Arm = tuple[label, mode, tau, arrive: string]
|
|
|
|
proc diversity(picks: seq[Pick]): tuple[distinctN, topShare, entBits, normEnt: float] =
|
|
## Shannon entropy (bits) of the CHOICE distribution over tiles. `normEnt` is
|
|
## H / log2(distinct): 1.0 = the arm spreads its picks over exactly as many
|
|
## tiles as the baseline, 0.0 = every pick is the same tile.
|
|
var counts: Table[(int, int), int]
|
|
for p in picks: counts[(p.col, p.row)] = counts.getOrDefault((p.col, p.row)) + 1
|
|
result.distinctN = counts.len.float
|
|
if picks.len == 0: return
|
|
var h = 0.0
|
|
var top = 0
|
|
for _, n in counts.pairs:
|
|
let q = n.float / picks.len.float
|
|
h -= q * log2(q)
|
|
top = max(top, n)
|
|
result.topShare = top.float / picks.len.float
|
|
result.entBits = h
|
|
result.normEnt = if result.distinctN > 1.0: h / log2(result.distinctN) else: 0.0
|
|
|
|
proc pctS(x, n: int): string =
|
|
if n == 0: return " n/a"
|
|
pc(x.float / n.float)
|
|
proc isPerp(p: Pick): bool = p.turn > PerpDeg
|
|
proc isPromoted(p: Pick): bool = p.promoted
|
|
proc isFar(p: Pick): bool = p.tta > 15.0
|
|
proc isReached(p: Pick): bool = p.reached
|
|
proc isHotAtTta(p: Pick): bool = p.hotAtTta
|
|
|
|
proc row(label: string, picks: seq[Pick]): string =
|
|
let n = picks.len
|
|
let emp = picks.filterIt(isPromoted(it))
|
|
let nes = picks.filterIt(not isPromoted(it))
|
|
let perp = picks.filterIt(isPerp(it)).len
|
|
let perpE = emp.filterIt(isPerp(it)).len
|
|
let perpN = nes.filterIt(isPerp(it)).len
|
|
let far = picks.filterIt(isFar(it)).len
|
|
let reach = picks.filterIt(isReached(it)).len
|
|
let hot = picks.filterIt(isHotAtTta(it)).len
|
|
let d = diversity(picks)
|
|
&"{label:<22} {pctS(perp, n):>7} {pctS(perpE, emp.len):>7} {pctS(perpN, nes.len):>7}" &
|
|
&" {pctS(far, n):>7} {f1(mean(picks.mapIt(it.tta))):>6}" &
|
|
&" {pctS(reach, n):>7} {pctS(hot, n):>7} {pctS(emp.len, n):>7}" &
|
|
&" {d.distinctN.int:>6} {f2(d.entBits):>6} {f2(d.normEnt):>6} {pc(d.topShare):>7}" &
|
|
&" {f1(mean(picks.mapIt(it.cand.float))):>5}"
|
|
|
|
proc header(): string =
|
|
result = "arm".align(22, ' ')
|
|
for (h, w) in [("perp", 7), ("perpE", 7), ("perpN", 7), ("far", 7), ("mtta", 6),
|
|
("REACH", 7), ("hotArr", 7), ("empty", 7), ("tiles", 6),
|
|
("Hbits", 6), ("H/", 6), ("top1", 7), ("cand", 5)]:
|
|
result &= " " & h.align(w, ' ')
|
|
|
|
# `perpE`/`perpN` = the perpendicular rate in the FORCED (empty safe set) and the
|
|
# non-empty populations; `REACH` = the headline (tile actually stood on at tta);
|
|
# `tiles`/`Hbits`/`H/`/`top1` = the diversity cost; `cand` = mean draw-set size.
|
|
let arms: seq[Arm] = @[
|
|
("BASELINE (off)", "off", "0", "0"),
|
|
("turn-soft tau90", "turn-soft", "90", "0"),
|
|
("turn-soft tau45", "turn-soft", "45", "0"),
|
|
("turn-soft tau20", "turn-soft", "20", "0"),
|
|
("turn-topk", "turn-topk", "45", "0"),
|
|
("turn-rej tau60", "turn-rej", "60", "0"),
|
|
("dist-soft tau90", "dist-soft", "90", "0"),
|
|
("dist-soft tau30", "dist-soft", "30", "0"),
|
|
("both-soft tau90", "both-soft", "90", "0"),
|
|
("both-soft tau45", "both-soft", "45", "0"),
|
|
("both-soft tau20", "both-soft", "20", "0"),
|
|
("both-topk", "both-topk", "45", "0"),
|
|
("both-rej tau60", "both-rej", "60", "0"),
|
|
# j151 interaction: a soft distance preference vs the HARD arrival bound.
|
|
("arrive15 (j151)", "off", "0", "15"),
|
|
("arrive15+both t45", "both-soft", "45", "15")]
|
|
|
|
echo "\n", header()
|
|
for a in arms:
|
|
putEnv("TR_TFIL_GEO_MODE", a.mode)
|
|
putEnv("TR_TFIL_GEO_TAU", a.tau)
|
|
putEnv("TR_TFIL_ARRIVE_TICKS", a.arrive)
|
|
var picks: seq[Pick]
|
|
for f in fixtures:
|
|
if not fileExists(f): continue
|
|
for seed in [7, 8, 9]: picks.add replay(f, seed)
|
|
echo row(a.label, picks)
|
|
|
|
# ── per-fixture detail (--detail only), for the BASELINE arm ────────────────
|
|
if detail:
|
|
putEnv("TR_TFIL_GEO_MODE", arms[0].mode)
|
|
putEnv("TR_TFIL_GEO_TAU", arms[0].tau)
|
|
putEnv("TR_TFIL_ARRIVE_TICKS", arms[0].arrive)
|
|
var total: seq[Pick]
|
|
for f in fixtures:
|
|
if not fileExists(f):
|
|
echo "skip (missing): ", f; continue
|
|
for seed in [7, 8, 9]:
|
|
let p = replay(f, seed)
|
|
total.add p
|
|
if seed == 7: report("seed 7", f, p)
|
|
report("ALL FIXTURES x 3 SEEDS", "", total)
|