Files
SirRoboGarage/common_libs/tests/test_tfil_ring_weights.nim
SirStone 9caf1d3728 movement: range-weighted TFIL variant + tamed heat field (opt-in, default unchanged)
New mover `the_floor_is_lava_ring.nim`, a COPY of `the_floor_is_lava.nim` (which
stays byte-identical - the user explicitly wants the current TFIL preserved).
Selected only via `TR_MOVEMENT=tfil_ring`; the default stays `tfil`.

WHY: our measured real hit rate vs DrussGT is strongly range-dependent - 21.6%
at 0-100px, 27.1% at 100-200px, 19.3% at 200-300, 10.9% at 300-400, 6.8% at
400-600, 5.4% at 600-800 - but we shoot from ~450px on average. Plain TFIL has
no range preference at all.

THE ONE CHANGE: the final tile draw is re-weighted toward a target band.
  rangeW(d) = 1.0 if lo<=d<=hi; exp(-((lo-d)/K)^2) if d<lo; exp(-((d-hi)/K)^2) if d>hi
  w_i = rangeW(d_i)^(1/T);  chosen ~ Categorical(w)
FLAT TOP on purpose: a Gaussian centred on the band midpoint would collapse the
band to a point and destroy the within-band hedge. `T` is the only knob;
`TR_TFIL_RANGE_TEMP=0` gives plain `rand(candidates.high)` - the exact control
arm. Safety stays a HARD constraint: the weighting only reorders the draw among
the pool the old code already accepted, so it can never pick a tile the old code
rejected (monotone refinement). Small pools (<4) stay uniform.
Randomness is deliberately KEPT: a measured A/B showed committing to the "best"
tile made real hit rate WORSE (7.02% -> 5.10%), so the distribution is tilted,
never removed.

HEAT TAMING (ring copy only; env-overridable):
  TR_TFIL_CORRIDOR_HEAT  20.0 -> 5.0
  TR_TFIL_WALL_HOTNESS   30.0 -> 10.0
Rationale, measured: `CorridorHeat=20` is TWICE `PathDangerThreshold=10`, so a
single corridor could poison a path by itself; `WallHotness=30` with
`WallRadiance=10` put the outer two tile rings over threshold on their own.
Per-source shares of total lava: wall 60.6%, corridor 25.4%, pillar 7.4%,
everything else <3%.

MEASURED EFFECT (primary fixture, 20,026 ticks / 15 rounds, field identity
verified max diff 0.000e+00):
  metric                        original(20/30)   ring(5/10)
  band-weightable ticks              10.79%         26.45%
  mean safeTiles/tick                 15.19          85.04
  ticks with 0 safe (pre-fallback)    58.5%           7.5%
  safePool >= 4                       39.05%         92.47%
  >=1 safe tile in 100-200px          11.84%         26.64%
  MEAN CLOSEST-SAFE-TILE DISTANCE    397.78px       284.84px
  tiles > 10 threshold                 0.61           0.15
The 397.78px figure is why the bot stayed far away: the safety filter left
nothing safe near the target, and 397px is our WORST range. Control: setting
corridor=20 wall=30 reproduces the original baseline exactly.
CEILING, honestly: even at corridor 0 / wall 0 only ~40% of ticks are
band-weightable, so no constant tweak fully unlocks the range weighting.

Ram unification: the ring mover takes a `band` field; ramming becomes just
`band=(0,50)`, so there is one movement engine. The `tfil` path is unchanged.

Observability: magenta annulus at the band edges, candidates tinted by weight,
chosen tile marked; one `[tfil_ring]` log line on change (now including
corridorHeat/wallHotness).

Guards: test_tfil_ring_weights 24/24 (new, pure, no battle), test_gun_harness 39,
test_vbullet_metric 11, test_power_selection 3, test_adaptive_radar 41.
UNVERIFIED: the mover's live effect. It has not been run in a battle yet.
2026-09-21 23:46:47 +02:00

159 lines
6.7 KiB
Nim

## Pure unit tests for the range-weighted tile draw in
## `movements/the_floor_is_lava_ring.nim` (the new TFIL-RING mover).
##
## NO battle, NO Java, NO server. Run with:
## nim c -r common_libs/tests/test_tfil_ring_weights.nim
##
## These pin the two contracts that make the mover safe to ship:
## 1. the weight SHAPE (flat top exactly 1.0 in band, Gaussian falloff
## outside, uniform as T -> inf, uniform for pools smaller than 4);
## 2. the draw can only ever return an index INSIDE the candidate pool, so it
## can never pick a tile the unweighted mover would have rejected.
import std/[math, random]
import movements/the_floor_is_lava_ring
var failures = 0
proc check(name: string, ok: bool) =
if ok: echo "PASS: ", name
else: echo "FAIL: ", name; inc failures
const
Lo = 100.0
Hi = 200.0
K = 60.0
# ── 1. flat top is exactly 1.0 across the band ───────────────────────────────
proc testFlatTop() =
var allOne = true
var d = Lo
while d <= Hi:
if rangeWeight(d, Lo, Hi, K) != 1.0: allOne = false
d += 2.5
check "flat top: weight is exactly 1.0 for every d in [lo, hi]", allOne
check "flat top: lower edge d == lo is 1.0", rangeWeight(Lo, Lo, Hi, K) == 1.0
check "flat top: upper edge d == hi is 1.0", rangeWeight(Hi, Lo, Hi, K) == 1.0
# The flat top is what preserves the within-band hedge: all in-band tiles are
# weighted identically, so the random draw still spreads across the band.
check "flat top: two different in-band tiles are equally weighted",
rangeWeight(120.0, Lo, Hi, K) == rangeWeight(180.0, Lo, Hi, K)
# ── 2. weights fall off outside the band ─────────────────────────────────────
proc testFalloff() =
let wBelow = rangeWeight(Lo - 10.0, Lo, Hi, K)
let wAbove = rangeWeight(Hi + 10.0, Lo, Hi, K)
check "falloff: below lo is strictly between 0 and 1", wBelow > 0.0 and wBelow < 1.0
check "falloff: above hi is strictly between 0 and 1", wAbove > 0.0 and wAbove < 1.0
check "falloff: symmetric (lo-d == d-hi)",
abs(wBelow - wAbove) < 1e-12
check "falloff: monotone decreasing away from the band",
rangeWeight(Lo - 40.0, Lo, Hi, K) < rangeWeight(Lo - 20.0, Lo, Hi, K) and
rangeWeight(Hi + 40.0, Lo, Hi, K) < rangeWeight(Hi + 20.0, Lo, Hi, K)
check "falloff: exactly 1.0 at the band edge, < 1.0 just outside",
rangeWeight(Lo, Lo, Hi, K) == 1.0 and
rangeWeight(Lo - 0.001, Lo, Hi, K) < 1.0
# ── 3. T -> infinity gives uniform weights (the old distribution) ────────────
proc testTemperature() =
let dists = @[50.0, 120.0, 180.0, 260.0, 400.0]
let wHuge = bandWeights(dists, Lo, Hi, K, 1e9)
var allOne = true
for w in wHuge:
if abs(w - 1.0) > 1e-6: allOne = false
check "T -> inf: every weight collapses to 1.0 (uniform)", allOne
let wTiny = bandWeights(dists, Lo, Hi, K, 0.05)
check "T -> 0: in-band tiles dominate the outside tiles",
wTiny[1] > wTiny[0] and wTiny[2] > wTiny[3] and wTiny[2] > wTiny[4]
# In-band weight is exactly 1.0 regardless of T (1 ^ anything == 1).
let wDefault = bandWeights(dists, Lo, Hi, K, 0.4)
check "in-band weight is exactly 1.0 at the default T", wDefault[1] == 1.0
# ── 4. small pools are forced uniform (melee safety) ─────────────────────────
proc testSmallPoolUniform() =
let w2 = bandWeights(@[50.0, 900.0], Lo, Hi, K, 0.4)
check "pool of 2 -> uniform (never shaped)",
w2.len == 2 and w2[0] == 1.0 and w2[1] == 1.0
let w3 = bandWeights(@[50.0, 150.0, 900.0], Lo, Hi, K, 0.4)
check "pool of 3 -> uniform (never shaped)",
w3[0] == 1.0 and w3[1] == 1.0 and w3[2] == 1.0
let w4 = bandWeights(@[50.0, 150.0, 900.0, 950.0], Lo, Hi, K, 0.4)
check "pool of 4 IS shaped (shaping starts at MinRingPool)",
w4[1] > w4[0] and w4[1] > w4[2]
# ── 5. the chosen index is ALWAYS inside the candidate pool ──────────────────
proc testIndexInsidePool() =
let dists = @[50.0, 120.0, 180.0, 260.0, 400.0, 900.0]
let w = bandWeights(dists, Lo, Hi, K, 0.4)
var rng = initRand(12345)
var allIn = true
for _ in 0..<20000:
let idx = weightedIndex(w, rand(rng, 1.0))
if idx < 0 or idx >= dists.len: allIn = false
check "random draws: chosen index is always within candidates", allIn
# Edge variates and degenerate weight vectors must stay in range too.
check "u == 0 -> index 0", weightedIndex(w, 0.0) == 0
# A near-1 variate returns the LAST tile only when that tile has weight; use a
# uniform vector so the expectation is unambiguous.
check "u just below 1 -> last index (uniform weights)",
weightedIndex(@[1.0, 1.0, 1.0, 1.0], 0.999999) == 3
check "empty pool -> 0", weightedIndex(newSeq[float](), 0.5) == 0
check "all-zero weights -> a valid in-pool index",
weightedIndex(@[0.0, 0.0, 0.0], 0.5) in 0..2
check "single weight -> index 0", weightedIndex(@[1.0], 0.99) == 0
# The `var Rand` wrapper must agree on the range contract.
var rng2 = initRand(1)
let idx2 = weightedIndex(w, rng2)
check "var Rand wrapper returns an in-pool index",
idx2 >= 0 and idx2 < w.len
# ── 6. the default T actually tilts the draw toward the band ─────────────────
proc testTilt() =
# Two in-band tiles, three far. Uniform would pick an in-band tile 2/5 = 40%
# of the time; the default T = 0.4 must push that well above 80%.
let dists = @[60.0, 120.0, 180.0, 500.0, 700.0]
let w = bandWeights(dists, Lo, Hi, K, 0.4)
var rng = initRand(999)
var inBand = 0
const N = 20000
for _ in 0..<N:
let idx = weightedIndex(w, rand(rng, 1.0))
if idx == 1 or idx == 2: inc inBand
check "default T=0.4 tilts the draw toward the in-band tiles (>80% vs 40%)",
inBand.float / N.float > 0.8
# ── 7. OFF path (temp <= 0) is uniform, matching the old control arm ─────────
proc testOffPathUniform() =
let dists = @[50.0, 120.0, 180.0, 260.0, 400.0]
let wOff = bandWeights(dists, Lo, Hi, K, 0.0)
var allOne = true
for w in wOff:
if w != 1.0: allOne = false
check "temp <= 0 (OFF path) -> uniform weights", allOne
# ── run ──────────────────────────────────────────────────────────────────────
testFlatTop()
testFalloff()
testTemperature()
testSmallPoolUniform()
testIndexInsidePool()
testTilt()
testOffPathUniform()
if failures == 0:
echo "\nAll tfil-ring weight checks passed."
else:
echo "\n", failures, " tfil-ring weight check(s) FAILED."
quit(1)