## 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, sets] 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 # j165: the TFIL-RING fork's arrival commitment. `tfil_ring_replay` includes # the ring mover (for its PRIVATE commitTarget/commitTicks) and re-exports it, # so this is the only ring import the guard needs. import tfil_ring_replay 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 # ── 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() # the CEILING, stated: the bound is a filter on the SAFE set, so a tick whose # every safe tile is past the horizon keeps the full pool (never starved) — # those picks stay long, and the guard below measures exactly how many. check "j151: picks past the 15-tick horizon collapse (" & $off.beyond & "/" & $off.picks & " -> " & $on15.beyond & "/" & $on15.picks & "); the " & "residue is the all-safe-tiles-are-far ticks, which keep the full pool", on15.beyond < off.beyond div 2 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 # ── j152: the GEOMETRIC DRAW (TR_TFIL_GEO_MODE / TR_TFIL_GEO_TAU, default off) ─ ## Heat still gates the pool with the same hard filter; geometry only re-weights ## the survivors of that filter — INCLUDING the 2 promoted least-hot tiles the ## ~65% forced picks choose from, which is what j9 (`TR_TFIL_TURN_BIAS`) could ## not see. What must hold: ## 1. OFF by default and the OFF path is today's uniform draw byte-for-byte ## (the golden check #1 above runs with the knobs unset and is that proof; ## the last check here adds "off" == "unset" for the same seed). ## 2. NO STARVATION: a pool in which EVERY tile is perpendicular still returns ## a pick, in every form — the weight may never empty or index past the ## pool, whatever the TAU. ## 3. `off` and an all-ties pool both degenerate to the uniform draw. ## 4. The form is parsed, and junk stays off. proc testJ152() = delEnv("TR_TFIL_GEO_MODE"); delEnv("TR_TFIL_GEO_TAU") loadTfilCommitEnv() check "j152: both geometry knobs default to OFF (today's uniform draw)", TfilGeoMode == gdoOff and TfilGeoTau == 0.0 check "j152: the mode string parses both axes (dim + form)", parseGeo("both-rej") == (gdoBoth, gfRej) and parseGeo("turn-topk") == (gdoTurn, gfTopK) and parseGeo("dist-soft") == (gdoDist, gfSoft) and parseGeo("turn") == (gdoTurn, gfSoft) check "j152: junk and 'off' both parse to OFF, never to a live arm", parseGeo("off").dim == gdoOff and parseGeo("sideways").dim == gdoOff # 2. NO STARVATION: a pool where EVERY tile is 150 deg off the heading, at # three different distances. No form may return an index outside the pool. randomize(1) let allPerpT = @[150.0, 150.0, 150.0] let allPerpD = @[2.0, 30.0, 48.0] for form in [gfSoft, gfTopK, gfRej]: for tau in [1.0, 20.0, 5000.0]: var seen: seq[int] for _ in 0..<300: seen.add geoPick(allPerpT, allPerpD, gdoTurn, form, tau) # NO STARVATION = a pick always exists and is in range. It is NOT "every # tile stays reachable": topk and rej are hard forms BY DESIGN and may # legitimately return one tile forever when the whole pool is bad. check "j152: no starvation — an all-perpendicular pool still returns " & "an in-range pick (" & $form & ", tau " & $tau & ")", seen.len == 300 and seen.allIt(it in 0..2) # 3. every tile costs the same => every weight ties => the uniform draw randomize(2) var tieSeen: seq[int] for _ in 0..<300: tieSeen.add geoPick(@[40.0, 40.0, 40.0], @[10.0, 10.0, 10.0], gdoTurn, gfSoft, 45.0) check "j152: an all-ties pool degenerates to the uniform draw (all 3 seen, " & "none starved)", tieSeen.toHashSet().len == 3 var hitFar = 0 randomize(3) for _ in 0..<400: if geoPick(@[0.0, 180.0], @[1.0, 1.0], gdoTurn, gfSoft, 10.0) == 0: inc hitFar check "j152: the soft form really tilts (a straight-ahead tile is drawn " & ">" & $hitFar & "/400 of the time with tau=10)", hitFar > 300 # 4. "off" == "unset" for the same seed: the shipped stream, unchanged. let a = replayJ151(0.0) delEnv("TR_TFIL_GEO_MODE"); delEnv("TR_TFIL_GEO_TAU") loadTfilCommitEnv() let b = replayJ151(0.0) check "j152: geometry off reproduces the shipped draw exactly (same picks, " & "same mean tta, same pool)", a.picks == b.picks and a.meanTta == b.meanTta and a.meanPool == b.meanPool # ── j153: HOLD WHEN TRAPPED (TR_TFIL_HOLD_WHEN_TRAPPED, default 0 = off) ──── ## The owner's rule: "if no tile is found to go, to not choose the less ## dangerous, but to stay still! the next tick probably the situation already ## changed and we did not commit to any dangerous place." ## What must hold, and nothing more: ## 1. OFF by default, and the OFF stream is byte-for-byte today's (the golden ## check #1 above already proves the default path; this adds the explicit ## "unset == 0 == 1-off-by-parsing" arm). ## 2. ON + EMPTY safe set => no movement command for that tick. ## 3. The hold is ONE tick: it never latches, and a later safe tile IS taken ## (no stuck bot, no held-then-forever-silent). ## 4. Holding does not skip the rest of the tick: the bullet tracking the GUN ## and the lava field are updated exactly as on a non-held tick. (The gun ## itself lives in the bot loop, not in this module — computeMove never ## emits a fire command — so the real risk is a hold that `return`s too ## early and freezes the bullet tracker; that is what this checks.) type HoldRec = object call: int spd, trn: float held: bool picked: bool bullets: int ## tracked bullets after this tick (the fire tracker's) proc replayJ153(hold: bool): seq[HoldRec] = putEnv("TR_TFIL_HOLD_WHEN_TRAPPED", (if hold: "1" else: "0")) loadTfilCommitEnv() let states = loadStates() let starts = loadRoundStarts() randomize(Seed) var m = initTFIL() var lastPicks = 0 for i in 0.. 0 let on = replayJ153(true) var held, heldMoved, heldPicked = 0 var nonHeldMoving = 0 for i in 0.. 0.001: inc heldMoved if on[i].picked: inc heldPicked elif abs(on[i].spd) > 0.001: inc nonHeldMoving check "j153: with the knob ON the safe set really is empty often enough to " & "matter (" & $held & " held ticks of " & $on.len & ")", held > on.len div 100 check "j153: a held tick emits NO movement (speed 0) and no pick " & "(" & $heldMoved & " moving holds, " & $heldPicked & " held picks)", held > 0 and heldMoved == 0 and heldPicked == 0 check "j153: the hold is not a freeze — " & $nonHeldMoving & " non-held " & "ticks still move and the bot still picks", nonHeldMoving > 0 and on.filterIt(it.picked).len > 0 # no latch: a held tick must be followed by movement again (within a couple of # ticks), and a pick must still be taken somewhere after the holds. # A HOLD is not a latch: the hold is decided at the pick site, and the pick # site only runs when the commitment has expired, so every held tick is a FRESH # evaluation of the field. Observable consequence: hold runs end, and the tick # after a run is a moving tick again. (A latching implementation would show ONE # run per round and ~0 resumptions.) A long run therefore means a long trap, not # a stuck bot — that is why the run LENGTH is deliberately not asserted. var runs = 0 var resumed = 0 var worst = 0 var run = 0 for i in 0.. 0: inc runs worst = max(worst, run) if abs(on[i].spd) > 0.001: inc resumed run = 0 if run > 0: inc runs worst = max(worst, run) var pickedAfter = 0 var sawHold = false for r in on: if r.held: sawHold = true elif sawHold and r.picked: inc pickedAfter check "j153: the hold is NOT a latch — " & $resumed & " of " & $runs & " maximal hold runs resume moving on the very next tick (longest run " & $worst & " ticks = a trap that lasts, not a stuck bot) and " & $pickedAfter & " picks happen after a hold", runs > 0 and resumed * 2 > runs and pickedAfter > 0 # the gun path: a held tick must leave the bullet tracker exactly where a # non-held tick would. If the hold returned before the tracker update, the # bullet counts would diverge from the first hold onwards. var firstDiv = -1 for i in 0.. 16/18/32/32/48). 16 is also the # FIRST window that admits the enemy's SECOND shot at all, so nothing shorter # can be surprised by a third bullet. # # A fully hot field is painted with the virtual pillar at radiance 0 # (`max(0, hotness - d*0) = hotness` on every tile) — the one heat source that # covers the whole reachable hull at once, so the safe set is provably empty. proc testJ154() = delEnv("TR_TFIL_HOLD_MAX_TICKS") loadTfilCommitEnv() check "j154: TR_TFIL_HOLD_MAX_TICKS defaults to 0 = today's behaviour exactly", TfilHoldMaxTicks == 0 and not TfilHoldWhenTrapped # 1. DEFAULT PARITY: an explicit 0 is indistinguishable from unset, over the # whole fixture, tick for tick. (The golden above covers UNSET; this covers # the explicit zero the owner would put in an arm.) let unset = replay(loadStates(), loadRoundStarts()) putEnv("TR_TFIL_HOLD_MAX_TICKS", "0") loadTfilCommitEnv() let zero = replay(loadStates(), loadRoundStarts()) var diff = -1 if unset.len != zero.len: diff = min(unset.len, zero.len) else: for i in 0.. 0 # 2. knob parsing putEnv("TR_TFIL_HOLD_MAX_TICKS", "16"); loadTfilCommitEnv() check "j154: TR_TFIL_HOLD_MAX_TICKS=16 is read", TfilHoldMaxTicks == 16 putEnv("TR_TFIL_HOLD_MAX_TICKS", "junk"); loadTfilCommitEnv() check "j154: a malformed value falls back to 0 (off)", TfilHoldMaxTicks == 0 putEnv("TR_TFIL_HOLD_MAX_TICKS", "-8"); loadTfilCommitEnv() check "j154: a negative value clamps to 0", TfilHoldMaxTicks == 0 delEnv("TR_TFIL_HOLD_MAX_TICKS"); loadTfilCommitEnv() const HoldN = 4 ## the budget under test; any small N exercises it const Hot = 100.0 ## every tile at 100 >> PathDangerThreshold 10 let ws = WorldState(enemyX: 0.0, enemyY: 0.0, enemyHeading: 0.0, enemySpeed: 0.0, enemyEnergy: 100.0, selfX: 400.0, selfY: 300.0, selfHeading: 0.0, selfSpeed: 8.0, selfEnergy: 100.0, arenaWidth: ArenaW, arenaHeight: ArenaH, tick: 0, enemies: @[]) ## One tick. `hot` = the pillar heat (0 = a clean field). A non-nil `bullet` ## is installed as the tracked set, which is the ONLY way an inbound bullet ## ever exists here (the enemy is out of the arena in these worlds). proc tick(m: var TFILModule, hot: float, t: int, bullet: TrackedBullet = TrackedBullet(alive: false)): MoveCommand = PillarHotness = hot PillarRadiance = 0.0 var w = ws w.tick = t if bullet.alive: m.bullets = @[bullet] result = m.computeMove(w) ## A module with the grid initialised and NO live commitment, so tick 0 of a ## scenario is a REPLAN tick (where, and only where, a hold may be taken). proc fresh(): TFILModule = PillarHotness = 0.0; PillarRadiance = 0.0 result = initTFIL() randomize(Seed) discard tick(result, 0.0, 0) result.commitTicks = 0 result.picks = 0 result.commitTarget = (x: 400.0, y: 300.0) type Rec = tuple[held: bool, ht: int, picked: bool] ## The held/pick pattern of a run, plus the counter at each tick. proc run(m: var TFILModule, hot: seq[float]): seq[Rec] = for t, h in hot: let before = m.picks discard tick(m, h, t + 1) result.add (held: m.lastHeld, ht: m.holdTicks, picked: m.picks != before) # A 1-tick commitment makes every tick a replan tick, so the scenario is a # clean read of the hold rule alone (no commitment state leaking in). putEnv("TR_TFIL_COMMIT_TICKS", "1") putEnv("TR_TFIL_HOLD_MAX_TICKS", $HoldN) loadTfilCommitEnv() # 3. the BOUND: at most N consecutive held ticks, then the normal promote-the-2 # fallback takes over — the hold can never latch. let hotAll = @[Hot, Hot, Hot, Hot, Hot, Hot, Hot, Hot, Hot, 0.0, 0.0, Hot, Hot, Hot, Hot, Hot, Hot, 0.0, 0.0, 0.0] var m = fresh() let r = run(m, hotAll) var firstPick = -1 for i, e in r: if e.picked: firstPick = i; break echo "\n j154 run (held/ht/picked per tick, index: value):" for i, e in r: echo " ", i, ": ", (if e.held: "H" else: "."), e.ht, (if e.picked: " P" else: " ") check "j154: with the safe set EMPTY the mover HOLDS (" & $HoldN & " ticks) instead of promoting, and releases into a pick on tick " & $(firstPick + 1) & " — the bound is N, not 'until a tile appears'", firstPick == HoldN and r[0].held and r[HoldN - 1].held and r[HoldN - 1].ht == HoldN and not r[HoldN].held and r[HoldN].picked # 4. RELEASE THE MOMENT A SAFE TILE EXISTS: the field cools at index 10, and # that very tick is a pick, not a hold — no tick of latency. check "j154: the hold releases on the SAME tick a safe tile appears " & "(index 10 cooled -> picked=" & $r[10].picked & ", held=" & $r[10].held & ", counter=" & $r[10].ht & ")", r[10].picked and not r[10].held and r[10].ht == 0 # ... and the budget REFILLS: a fresh empty streak holds a full N again, # i.e. the bound is per streak and the counter is not cumulative. let streak2 = r[11 .. ^1] var held2 = 0 for e in streak2: if e.held: inc held2 check "j154: the counter RESET when the safe tile was taken — the second " & "empty streak holds a full N again (" & $held2 & " ticks), never the " & "accumulated " & $r[10].ht & "+" & $r[11].ht, streak2[0].held and held2 >= HoldN and r[11].ht == 1 # 5. the held command is the SAME stop the mover already emits at its target, # and the gun path is untouched: on a held tick the fire detector still # latches the enemy's wave (the bot aims and fires from tracked state after # go(), on every tick, whatever speed it just commanded). m = fresh() PillarHotness = Hot; PillarRadiance = 0.0 var wFar = ws wFar.enemies = @[EnemyInfo(id: 1, x: 760.0, y: 300.0, heading: 180.0, speed: 0.0, energy: 100.0)] wFar.tick = 1 discard m.computeMove(wFar) # enemy seen at 100.0 energy m.commitTicks = 0 # armed: a replan tick, as above wFar.tick = 2 wFar.enemies[0].energy = 98.5 # a 1.5 drop = a 1.5-power shot let gunCmd = m.computeMove(wFar) check "j154: the gun still fires while holding — the mover held (" & $m.lastHeld & ") on the very tick the enemy fired, and the fire " & "detector still latched the wave (" & $m.bullets.len & " tracked)", m.lastHeld and m.bullets.len > 0 check "j154: the held command is the stop the mover already emits at its " & "target (speed 0, turn 0) — no new signal, no movement side effect", gunCmd.speed == 0.0 and gunCmd.turnRate == 0.0 # 6. PANIC RELEASE (required). A tracked bullet on a collision course, 9 ticks # out, overrides the hold on the tick it exists. The same bullet offset # laterally does NOT, so the release is specific, not "any bullet". # The budget is the DERIVED 16 here, so the horizon is min(16, 16) = 16. putEnv("TR_TFIL_HOLD_MAX_TICKS", "16") loadTfilCommitEnv() let inbound = TrackedBullet(originX: 570.0, originY: 300.0, x: 570.0, y: 300.0, velX: -17.0, velY: 0.0, power: 1.0, alive: true, age: 0) let missing = TrackedBullet(originX: 570.0, originY: 500.0, x: 570.0, y: 500.0, velX: -17.0, velY: 0.0, power: 1.0, alive: true, age: 0) m = fresh() discard tick(m, Hot, 1, inbound) check "j154: PANIC RELEASE — a tracked bullet 9 ticks from our position " & "overrides the hold on the same tick (held=" & $m.lastHeld & ", picked=" & $(m.picks > 0) & ")", (not m.lastHeld) and m.picks > 0 and m.holdTicks == 0 m = fresh() let cmdMiss = tick(m, Hot, 1, missing) check "j154: ... and it is SPECIFIC: the same bullet 200px off our line " & "still holds (held=" & $m.lastHeld & "), so the release is an arrival " & "test, not a bullet count", m.lastHeld and m.picks == 0 and cmdMiss.speed == 0.0 proc withBullet(b: TrackedBullet): TFILModule = result = initTFIL() result.bullets = @[b] check "j154: the panic horizon is the DERIVED budget min(N, 16) ticks — the " & "arrival test fires inside it and not outside", bulletPanic(initTFIL(), 400.0, 300.0, 16.0) == false and bulletPanic(withBullet(inbound), 400.0, 300.0, 16.0) == true and bulletPanic(withBullet(inbound), 400.0, 300.0, 4.0) == false # 7. a hold NEVER interrupts a live commitment (j153's comment claimed that; # j154's code enforces it). Take a pick, keep the field hot, and the mover # must keep driving to its committed target. putEnv("TR_TFIL_COMMIT_TICKS", "15") loadTfilCommitEnv() m = fresh() discard tick(m, 0.0, 1) # a clean field first: that tick PICKS let pickedFirst = m.picks > 0 let live = m.commitTicks let cmdLive = tick(m, Hot, 2) # now the field goes fully hot check "j154: a hold never interrupts a live commitment — with " & $live & " ticks on the clock the mover keeps driving to its target (speed " & $cmdLive.speed & "), it does not freeze", pickedFirst and live > 0 and (not m.lastHeld) and cmdLive.speed != 0.0 putEnv("TR_TFIL_COMMIT_TICKS", "15") delEnv("TR_TFIL_HOLD_MAX_TICKS") loadTfilCommitEnv() PillarHotness = 0.0; PillarRadiance = 0.0 check "j154: clearing the knob restores today's behaviour exactly", TfilHoldMaxTicks == 0 # ── j165: the TFIL-RING arrival commitment (TR_TFIL_RING_*, default OFF) ───── # The same two mechanisms ported from `the_floor_is_lava.nim` (j144) onto # RING-SPECIFIC env names, so the two forks never share a namespace by # accident. What must hold, and nothing more: # 1. DEFAULT PARITY: with both knobs unset the ring mover is byte-for-byte # the PRE-CHANGE ring mover over the whole fixture replay. # 2. the arrival commitment ENGAGES: the committed target is actually # REACHED far more often, and there are strictly fewer mid-flight # re-targets than the shipped fixed 5-tick dwell. # 3. the no-reversal pool is SPEED-GATED: the stream may only diverge from # the unarmed build on a tick whose |selfSpeed| is below the gate, and the # pool itself can never be emptied. when declared(TfilRingCommitArrival): proc ringRunStats(): tuple[picks, reached: int] = ## Count picks over the replay and how many of them REPLACED a target the bot ## had actually stood on (< 18px, the same arrival radius the mover uses). ## Under a fixed dwell this is rare: the target is replaced mid-flight. randomize(Seed) var m = initTFILRing() let states = loadStates() let starts = loadRoundStarts() var prev = (x: 0.0, y: 0.0) var hadPick = false for i in 0.. 0: golden.add line check "j165: ring golden covers the whole fixture (>= 15000 ticks)", golden.len >= 15000 check "j165: the unset 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 [", ringRecLine(recs[firstDiff]), "] want [", golden[firstDiff], "]" delEnv("TR_TFIL_RING_COMMIT_ARRIVAL") delEnv("TR_TFIL_RING_NOREV_SPEED") loadTfilRingCommitEnv() check "j165: both knobs DEFAULT OFF with the env deleted", (not TfilRingCommitArrival) and TfilRingNoRevSpeed == 0.0 putEnv("TR_TFIL_COMMIT_ARRIVAL", "1") # tfil's names must NOT leak across putEnv("TR_TFIL_NOREV_SPEED", "9") loadTfilRingCommitEnv() check "j165: the env names are RING-SPECIFIC — tfil's " & "TR_TFIL_COMMIT_ARRIVAL / TR_TFIL_NOREV_SPEED leave ring untouched", (not TfilRingCommitArrival) and TfilRingNoRevSpeed == 0.0 delEnv("TR_TFIL_COMMIT_ARRIVAL") delEnv("TR_TFIL_NOREV_SPEED") # 2. the arrival commitment engages let off = ringRunStats() TfilRingCommitArrival = true let on = ringRunStats() TfilRingCommitArrival = false check "j165: ARRIVAL ENGAGES — a committed target is actually REACHED on " & $on.reached & "/" & $on.picks & " picks, vs " & $off.reached & "/" & $off.picks & " on the shipped fixed 5-tick dwell", on.picks > 0 and (on.reached.float / on.picks.float) > (off.reached.float / off.picks.float) check "j165: ... and it holds instead of re-targeting mid-flight: " & $on.picks & " picks vs " & $off.picks & " on the same replay", on.picks < off.picks # 3. the no-reversal pool is speed-gated, and can never be emptied check "j165: norevPool with the gate off returns EVERY candidate", norevPool(@[10.0, 120.0, -170.0], 0.0) == @[0, 1, 2] check "j165: norevPool armed keeps only the non-reversing candidates", norevPool(@[10.0, 120.0, -170.0], 4.0) == @[0] check "j165: norevPool never empties — all-behind falls back to the least bad", norevPool(@[170.0, 150.0, 179.0], 4.0) == @[1] and norevPool(@[170.0, 179.0], 4.0).len > 0 # Engine gate: the streams can only DIVERGE at a gated pick. (Divergence # then persists for many ticks — a different target steers differently — so # "every diverging tick is slow" is the wrong claim; "the FIRST divergence # is a slow-tick pick" is the right one.) TfilRingNoRevSpeed = 4.0 let armed = replayRing() TfilRingNoRevSpeed = 0.0 var nDiv = 0 var firstArmed = -1 for i in 0.. 0 check "j165: ... and it is SPEED-GATED: the first divergence (tick " & $firstArmed & ") is a PICK made at |selfSpeed| = " & $(if firstArmed >= 0: abs(states[firstArmed].selfSpeed) else: -1.0) & " < 4.0", firstArmed >= 0 and armed[firstArmed].picked and abs(states[firstArmed].selfSpeed) < 4.0 # ── driver ─────────────────────────────────────────────────────────────────── testDefaultParity() when declared(loadTfilCommitEnv): testKnobParsing() testArms() testJ144() testJ145() testJ146() testJ147() testJ151() testJ152() testJ150() testJ154() testJ153() when declared(TfilRingCommitArrival): testJ165() if failures > 0: echo "\n", failures, " check(s) FAILED" quit(1) echo "\nAll TFIL commit-env checks passed."