j127: forced-share allocator (TR_RACK_SHARE, default-off) + allocation batch pre-registration
This commit is contained in:
@@ -439,6 +439,7 @@ proc knownEnvNames*(): seq[string] =
|
||||
result = @[
|
||||
# exported constants — the single source of truth (do not re-spell these)
|
||||
MetricEnvVar, SelectorModeEnvVar, TieBreakEnvVar,
|
||||
RackShareEnvVar,
|
||||
PowerPolicyEnvVar, PowerFarDistEnvVar, PowerFarCapEnvVar, PowerMidCapEnvVar,
|
||||
PowerRefEnvVar, PowerEnergyHiEnvVar, PowerEnergyLoEnvVar,
|
||||
PowerEnergyMinEnvVar, PowerEnergyMaxEnvVar, PowerFinishKillEnvVar,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -977,3 +977,57 @@ The phase-1 conclusion stands and is now joined by the phase-2 one:
|
||||
| `/tmp/ab/j123_b1` | `2a98aba` | 270 (0 failed, 0 never started, 0 excluded) | pattern, len6, len16, depth100, rad_offset, rad_scale | `len6` the only rule-2 win (wins +0.49, sign p=0.039); **red flag: all 5 arms wins-positive incl. the bearing-invariant `rad_offset`** |
|
||||
| `/tmp/ab/j123_b2` | `1b59b65` | 396 (0 failed, 0 never started, 0 excluded) | pattern, len6, len16, rad_offset | **`len6` does not replicate** (−0.04 wins, p=1; +1.8 dmg, p=0.30); all arms not distinguishable; the incumbent is already tuned |
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Allocation: is the selector leaving value on the table?
|
||||
|
||||
**Question (owner-spotted, 2026-09-26).** The selector's own `/tmp/gun_stats.jsonl`
|
||||
(10,608 rounds, 2-gun racks, selector ON) gives Pattern ~66% of selection ticks and
|
||||
that *looks* justified by per-shot hit rate. But `GUN_SELECTOR_FLOOR` is a FITNESS
|
||||
floor (only ignores a gun below 25% of the best), **not a SHARE floor**: nothing
|
||||
guarantees the second gun any share of the shots. The ~66/34 split is an OUTCOME of
|
||||
ranking + hysteresis, not a POLICY. **Nobody has ever tested whether a deliberately
|
||||
different split beats `pattern` alone.** This batch builds the mechanism
|
||||
(`TR_RACK_SHARE`, default-off) and asks directly:
|
||||
|
||||
> **Is gun ALLOCATION a lever?**
|
||||
|
||||
### Pre-registration (written BEFORE the batch)
|
||||
|
||||
* **Mechanism.** `TR_RACK_SHARE=pattern:50,bitbrain:50` (relative weights,
|
||||
case-insensitive `RackGunNames`) replaces `chooseFromFit`'s ranking with a
|
||||
deterministic **deficit round-robin** over the named **ADMITTED** guns. Each
|
||||
allocation is held for `GUN_SELECTOR_DWELL` ticks (turret convergence) so the
|
||||
share is over dwell epochs; selected-tick counts follow the weights.
|
||||
Deterministic (not randomized) because the question is whether a *specified*
|
||||
split is better, the schedule is auditable, and it needs no seed.
|
||||
* **Default-OFF parity.** Unset `TR_RACK_SHARE` leaves the weights empty and
|
||||
`selectGun` takes the unchanged ranking path. Parity proven before fighting:
|
||||
`test_gun_harness 39`, `test_rack_membership 48`, `test_selector_tiebreak 19`,
|
||||
`test_env_report 25` — all green at their exact shipped counts.
|
||||
* **Panel.** `tools/ab/panel_movement.txt` — the frozen 15-opponent movement panel
|
||||
(never edited). Unit of evidence = number of opponents.
|
||||
* **Arms** (`tools/ab/arms_alloc.txt`, 6 arms): `pattern` (reference, shipped);
|
||||
`sel_pb` (selector ON, Pattern+BitBrain, shipped policy = the ~66/34 OUTCOME);
|
||||
`share_5050_pb`; `share_7030_pb`; `share_5050_pt` (TMHorizon); `share_5050_pk`
|
||||
(KNN). Movement pinned `TR_MOVEMENT=strafe` in every arm.
|
||||
* **Judge on damage/run and ROUND WINS** (standing rule); hit rate and distance
|
||||
are explanation. Per-opponent deltas vs the reference; CI, sign test, sign-flip
|
||||
permutation, MDE.
|
||||
* **Decision rule (pre-registered).**
|
||||
1. If a **forced-share arm beats `pattern`** (CI excluding 0 **and** sign-flip
|
||||
p<0.05) → **ALLOCATION IS A LEVER**; the selector was leaving value on the
|
||||
table and the next job tunes the share.
|
||||
2. If **no forced-share arm beats `pattern`** → the ~66/34 allocation was
|
||||
already right; the rack question is genuinely CLOSED.
|
||||
3. Either way, report whether the forced arms differ from `sel_pb` — that
|
||||
isolates `policy` from `gun choice`.
|
||||
* **Allocation evidence.** Per arm, the **applied share** (from each run's
|
||||
`gun_stats.jsonl` `selected` counts) and **each gun's per-shot hit rate**
|
||||
(realShots/realHits, same battles). Per-shot hit rate is the right signal HERE
|
||||
because the movement is identical inside a battle.
|
||||
|
||||
### RESULTS
|
||||
|
||||
_(filled in after the batch)_
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# arms_alloc.txt — ALLOCATION BATCH: is the selector's ~66/34 share already
|
||||
# right, or is ALLOCATION a lever?
|
||||
#
|
||||
# Format: name | ENV=value ENV=value | label
|
||||
#
|
||||
# ONE frozen binary (tournament_run.sh builds it from `git archive HEAD`); every
|
||||
# arm below differs ONLY by its env dict. No per-arm rebuild.
|
||||
#
|
||||
# MOVEMENT IS PINNED IN EVERY ARM (`TR_MOVEMENT=strafe`) — movement-default and
|
||||
# concurrent-job hygiene, same as every other gun batch.
|
||||
#
|
||||
# GUN_STATS_PATH is set to the SAME RELATIVE name in every arm: the runner's
|
||||
# generated bot wrapper `cd`s into a PER-RUN bot dir, so each run writes its own
|
||||
# `gun_stats_alloc.jsonl` under
|
||||
# <outdir>/.work/<opponent>/<arm>/run<N>/bots/ModularBot/
|
||||
# That is the per-run isolation that makes the applied share and the per-gun
|
||||
# per-shot hit rates auditable per arm.
|
||||
#
|
||||
# Reference = `pattern` alone (the shipped rack). `sel_pb` is the shipped
|
||||
# selector with Pattern+BitBrain admitted, i.e. the ~66/34 OUTCOME arm; the
|
||||
# `share_*` arms FORCE a share with `TR_RACK_SHARE` (deficit round-robin over
|
||||
# dwell epochs) so `policy` is isolated from `gun choice`.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# 1. REFERENCE. The shipped `onlyPattern` rack, movement pinned.
|
||||
pattern | TR_MOVEMENT=strafe GUN_STATS_PATH=gun_stats_alloc.jsonl | shipped onlyPattern rack (REFERENCE)
|
||||
|
||||
# 2. Shipped selector, Pattern+BitBrain rack. The ~66/34 split is an OUTCOME.
|
||||
sel_pb | TR_MOVEMENT=strafe TR_RACK_BITBRAIN=both GUN_STATS_PATH=gun_stats_alloc.jsonl | selector ON, Pattern+BitBrain (shipped policy)
|
||||
|
||||
# 3. FORCED 50/50 with BitBrain.
|
||||
share_5050_pb | TR_MOVEMENT=strafe TR_RACK_BITBRAIN=both TR_RACK_SHARE=pattern:50,bitbrain:50 GUN_STATS_PATH=gun_stats_alloc.jsonl | forced 50/50 Pattern+BitBrain
|
||||
|
||||
# 4. FORCED 70/30 with BitBrain (close to the observed 66/34).
|
||||
share_7030_pb | TR_MOVEMENT=strafe TR_RACK_BITBRAIN=both TR_RACK_SHARE=pattern:70,bitbrain:30 GUN_STATS_PATH=gun_stats_alloc.jsonl | forced 70/30 Pattern+BitBrain
|
||||
|
||||
# 5. FORCED 50/50 with TMHorizon (closest competitor by measured per-shot rate).
|
||||
share_5050_pt | TR_MOVEMENT=strafe TR_RACK_TMHORIZON=both TR_RACK_SHARE=pattern:50,tmhorizon:50 GUN_STATS_PATH=gun_stats_alloc.jsonl | forced 50/50 Pattern+TMHorizon
|
||||
|
||||
# 6. FORCED 50/50 with KNN.
|
||||
share_5050_pk | TR_MOVEMENT=strafe TR_RACK_KNN=both TR_RACK_SHARE=pattern:50,knn:50 GUN_STATS_PATH=gun_stats_alloc.jsonl | forced 50/50 Pattern+KNN
|
||||
Reference in New Issue
Block a user