j127: forced-share allocator (TR_RACK_SHARE, default-off) + allocation batch pre-registration

This commit is contained in:
2026-09-26 10:13:22 +02:00
parent 2e336452e6
commit 6fd5fe6328
5 changed files with 255 additions and 3 deletions
+103 -2
View File
@@ -135,6 +135,105 @@ proc rackActive*(membership: openArray[RackMembership], mode: RackMode): string
result.add RackGunNames[i]
if result.len == 0: result = "FULL"
# ── forced-share allocator (TR_RACK_SHARE) ───────────────────────────────────
#
# The shipped selector RANKS guns and lets the ranking (plus hysteresis) decide
# each gun's share. `GUN_SELECTOR_FLOOR` is a FITNESS floor, so nothing
# guarantees the second gun ANY share of the shots — the ~66/34 split observed
# with a 2-gun rack is an OUTCOME, not a policy. `TR_RACK_SHARE` makes the share
# a POLICY: when set, the live selection is a deterministic deficit-round-robin
# over the named, ADMITTED guns instead of `chooseFromFit`'s ranking.
#
# TR_RACK_SHARE=pattern:50,bitbrain:50
# TR_RACK_SHARE=pattern:0.7,bitbrain:0.3
#
# Values are RELATIVE weights (fractions or percentages — only the ratio
# matters) and names are case-insensitive `RackGunNames` (the `GunNames` rack
# order). The schedule holds each allocated gun for the selector dwell window
# (`GUN_SELECTOR_DWELL`) so the turret converges between switches, exactly like
# the shipped hysteresis; the share is therefore over dwell EPOCHS, and selected
# ticks follow the weight ratio (the `gun_stats.jsonl` `selected` counts are the
# liveness proof). OFF by default: an unset variable leaves the weights empty
# and `selectGun` takes the unchanged ranking path byte-for-byte.
const RackShareEnvVar* = "TR_RACK_SHARE"
type
RackShare* = object
active*: bool
weights*: seq[float] ## indexed by gun id; 0.0 = not in the schedule
named*: seq[string] ## gun names in declared order (audit only)
proc parseRackShare*(value: string,
membership: openArray[RackMembership]): RackShare =
## Parse `TR_RACK_SHARE`. Empty / malformed input is never fatal: it warns on
## stderr and returns an INACTIVE share (empty weights), so a typo can only
## fall back to the shipped selector, never take the bot down. A named gun
## that the rack removes (`TR_RACK_<GUN>=off`) is a loud ERROR and also leaves
## the share inactive — a forced share must only allocate among ADMITTED guns.
let v = value.strip()
if v.len == 0: return
var weights = newSeq[float](len(RackGunNames))
var named: seq[string]
var total = 0.0
for part in v.split(','):
let p = part.strip()
if p.len == 0: continue
let ci = p.find(':')
if ci <= 0 or ci == p.high:
stderr.writeLine("[gun_harness] ERROR: bad " & RackShareEnvVar &
" entry '" & p & "' (want GUN:weight); share disabled")
return
let gname = p[0..<ci].strip()
var wstr = p[ci+1..^1].strip()
if wstr.endsWith("%"): wstr = wstr[0..^2].strip()
var w: float
try: w = parseFloat(wstr)
except ValueError:
stderr.writeLine("[gun_harness] ERROR: bad " & RackShareEnvVar &
" weight '" & wstr & "' for '" & gname &
"'; share disabled")
return
if w < 0.0 or w != w:
stderr.writeLine("[gun_harness] ERROR: " & RackShareEnvVar &
" weight for '" & gname & "' must be >= 0; share disabled")
return
var gid = -1
for i in 0..<len(RackGunNames):
if RackGunNames[i].toLowerAscii() == gname.toLowerAscii():
gid = i
break
if gid < 0:
stderr.writeLine("[gun_harness] ERROR: unknown gun '" & gname & "' in " &
RackShareEnvVar & " (valid: " &
RackGunNames.join("|") & "); share disabled")
return
if gid < membership.len and membership[gid] == rmOff:
stderr.writeLine("[gun_harness] ERROR: " & RackShareEnvVar & " names '" &
RackGunNames[gid] & "' but TR_RACK_" & RackGunNames[gid] &
"=off — a forced share allocates only among ADMITTED " &
"guns; share disabled")
return
weights[gid] += w
named.add RackGunNames[gid]
total += w
if total <= 0.0:
stderr.writeLine("[gun_harness] ERROR: " & RackShareEnvVar &
" weights sum to <= 0; share disabled")
return
for i in 0..<weights.len: weights[i] /= total
result = RackShare(active: true, weights: weights, named: named)
var parts: seq[string]
for i in 0..<weights.len:
if weights[i] > 0.0: parts.add RackGunNames[i] & "=" & $weights[i]
stderr.writeLine("[gun_harness] " & RackShareEnvVar & " active: " &
parts.join(" ") & " (deficit round-robin over dwell epochs)")
let ActiveRackShare* = parseRackShare(getEnv(RackShareEnvVar, ""),
ActiveRackMembership)
## Process-wide forced-share schedule, frozen at startup. Empty weights when
## `TR_RACK_SHARE` is unset or malformed.
const
## ── Range-aware firing gate ────────────────────────────────────────────────
## A real shot departs with whatever misalignment the gun had at fire time,
@@ -200,7 +299,8 @@ proc selectShotPolicy*(t: var VirtualTracker, targetId = -1, tick = 0,
enemyEnergy = 100.0,
ramming = false,
rackMode: RackMode = rm1v1,
membership: openArray[RackMembership] = []
membership: openArray[RackMembership] = [],
share: seq[float] = ActiveRackShare.weights
): (GunId, int, float, PowerCap) =
## `selectShot` plus the energy-aware power-policy decision, so a caller can
## log the cap and its reason (see `applyPowerPolicy` in virtual_bullets).
@@ -215,7 +315,8 @@ proc selectShotPolicy*(t: var VirtualTracker, targetId = -1, tick = 0,
## is the process-wide `TR_RACK_*` table, passed by the live bot. An empty
## membership admits every gun (the pre-change behaviour).
let gunId = t.selectGun(targetId, tick,
rackMode = rackMode, membership = membership)
rackMode = rackMode, membership = membership,
share = share)
let (prefBin, preferred) = t.bestPower(gunId, targetId)
# pEst / pRef mirror `bestPower`'s own fitness source (per-target when data
# exists, else the deterministic aggregate). An empty bin carries no rate of
+55 -1
View File
@@ -402,6 +402,11 @@ type
# reads/writes these; `bestGun`/`chooseFromFit` stay memoryless.
currentGun*: GunId
currentSince*: int
# Forced-share allocator state (`TR_RACK_SHARE`, see `selectSharedGun`).
# `shareDeficit` accumulates one round of weights per allocation EPOCH; the
# chosen gun has 1.0 subtracted, so the long-run allocation follows the
# weights exactly. Empty/unused when the share is inactive.
shareDeficit*: seq[float]
proc initTracker*(numGuns: int, metric = ActiveMetric): VirtualTracker =
## `metric` defaults to the process-wide `GUN_VBULLET_METRIC` switch; pass it
@@ -1175,10 +1180,52 @@ proc bestGun*(t: VirtualTracker, targetId: int = -1,
referenceRate = t.peakRateRef,
rackMode = rackMode, membership = membership)
proc selectSharedGun*(t: var VirtualTracker, share: openArray[float],
tick: int, rackMode: RackMode,
membership: openArray[RackMembership]): GunId =
## Forced-share allocation (`TR_RACK_SHARE`). Deterministic deficit
## round-robin over the named guns that the CURRENT rack admits; returns -1
## when no named gun is admitted (caller falls back to the normal ranking).
##
## The schedule advances once per allocation EPOCH, not once per tick: the
## incumbent is held for `ActiveDwellTicks`, so the turret converges on the
## allocated gun before the next allocation — the same anti-chatter reason the
## shipped hysteresis exists. Selected-tick counts therefore follow the weight
## ratio, which is what `gun_stats.jsonl` reports as `selected`.
var guns: seq[GunId]
var total = 0.0
for g in 0..<share.len:
if share[g] > 0.0 and rackAdmitted(g, rackMode, membership):
guns.add g
total += share[g]
if guns.len == 0 or total <= 0.0: return -1
# Hold the incumbent through its dwell window (it is already one of the
# admitted share guns). This is what keeps aim convergence; the deficit below
# is NOT advanced on a held tick.
if t.currentGun in guns and (tick - t.currentSince) < ActiveDwellTicks:
return t.currentGun
if t.shareDeficit.len != share.len:
t.shareDeficit.setLen(share.len)
for g in guns:
t.shareDeficit[g] += share[g] / total
var pick = guns[0]
var best = t.shareDeficit[pick]
for g in guns:
if t.shareDeficit[g] > best:
best = t.shareDeficit[g]
pick = g
t.shareDeficit[pick] -= 1.0
t.currentGun = pick
t.currentSince = tick
result = pick
proc selectGun*(t: var VirtualTracker, targetId: int = -1, tick = 0,
diag: ptr SelectorDiag = nil,
rackMode: RackMode = rm1v1,
membership: openArray[RackMembership] = []): GunId =
membership: openArray[RackMembership] = [],
share: seq[float] = @[]): GunId =
## Stateful, sticky gun selection — the LIVE path (`selector.selectShot` calls
## this). Wraps the pure `chooseFromFit` ranking with two commitments:
##
@@ -1198,6 +1245,13 @@ proc selectGun*(t: var VirtualTracker, targetId: int = -1, tick = 0,
## `peakRateRef` history) and is threaded through the whole live loop; the bot
## does not need to know about it. `bestGun`/`chooseFromFit` stay pure for the
## offline tools.
##
## `share` (non-empty) is the `TR_RACK_SHARE` forced allocation: it bypasses
## `chooseFromFit` entirely (see `selectSharedGun`). Empty is the shipped
## ranking path, byte-for-byte.
if share.len > 0:
let shared = t.selectSharedGun(share, tick, rackMode, membership)
if shared >= 0: return shared
let fit = t.fitnessFor(targetId)
var local: SelectorDiag
let d = if diag != nil: diag else: addr local