Energy economy: the cliff becomes a SLOPE, plus a finishing cap. 11% less energy.
The user's request: "when our bot is low OR enemy is low, it is useless to use high power instead low fast bullets have more chances to finish the enemy. Let's do a math slope: starting from some health down, the power goes down with it." 1. ENERGY SLOPE (`TR_POWER_ENERGY_*`), replacing the old hard step at 50 energy: cap = ENERGY_MAX at/above ENERGY_HI, ENERGY_MIN at/below ENERGY_LO, LINEAR in power between, clamped. Defaults HI=80 LO=20 MIN=0.5 MAX=3.0, so no cap >=80, 0.5 at <=20, and e.g. E=65 -> 2.375, E=50 -> 1.75, E=35 -> 1.125. Rationale: bullet speed is 20-3p, so lower power = FASTER bullet (less lead error, higher hit chance), fires more often (10+2p) and drains slower (p/shot). E[dE] = p(3P-1) => break-even hit probability is 1/3 INDEPENDENT of power, and our measured rates are 5-27%, far below it. 2. FINISHING CAP (`TR_POWER_FINISH_KILL`, default ON): cap power at the SMALLEST bullet that still removes the enemy's remaining energy - `E<=4 -> p=E/4` (min 0.1), `4<E<=16 -> p=(E+2)/6`, `E>16 -> no cap`. Rationale, and it makes the user's instinct stronger than a heuristic: server 1.3.1 caps the damage SCORE at the energy ACTUALLY REMOVED, so overkill is WASTED damage AND ~6x the energy for ZERO extra score. Damage is 4p (p<=1) / 6p-2 (p>1). Both are min-composed with the existing far/below-average caps, may only LOWER power (exhaustively tested), and are exempt while ramming. `TR_POWER_POLICY=0` still returns the uncapped control exactly. MEASURED ENERGY SAVING (offline replay of the DrussGT fixtures, 28,797 ticks): arm shots energy meanP E/1k ticks vs cliff control(uncapped) 1913 4646 2.43 161.4 -90.2% cliff (today) 2363 2443 1.03 84.8 0.0% slope 2404 2178 0.91 75.6 ** 10.9% LESS ** slope+finish 2404 2167 0.90 75.3 ** 11.3% LESS ** So the slope spends ~11% less energy than the cliff AND fires slightly MORE shots (2404 vs 2363) - both directions at once. HONEST NOTE on the finishing rule's reach here: ticks where the enemy is low (0 < E <= 16) are only 2252/28797 = 7.8% of these fixtures, so finishing adds just ~11 energy of saving against DrussGT. It matters in CLOSER fights, not this one. Verification: test_power_policy 58 (was 26) in BOTH the default and TR_POWER_POLICY=0 control arms - slope at E=100/80/65/50/35/20/5, powerToKill across E=0.1..100, the inverse-cover property for E<=16, monotonicity, ram exemption, and an exhaustive sweep proving power <= preference. Guards: test_gun_harness 39, test_vbullet_metric 11, test_power_selection 3, test_adaptive_radar 41, test_tfil_ring_weights 24, test_ram_decision 40, test_rack_membership 48, test_selector_tiebreak 19, test_tm_pattern_registration 20, test_vbullet_admit_gate 12. acceptance 12/12 PASS. ModularBot compiles release. Adds `common_libs/tests/measure_power_policy.nim` (the energy/histogram tool) and updates docs/env_reference.md for the new `energySlope|finishKill` log reasons. NOT MEASURED: the battle/hit-rate effect. The offline figures use the fixture shooter's energy as a proxy, open-loop; the RELATIVE saving is the meaningful part.
This commit is contained in:
@@ -798,40 +798,66 @@ proc bestPower*(t: VirtualTracker, gunId: GunId, targetId: int = -1): (int, floa
|
||||
#
|
||||
# `bestPower` answers "which power bin does this gun's own virtual data prefer?"
|
||||
# and is deliberately left untouched. The policy below CAPS that preference using
|
||||
# only cheap, always-available state — range, our own energy, and the gun's own
|
||||
# rate — so a long-range or low-energy shot trades single-hit damage for a
|
||||
# faster bullet (speed = 20-3p, so LOW power is FASTER and needs less lead) and a
|
||||
# shorter fire interval (10+2p, so LOW power = MORE shots). It never RAISES
|
||||
# power, so the shipped behaviour is exactly the `cap = 3.0` case, which is also
|
||||
# the control arm (`TR_POWER_POLICY=0`).
|
||||
# only cheap, always-available state — range, our own energy, the ENEMY's energy,
|
||||
# and the gun's own rate. It never RAISES power, so the shipped behaviour is
|
||||
# exactly the `cap = 3.0` case, which is also the control arm
|
||||
# (`TR_POWER_POLICY=0`). Every rule is an ADDITIONAL cap: the applied cap is the
|
||||
# minimum of all of them.
|
||||
#
|
||||
# Two rules are the energy-economy improvement:
|
||||
#
|
||||
# 1. ENERGY SLOPE (replaces the old `TR_POWER_LOW_ENERGY` CLIFF at 50): cap our
|
||||
# power as a LINEAR function of OUR energy — `TR_POWER_ENERGY_MAX` at/above
|
||||
# `TR_POWER_ENERGY_HI`, `TR_POWER_ENERGY_MIN` at/below `TR_POWER_ENERGY_LO`,
|
||||
# linear in between. A lower power is a FASTER bullet (speed = 20-3p, so less
|
||||
# lead error -> higher hit chance), fires more often (interval 10+2p) and
|
||||
# drains energy more slowly (cost p/shot). Energy math: E[dE] = p(3P-1), so
|
||||
# the break-even hit probability is 1/3 INDEPENDENT of power; our measured
|
||||
# hit rates are 5-27%, far below 1/3, so every point of power above the
|
||||
# minimum costs more than it returns. Below HI the old code was a hard step.
|
||||
#
|
||||
# 2. FINISHING SLOPE (`TR_POWER_FINISH_KILL`): when the ENEMY is low, cap power
|
||||
# at the SMALLEST bullet that still removes its remaining energy. Server
|
||||
# 1.3.1 caps the `bulletDamage` score at the energy ACTUALLY REMOVED, so
|
||||
# overkill is WASTED damage AND ~6x the energy for ZERO extra score. Damage
|
||||
# is `4p` for p<=1 and `6p-2` for p>1, so the minimum power that covers `E`
|
||||
# is `E/4` (clamped to >=0.1) for E<=4, `(E+2)/6` for 4<E<=16, and no cap
|
||||
# above 16 (even p=3.0 removes only 16).
|
||||
#
|
||||
# Measured basis (real shots vs DrussGT, 8-16 runs): hit rate 21.6% at 0-100px,
|
||||
# 27.1% at 100-200, then 19.3% at 200-300, 10.9% at 300-400, 6.8% at 400-600 and
|
||||
# 5.4% at 600-800. Energy math: E[dE] = p(3P-1), so the break-even hit
|
||||
# probability is 1/3 INDEPENDENT of power; at range/low energy the extra speed
|
||||
# and shots of p=1.0 dominate. Damage is 4p (p<=1) / 6p-2 (p>1).
|
||||
# 5.4% at 600-800. Damage is 4p (p<=1) / 6p-2 (p>1).
|
||||
const
|
||||
PowerPolicyEnvVar* = "TR_POWER_POLICY" ## 0 = control arm (uncapped)
|
||||
PowerFarDistEnvVar* = "TR_POWER_FAR_DIST" ## px; beyond this = bad-chances zone
|
||||
PowerLowEnergyEnvVar* = "TR_POWER_LOW_ENERGY" ## self energy below this = conserve
|
||||
PowerFarCapEnvVar* = "TR_POWER_FAR_CAP" ## cap for far / low-energy
|
||||
PowerFarCapEnvVar* = "TR_POWER_FAR_CAP" ## cap for far
|
||||
PowerMidCapEnvVar* = "TR_POWER_MID_CAP" ## cap when close+healthy but not above avg
|
||||
PowerRefEnvVar* = "TR_POWER_REF" ## 0 = gun's own mean; >0 = fixed P_ref
|
||||
PowerEnergyHiEnvVar* = "TR_POWER_ENERGY_HI" ## self energy at/above which = no slope cap
|
||||
PowerEnergyLoEnvVar* = "TR_POWER_ENERGY_LO" ## self energy at/below which = ENERGY_MIN
|
||||
PowerEnergyMinEnvVar* = "TR_POWER_ENERGY_MIN" ## the cap at/below ENERGY_LO
|
||||
PowerEnergyMaxEnvVar* = "TR_POWER_ENERGY_MAX" ## the cap at/above ENERGY_HI (3.0 = uncapped)
|
||||
PowerFinishKillEnvVar* = "TR_POWER_FINISH_KILL" ## 1 = cap to the smallest killing bullet
|
||||
|
||||
let PowerPolicyEnabled* = envBool(PowerPolicyEnvVar, true)
|
||||
let PowerFarDist* = envFloat(PowerFarDistEnvVar, 200.0)
|
||||
let PowerLowEnergy* = envFloat(PowerLowEnergyEnvVar, 50.0)
|
||||
let PowerFarCap* = envFloat(PowerFarCapEnvVar, 1.0)
|
||||
let PowerMidCap* = envFloat(PowerMidCapEnvVar, 2.0)
|
||||
let PowerRefFixed* = envFloat(PowerRefEnvVar, 0.0)
|
||||
let PowerEnergyHi* = envFloat(PowerEnergyHiEnvVar, 80.0)
|
||||
let PowerEnergyLo* = envFloat(PowerEnergyLoEnvVar, 20.0)
|
||||
let PowerEnergyMin* = envFloat(PowerEnergyMinEnvVar, 0.5)
|
||||
let PowerEnergyMax* = envFloat(PowerEnergyMaxEnvVar, 3.0)
|
||||
let PowerFinishKill* = envBool(PowerFinishKillEnvVar, true)
|
||||
|
||||
type
|
||||
PowerReason* = enum
|
||||
prFull ## above-average chances, close, healthy -> full power
|
||||
prFar ## beyond TR_POWER_FAR_DIST -> bad-chances zone
|
||||
prLowEnergy ## self energy below TR_POWER_LOW_ENERGY -> conserve
|
||||
prBelowAvg ## chances not above the gun's own average -> no power 3.0
|
||||
prRam ## ramming: exempt (at contact P->1, so 3.0 is correct)
|
||||
prFull ## no cap binds -> the gun's own preference
|
||||
prFar ## beyond TR_POWER_FAR_DIST -> bad-chances zone
|
||||
prEnergySlope ## OUR energy below TR_POWER_ENERGY_HI -> linear conserve cap
|
||||
prFinishKill ## ENEMY energy low -> smallest bullet that still finishes it
|
||||
prBelowAvg ## chances not above the gun's own average -> no power 3.0
|
||||
prRam ## ramming: exempt (at contact P->1, so 3.0 is correct)
|
||||
|
||||
PowerCap* = object
|
||||
power*: float ## the power to fire (<= the gun's preference)
|
||||
@@ -840,11 +866,52 @@ type
|
||||
|
||||
proc powerReasonName*(r: PowerReason): string =
|
||||
case r
|
||||
of prFull: "full"
|
||||
of prFar: "far"
|
||||
of prLowEnergy: "lowEnergy"
|
||||
of prBelowAvg: "belowAvg"
|
||||
of prRam: "ram"
|
||||
of prFull: "full"
|
||||
of prFar: "far"
|
||||
of prEnergySlope: "energySlope"
|
||||
of prFinishKill: "finishKill"
|
||||
of prBelowAvg: "belowAvg"
|
||||
of prRam: "ram"
|
||||
|
||||
proc bulletDamageAtPower*(power: float): float =
|
||||
## Energy the server removes for a bullet of `power`: `4p` for `p <= 1` and
|
||||
## `6p - 2` for `p > 1` (and 0 for `p <= 0`), mirroring the server's
|
||||
## `rules/math.kt calcBulletDamage`. Kept local so `gun_harness` has no
|
||||
## dependency on the movement modules (which define the same `bulletDamage`).
|
||||
if power <= 0.0: return 0.0
|
||||
var p = power
|
||||
if p < 0.1: p = 0.1
|
||||
elif p > 3.0: p = 3.0
|
||||
result = 4.0 * p
|
||||
if p > 1.0: result += 2.0 * (p - 1.0)
|
||||
|
||||
proc powerToKill*(enemyEnergy: float): float =
|
||||
## The SMALLEST power whose bullet damage covers `enemyEnergy`, i.e. the
|
||||
## inverse of `bulletDamageAtPower`:
|
||||
## E <= 4 -> p = E/4 (clamped to >= 0.1)
|
||||
## E > 4 -> p = (E+2)/6
|
||||
## E > 16 -> 3.0 (no cap: even p=3.0 removes only 16, so nothing smaller
|
||||
## helps and the normal rules decide)
|
||||
## Exactly covers E in the first two branches (`4p = E` and `6p-2 = E`); the
|
||||
## clamp makes it cover E <= 0.4 too. PURE, exposed for testing.
|
||||
if enemyEnergy > 16.0: return 3.0
|
||||
if enemyEnergy <= 4.0: return max(0.1, enemyEnergy / 4.0)
|
||||
(enemyEnergy + 2.0) / 6.0
|
||||
|
||||
proc energySlopeCap*(selfEnergy: float,
|
||||
hi = PowerEnergyHi,
|
||||
lo = PowerEnergyLo,
|
||||
capMin = PowerEnergyMin,
|
||||
capMax = PowerEnergyMax): float =
|
||||
## Linear power cap vs OUR energy: `capMax` at/above `hi`, `capMin` at/below
|
||||
## `lo`, linear in POWER in between, clamped to `[capMin, capMax]`. The
|
||||
## shipped defaults (3.0 at 80, 0.5 at 20) mean "no cap above 80, half-power at
|
||||
## or below 20". `capMax` is the value at/above HI; at its default 3.0 that is
|
||||
## exactly "no cap from this rule". PURE, exposed for testing.
|
||||
if selfEnergy >= hi: return capMax
|
||||
if selfEnergy <= lo: return capMin
|
||||
let t = (selfEnergy - lo) / (hi - lo)
|
||||
clamp(capMin + t * (capMax - capMin), capMin, capMax)
|
||||
|
||||
proc binIndexForPower*(power: float): int =
|
||||
## Index of `power` in `PowerBins`; if it is not an exact bin value, the
|
||||
@@ -857,38 +924,51 @@ proc binIndexForPower*(power: float): int =
|
||||
|
||||
proc applyPowerPolicy*(preferredPower, dist, selfEnergy, pEst, pRef: float,
|
||||
ramming: bool,
|
||||
enemyEnergy = 100.0,
|
||||
enabled = PowerPolicyEnabled,
|
||||
farDist = PowerFarDist,
|
||||
lowEnergy = PowerLowEnergy,
|
||||
energyHi = PowerEnergyHi,
|
||||
energyLo = PowerEnergyLo,
|
||||
energyMin = PowerEnergyMin,
|
||||
energyMax = PowerEnergyMax,
|
||||
finishKill = PowerFinishKill,
|
||||
farCap = PowerFarCap,
|
||||
midCap = PowerMidCap): PowerCap =
|
||||
## PURE cap core — no tracker, no battle. `power = min(preferredPower, cap)`,
|
||||
## so the result can only ever LOWER the gun's own preference. Order of
|
||||
## precedence: ram (exempt) > far > low energy > below average > full.
|
||||
## so the result can only ever LOWER the gun's own preference. Each rule is an
|
||||
## additional cap; the applied cap is the MINIMUM of all of them, and `reason`
|
||||
## names the rule that set it (ties resolved by the order below, which is also
|
||||
## the precedence order): ram (exempt) > far > energy slope > below average >
|
||||
## finishing. `prFull` means nothing capped.
|
||||
##
|
||||
## `pEst` is the gun's rate for the bin it chose (or its aggregate when that
|
||||
## bin is empty); `pRef` is the gun's aggregate mean (or the fixed
|
||||
## `TR_POWER_REF`). A cold gun has no data, so `pEst <= pRef` is vacuously
|
||||
## true and it gets the mid cap — deliberately conservative until it has
|
||||
## evidence its chances are above average.
|
||||
##
|
||||
## `enemyEnergy` drives the finishing rule; its default (100) means "no
|
||||
## finishing cap", so every pre-existing caller is unchanged. The finishing
|
||||
## rule is skipped for `enemyEnergy <= 0` (a dead/unknown target), so a zero
|
||||
## energy reading cannot collapse power to 0.1.
|
||||
if ramming:
|
||||
return PowerCap(power: preferredPower, cap: 3.0, reason: prRam)
|
||||
if not enabled:
|
||||
return PowerCap(power: preferredPower, cap: 3.0, reason: prFull)
|
||||
var cap: float
|
||||
var reason: PowerReason
|
||||
if dist > farDist:
|
||||
cap = farCap
|
||||
reason = prFar
|
||||
elif selfEnergy < lowEnergy:
|
||||
cap = farCap
|
||||
reason = prLowEnergy
|
||||
elif pEst <= pRef:
|
||||
cap = midCap
|
||||
reason = prBelowAvg
|
||||
else:
|
||||
cap = 3.0
|
||||
reason = prFull
|
||||
var cap = 3.0
|
||||
var reason = prFull
|
||||
template noteCap(c: float, r: PowerReason) =
|
||||
## Adopt `c` as the cap only when it is STRICTLY smaller, so ties keep the
|
||||
## higher-precedence rule's reason (the order of the calls below).
|
||||
if c < cap:
|
||||
cap = c
|
||||
reason = r
|
||||
if dist > farDist: noteCap(farCap, prFar)
|
||||
noteCap(energySlopeCap(selfEnergy, energyHi, energyLo, energyMin, energyMax),
|
||||
prEnergySlope)
|
||||
if pEst <= pRef: noteCap(midCap, prBelowAvg)
|
||||
if finishKill and enemyEnergy > 0.0:
|
||||
noteCap(powerToKill(enemyEnergy), prFinishKill)
|
||||
PowerCap(power: min(preferredPower, cap), cap: cap, reason: reason)
|
||||
|
||||
proc chooseFromFit*(fit: seq[GunFitness], diag: ptr SelectorDiag = nil,
|
||||
|
||||
Reference in New Issue
Block a user