j145 TFIL: a turn-cost tiebreak among the SAFE tiles (default-off)

The picker scored candidates on pathMaxHeat alone and then drew uniformly
among the survivors, so a mirror-side tile was as likely as a straight-ahead
one. Added a continuous turn cost as a DRAW WEIGHT applied only after the
hard heat filter:

  w = max(1, round(1 + TR_TFIL_TURN_BIAS * (1 - max(0,|turn| - REF)/180)))

- TR_TFIL_TURN_BIAS (default 0) is the odds ratio straight-ahead vs 180 deg;
  TR_TFIL_TURN_REF_DEG (default 45) is where the penalty starts. Both
  default-off-effect: the default-path golden in test_tfil_commit_env.nim is
  unchanged and still passes.
- Turn cost is NEVER folded into the heat score. The filter stays hard.
- The draw stays random (j51 measured an argmin worse); every weight is
  floored at 1, so the pool can never be emptied and bias 0 is exactly the
  shipped uniform draw.
- |turn| now travels on the ScoredTile, and the commit log gained turn /
  minturn / promote so a caller can measure the regret of the draw.

Guards: 51 -> 66 checks (an absurd 99:1 bias never rescues an over-threshold
tile; mean |turn|, draw regret, >90 and mirror-side shares all fall; path
heat does not rise). env_report + .env.example updated.
This commit is contained in:
2026-09-26 21:56:54 +02:00
parent 0e7e124c7f
commit 39c90fd930
7 changed files with 595 additions and 20 deletions
+213
View File
@@ -6,6 +6,8 @@
## 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
@@ -445,6 +447,216 @@ when declared(loadTfilCommitEnv):
" 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)
# ── driver ───────────────────────────────────────────────────────────────────
testDefaultParity()
@@ -452,6 +664,7 @@ when declared(loadTfilCommitEnv):
testKnobParsing()
testArms()
testJ144()
testJ145()
if failures > 0:
echo "\n", failures, " check(s) FAILED"