From 6fd5fe632893c125c629d5f39f100b91c749330d Mon Sep 17 00:00:00 2001 From: Davide Cappellini Date: Sat, 26 Sep 2026 10:13:22 +0200 Subject: [PATCH] j127: forced-share allocator (TR_RACK_SHARE, default-off) + allocation batch pre-registration --- ModularBot_garage/src/env_report.nim | 1 + common_libs/gun_harness/selector.nim | 105 +++++++++++++++++++- common_libs/gun_harness/virtual_bullets.nim | 56 ++++++++++- docs/gun_campaign.md | 54 ++++++++++ tools/ab/arms_alloc.txt | 42 ++++++++ 5 files changed, 255 insertions(+), 3 deletions(-) create mode 100644 tools/ab/arms_alloc.txt diff --git a/ModularBot_garage/src/env_report.nim b/ModularBot_garage/src/env_report.nim index 96cbd8a..dfd7d26 100644 --- a/ModularBot_garage/src/env_report.nim +++ b/ModularBot_garage/src/env_report.nim @@ -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, diff --git a/common_libs/gun_harness/selector.nim b/common_libs/gun_harness/selector.nim index 57da816..6fb753a 100644 --- a/common_libs/gun_harness/selector.nim +++ b/common_libs/gun_harness/selector.nim @@ -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_=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..= 0; share disabled") + return + var gid = -1 + for i in 0.. 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 diff --git a/common_libs/gun_harness/virtual_bullets.nim b/common_libs/gun_harness/virtual_bullets.nim index efdd691..afbbb81 100644 --- a/common_libs/gun_harness/virtual_bullets.nim +++ b/common_libs/gun_harness/virtual_bullets.nim @@ -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.. 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 diff --git a/docs/gun_campaign.md b/docs/gun_campaign.md index e7b0b32..32f3dce 100644 --- a/docs/gun_campaign.md +++ b/docs/gun_campaign.md @@ -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)_ diff --git a/tools/ab/arms_alloc.txt b/tools/ab/arms_alloc.txt new file mode 100644 index 0000000..9f11655 --- /dev/null +++ b/tools/ab/arms_alloc.txt @@ -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 +# /.work///run/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