## Guard test for the TFIL commitment knobs: ## TR_TFIL_TILE_REPLAN (self | off | enemy) default `self` = shipped ## TR_TFIL_COMMIT_TICKS (int) default 15 = shipped ## TR_TFIL_NO_REV (0/1) default 0 = shipped ## TR_TFIL_COMMIT_LOG (path) default off ## TR_TFIL_COMMIT_ARRIVAL (0/1) default 0 = shipped (j144) ## TR_TFIL_COMMIT_MARGIN (float) default 0.0 = shipped (j144) ## TR_TFIL_NOREV_SPEED (float) default 0.0 = shipped (j144) ## TR_TFIL_TURN_BIAS (float) default 0.0 = shipped (j145) ## TR_TFIL_TURN_REF_DEG (float) default 45.0 (j145) ## ## NO battle, NO Java, NO server. Run with: ## nim c -r --path:common_libs common_libs/tests/test_tfil_commit_env.nim ## ## The important check is #1: with EVERY knob unset the mover must reproduce, ## byte-for-byte, the move commands recorded from the PRE-CHANGE build. The ## golden `tests/fixtures/tfil_commit_default.golden` was generated from the ## shipped mover at commit f842ac0 by compiling THIS file with ## `-d:tfilGenGolden` against `git archive f842ac0` (see the golden's header). ## Regenerating it from the new code would defeat the check — only do that after ## a DELIBERATE change to the shipped defaults. ## ## *** DELIBERATE DEFAULT CHANGE (this commit): the virtual centre pillar was ## removed from the shipped default (PillarHotness/PillarRadiance 30/10 -> 0/0; ## TR_TFIL_PILLAR_ON=1 restores it). That legitimately changes the DEFAULT move ## path, so the golden was regenerated from the NEW default. This is NOT a ## regression being blessed: the fixture replay below is byte-for-byte stable ## for a fixed build, it simply runs against a different (pillar-free) field. ## If a future change makes this check fail, it is a REAL default-path diff ## ## The knob-dependent half of this file is wrapped in ## `when declared(loadTfilCommitEnv)` so the SAME file still compiles against ## the pre-change module and can regenerate the golden there. That is the whole ## point: the golden must come from the old code, not from the new one. ## ## The remaining checks prove the non-default arms actually do something (an ## A/B whose treatment did not apply is worthless) and that the soft ## no-reversal preference can never empty the candidate pool. import std/[os, json, random, math, sequtils] import std/strutils except fromHex # `fromHex` would clash with color.fromHex import gun_harness/gun_interface # Private-field access: include (do NOT import) the shipped mover. include movements/the_floor_is_lava const repoRoot = currentSourcePath().parentDir.parentDir.parentDir const fixtureRel = "tr_drussgt_vs_modularbot.jsonl" const goldenPath = currentSourcePath().parentDir / "fixtures" / "tfil_commit_default.golden" const Seed = 20250923 const ArenaW = 800.0 const ArenaH = 600.0 var failures = 0 proc check(name: string, ok: bool) = if ok: echo "PASS: ", name else: echo "FAIL: ", name; inc failures # ── fixture replay (works on BOTH the old and the new module) ──────────────── type TickRec = object call: int spd, trn: float tx, ty: float ct: int proc loadStates(): seq[WorldState] = let path = repoRoot / "tools" / "fixtures" / fixtureRel for rawLine in lines(path): let line = rawLine.strip() if line.len == 0: continue let n = parseJson(line) if n.hasKey("meta") or n.hasKey("end"): continue let ex = n["ex"].getFloat() let ey = n["ey"].getFloat() result.add WorldState( enemyX: ex, enemyY: ey, enemyHeading: n["eh"].getFloat(), enemySpeed: n["es"].getFloat(), enemyEnergy: n["ee"].getFloat(), selfX: n["sx"].getFloat(), selfY: n["sy"].getFloat(), selfHeading: n["sh"].getFloat(), selfSpeed: n["ss"].getFloat(), selfEnergy: n["se"].getFloat(), arenaWidth: ArenaW, arenaHeight: ArenaH, tick: n["tick"].getInt(), enemies: @[EnemyInfo(id: 1, x: ex, y: ey, heading: n["eh"].getFloat(), speed: n["es"].getFloat(), energy: n["ee"].getFloat())]) proc loadRoundStarts(): seq[int] = let side = repoRoot / "tools" / "fixtures" / "drussgt_meta" / (fixtureRel & ".rounds.json") if not fileExists(side): return for r in parseFile(side)["rounds"]: result.add r["startTick"].getInt() proc replay(states: seq[WorldState], starts: seq[int]): seq[TickRec] = ## Drive the REAL computeMove over the recorded WorldState stream with a fixed ## RNG seed, exactly as `measure_tfil_heat_field.nim` does. Touches NO env ## knob, so it is identical on the old and the new module. randomize(Seed) var m = initTFIL() for i in 0.. 0/0). Set\n" g.add "# TR_TFIL_PILLAR_ON=1 to restore the old field. Not a regression: the shipped\n" g.add "# default change is intentional, so the default path legitimately differs.\n" g.add "# Format: call speed turnRate targetX targetY commitTicks\n" for r in recs: g.add recLine(r) & "\n" createDir(goldenPath.parentDir) writeFile(goldenPath, g) echo "wrote ", goldenPath, " (", recs.len, " ticks)" quit(0) # ── 1. default-path parity against the pre-change build ────────────────────── proc testDefaultParity() = # NOTE: the golden below was regenerated when the virtual centre pillar was # removed from the shipped DEFAULT (DELIBERATE change, not an accidental # regression — see the file header). With the pillar restored via # TR_TFIL_PILLAR_ON=1 the old golden would no longer match, by design. doAssert fileExists(goldenPath), "missing golden: " & goldenPath # j134: the golden was generated from the SHIPPED detector. Force the shipped # fire-detection path (`TR_FIRE_FIX=0`) for this check so it still proves the # PRE-CHANGE path is byte-identical. The correction ON legitimately changes # the path (it no longer drops an over-cap energy delta), which is the whole # point of j134 — see `test_strafe_fire_fix.nim` for the per-behaviour checks. when declared(TfilFireFix): let savedFireFix = TfilFireFix TfilFireFix = false let recs = replay(loadStates(), loadRoundStarts()) when declared(TfilFireFix): TfilFireFix = savedFireFix var golden: seq[string] for rawLine in lines(goldenPath): if rawLine.startsWith("#"): continue let line = rawLine.strip() if line.len > 0: golden.add line check "golden covers the whole fixture (>= 15000 ticks)", golden.len >= 15000 check "default replay covers the same number of ticks", recs.len == golden.len var firstDiff = -1 for i in 0..= 0: echo " first divergence at tick index ", firstDiff, ": got [", recLine(recs[firstDiff]), "] want [", golden[firstDiff], "]" # ── everything below needs the NEW knob API ────────────────────────────────── when declared(loadTfilCommitEnv): type ArmStats = object ticks: int picks: int byReason: array[TfilReplanReason, int] intervals: seq[int] reversals: int revSlow: int ## reversal picks made at |speed| < MaxSpeed/2 revMidSlow:int ## ... on a target we had NOT yet reached (j144: the ## owner's failure mode: the target flips opposite ## while the bot is still accelerating) meanSpeed: float proc setArm(tileReplan: string, commitTicks: string, noRev: string) = putEnv("TR_TFIL_TILE_REPLAN", tileReplan) putEnv("TR_TFIL_COMMIT_TICKS", commitTicks) putEnv("TR_TFIL_NO_REV", noRev) loadTfilCommitEnv() proc clearJ144() = ## The shipped default for every j144 knob: present but OFF. Setting the ## var directly (not through the env) is the honest way to prove the ## defaults, because putEnv("") is indistinguishable from unset. when declared(TfilCommitArrival): TfilCommitArrival = false TfilCommitMargin = 0.0 TfilNoRevSpeed = 0.0 proc setJ144(arrival: bool, margin: float, norevSpeed: float) = when declared(TfilCommitArrival): TfilCommitArrival = arrival TfilCommitMargin = margin TfilNoRevSpeed = norevSpeed proc parseLog(path: string): seq[JsonNode] = if not fileExists(path): return for rawLine in lines(path): let line = rawLine.strip() if line.len > 0: result.add parseJson(line) proc stats(log: seq[JsonNode]): ArmStats = var spSum = 0.0 result.ticks = log.len for o in log: let sp = o["sp"].getFloat() spSum += sp if o["pick"].getInt() == 1: inc result.picks result.intervals.add o["interval"].getInt() let rev = o["rev"].getInt() == 1 if rev: inc result.reversals # `mid` is j144's field: the pick replaced a target we had not reached. let mid = o.hasKey("mid") and o["mid"].getInt() == 1 if rev and mid and abs(sp) < MaxSpeed / 2.0: inc result.revMidSlow if rev and abs(sp) < MaxSpeed / 2.0: inc result.revSlow let r = o["reason"].getStr() for rr in TfilReplanReason: if reasonName(rr) == r: inc result.byReason[rr] if log.len > 0: result.meanSpeed = spSum / log.len.float proc meanInterval(s: ArmStats): float = if s.intervals.len == 0: return 0.0 var t = 0 for v in s.intervals: t += v t.float / s.intervals.len.float proc reversalRate(s: ArmStats): float = if s.picks == 0: return 0.0 100.0 * s.reversals.float / s.picks.float proc replayLogged(tag: string): ArmStats = let logPath = getTempDir() / ("tfil_commit_" & tag & ".jsonl") removeFile(logPath) closeTfilCommitLog() putEnv("TR_TFIL_COMMIT_LOG", logPath) loadTfilCommitEnv() discard replay(loadStates(), loadRoundStarts()) closeTfilCommitLog() putEnv("TR_TFIL_COMMIT_LOG", "") loadTfilCommitEnv() result = stats(parseLog(logPath)) proc replayJ144(tag: string, arrival: bool, margin, norevSpeed: float): ArmStats = ## The same replay, but the j144 knobs are forced through the module vars ## AFTER the env reload (these arms are not env-driven here, so nothing can ## be confused with a `.env` the owner might have lying around). let logPath = getTempDir() / ("tfil_commit_" & tag & ".jsonl") removeFile(logPath) closeTfilCommitLog() putEnv("TR_TFIL_COMMIT_LOG", logPath) loadTfilCommitEnv() setJ144(arrival, margin, norevSpeed) discard replay(loadStates(), loadRoundStarts()) closeTfilCommitLog() putEnv("TR_TFIL_COMMIT_LOG", "") loadTfilCommitEnv() clearJ144() result = stats(parseLog(logPath)) # ── 2. knob parsing ──────────────────────────────────────────────────────── proc testKnobParsing() = setArm("off", "15", "0") check "TR_TFIL_TILE_REPLAN=off -> ttrOff", TfilTileReplanMode == ttrOff setArm("enemy", "15", "0") check "TR_TFIL_TILE_REPLAN=enemy -> ttrEnemy", TfilTileReplanMode == ttrEnemy setArm("self", "15", "0") check "TR_TFIL_TILE_REPLAN=self -> ttrSelf", TfilTileReplanMode == ttrSelf setArm("bogus", "15", "0") check "unknown TR_TFIL_TILE_REPLAN falls back to the shipped ttrSelf", TfilTileReplanMode == ttrSelf setArm("self", "30", "0") check "TR_TFIL_COMMIT_TICKS=30 is honoured", TfilCommitTicks == 30 setArm("self", "0", "0") check "TR_TFIL_COMMIT_TICKS=0 is clamped to >= 1", TfilCommitTicks == 1 setArm("self", "15", "1") check "TR_TFIL_NO_REV=1 -> TfilNoRev", TfilNoRev setArm("self", "15", "0") check "TR_TFIL_NO_REV unset -> false (shipped)", not TfilNoRev setArm("self", "15", "0") # ── 3. the treatments actually bite ──────────────────────────────────────── proc testArms() = # arm A: control (shipped defaults) setArm("self", "15", "0") let a = replayLogged("a") check "arm A (control): tile-change replans dominate the picks", a.byReason[rrTileSelf].float / max(1, a.picks).float > 0.5 check "arm A (control): mean decision interval is ~5 ticks (commitment cancelled by motion)", meanInterval(a) < 8.0 check "arm A (control): reversal-pick rate is high (> 20%)", reversalRate(a) > 20.0 # arm B: honour the commitment setArm("off", "15", "0") let b = replayLogged("b") check "arm B (TR_TFIL_TILE_REPLAN=off): ZERO tile-change replans", b.byReason[rrTileSelf] == 0 and b.byReason[rrTileEnemy] == 0 check "arm B: commitment is only ended by expiry or the danger valve (plus the first pick of each round)", b.byReason[rrExpiry] + b.byReason[rrDanger] + b.byReason[rrInit] == b.picks check "arm B: the danger valve is a real but minor part of the replans (<10%)", b.byReason[rrDanger].float / max(1, b.picks).float < 0.10 check "arm B: decision interval rises to the commit length (~15)", meanInterval(b) > 12.0 check "arm B: FEWER decisions than arm A", b.picks < a.picks # arm C: B + soft no-reversal setArm("off", "15", "1") let c = replayLogged("c") check "arm C: reversal-pick rate drops vs arm B", reversalRate(c) < reversalRate(b) check "arm C: the candidate pool is never emptied (pick count within 2% of arm B)", abs(c.picks - b.picks).float <= 0.02 * b.picks.float check "arm C: no tile-change replans (same commitment as B)", c.byReason[rrTileSelf] == 0 # The preference is a WEIGHT, never a filter: no candidate can get weight 0. let wAllBack = noRevWeights(@[91.0, 120.0, 180.0, -179.0, -91.0], 0.0) check "arm C: no-reversal weights are never 0 (pool cannot empty)", wAllBack.allIt(it >= 1) check "arm C: an all-backward pool falls back to uniform (every weight 1)", wAllBack.allIt(it == 1) let wMixed = noRevWeights(@[0.0, 45.0, 90.0, 90.1, 180.0], 0.0) check "arm C: forward tiles (<=90 deg) are down-weighted by 3:1", wMixed[0] == NoRevForwardWeight and wMixed[1] == NoRevForwardWeight and wMixed[2] == NoRevForwardWeight and wMixed[3] == 1 and wMixed[4] == 1 # arm D: keyed to the enemy's tile setArm("enemy", "15", "0") let d = replayLogged("d") check "arm D (TR_TFIL_TILE_REPLAN=enemy): some replans are keyed to the TARGET's tile", d.byReason[rrTileEnemy] > 0 check "arm D: no self-tile cancels", d.byReason[rrTileSelf] == 0 check "arm D: the replan trigger differs from arm A (different pick count)", d.picks != a.picks and d.byReason[rrTileEnemy] > d.byReason[rrTileSelf] # arm E: commit duration setArm("off", "30", "0") let e = replayLogged("e") check "arm E (TR_TFIL_COMMIT_TICKS=30): interval rises to ~30", meanInterval(e) > 27.0 check "arm E: fewer decisions than arm B (15)", e.picks < b.picks setArm("self", "15", "0") echo "\n arm diagnostics (offline fixture replay):" for (nm, s) in [("A control", a), ("B commit", b), ("C commit+noRev", c), ("D enemyTile", d), ("E commit30", e)]: echo " ", nm.alignLeft(15), " ticks=", s.ticks, " picks=", s.picks, " interval=", meanInterval(s).formatFloat(ffDecimal, 2), " tileSelf=", s.byReason[rrTileSelf], " tileEnemy=", s.byReason[rrTileEnemy], " danger=", s.byReason[rrDanger], " expiry=", s.byReason[rrExpiry], " rev=", reversalRate(s).formatFloat(ffDecimal, 1), "%" # ── 4. j144: arrival-based commitment + hysteresis + no-reversal (default OFF) ── proc testJ144() = # 4a. the shipped default is OFF for all three knobs clearJ144() check "j144: every new knob defaults to today's behaviour (off / 0 / 0)", not TfilCommitArrival and TfilCommitMargin == 0.0 and TfilNoRevSpeed == 0.0 # 4b. the CONTROL carries the owner's reported pathology — without this the # checks below would be vacuously true. setArm("self", "15", "0") let ctl = replayJ144("j144_ctl", false, 0.0, 0.0) check "j144 CONTROL: opposite-direction switches DO happen while the bot is " & "still accelerating toward an unreached target (> 20) — the owner's bug", ctl.revMidSlow > 20 check "j144 CONTROL: the commitment is abandoned long before it could arrive " & "(mean hold < 6 ticks)", meanInterval(ctl) < 6.0 # 4c. arrival: the tile-boundary crossing no longer ends the commitment let ar = replayJ144("j144_arrive", true, 0.0, 0.0) check "j144 ARRIVAL: zero tile-change replans (the boundary no longer cancels)", ar.byReason[rrTileSelf] == 0 check "j144 ARRIVAL: commitments DO end by reaching the tile", ar.byReason[rrArrival] > 0 check "j144 ARRIVAL: the mean hold rises well past the ~19 ticks the distance needs", meanInterval(ar) > 15.0 and meanInterval(ar) > meanInterval(ctl) * 2.0 check "j144 ARRIVAL: fewer decisions than the control", ar.picks < ctl.picks check "j144 ARRIVAL: the danger valve is still live (a genuine threat can break it)", ar.byReason[rrDanger] > 0 # 4d. hysteresis: "the path is still good" must not be able to switch us let hy = replayJ144("j144_hyst", true, 10.0, 0.0) check "j144 HYSTERESIS: margin-triggered switches exist and are labelled", hy.byReason[rrHyst] > 0 check "j144 HYSTERESIS: the margin releases the commitment EARLIER than waiting " & "for arrival, never on a boundary crossing", hy.byReason[rrTileSelf] == 0 and meanInterval(hy) < meanInterval(ar) check "j144 HYSTERESIS: the mean hold stays far above the control's", meanInterval(hy) > meanInterval(ctl) * 2.0 check "j144 HYSTERESIS: a margin of 0 leaves the arrival arm's decisions unchanged", replayJ144("j144_hyst0", true, 0.0, 0.0).picks == ar.picks # 4e. THE decisive guard. `norevPool` carries the invariant in PURE form: # whenever a forward (<=90 deg) candidate exists, a slow mid-flight # switch can never take a rearward one. These three fail on any # implementation that filters without the all-rearward escape. check "j144 NO-REV: with a forward candidate available the slow switch is " & "NEVER rearward", norevPool(@[10.0, 200.0], 4.0) == @[0] check "j144 NO-REV: the all-rearward case is never empty (no starvation) and " & "takes the LEAST-bad turn", norevPool(@[170.0, 100.0, 140.0], 4.0) == @[1] check "j144 NO-REV: the knob off (threshold 0) keeps every candidate", norevPool(@[10.0, 200.0], 0.0) == @[0, 1] check "j144 NO-REV: exactly 90 deg either way still counts as forward", norevPool(@[90.0, -90.0, 91.0], 4.0) == @[0, 1] # 4f. and the same guarantee measured on the recorded fixture let full = replayJ144("j144_full", true, 10.0, 4.0) let nrOnly = replayJ144("j144_nr_only", false, 0.0, 4.0) check "j144 FULL FIX: opposite-direction slow mid-flight switches fall by " & ">=75% vs the control (" & $ctl.revMidSlow & " -> " & $full.revMidSlow & ")", full.revMidSlow * 4 <= ctl.revMidSlow check "j144 FULL FIX: no-rev cuts them further on top of arrive+hyst (" & $hy.revMidSlow & " -> " & $full.revMidSlow & ")", full.revMidSlow < hy.revMidSlow check "j144 NO-REV ALONE: the same holds with only the knob set (no arrival, " & "no margin): " & $nrOnly.revMidSlow & " vs control " & $ctl.revMidSlow & " (>=40% cut)", nrOnly.revMidSlow * 10 <= ctl.revMidSlow * 6 check "j144 FULL FIX: total reversal picks at low speed are down vs control", full.revSlow < ctl.revSlow check "j144 NO-REV: the pool is never emptied — decisions stay within 5% of the " & "same-margin arm without it", abs(full.picks.float - hy.picks.float) <= 0.05 * hy.picks.float echo "\n j144 diagnostics (offline fixture replay):" for (nm, s) in [("control", ctl), ("arrive", ar), ("arrive+hyst", hy), ("arrive+hyst+norev", full), ("norev alone", nrOnly)]: echo " ", nm.alignLeft(18), " picks=", s.picks, " interval=", meanInterval(s).formatFloat(ffDecimal, 2), " revSlow=", s.revSlow, " revMidSlow=", s.revMidSlow, " arrival=", s.byReason[rrArrival], " hyst=", s.byReason[rrHyst], " danger=", s.byReason[rrDanger] # ── 5. j145: the turn-cost TIEBREAK among SAFE tiles (default OFF) ───────── type PickRec = object turn: float ## |heading change| from the travel direction to the pick minTurn: float ## the smallest |turn| AVAILABLE in that candidate set — ## turn - minTurn is the regret of the draw, which is ## the confound-free form of the mechanism metric pathHeat: float ## max lava on the straight path the bot was told to walk promoted: bool ## the pick had to break the hard heat filter reached: bool ## the commitment ended with the bot on the tile const DangerThreshold = 10.0 ## PathDangerThreshold inside the mover proc probePathHeat(m: TFILModule, fx, fy, tx, ty: float): float = ## Mirrors the mover's own sampler (PathSampleStep = 18, ~half a tile). The ## field is rebuilt from scratch every computeMove, so the `m.lava` visible ## just after the call is exactly the field the pick was made against. let ddx = tx - fx let ddy = ty - fy let lineDist = sqrt(ddx*ddx + ddy*ddy) if lineDist <= 0.1: return 0.0 let steps = max(1, int(lineDist / 18.0)) var h = 0.0 for si in 0..steps: let f = si.float / steps.float let (sc, sr) = m.tileAt(fx + ddx * f, fy + ddy * f) h = max(h, m.lavaAt(sc, sr)) h proc replayJ145(tag: string, bias, refDeg: float, arrive: bool, norevSpeed: float): seq[PickRec] = ## Drive the REAL computeMove with the j145 knobs set through the env (and ## the j144 knobs forced through the vars, so no `.env` can be in the way), ## and record what each pick actually cost. putEnv("TR_TFIL_TURN_BIAS", $bias) putEnv("TR_TFIL_TURN_REF_DEG", $refDeg) putEnv("TR_TFIL_COMMIT_LOG", "") loadTfilCommitEnv() TfilCommitArrival = arrive TfilCommitMargin = 0.0 TfilNoRevSpeed = norevSpeed randomize(Seed) var m = initTFIL() let states = loadStates() let starts = loadRoundStarts() var prev = (x: 0.0, y: 0.0) var hadPicks = false for i in 0.. 180.0: rd -= 360.0 while rd < -180.0: rd += 360.0 result.add PickRec(turn: abs(rd), minTurn: m.lastPickMinTurn, pathHeat: probePathHeat(m, ws.selfX, ws.selfY, m.commitTarget.x, m.commitTarget.y), promoted: m.lastPickPromoted, reached: hadPicks and sqrt((ws.selfX-prev.x)^2 + (ws.selfY-prev.y)^2) < 18.0) prev = m.commitTarget hadPicks = true putEnv("TR_TFIL_TURN_BIAS", "") putEnv("TR_TFIL_TURN_REF_DEG", "") loadTfilCommitEnv() TfilCommitArrival = false TfilNoRevSpeed = 0.0 discard tag type TurnStats = object picks: int turnSum: float minTurnSum: float tookMin: int ## the draw landed on the smallest-turn candidate bigTurn: int ## |turn| > 90 deg flip: int ## |turn| > 135 deg — the opposite side heatSum: float ## mean path heat of the tile we actually walked to hotPicks: int ## ... over the hard threshold badHot: int ## ... over the threshold WITHOUT the filter being ## broken — MUST be 0 at any turn bias broken: int ## picks that had to promote a hot tile (fewer than ## two safe tiles existed) — the shipped fallback reached: int proc turnStats(p: seq[PickRec]): TurnStats = result.picks = p.len for r in p: result.turnSum += r.turn result.minTurnSum += r.minTurn if r.turn <= r.minTurn + 0.5: inc result.tookMin if r.turn > 90.0: inc result.bigTurn if r.turn > 135.0: inc result.flip result.heatSum += r.pathHeat if r.pathHeat > DangerThreshold: inc result.hotPicks if not r.promoted: inc result.badHot if r.promoted: inc result.broken if r.reached: inc result.reached proc meanTurn(s: TurnStats): float = if s.picks == 0: return 0.0 s.turnSum / s.picks.float proc meanRegret(s: TurnStats): float = ## How many degrees WORSE than the best available tile the draw actually was. ## Immune to the composition confound that the raw mean |turn| has (a bias ## arm makes different picks, so the two arms' candidate sets differ). if s.picks == 0: return 0.0 (s.turnSum - s.minTurnSum) / s.picks.float proc meanPathHeat(s: TurnStats): float = if s.picks == 0: return 0.0 s.heatSum / s.picks.float proc pct(n, d: int): string = if d == 0: return "-" (100.0 * n.float / d.float).formatFloat(ffDecimal, 1) & "%" proc testJ145() = # 5a. the shipped default is OFF — the parity check above is the proof check "j145: the turn bias defaults to today's uniform draw (bias 0)", TfilTurnBias == 0.0 and TfilTurnRefDeg == 45.0 # 5b. the weighting, in pure form check "j145: bias 0 gives every safe tile weight 1 (byte-identical to the " & "shipped uniform draw)", turnWeights(@[0.0, 91.0, 180.0], 0.0, 45.0) == @[1, 1, 1] check "j145: the penalty is CONTINUOUS past the reference angle, where the " & "binary TR_TFIL_NO_REV cannot see (bias 9, ref 45: 45/90/135/180 deg " & "-> 10/8/6/3)", turnWeights(@[45.0, 90.0, 135.0, 180.0], 9.0, 45.0) == @[10, 8, 6, 3] check "j145: a turn inside the reference angle is never penalised", turnWeights(@[0.0, 20.0, 45.0], 5.0, 45.0) == @[6, 6, 6] check "j145: the weight falls monotonically with the turn (ref 0, bias 9: " & "0/30/60/90/120/180 deg -> 10/9/8/8/7/1)", turnWeights(@[0.0, 30.0, 60.0, 90.0, 120.0, 180.0], 9.0, 0.0) == @[10, 9, 7, 6, 4, 1] check "j145: the weight is floored at 1, so the pool can never be starved", turnWeights(@[0.0, 180.0, 179.0], 9.0, 0.0)[1] >= 1 and turnWeights(@[0.0, 180.0, 179.0], 9.0, 0.0) == @[10, 1, 1] check "j145: `bias` IS the odds ratio — with ref 0 a straight-ahead safe tile " & "is drawn 1+bias times as often as a 180 deg one (9 -> 10:1)", turnWeights(@[0.0, 180.0], 9.0, 0.0) == @[10, 1] # 5c. THE GATE: an absurd turn cost must not rescue a hot tile. The heat # filter is UPSTREAM of the weighting, so an over-threshold pick can # only ever be one the mover had to promote because nothing was safe. let wild = turnStats(replayJ145("wild", 99.0, 45.0, true, 4.0)) check "j145: with an absurd turn bias (" & $wild.picks & " picks) NO tile " & "over the heat threshold is ever chosen unless the filter had to be " & "broken (" & $wild.badHot & " violations)", wild.badHot == 0 check "j145: the over-threshold picks that do happen are only the promoted " & "ones (" & $wild.hotPicks & "/" & $wild.picks & ", the shipped " & "fewer-than-2-safe-tiles fallback)", wild.badHot == 0 # 5d. the mechanism: the turn really gets smaller, without paying for it in # heat, and without emptying the pool let off = turnStats(replayJ145("off", 0.0, 45.0, true, 4.0)) let mild = turnStats(replayJ145("mild", 9.0, 0.0, true, 4.0)) let firm = turnStats(replayJ145("firm", 39.0, 0.0, true, 4.0)) check "j145: with the bias on, the mean |turn| to the chosen tile falls " & "(" & meanTurn(off).formatFloat(ffDecimal, 1) & " -> " & meanTurn(mild).formatFloat(ffDecimal, 1) & " -> " & meanTurn(firm).formatFloat(ffDecimal, 1) & " deg)", meanTurn(mild) < meanTurn(off) * 0.95 and meanTurn(firm) < meanTurn(off) * 0.95 check "j145: the REGRET of the draw (how many degrees worse than the best " & "AVAILABLE candidate) falls " & "(" & meanRegret(off).formatFloat(ffDecimal, 1) & " -> " & meanRegret(mild).formatFloat(ffDecimal, 1) & " -> " & meanRegret(firm).formatFloat(ffDecimal, 1) & " deg) — the " & "confound-free form of the mechanism", meanRegret(mild) < meanRegret(off) * 0.9 and meanRegret(firm) < meanRegret(off) * 0.9 check "j145: the share of picks needing >90 deg of turn falls " & "(" & pct(off.bigTurn, off.picks) & " -> " & pct(mild.bigTurn, mild.picks) & " -> " & pct(firm.bigTurn, firm.picks) & ")", mild.bigTurn < off.bigTurn check "j145: a mirror-image tile no longer beats a straight-ahead one as " & "readily — opposite-side picks fall " & pct(off.flip, off.picks) & " -> " & pct(mild.flip, mild.picks) & " -> " & pct(firm.flip, firm.picks), mild.flip.float < off.flip.float * 0.95 check "j145: SAFETY COST — the mean path heat of the chosen tile does not " & "rise (bias off " & meanPathHeat(off).formatFloat(ffDecimal, 2) & " vs bias 9 " & meanPathHeat(mild).formatFloat(ffDecimal, 2) & " vs bias 39 " & meanPathHeat(firm).formatFloat(ffDecimal, 2) & ")", meanPathHeat(mild) <= meanPathHeat(off) * 1.05 and meanPathHeat(firm) <= meanPathHeat(off) * 1.05 check "j145: the bias never empties the pool — decisions stay within 5% of " & "the same arm without it (" & $off.picks & " -> " & $mild.picks & " / " & $firm.picks & ")", abs(firm.picks.float - off.picks.float) <= 0.05 * off.picks.float echo "\n j145 diagnostics (offline fixture replay, arrive+norev base):" for (nm, s) in [("bias 0 (shipped)", off), ("bias 9 ref0", mild), ("bias 39 ref0", firm)]: echo " ", nm.alignLeft(18), " picks=", s.picks, " mean|turn|=", meanTurn(s).formatFloat(ffDecimal, 1), " regret=", meanRegret(s).formatFloat(ffDecimal, 1), " tookMin=", pct(s.tookMin, s.picks), " >90deg=", pct(s.bigTurn, s.picks), " >135deg=", pct(s.flip, s.picks), " filter broken=", pct(s.broken, s.picks), " mean path heat=", meanPathHeat(s).formatFloat(ffDecimal, 2), " reached=", pct(s.reached, s.picks) # ── 6. j146: the bullet's OWN heat is tunable (default = today's const) ───── const ShapeBase = "TR_MOVEMENT=tfil TR_TFIL_COMMIT_ARRIVAL=1 TR_TFIL_NOREV_SPEED=4" const ShapeShipped = ShapeBase & " TR_TFIL_CORRIDOR_HEAT=20 TR_TFIL_WALL_HOTNESS=30 TR_TFIL_WALL_RADIANCE=10" & " TR_TFIL_BULLET_CORE=10 TR_TFIL_BULLET_AURA=5" const ShapeMiddle = ShapeBase & " TR_TFIL_CORRIDOR_HEAT=10 TR_TFIL_WALL_HOTNESS=15 TR_TFIL_WALL_RADIANCE=5" & " TR_TFIL_BULLET_CORE=20 TR_TFIL_BULLET_AURA=10" proc setShape(corridor, wallHot, wallRad, bCore, bAura: float) = putEnv("TR_TFIL_CORRIDOR_HEAT", $corridor) putEnv("TR_TFIL_WALL_HOTNESS", $wallHot) putEnv("TR_TFIL_WALL_RADIANCE", $wallRad) putEnv("TR_TFIL_BULLET_CORE", $bCore) putEnv("TR_TFIL_BULLET_AURA", $bAura) loadTfilHeatShapeEnv() proc clearShape() = for n in ["TR_TFIL_CORRIDOR_HEAT", "TR_TFIL_WALL_HOTNESS", "TR_TFIL_WALL_RADIANCE", "TR_TFIL_BULLET_CORE", "TR_TFIL_BULLET_AURA"]: putEnv(n, "") loadTfilHeatShapeEnv() proc replayWithShape(envspec: string): seq[TickRec] = ## `replay` with the heat-shape knobs written out in full. Used to prove ## DEFAULT-OFF-EFFECT without the golden: the shipped shape spelled out as ## env must give the SAME move commands as the build with them unset. for tok in envspec.splitWhitespace(): putEnv(tok.split('=', 1)[0], tok.split('=', 1)[1]) loadTfilHeatShapeEnv() result = replay(loadStates(), loadRoundStarts()) for tok in envspec.splitWhitespace(): putEnv(tok.split('=', 1)[0], "") loadTfilHeatShapeEnv() proc replayShape(tag, envspec: string): seq[PickRec] = ## replayJ145's replay, but the FIELD SHAPE comes from `envspec` instead of ## the turn knobs, and the turn bias is left OFF — so the only thing that ## moves between two calls is the shape. for tok in envspec.splitWhitespace(): putEnv(tok.split('=', 1)[0], tok.split('=', 1)[1]) loadTfilHeatShapeEnv() # the shape this arm actually wants putEnv("TR_TFIL_COMMIT_LOG", "") putEnv("TR_TFIL_TURN_BIAS", "") putEnv("TR_TFIL_TURN_REF_DEG", "") loadTfilCommitEnv() TfilCommitArrival = true TfilCommitMargin = 0.0 TfilNoRevSpeed = 4.0 randomize(Seed) var m = initTFIL() let states = loadStates() let starts = loadRoundStarts() var prev = (x: 0.0, y: 0.0) var hadPicks = false for i in 0.. " & $DangerThreshold & "), while a " & "corridor at 10 paints " & $corr10Ahead & " on its own and never " & "reaches it", coreRaised > DangerThreshold and corr10Ahead <= DangerThreshold check "j146: the knobs really change the field — the same bullet paints " & $coreShipped & " with the shipped core and " & $coreRaised & " with the retune", coreRaised > coreShipped # 6d. and the shape as a whole restores a REAL safe set (the j145 cause) let sh = turnStats(replayShape("shipped", ShapeShipped)) let mid = turnStats(replayShape("middle", ShapeMiddle)) check "j146: the middle shape really restores a safe set — the picks that " & "have to break the hard filter fall " & pct(sh.broken, sh.picks) & " -> " & pct(mid.broken, mid.picks), mid.broken.float < sh.broken.float * 0.75 check "j146: and it does NOT buy that with more lava — the mean heat of the " & "path we were told to walk FALLS " & meanPathHeat(sh).formatFloat(ffDecimal, 2) & " -> " & meanPathHeat(mid).formatFloat(ffDecimal, 2), meanPathHeat(mid) < meanPathHeat(sh) clearShape() check "j146: clearing the knobs restores the shipped field exactly", BulletCore == 10.0 and BulletAura == 5.0 and CorridorHeat == 20.0 and WallHotness == 30.0 and WallRadiance == 10.0 echo "\n j146 diagnostics (offline fixture replay, arrive+norev base):" for (nm, s) in [("shipped 20/30/10/10/5", sh), ("middle 10/15/5/20/10", mid)]: echo " ", nm.alignLeft(22), " picks=", s.picks, " filter broken=", pct(s.broken, s.picks), " mean path heat=", meanPathHeat(s).formatFloat(ffDecimal, 2), " >90deg=", pct(s.bigTurn, s.picks) ## j147: TR_FIRE_LAG back-dates the ghost by the MEASURED detection lag (1 ## tick live: 1777/1777 matched spawns, `measure_fire_ghost_lag.py`). Default ## 0 must be byte-for-byte today's spawn, and lag=n must move the ghost exactly ## n bullet steps downrange — which is what shortens the arrival deadline, ## because every mover derives the deadline from the ghost's own position. proc testJ147() = let ws = WorldState(enemyX: 0.0, enemyY: 0.0, enemyHeading: 0.0, enemySpeed: 0.0, enemyEnergy: 100.0, selfX: 400.0, selfY: 320.0, selfHeading: 0.0, selfSpeed: 0.0, selfEnergy: 100.0, arenaWidth: ArenaW, arenaHeight: ArenaH, tick: 1, enemies: @[]) let ei = EnemyInfo(id: 1, x: 200.0, y: 320.0, heading: 0.0, speed: 0.0, energy: 100.0) const Power = 1.0 let speed = 20.0 - 3.0 * Power # 17 px/tick proc spawnGhost(): TrackedBullet = var m = initTFIL() discard m.computeMove(ws) # initGrid m.spawnTrackedWave(ws, ei, Power) m.bullets[^1] # 1. default parity: unset -> 0, and the ghost is EXACTLY the scanned origin delEnv("TR_FIRE_LAG") loadFireTrackerEnv() check "j147: TR_FIRE_LAG unset -> FireLag 0", FireLag == 0 let g0 = spawnGhost() check "j147: default (lag 0) puts the ghost exactly on the scanned enemy", g0.x == ei.x and g0.y == ei.y # 2. lag 1 back-dates by EXACTLY one bullet step, on the ghost's own heading putEnv("TR_FIRE_LAG", "1") loadFireTrackerEnv() let g1 = spawnGhost() check "j147: TR_FIRE_LAG=1 places the ghost one bullet step downrange", FireLag == 1 and abs((g1.x - g0.x) - g1.velX) < 1e-9 and abs((g1.y - g0.y) - g1.velY) < 1e-9 check "j147: ... and that step is the true bullet speed, not a scaled one", abs(sqrt(g1.velX * g1.velX + g1.velY * g1.velY) - speed) < 1e-9 # 3. the ARRIVAL DEADLINE shortens by exactly `lag` ticks. The mover's own # arrival proxy is dist(self, ghost) / speed (tfil `heatDecay(along/speed)`, # the `dot < 0` reap); the true bullet is one step further along. let etaGhost = sqrt((ws.selfX - g1.x)^2 + (ws.selfY - g1.y)^2) / sqrt(g1.velX * g1.velX + g1.velY * g1.velY) let etaTrue0 = sqrt((ws.selfX - g0.x)^2 + (ws.selfY - g0.y)^2) / speed let etaTrue1 = sqrt((ws.selfX - (g0.x + g0.velX))^2 + (ws.selfY - (g0.y + g0.velY))^2) / speed check "j147: with lag=1 the mover's deadline equals the TRUE remaining " & "flight (" & etaGhost.formatFloat(ffDecimal, 6) & " vs " & etaTrue1.formatFloat(ffDecimal, 6) & "), the lag-0 deadline being " & etaTrue0.formatFloat(ffDecimal, 6) & " — a full tick late", abs(etaGhost - etaTrue1) < 1e-9 and abs((etaTrue0 - etaTrue1) - 1.0) < 1e-9 # 4. lag n is n steps, and n=2 shortens the deadline by exactly 2 putEnv("TR_FIRE_LAG", "2") loadFireTrackerEnv() let g2 = spawnGhost() check "j147: TR_FIRE_LAG=2 back-dates by two steps", abs((g2.x - g0.x) - 2.0 * g0.velX) < 1e-9 and abs((g2.y - g0.y) - 2.0 * g0.velY) < 1e-9 let etaTrue2 = sqrt((ws.selfX - (g0.x + 2.0 * g0.velX))^2 + (ws.selfY - (g0.y + 2.0 * g0.velY))^2) / speed check "j147: ... so the deadline shortens by exactly 2 ticks", abs((etaTrue0 - etaTrue2) - 2.0) < 1e-9 # 5. a junk value falls back to 0, never to a negative/garbage back-date putEnv("TR_FIRE_LAG", "junk") loadFireTrackerEnv() let gj = spawnGhost() putEnv("TR_FIRE_LAG", "-4") loadFireTrackerEnv() let gn = spawnGhost() check "j147: a junk / negative TR_FIRE_LAG degrades to the shipped lag 0", FireLag == 0 and gj.x == ei.x and gn.x == ei.x # 6. the ARRIVAL DEADLINE end-to-end: the ghost is reaped (`dot < 0`, the # geometric arrival) exactly `lag` ticks earlier, because it is `lag` # steps further along. This is the deadline the decision actually uses. proc ticksToReap(): int = var m = initTFIL() randomize(Seed) discard m.computeMove(ws) m.spawnTrackedWave(ws, ei, Power) for t in 1..80: discard m.computeMove(ws) if m.bullets.len == 0: return t 99 putEnv("TR_FIRE_LAG", "0") loadFireTrackerEnv() let reap0 = ticksToReap() putEnv("TR_FIRE_LAG", "1") loadFireTrackerEnv() let reap1 = ticksToReap() check "j147: the ghost arrives — and is reaped — exactly 1 tick earlier " & "with the back-date (" & $reap0 & " -> " & $reap1 & " ticks)", reap0 > 0 and reap1 > 0 and reap0 - reap1 == 1 # 7. restore the shipped default for every later check in this process delEnv("TR_FIRE_LAG") loadFireTrackerEnv() check "j147: clearing the knob restores the shipped spawn exactly", FireLag == 0 and spawnGhost().x == ei.x # ── driver ─────────────────────────────────────────────────────────────────── testDefaultParity() when declared(loadTfilCommitEnv): testKnobParsing() testArms() testJ144() testJ145() testJ146() testJ147() if failures > 0: echo "\n", failures, " check(s) FAILED" quit(1) echo "\nAll TFIL commit-env checks passed."