## Offline energy-economy measurement for the power policy (TR_POWER_*). ## ## Replays the committed DrussGT movement fixtures through the real VirtualTracker ## (same path the range/acceptance tests use) and, per tick, computes the power ## the SHIPPED rack's only gun (Pattern, id 5) would prefer (`bestPower`). It then ## simulates four policy arms against that SAME preference sequence: ## ## control : uncapped (TR_POWER_POLICY=0) — the baseline ## cliff : TODAY's shipped rule (far 1.0, self energy < 50 -> 1.0, belowAvg 2.0) ## slope : the new linear self-energy cap (finishing OFF) ## finish : slope + the new smallest-killing-bullet cap (finishing ON) ## ## The fire schedule uses the server's gun heat: a shot adds `1 + p/5` heat and ## the gun cools `0.1`/tick, so the interval is `10 + 2p` ticks — LOWER power fires ## more often. `energySpent` sums the fired power over shots; the histogram counts ## shots at each 0.1-wide power bucket. ## ## IMPORTANT (labelled in the output): the trajectory is recorded (open-loop, ## perfect-information) so this is NOT a closed-loop hit-rate A/B. What is ## MEASURED is the policy's energy draw over real recorded movement; what is ## INFERRED is the resulting battle outcome. Hit rates are NOT modelled here. ## ## Run: nim c -r common_libs/tests/measure_power_policy.nim import std/[os, strformat, strutils, math, tables, algorithm] import gun_harness/offline_range import gun_harness/virtual_bullets import range_guns const repoRoot = currentSourcePath().parentDir.parentDir.parentDir fixturesDir = repoRoot / "tools" / "fixtures" ShippedGunId = 5 ## Pattern — the only gun in the shipped rack CoolRate = 0.1 ## gun heat lost per tick (server config) OldCliffEnergy = 50.0 ## TR_POWER_LOW_ENERGY's old hard threshold const FixtureNames = [ "drussgt_vs_spinbot.jsonl", "drussgt_vs_ramfire.jsonl", "drussgt_vs_crazy.jsonl", "drussgt_vs_corners.jsonl", "drussgt_vs_drussgt.jsonl", ] type Arm* = enum aControl ## uncapped — the control arm aCliff ## today's shipped cliff aSlope ## new energy slope only aFinish ## energy slope + finishing TickRec = object dist, selfE, enemyE: float prefPower: float pEst, pRef: float SimResult = object shots: int energy: float hist: Table[int, int] ## key = round(power*10) proc armName(a: Arm): string = case a of aControl: "control" of aCliff: "cliff " of aSlope: "slope " of aFinish: "finish " proc firedPower(arm: Arm, r: TickRec): float = ## The power this arm would fire given the gun's own preference and the ## per-tick state. `applyPowerPolicy` already returns `min(preference, cap)`. case arm of aControl: r.prefPower of aCliff: var cap = 3.0 if r.dist > PowerFarDist: cap = PowerFarCap elif r.selfE < OldCliffEnergy: cap = PowerFarCap elif r.pEst <= r.pRef: cap = PowerMidCap min(r.prefPower, cap) of aSlope: applyPowerPolicy(r.prefPower, r.dist, r.selfE, r.pEst, r.pRef, false, enemyEnergy = r.enemyE, enabled = true, finishKill = false).power of aFinish: applyPowerPolicy(r.prefPower, r.dist, r.selfE, r.pEst, r.pRef, false, enemyEnergy = r.enemyE, enabled = true, finishKill = true).power proc collect(fx: Fixture): seq[TickRec] = ## One pass through the real tracker, recording the per-tick preference and ## policy inputs. The preference sequence is arm-INDEPENDENT (virtual bullets ## are spawned for every bin regardless of what we fire), so all four arms are ## compared against the exact same sequence. var recs: seq[TickRec] var tickIdx = 0 let drivers = buildAllGunDrivers(seed = 1) let cb = proc(t: ptr VirtualTracker) = let si = tickIdx inc tickIdx if si >= fx.states.len: return let st = fx.states[si] let tid = fx.enemyId let (prefBin, prefPower) = t[].bestPower(ShippedGunId, tid) let fit = t[].fitnessFor(tid) let pRef = gunRate(fit[ShippedGunId], pooled = true) let pEst = if fit[ShippedGunId].bins[prefBin].count == 0: pRef else: fit[ShippedGunId].bins[prefBin].hitRate() recs.add TickRec( dist: hypot(st.enemyX - st.selfX, st.enemyY - st.selfY), selfE: st.selfEnergy, enemyE: st.enemyEnergy, prefPower: prefPower, pEst: pEst, pRef: pRef) discard replayFixture(fx, drivers, metric = bmPath, tickCb = cb) recs proc simulate(recs: seq[TickRec], arm: Arm): SimResult = ## Fire whenever the gun is cool (heat <= 0), drawing `power` energy per shot ## and adding `1 + p/5` heat. Mirrors the live `setFire` + `getEnergy() > power` ## guard, so a shot is skipped if our energy cannot cover it. var heat = 0.0 for r in recs: heat = max(0.0, heat - CoolRate) if heat > 1e-9: continue let p = firedPower(arm, r) if r.selfE <= p: continue result.energy += p inc result.shots let key = int(round(p * 10.0)) result.hist[key] = result.hist.getOrDefault(key) + 1 heat = 1.0 + p / 5.0 proc addHist(dst: var Table[int, int], src: Table[int, int]) = for k, v in src: dst[k] = dst.getOrDefault(k) + v proc histLine(h: Table[int, int]): string = var keys: seq[int] for k in h.keys: keys.add k keys.sort() for k in keys: if result.len > 0: result.add " " result.add fmt"p={k.float/10.0:.1f}:{h[k]}" proc main() = echo "=== offline energy-economy measurement (power policy) ===" echo "fixtures: ", FixtureNames.len, " gun: Pattern(id=", ShippedGunId, ")" echo "heat model: +1+p/5 per shot, -0.1/tick => interval 10+2p ticks" echo "" var totalTicks = 0 var lowEnemyTicks = 0 var agg: array[Arm, SimResult] echo "fixture ticks arm shots energy meanP" echo "-".repeat(72) for name in FixtureNames: let path = fixturesDir / name if not fileExists(path): echo " (missing: ", path, ")" continue let fx = loadFixture(path) let recs = collect(fx) totalTicks += recs.len for r in recs: if r.enemyE > 0.0 and r.enemyE <= 16.0: inc lowEnemyTicks for arm in Arm: let s = simulate(recs, arm) agg[arm].shots += s.shots agg[arm].energy += s.energy agg[arm].hist.addHist(s.hist) let meanP = if s.shots > 0: s.energy / s.shots.float else: 0.0 echo fmt"{name:<26} {recs.len:>6} {armName(arm):<8} {s.shots:>6} " & fmt"{s.energy:>8.0f} {meanP:>6.2f}" echo "" echo "=== AGGREGATE over all fixtures (", totalTicks, " ticks) ===" echo "arm shots energy meanP E/1k ticks vs control vs cliff" echo "-".repeat(72) let base = agg[aControl].energy let cliff = agg[aCliff].energy for arm in Arm: let s = agg[arm] let meanP = if s.shots > 0: s.energy / s.shots.float else: 0.0 let per1k = if totalTicks > 0: s.energy / totalTicks.float * 1000.0 else: 0.0 let vsControl = if base > 0: (base - s.energy) / base * 100.0 else: 0.0 let vsCliff = if cliff > 0: (cliff - s.energy) / cliff * 100.0 else: 0.0 echo fmt"{armName(arm):<8} {s.shots:>7} {s.energy:>9.0f} {meanP:>7.2f} " & fmt"{per1k:>11.1f} {vsControl:>10.1f}% {vsCliff:>9.1f}%" echo "" echo fmt"low-enemy ticks (0 < E <= 16, where the finishing rule can bind): " & fmt"{lowEnemyTicks}/{totalTicks} ({lowEnemyTicks.float/max(1,totalTicks).float*100.0:.1f}%)" echo "" echo "=== POWER HISTOGRAM (shots per 0.1-wide power bucket, all fixtures) ===" for arm in Arm: echo armName(arm), ": ", histLine(agg[arm].hist) echo "" echo "=== HIT-CHANCE / BREAK-EVEN REASONING (INFERRED, not measured here) ===" echo "E[dE] = p(3P-1): the break-even hit probability is 1/3 INDEPENDENT of power." echo "Our measured real hit rates are 5-27% (far below 1/3), so every point of" echo "power costs more energy than it returns. A smaller bullet needs MORE hits" echo "(ceil(E/damage)) but each hit is MORE LIKELY (speed 20-3p => less lead" echo "error) and shots come FASTER (interval 10+2p). This tool measures only the" echo "ENERGY side; which effect wins for hit rate needs the battle A/B." when isMainModule: main()