23bce2dad5
TR_RAM_FLOOR_ENERGY (0.0 = off): at/below this self energy we start no NEW shot, holding back the reserve for a final ram exchange. Justified by the only energy gain in the game being +3*power per bullet hit LANDED, so not firing denies the enemy its only refill. Blocks only NEW shots (gunHeat already gates committed ones) and is bypassed while ramming. TR_RAM_ENEMY_ENERGY (0.0 = off): last-scanned enemy energy <= this -> ram mode. Enemy energy IS observable (ScannedBotEvent.energy, schemas.nim:306), 1-8 ticks stale. This is the shipped finisher with its energy tolerance promoted to a knob, keeping the self>enemy surplus guard because RAM_DAMAGE 0.6 applies to BOTH bots on every contact tick. Open-loop measurement (measure_ramfloor_energy, 8149 recordings / 29871 rounds / 33.8M ticks): 'both low' is COMMON (10.4% of ticks below 20, 15.1% below 25) but neither side goes low first (enemy 52.7% / us 47.3%), and the owner's literal trigger - enemy so low it cannot fire (energy <= 1.95) - is only 2.5% of ticks, 1.1% while we are healthy. Guards 136 -> 147 in test_tfil_commit_env.nim, all green. A/B PRE-REGISTERED in docs/ram_floor_exhaustion_ab.md and NOT RUN.
1607 lines
77 KiB
Nim
1607 lines
77 KiB
Nim
## 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
|
|
import movements/ram_decision
|
|
# 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..<states.len:
|
|
if i == 0 or i in starts: m.resetRound()
|
|
let cmd = m.computeMove(states[i])
|
|
result.add TickRec(call: m.callCount, spd: cmd.speed, trn: cmd.turnRate,
|
|
tx: m.commitTarget.x, ty: m.commitTarget.y,
|
|
ct: m.commitTicks)
|
|
|
|
proc recLine(r: TickRec): string =
|
|
$r.call & " " & $r.spd & " " & $r.trn & " " & $r.tx & " " & $r.ty & " " & $r.ct
|
|
|
|
# ── golden generation (OLD module only) ──────────────────────────────────────
|
|
|
|
when defined(tfilGenGolden):
|
|
block:
|
|
let recs = replay(loadStates(), loadRoundStarts())
|
|
var g = "# TFIL default-path parity golden.\n"
|
|
g.add "# Originally generated from commit f842ac0 (shipped mover, BEFORE the commit knobs).\n"
|
|
g.add "# REGENERATED from the NEW default after the DELIBERATE removal of the virtual\n"
|
|
g.add "# centre pillar (PillarHotness/PillarRadiance 30/10 -> 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..<min(recs.len, golden.len):
|
|
if recLine(recs[i]) != golden[i]:
|
|
firstDiff = i
|
|
break
|
|
check "DEFAULT PATH IS BYTE-FOR-BYTE THE PRE-CHANGE MOVER (call/speed/turnRate/target/commitTicks)",
|
|
firstDiff < 0
|
|
if firstDiff >= 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..<states.len:
|
|
let ws = states[i]
|
|
if i == 0 or i in starts:
|
|
m.resetRound()
|
|
hadPicks = false
|
|
let before = m.picks
|
|
discard m.computeMove(ws)
|
|
if m.picks != before:
|
|
let travelDeg = if ws.selfSpeed < -0.01: ws.selfHeading + 180.0
|
|
else: ws.selfHeading
|
|
var rd = arctan2(m.commitTarget.y - ws.selfY, m.commitTarget.x - ws.selfX) *
|
|
180.0 / PI - travelDeg
|
|
while rd > 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..<states.len:
|
|
let ws = states[i]
|
|
if i == 0 or i in starts:
|
|
m.resetRound()
|
|
hadPicks = false
|
|
let before = m.picks
|
|
discard m.computeMove(ws)
|
|
if m.picks != before:
|
|
result.add PickRec(turn: m.lastPickMinTurn,
|
|
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
|
|
for tok in envspec.splitWhitespace():
|
|
putEnv(tok.split('=', 1)[0], "")
|
|
clearShape()
|
|
loadTfilCommitEnv()
|
|
TfilCommitArrival = false
|
|
TfilNoRevSpeed = 0.0
|
|
discard tag
|
|
|
|
## Heat the field paints on a tile a single tracked bullet sits on, with the
|
|
## corridor and the wall field switched OFF so the number is the BULLET's own
|
|
## contribution and nothing else. `aheadPx` walks forward along the bullet's
|
|
## travel, where the CORRIDOR (drawn only from the bullet position forward)
|
|
## actually paints. One enemy-free world, one bullet.
|
|
proc bulletTileHeat(bCore, bAura, corridor, aheadPx: float): float =
|
|
setShape(corridor, 0.0, 0.0, bCore, bAura)
|
|
let ws = WorldState(enemyX: 0.0, enemyY: 0.0, enemyHeading: 0.0,
|
|
enemySpeed: 0.0, enemyEnergy: 100.0,
|
|
selfX: 400.0, selfY: 120.0, selfHeading: 0.0,
|
|
selfSpeed: 0.0, selfEnergy: 100.0,
|
|
arenaWidth: ArenaW, arenaHeight: ArenaH, tick: 1,
|
|
enemies: @[])
|
|
var m = initTFIL()
|
|
randomize(Seed)
|
|
discard m.computeMove(ws) # initGrid
|
|
# heading up the arena, the bot BELOW it, so `advanceBullets` keeps it alive
|
|
m.bullets = @[TrackedBullet(originX: 410.0, originY: 320.0, x: 410.0, y: 320.0,
|
|
velX: 0.0, velY: -20.0, power: 1.0,
|
|
alive: true, age: 0)]
|
|
discard m.computeMove(ws)
|
|
if m.bullets.len == 0: return 0.0
|
|
let (c, r) = m.tileAt(m.bullets[0].x, m.bullets[0].y - aheadPx)
|
|
result = m.lavaAt(c, r)
|
|
|
|
proc testJ146() =
|
|
# 6a. DEFAULT-OFF-EFFECT: the defaults are today's `const`s, so the shipped
|
|
# shape spelled out in full as env and the knobs left UNSET must be the
|
|
# same build, tick for tick. (The golden above proves it against the
|
|
# pre-change build; this proves it against today's defaults too.)
|
|
clearShape()
|
|
check "j146: TR_TFIL_BULLET_CORE/AURA default to today's consts (10.0/5.0)",
|
|
BulletCore == 10.0 and BulletAura == 5.0
|
|
let explicit = replayWithShape("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")
|
|
let unset = replay(loadStates(), loadRoundStarts())
|
|
var firstDiff = -1
|
|
if explicit.len != unset.len:
|
|
firstDiff = min(explicit.len, unset.len)
|
|
else:
|
|
for i in 0..<unset.len:
|
|
if recLine(explicit[i]) != recLine(unset[i]):
|
|
firstDiff = i
|
|
break
|
|
check "j146: the SHIPPED SHAPE written out in full as env (20/30/10/10/5) is " &
|
|
"BYTE-FOR-BYTE the unset build over " & $unset.len & " ticks — the new " &
|
|
"knobs are default-off-effect",
|
|
firstDiff < 0
|
|
|
|
# 6b. knob parsing, including the fallbacks
|
|
setShape(20.0, 30.0, 10.0, 20.0, 10.0)
|
|
check "j146: TR_TFIL_BULLET_CORE=20 / TR_TFIL_BULLET_AURA=10 are read",
|
|
BulletCore == 20.0 and BulletAura == 10.0
|
|
putEnv("TR_TFIL_BULLET_CORE", "not-a-number")
|
|
putEnv("TR_TFIL_BULLET_AURA", "")
|
|
loadTfilHeatShapeEnv()
|
|
check "j146: a malformed / empty value falls back to the DEFAULT (10.0/5.0)",
|
|
BulletCore == 10.0 and BulletAura == 5.0
|
|
putEnv("TR_TFIL_BULLET_CORE", "-5")
|
|
loadTfilHeatShapeEnv()
|
|
check "j146: a negative value clamps to 0 (heat can never go backwards)",
|
|
BulletCore == 0.0
|
|
putEnv("TR_TFIL_BULLET_CORE", "")
|
|
loadTfilHeatShapeEnv()
|
|
|
|
# 6c. THE MECHANISM CLAIM: with the shipped core 10 a bullet is never
|
|
# dangerous ON ITS OWN (10 == PathDangerThreshold, and `safe` is
|
|
# `<=`), while a corridor at 10 is not over threshold either. Raise the
|
|
# core to 20 and the bullet's core IS over the threshold by itself.
|
|
let coreShipped = bulletTileHeat(10.0, 5.0, 0.0, 0.0)
|
|
let coreRaised = bulletTileHeat(20.0, 10.0, 0.0, 0.0)
|
|
let corr10Ahead = bulletTileHeat(0.0, 0.0, 10.0, 72.0)
|
|
check "j146: with the SHIPPED bullet core (10) a bullet's own core tile is " &
|
|
"NOT over PathDangerThreshold (" & $coreShipped & " <= " &
|
|
$DangerThreshold & ") — the corridor carries the whole field",
|
|
coreShipped <= DangerThreshold
|
|
check "j146: TR_TFIL_BULLET_CORE=20 puts the bullet's core ALONE over the " &
|
|
"threshold (" & $coreRaised & " > " & $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..<off.len:
|
|
if recLine(off[i]) != recLine(on[i]): diff = i; break
|
|
check "j150: with TR_TFIL_DIAG=1 the move stream is BYTE-FOR-BYTE the " &
|
|
"diag-off one over " & $off.len & " ticks — the counters are inert",
|
|
diff < 0
|
|
|
|
# 2. the histogram is populated and its stage chain is monotone
|
|
let st = TfilLoss # kept: step 4 clears the live counter
|
|
check "j150: the histogram counted picks (" & $st.picks & ") and every " &
|
|
"stage is non-increasing (reach >= 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..<states.len:
|
|
if i == 0 or i in starts: m.resetRound()
|
|
discard m.computeMove(states[i])
|
|
if m.picks != lastPicks:
|
|
lastPicks = m.picks
|
|
let tta = sqrt((m.commitTarget.x - states[i].selfX)^2 +
|
|
(m.commitTarget.y - states[i].selfY)^2) / MaxSpeed
|
|
inc result.picks
|
|
if tta > 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..<states.len:
|
|
if i == 0 or i in starts:
|
|
m.resetRound()
|
|
lastPicks = 0 # resetRound zeroes `picks`: not a new pick
|
|
let cmd = m.computeMove(states[i])
|
|
result.add HoldRec(call: m.callCount, spd: cmd.speed, trn: cmd.turnRate, held: m.lastHeld,
|
|
picked: m.picks != lastPicks, bullets: m.bullets.len)
|
|
lastPicks = m.picks
|
|
delEnv("TR_TFIL_HOLD_WHEN_TRAPPED")
|
|
loadTfilCommitEnv()
|
|
|
|
proc testJ153() =
|
|
delEnv("TR_TFIL_HOLD_WHEN_TRAPPED")
|
|
loadTfilCommitEnv()
|
|
check "j153: TR_TFIL_HOLD_WHEN_TRAPPED defaults to OFF (today's " &
|
|
"promote-the-2-least-hot fallback)", not TfilHoldWhenTrapped
|
|
|
|
let off = replayJ153(false) # knob explicitly 0
|
|
let unset = replay(loadStates(), loadRoundStarts()) # knob never set
|
|
var diff = -1
|
|
if off.len != unset.len: diff = min(off.len, unset.len)
|
|
else:
|
|
for i in 0..<off.len:
|
|
if unset[i].spd != off[i].spd or unset[i].trn != off[i].trn or
|
|
unset[i].call != off[i].call:
|
|
diff = i; break
|
|
check "j153: with the knob unset the move stream is BYTE-FOR-BYTE the " &
|
|
"knob-0 one over " & $off.len & " ticks — the default is today's",
|
|
diff < 0 and off.len > 0
|
|
|
|
let on = replayJ153(true)
|
|
var held, heldMoved, heldPicked = 0
|
|
var nonHeldMoving = 0
|
|
for i in 0..<on.len:
|
|
if on[i].held:
|
|
inc held
|
|
if abs(on[i].spd) > 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..<on.len:
|
|
if on[i].held:
|
|
inc run
|
|
else:
|
|
if run > 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..<min(on.len, off.len):
|
|
if on[i].bullets != off[i].bullets:
|
|
if on[i].held or off[i].held: firstDiv = i
|
|
break
|
|
check "j153: holding does not freeze the fire/bullet bookkeeping the GUN " &
|
|
"reads (bullet counts identical on held vs non-held ticks)",
|
|
firstDiv < 0
|
|
|
|
# ── j154: the BOUNDED hold (TR_TFIL_HOLD_MAX_TICKS, default 0 = off) ────────
|
|
#
|
|
# The budget is DERIVED from the enemy's own rate of fire, not chosen. Server
|
|
# `rules/math.kt`: `calcGunHeat(p) = 1 + p/5`, `calcBulletDamage(3.0) = 16`;
|
|
# `core/GunEngine.kt`: the gun cools 0.1 per tick and may only fire at heat == 0.
|
|
# So two 3.0-power shots are 1.6/0.1 = 16 ticks apart, and a brute-force search
|
|
# over the 0.1 power quantisation says 32 damage is the most the enemy can land
|
|
# in any 16-tick window (8/11/16/24/32 ticks -> 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..<unset.len:
|
|
if recLine(unset[i]) != recLine(zero[i]): diff = i; break
|
|
check "j154: TR_TFIL_HOLD_MAX_TICKS=0 is BYTE-FOR-BYTE the unset build over " &
|
|
$unset.len & " ticks (default path unchanged)",
|
|
diff < 0 and unset.len > 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
|
|
|
|
proc testJ160() =
|
|
## j160 — the energy-reserve FIRING FLOOR (TR_RAM_FLOOR_ENERGY) and the
|
|
## ENEMY-EXHAUSTION ram trigger (TR_RAM_ENEMY_ENERGY). Both default 0.0 =
|
|
## today's behaviour. Pure logic only; no bot, no server.
|
|
const F = 5.0
|
|
|
|
# 1. DEFAULT PARITY, floor: with the knob unset the floor blocks NOTHING over
|
|
# a grid that includes every measured low-energy region (<=5: 9.4% of
|
|
# ticks in the 8612-round closed-loop corpus, p01 self energy = 0.2).
|
|
var blocked = 0
|
|
for e in [-1.0, 0.0, 0.1, 1.0, 2.5, 5.0, 7.0, 20.0, 46.0, 100.0, 120.0]:
|
|
if fireFloorBlocks(0.0, e): inc blocked
|
|
check "j160: TR_RAM_FLOOR_ENERGY unset (=0) suppresses fire on NO input, " &
|
|
"so the default path is byte-for-byte today's (" & $blocked & " blocked)",
|
|
blocked == 0
|
|
|
|
# 2. DEFAULT PARITY, trigger: with the knob unset the reason over a grid is
|
|
# the PRE-j160 result — the new arm is unreachable, and every old arm still
|
|
# returns exactly what it returned before.
|
|
var newArm, mismatch: int
|
|
for dist in [10.0, 100.0, 299.0, 301.0, 500.0]:
|
|
for se in [0.5, 5.0, 19.0, 21.0, 60.0, 100.0]:
|
|
for ee in [0.0, 1.0, 5.0, 19.9, 20.0, 40.0, 100.0]:
|
|
let inp = RamInputs(dist: dist, selfEnergy: se, enemyEnergy: ee)
|
|
let got = ramTrigger(inp)
|
|
if got == rrExhausted: inc newArm
|
|
# the pre-j160 body, verbatim
|
|
var want: RamReason = rrNone
|
|
if ee > 0.0:
|
|
if dist < RamFinisherDist and ee < RamFinisherEnergy and se > ee: want = rrFinisher
|
|
elif se < RamDesperationEnergy and ee < RamDesperationEnergy and dist < RamDesperationDist: want = rrDesperation
|
|
if got != want: inc mismatch
|
|
check "j160: TR_RAM_ENEMY_ENERGY unset (=0) makes the exhaustion arm " &
|
|
"unreachable (" & $newArm & " hits) and leaves the old finisher / " &
|
|
"desperation verdicts identical (" & $mismatch & " mismatches over 210 " &
|
|
"input combinations)",
|
|
newArm == 0 and mismatch == 0
|
|
|
|
# 3. the floor suppresses AT the threshold, not above it.
|
|
check "j160: the floor blocks AT the threshold (self == 5.0 <= floor 5.0)",
|
|
fireFloorBlocks(F, 5.0)
|
|
check "j160: the floor blocks just below it and not just above it — one " &
|
|
"tick of hysteresis, no dead band",
|
|
fireFloorBlocks(F, 4.999) and not fireFloorBlocks(F, 5.001)
|
|
|
|
# 4. it never suppresses while we are healthy, at ANY floor setting.
|
|
var healthy = 0
|
|
for floor in [0.5, 1.0, 5.0, 20.0, 25.0, 40.0]:
|
|
for e in [floor, 46.0, 60.0, 100.0, 120.0]:
|
|
if e > floor and fireFloorBlocks(floor, e): inc healthy
|
|
check "j160: the floor NEVER blocks above its own threshold — healthy energy " &
|
|
"fires for every floor/energy pair (" & $healthy & " violations)",
|
|
healthy == 0
|
|
|
|
# 5. the trigger switches to ram EXACTLY at the tolerance, no earlier.
|
|
let inp2 = RamInputs(dist: 100.0, selfEnergy: 60.0, enemyEnergy: 10.0)
|
|
check "j160: the exhaustion trigger fires EXACTLY at TR_RAM_ENEMY_ENERGY " &
|
|
"(enemy 10.0 <= tol 10.0) and not one tick above (10.001)",
|
|
ramTrigger(inp2, enemyEnergyTol = 10.0) == rrExhausted and
|
|
ramTrigger(RamInputs(dist: 100.0, selfEnergy: 60.0, enemyEnergy: 10.001),
|
|
enemyEnergyTol = 10.0) != rrExhausted
|
|
|
|
# 6. the surplus guard survives: 0.6/contact is applied to BOTH bots, so we
|
|
# only ram an exhausted enemy while WE hold the surplus.
|
|
check "j160: the exhaustion trigger keeps the finisher's energy-surplus " &
|
|
"guard — an exhausted enemy while WE are lower is a ram we lose",
|
|
ramTrigger(RamInputs(dist: 250.0, selfEnergy: 2.0, enemyEnergy: 3.0),
|
|
enemyEnergyTol = 10.0) != rrExhausted
|
|
|
|
# 7. it is the finisher's own shape: the existing range guard still applies.
|
|
check "j160: the exhaustion trigger keeps the finisher's 300px range guard " &
|
|
"(enemy exhausted at 301px is not a ram)",
|
|
ramTrigger(RamInputs(dist: 301.0, selfEnergy: 60.0, enemyEnergy: 3.0),
|
|
enemyEnergyTol = 10.0) != rrExhausted
|
|
|
|
# 8. COMPOSITION: ramming WINS. The floor is the reserve FOR the ram, so once
|
|
# the ram is engaged the reserve is being spent, not held. No starvation:
|
|
# the floor alone can never make us unable to close.
|
|
check "j160: RAMMING wins the conflict — at self energy 0.1 (below any " &
|
|
"sane floor) an engaged ram is never floor-blocked, so the two " &
|
|
"compose instead of deadlocking each other",
|
|
fireFloorBlocks(F, 0.1, ramming = true) == false and
|
|
fireFloorBlocks(F, 0.1, ramming = false) == true
|
|
|
|
# 9. and the floor can never be engaged at all without self energy being
|
|
# genuinely low — the guard the owner asked for, stated as a property.
|
|
var unsafe = 0
|
|
for floor in [0.5, 5.0, 20.0, 25.0]:
|
|
for e in [0.0, 1.0, 10.0, 25.0, 50.0, 100.0]:
|
|
if fireFloorBlocks(floor, e, ramming = false) and e > floor: inc unsafe
|
|
check "j160: the floor is a LOW-ENERGY guard only — it can never suppress " &
|
|
"fire while we are healthy, in any configuration (" & $unsafe & ")",
|
|
unsafe == 0
|
|
|
|
# 10. both knobs together: the exhausted trigger still fires while the floor
|
|
# is at full strength, and the floor still holds when no ram is engaged.
|
|
check "j160: both knobs ON compose — exhaustion (enemy 3, us 60) still " &
|
|
"ram-bypasses the floor, and a non-ramming low-energy tick still holds",
|
|
fireFloorBlocks(F, 3.0, ramming = false) and
|
|
ramTrigger(RamInputs(dist: 100.0, selfEnergy: 60.0, enemyEnergy: 3.0),
|
|
enemyEnergyTol = 10.0) == rrExhausted and
|
|
not fireFloorBlocks(F, 3.0, ramming = true)
|
|
|
|
# ── driver ───────────────────────────────────────────────────────────────────
|
|
|
|
testDefaultParity()
|
|
when declared(loadTfilCommitEnv):
|
|
testKnobParsing()
|
|
testArms()
|
|
testJ144()
|
|
testJ145()
|
|
testJ146()
|
|
testJ147()
|
|
testJ151()
|
|
testJ152()
|
|
testJ150()
|
|
testJ154()
|
|
testJ153()
|
|
testJ160()
|
|
|
|
if failures > 0:
|
|
echo "\n", failures, " check(s) FAILED"
|
|
quit(1)
|
|
echo "\nAll TFIL commit-env checks passed."
|