Commit Graph

24 Commits

Author SHA1 Message Date
SirStone 3142b70aa5 Gate virtual-bullet spawn on rack admission: +68% tick rate, selected gun unchanged
The default rack is now Pattern-only (31c7c01), but membership filters SELECTION,
not SPAWNING - so all 13 unselected guns still ran `predict` + `spawnBullets`
every tick to feed fitness tables nobody reads. Measured waste: Tsetlin alone
0.98 ms/tick, KNN 0.30, plus 10 more. This generalises the gate TMPATTERN already
had to every gun, behind `TR_VBULLET_ADMIT_ONLY` (default 1 = gate, 0 = old).

OFFLINE COST (release build, 400 ticks, min of 2 reps, 13-gun rack):
  OFF  2.14 ms/tick  (implied 467 ticks/s)
  ON   0.04 ms/tick  (implied 27667 ticks/s)
  -> reclaimed 2.10 ms/tick, ~98% of the virtual-bullet cost. Tsetlin's 0.98
     disappears, KNN's 0.30 disappears, only Pattern (0.017) survives.
Note the absolute scale is lower than an earlier unoptimised measurement (~6.1
ms/t) because this is a -d:release build; the ON-vs-OFF DELTA is the robust result.

LIVE TICK RATE (ONE frozen binary, 4 runs/arm x 4 rounds, all 8 concurrent, gate
varied by env only):
  ON   146.2 ticks/s  (142.3, 145.2, 147.9, 149.4)
  OFF   86.9 ticks/s  ( 95.0,  41.4,  99.1, 112.1)
  NO OVERLAP: ON min 142.3 > OFF max 112.1. Excluding a game-outcome outlier in
  the OFF arm, OFF max is still 112.1. Outside the noise.
**+68% tick rate**, which also makes every future A/B faster. Both arms still pay
the fixed 8192-slot ring scan in tickBullets.

SAFETY, verified not assumed: Pattern is admitted under the shipped rack, so its
own fitness keeps accumulating and the SELECTED gun is unchanged - Pattern 100% in
all 4 runs both arms, and Pattern was the ONLY gun with vShots>0 in the ON arm
while all 13 had vShots>0 in the OFF arm. So admission is the correct predicate.
Mid-round transitions are safe by construction: only predict/spawn are gated, while
tickBullets still resolves every active bullet and the feedback case still calls
the owning gun's onResult.

A TOOLING BUG THIS CAUGHT, and a correction to the task's assumption:
`acceptance_offline_vs_online.nim` IS affected (I had assumed it was not). It
compares the live per-gun vShots against an offline replay that always spawns all
guns, so under the default gate the online non-Pattern vShots are 0 while offline
is ~400 - a guaranteed mismatch. Fixed by pinning `TR_VBULLET_ADMIT_ONLY=0` inside
that test (same putEnv/defer pattern as TR_RECORD_WORLDSTATE), keeping 12/12. The
test is about offline/online METRIC parity, so it needs every gun spawning.
Unaffected (verified from source): audit_virtual_guns.nim,
measure_cornering_guns.nim, sweep_tm_pattern.nim - all offline, none read
GUN_STATS_PATH.

Guards: test_vbullet_admit_gate 12 (new, pure), test_gun_harness 39,
test_vbullet_metric 11, test_power_selection 3, test_adaptive_radar 41,
test_tfil_ring_weights 24, test_power_policy 26, test_ram_decision 28,
test_rack_membership 48, test_selector_tiebreak 19, test_tm_pattern_registration 20,
test_tm_pattern_learning 3, acceptance_offline_vs_online 12/12. ModularBot compiles.
2026-09-22 02:24:18 +02:00
SirStone 31c7c01d28 SHIPPED: the default rack is now Pattern-only (+49% hit rate, +66% damage on the boss)
`DefaultRackMembership` now admits Pattern (id 5) and marks all 14 other guns
`rmOff`. **The selector mechanism is untouched** - `chooseFromFit`, the floor/band
logic, the hysteresis and the virtual-fitness plumbing are all intact and
functional. Only the rack membership changed, so this is reverted by env alone.

Evidence (measured, replicated three times, 10 adversaries): Pattern alone gives
10.36% real hit rate / 264 damage per run vs the full rack's 6.93% / 159. Pattern
significantly wins on DrussGT, Corners, Crazy and PatternMover, ties on three, and
the full rack never significantly beats it on ANY adversary. Mechanism: the
virtual signal keeps ranking the wrong guns first (HeadOn 46% of ticks at 2.0%
real; Linear 57.7% at 6.0% real while Pattern sits at 11.1%).

**THIS CONTRADICTS THE USER'S STANDING DIRECTIVE** to keep virtual-fitness
selection. Recorded plainly in docs/selector_negative_value.md with a SHIPPED
DECISION banner rather than done quietly: the mechanism is retained and one env
var away, because the measurement says it is negative value on every rack size
tested and on 10/10 adversaries.

Revert one-liner (no rebuild):
  TR_RACK_PATTERN=both TR_RACK_HEADON=both TR_RACK_LINEAR=both TR_RACK_TSETLIN=both \
  TR_RACK_CIRCULAR=both TR_RACK_GUESSFACTOR=both TR_RACK_WALLBOUNCE=both \
  TR_RACK_ACCEL=both TR_RACK_STOPSHOT=both TR_RACK_DISPLACE=both TR_RACK_AVGLEAD=both \
  TR_RACK_DECAYGF=both TR_RACK_KNN=both TR_RACK_TMSELECT=both ./out/ModularBot
The unit test `testRevertOverrideRestoresFullRack` exercises exactly this table.

FLOOR PATH, verified not assumed: `chooseFromFit` already returns `admitted[0]` on
the floor path, so it respects admission by construction. Cold field + shipped
default -> floor returns Pattern (id 5), NOT HeadOn. With an explicit all-`both`
membership the same cold field returns gun 0 (HeadOn) - the old behaviour. Four
assertions in `testFloorRespectsAdmission`.

LIVENESS: one 1-round battle with NO overrides -> Pattern selected 105/105 = 100%,
every other gun 0 including TMPattern.

Honesty caveat retained in the doc: 4 of the 10 opponents were Tank Royale
sample-bot PORTS rather than the original classic jars (only DrussGT is a real
classic jar through the shim).

Guards: test_rack_membership 48 (was 38; new floor/revert/default checks),
test_tm_pattern_registration 20 (5 checks hard-coded the old default and were
updated to assert the new one, with the TMPATTERN parity proof moved onto an
explicit old-rack table), test_gun_harness 39, test_vbullet_metric 11,
test_power_selection 3, test_adaptive_radar 41, test_tfil_ring_weights 24,
test_power_policy 26, test_ram_decision 28, test_selector_tiebreak 19,
test_tm_pattern_rack_live 4, test_tm_pattern_learning 3,
acceptance_offline_vs_online 12/12 VERDICT PASS. ModularBot compiles.

FOLLOW-ON THIS EXPOSED: membership filters SELECTION but not virtual-bullet
SPAWNING, so under `onlyPattern` the 13 unselected guns still predict and spawn
every tick. Tsetlin alone is ~5.3 ms/tick (~41% of the 13.16 ms per-tick budget),
so we are still paying for it while never using it. Gating spawn on admission
would reclaim that; it was deliberately NOT done here because it would alter the
measurement protocol mid-A/B.
2026-09-22 02:10:07 +02:00
SirStone 589a230106 TM radial gun: registered (default OFF) + label-bias fix that removes the bias but
retracts its own earlier learning claim

=== TASK 1: REGISTERED AS GUN 14, DEFAULT `off` ===
The radial TM gun is now a first-class rack member (`TMPATTERN`, id 14), forceable
alone with `TR_RACK_TMPATTERN=both` plus every other `TR_RACK_*=off`.
DEFAULT IS `off`, and the justification matters: `both` would let it compete for
selection AND (because the shared VirtualTracker ring is order-sensitive) shift
every other gun's learning order, so it CANNOT leave the default path unchanged.
With `off` its predict and spawnBullets are additionally GATED on rack admission
(the only gun wired that way), so the shipped default never spawns it at all:
zero cost, zero ring perturbation.
Live proof: 1-round battle with only TMPATTERN racked ->
  `gun 14 (TMPattern): vShots=400 selected=104 other-gun selections=0`.
Default-path-unchanged proof: parity checks that the 15-gun default bestGun/
selectGun equals the old 14-gun rack RNG-draw-for-RNG-draw, that gun 14 is never
selected by default, and acceptance 12/12.
Cost: 0.36 ms/tick (predict 0.30 + onResult 0.05) ~= 3% of the 13.16 ms budget.
Tsetlin in the same harness is 1.62 ms/tick, so the new gun is ~4.5x cheaper.

=== TASK 2: THE LABEL-BIAS FIX - AND A RETRACTION ===
Root cause confirmed: under bmPoint a SHORT radial correction resolves the virtual
bullet BEFORE the base arrival tick, so the label was dropped (labelMisses).
Fix: defer the label in a pending queue and flush it once the arrival tick is
recorded; labels still come from the BASE arrival tick.
  labelMisses        4,281,695  ->  0
  training samples   1,071,824  ->  5,345,847  (x5)
  radial head acc         48.8% ->  57.0%   (shuffled control 20.0%)
  bmPoint hit rate     9.4/5.8% ->  9.1/5.7%  (unchanged, within noise)
So the fix IMPROVES LEARNING but NOT the metric.

**RETRACTION OF THE PREVIOUS JOB'S CLAIM.** It reported the radial head's 48.8%
against a 36.7% majority baseline and concluded "conditional learning, not a
constant bias". With the bias removed, the correctly-measured majority baseline is
**58.2%** - so the head at 57.0% is AT/BELOW majority. The earlier apparent
conditional learning was PARTLY AN ARTEFACT OF THE BIASED SAMPLE. The bmPoint
metric win is real (TMRadial > Linear early 16/2 p=0.0013, overall 18/0 p<0.0001;
> shuffled 18/0 p<0.0001) but it comes from a NET-POSITIVE AVERAGE RADIAL SHIFT,
not from beating a majority classifier. Recorded plainly rather than left standing.

Guards: test_tm_pattern_registration 20 (new), test_tm_pattern_rack_live 4 (new),
test_gun_harness 39, test_vbullet_metric 11, test_power_selection 3 (the SIGSEGV is
gone - the knn_gun rewrite is now committed), test_adaptive_radar 41,
test_tfil_ring_weights 24, test_power_policy 26, test_ram_decision 28,
test_rack_membership 38, test_selector_tiebreak 19, test_tm_pattern_learning 3,
acceptance_offline_vs_online 12/12. ModularBot compiles (release).

Note: `common_libs/tests/range_guns.nim` still builds 14 offline drivers (the
offline sweep constructs TmPatternGun directly and acceptance only inspects ids
0..13), so nothing breaks - but a future job wanting it in the offline rack must
add a 15th driver and mirror the live admission gating. gun_stats.jsonl now emits
15 rows; downstream tooling should ignore id 14.
2026-09-22 01:58:33 +02:00
SirStone 0ede6d12ec selector: arrival-accuracy tie-break measured NEGATIVE; randomness is load-bearing
Hypothesis under test (from the gun audit, which named the tie-band as "the
lever that matters most"): `bmPath` is deliberately generous (2.3-3.6x
`bmPoint`), so a gun can sit in the tied band on a ray that sweeps the target's
path while its bullets ARRIVE badly. So: keep the `path`-ranked band (path beat
point on real hit rate 7.43% vs 4.70%, z=5.56), but narrow the random draw
inside it using a parallel `point` (arrival-accuracy) window.

RESULT: NO EFFECT. Real DrussGT, ONE frozen binary (/tmp/ModularBot_tieband,
md5 2c0c56e6...), env knobs only, 7 runs x 7 rounds per arm, server-side events
sidecar, exact two-sided permutation test on per-run rates.

  arm                              runs  shots  real %  dmg/run   d      p
  tbbase (shipped)                    7   4128   7.17     175      --     --
  tbpt  path-rank + point-narrow      7   3938   7.08     165    +0.14  0.88
  tbpc  =commit control               7   3759   4.44      98    +2.74  0.0012
  tbpt25 point margin 0.25            7   3683   5.59     119    +1.65  0.20
  tbtie05 / tbtie40 (band width)      7   3937/3917  5.84/6.28  133/144  1.49/1.00  0.11/0.25
  tbwin50 (SelectorWindow=50)         7   3983   6.05     139    +1.20  0.11
  tbfloor10 (FloorPeakFrac=0.10)      7   3829   5.33     118    +2.12  0.11

tbpt vs base: fully overlapping ranges, p=0.88. This is a REAL null, not a dead
arm - the mechanism was live, and it visibly changed the selected-gun mix
(Pattern 24%->16%, Accel 6%->16%, Tsetlin ~0%->13%).

CONTROL VALIDATED, AND THIS IS THE THIRD TIME: removing the random draw inside
the band is SIGNIFICANTLY WORSE (4.44%, p=0.0012). Combined with the earlier
hysteresis A/B (7.02% -> 5.10% for commitment) and the light-hysteresis result,
the selector's per-tick randomness is now load-bearing on three independent
measurements. Narrowing the band on ANY second virtual statistic has not helped.

Every knob swept (band width, floor, window) is nominally worse than shipped at
n=7; that is "no credible win" rather than "proven harm" (sd ~1.8pp, ~1pp
resolution, underpowered).

Shipped default stays `GUN_SELECTOR_TIEBREAK=off`; the feature is opt-in, fully
guarded, and costs zero extra work on the default path (point windows are scored
only when the mode is on).

Guards: test_selector_tiebreak 19 (new, pure), test_gun_harness 39,
test_vbullet_metric 11, test_adaptive_radar 41, test_tfil_ring_weights 24,
test_power_policy 26, test_ram_decision 28, test_rack_membership 38,
acceptance_offline_vs_online 12/12 PASS (offline path calls neither
chooseFromFit nor the tie-break).

STRATEGIC CONCLUSION: three selection-side attempts have now failed (hysteresis,
commitment, point tie-break). The selector is at a local optimum and the
remaining lever is the QUALITY OF THE GUNS, not the selection among them.
2026-09-22 01:07:12 +02:00
SirStone a73de13458 racks: separate melee and 1v1 gun racks, plus per-mode real hit-rate data
The user's plan: "separate racks for melee and 1v1, so the bot switches from
those based on the situation, and we can put the guns we want in one or both
racks."

MECHANISM
- `RackMode` (rm1v1/rmMelee) derived from SERVER TRUTH: `rackMode(enemyCount)`
  = 1v1 when the count is 1, melee otherwise. This is the SAME `getEnemyCount()`
  value the radar already uses, so there is now ONE definition of the mode.
  (Using the tracker's known-enemy count was a previous bug in the radar: it
  read 1 before the second enemy was scanned.)
- `RackMembership` per gun: both (default) | 1v1 | melee | off.
- The selector ranks only admitted guns - including the floor path and the
  incumbent-hysteresis path.
- Empty filtered set FALLS BACK to the full rack, so the bot can never end up
  with no gun.
- Env-overridable at process start, no rebuild: `TR_RACK_<GUN>` for all 14 guns
  (TR_RACK_HEADON, TR_RACK_LINEAR, ... TR_RACK_TMSELECT), values
  both|1v1|melee|off. Empty/unknown -> both + a stderr warning, never fatal.
- `[rack] mode=<1v1|melee> active=<guns> overrides=<...>` logged once per mode
  change, never per tick.

DEFAULT IS UNCHANGED: every gun ships `rmBoth`, so behaviour is byte-identical
until the user re-racks anything. Verified by the unit test's default-config
selection parity (RNG draw for RNG draw) and by `test_gun_harness` 39 and
acceptance 12/12. `chooseFromFit` iterates the admitted list in ascending id
order, so the random tie-break draws are unchanged.

NO TUNING DONE, deliberately: we had no per-gun melee hit-rate data, and an
earlier 15-paired-run experiment found pruning neutral-to-negative on hit rate
(p=0.57/0.21). So all guns stay `both` and the membership pass waits for data.

PER-MODE DATA PLUMBING (this is what unblocks that pass): per-gun real shot
accounting is now split by the rack in force at fire time, adding to
gun_stats.jsonl: realShots1v1, realHits1v1, realHitRate1v1, realShotsMelee,
realHitsMelee, realHitRateMelee.

Verification: test_rack_membership 38/38 (new, pure, no battle); test_gun_harness
39, test_vbullet_metric 11, test_power_selection 3, test_adaptive_radar 41,
test_tfil_ring_weights 24, test_power_policy 26, test_ram_decision 28;
acceptance_offline_vs_online 12/12 VERDICT PASS; ModularBot compiles. The live
`[rack]` line was observed switching 1v1 -> melee when the enemy died.

The offline range never calls the selector (only spawnBullets/tickBullets/
reportFor), so mode filtering cannot change the offline result and no offline
mode parameter was needed - confirmed by reasoning over the source and by 12/12.
2026-09-22 00:45:10 +02:00
SirStone c9825dfb0b power policy: cap power by range and energy, gate 3.0 on above-average chances
Implements the user's energy management request: "firing from more than 200px
should be a 'not good chances zone' so faster bullets and more chances to hit
matters more than single hit damage with low chances. When we are lower than 50
health, same thing. I would like to use 3.0 power only when the chances of
hitting are higher than average."

Design: a CAP on top of the existing `bestPower`, not a rewrite. `bestPower`
still answers "which bin does this gun's own data prefer"; the policy caps it:

  ramming                                       -> 3.0  (reason ram, exempt)
  dist > TR_POWER_FAR_DIST (200)                -> 1.0  (far)
  elif selfEnergy < TR_POWER_LOW_ENERGY (50)    -> 1.0  (lowEnergy)
  elif pEst <= pRef                             -> 2.0  (belowAvg)
  else                                          -> 3.0  (full)
  power = min(gunPreferredBinPower, cap)   # can only LOWER power

p=1.0 is the right "low" tier on the measured mechanics: bullet speed 20-3p so
p=1.0 gives speed 17 vs 11 at p=3.0 (55% faster = less lead error), fire
interval 10+2p so 12 ticks vs 16 (33% more shots), and drain 0.083/turn vs
0.1875 (2.25x slower). All three things the user asked for at long range.
pEst = the chosen bin's virtual rate (gun aggregate when the bin is empty);
pRef = the gun's aggregate mean unless TR_POWER_REF > 0. No-data guns are
vacuously below-average -> cap 2.0 (conservative, documented).

Control arm: TR_POWER_POLICY=0 = uncapped = today's behaviour exactly.
Knobs: TR_POWER_POLICY, TR_POWER_FAR_DIST, TR_POWER_LOW_ENERGY,
TR_POWER_FAR_CAP, TR_POWER_MID_CAP, TR_POWER_REF, TR_POWER_LOG.
TR_POWER_MID_CAP exists because the user did not specify the middle case
(close + healthy + not-above-average); 2.0 is the default, flippable to 1.0.

Seam: the cap lives in a pure `applyPowerPolicy` and is applied only in
`selectShot` (the single place real shots are chosen), so the logic is testable
without a battle. Ram is wired from `shouldRam` - the same value the movement
dispatch uses for the (0,50) band.

CORRECTION TO AN ASSUMPTION IN THE TASK: `offline_range.nim` does NOT call
`bestPower`/`selectShot` - it only replays virtual-bullet spawn/resolve across
all power bins, independent of the real shot's power. So there is no offline
power-selection path that could diverge from the live one, and the acceptance
test guards the metric, not the policy. Policy coverage therefore comes from the
new unit test.

Verification: test_power_policy 26/26 in BOTH modes (default and TR_POWER_POLICY=0
control arm); test_gun_harness 39, test_vbullet_metric 11, test_power_selection 3,
test_adaptive_radar 41, test_tfil_ring_weights 24; acceptance_offline_vs_online
12/12 VERDICT PASS (live battle). ModularBot compiles.

UNVERIFIED: the live effect on damage/survival/score. No A/B has run.
2026-09-21 23:59:56 +02:00
SirStone 18f778056b gun selector: hysteresis measured NEGATIVE, shipped at the lightest setting
Hypothesis under test: the selector chatters (~54 switches/100 ticks) and that
chatter suppresses firing, so committing to the virtual-best gun should raise
real hit rate. MEASURED AGAINST THE REAL DRUSSGT: it does not.

  setting              switches/100t   real hit %   dmg/run   shots/run
  no hysteresis 0/0         54.29        7.02%        217       244.8
  light 10/0.05              1.95        6.22%        191       235.3
  moderate 30/0.15           1.33        5.10%        156       233.9
  aggressive 60/0.30           -         5.72%        175       242.5
(16 runs x 7 rounds per config except aggressive = 8; server-side events
sidecar; permutation test baseline-vs-moderate p=0.002, baseline-vs-light
p=0.18.)

Hysteresis cuts chatter 28-54x but every variant fires slightly FEWER shots and
deals LESS damage than baseline. Mechanism [INFERRED, consistent with
docs/gun_rack_analysis.md 2/4]: the per-tick random tie-break among the tied
band is a hedge, and hysteresis destroys it by committing to the virtual-best
gun - which is not the real-best, because the virtual metric is a weak,
sign-unstable ranker. The chattering was load-bearing.

Shipped: GunDwellTicks=10, GunSwitchMargin=0.05 (GUN_SELECTOR_DWELL /
GUN_SELECTOR_MARGIN) - the only setting within the baseline's run-to-run spread.
GUN_SELECTOR_DWELL=0 GUN_SELECTOR_MARGIN=0 reproduces the pre-change selector
exactly.

Seam: VirtualTracker, which already owns the other selection state (fitness,
the relative floor's peakRateRef), so the bot needs no new fields. bestGun and
chooseFromFit stay pure/memoryless, which is why the existing random-tiebreak
test needed no change.

Guards: test_gun_harness 39/39 (33 original + 6 new hysteresis checks),
test_vbullet_metric, test_power_selection, acceptance_offline_vs_online 12/12.
2026-09-21 22:41:01 +02:00
SirStone 68e0375be2 feat(radar): adaptive melee radar sweeps only the arc containing all enemies
Replaces melee_scan in the rack. melee_scan spun the radar at the 45 deg/tick
cap unconditionally, so a full 360 deg revolution took 8 ticks and every enemy
was scanned roughly every 8 ticks. The new module starts with the same full
spin, and once it is SURE it has covered every enemy it sweeps back and forth
over only the minimal covering arc of all enemy bearings.

MEASURED, real melees via the bridge, per-enemy onScannedBot counts:
  3-bot melee (2 enemies): 29.6 -> 61.1 scans/100 melee-ticks  (2.06x)
  4-bot melee (3 enemies): 36.0 -> 75.7 scans/100 melee-ticks  (2.10x)
Covering-arc widths observed: mostly <90 deg in the 2-enemy case, up to 240 deg
in the 3-enemy case, so the gain shrinks as the arc widens - and at the
ExitTrackWidthDeg=300 fallback it degenerates to exactly the old full spin, so
there is no loss when narrowing would not help.

TRADEOFF, recorded rather than hidden: a wider arc legitimately takes longer to
traverse, so the freshness window costs 5-7 points (fresh<=16: 93-95% vs
98-100%) and more at fresh<=8 (75-76% vs 97-100%). More scans per enemy, at
slightly staler individual fixes.

DESIGN: acquisition spins 360 until every live known enemy was seen within
FreshnessTicks=16 (two revolutions of slack), no new id appeared, and the live
count matches getEnemyCount(); that must hold FreshStreakTicks=3 consecutive
ticks. Tracking then bang-bang sweeps the wraparound-aware covering arc
(350+10 -> 20 through 0) widened by MarginDeg=20 each end, at up to 45 deg/tick.
Fallbacks return to acquisition: any stale enemy, any new id, or an arc >= 300
deg. Enter 270 / Exit 300 gives 30 deg of hysteresis so it cannot flap.

Adds EnemyInfo.lastSeenTick (additive) so coverage is judged on staleness, not
mere knowledge - without it an enemy that slipped behind the sweep would keep
contributing its own stale bearing, which is self-confirming. The offline range
now round-trips that field from the fixture 'lst'.

COMPANION FIX, and it matters: the radar-mode switch used the TRACKER's known
enemy count, so in melee the bot saw one enemy before scanning the second, locked
to 1v1, and the melee radar never ran at all. Now uses getEnemyCount() (server
truth), so melee mode persists until one enemy is genuinely left.

41 new unit checks (wraparound arcs, straddle at 0/360, single/empty enemies,
the 45 deg/tick cap, every phase transition and fallback). melee_scan is kept
but marked DEPRECATED; nothing in the rack imports it.

Non-regression: 33 gun-harness checks, vbullet metric, power selection, and
12/12 offline==online acceptance all pass.
2026-09-21 21:35:22 +02:00
SirStone 2c94dc221a test(selector): 16 ranking rules A/B'd against the boss - none beat the shipped config
Added runtime-tunable ranking knobs to the selector, all defaulting to the
shipped values so behaviour is byte-identical when unset: GUN_SELECTOR_WINDOW,
MINOBS, TIE, FLOOR, POOL, RANK, SHRINK, SEED. rankScore supports mean, Wilson
lower bound, UCB, Thompson and shrinkage. Also fixed hitRate's most-recent-N
read for sub-WindowSize windows (windowHits).

RESULT: NO candidate credibly beat the shipped config. 13 runs x 8 rounds vs
DrussGT, 3612 shots, base 6.95% at 251 dmg/run; every candidate's per-run
interval overlaps base, and the nominal 'winners' are <=0.6 SE apart on far
fewer shots. Kept the shipped default. Valid outcome, recorded plainly.

THE FINDING THAT MATTERS MORE: the virtual-bullet ranking is ANTI-correlated
with real hit rate - Spearman ~ -0.37 for the shipped config. It is not merely
weak, it is INVERTED. The guns with the highest VIRTUAL rates have among the
lowest REAL rates: Tsetlin 12.9% virtual / 5.8% real, WallBounce 12.9 / 6.2,
StopShot 12.6 / 6.1, AvgLead 12.3 / 7.0 - while Linear sits at 10.2 virtual /
10.7 real and KNN at 7.5 / 9.0. So what carries the selector is the floor/tie
HEDGING, not the ranking: removing the floor drops us to 5.08% / 175 dmg.
That also kills the 'exploration' hypothesis - every gun spawns virtual bullets
every tick, so sampling is uniform and the bottleneck is SIGNAL QUALITY, not
under-sampling.

FINAL PER-GUN REAL HIT RATE vs DrussGT (13 runs, 3612 shots, overall 6.95%):
  Linear 10.7 | Circular 9.9 | KNN 9.0 | Pattern 8.6 | Accel 7.3 | AvgLead 7.0
  GuessFactor 6.9 | DecayGF 6.4 | WallBounce 6.2 | StopShot 6.1 | Tsetlin 5.8
  Displace 5.3 | HeadOn 5.2
Keep: Linear, Circular, KNN, Pattern, Accel, AvgLead. Marginal: GuessFactor,
DecayGF, WallBounce, StopShot. Below overall: Tsetlin, Displace, HeadOn - but
HeadOn must STAY as the floor fallback, since disabling the floor measurably
hurt.

CORRECTION TO A CLAIM I HAVE BEEN MAKING: the 12/12 offline==online acceptance
is FLAKY. It fails 11/12 on the UNMODIFIED HEAD source (control: KNN 81 online
vs 71 offline), and the mismatching gun moves between runs (KNN, then
WallBounce) - a live/offline boundary race. So '12/12' was a lucky run, and
that proof should be treated as strong-but-not-exact until the race is fixed.
This diff does not touch replayFixture/spawnBullets/tickBullets and the
selector is never called during replay, so it is pre-existing.

SIDE FINDING, not fixed: the shipped live bot never calls randomize(), so the
'random tie-break' is a FIXED sequence across process restarts.

Overfitting guard vs a non-surfer (SpinBot): inconclusive - ModularBot fires
only 17-31 real shots/run against fast bots because the range-aware firing gate
is strict at long range, so the guard has little power. Wilson looked better
(18.5% vs 8.6%) but on 70-92 shots with a 5-33% spread. Not evidence either way.
2026-09-21 06:31:00 +02:00
SirStone 57b2ac3849 feat(guns): scale-aware power selection (+52% damage); TM classifier gun built, measured, DISABLED
TASK 2 - power selection, a clear win. bestPower used an ABSOLUTE
MinHitRate = 0.40 bar. Measured per-bin virtual rates (rolling-100 fraction)
show no bin ever clears 40%, so 11 of 14 guns were stuck at bin 0 (power 1.0)
even where higher bins were comparable:
  Linear  p1.0 44% p1.5 39% p2.0 30% p3.0 29%   old bin 0 -> new bin 3
  Accel   p1.0 44% p1.5 40% p2.0 26% p3.0 29%   old bin 1 -> new bin 3
  Pattern p1.0 50% p1.5 40% p2.0 27% p3.0 12%   old bin 1 -> new bin 2
Replaced with a scale-aware PowerBarFrac = 0.50 (a dimensionless FRACTION of
the gun's own best bin rate). 13 of 14 selections now pick heavier bullets.
Real effect vs DrussGT (8 rounds x 3 runs): hit rate unchanged (7.56% ->
7.47%) but damage dealt +52% (157 -> 239 per run) and rounds end faster.
Same accuracy, half the shots, half again more damage.

TASK 1 - the TM pattern-classifier gun does NOT earn its slot. It was built as
a mixture of experts with a corrected-Granmo TM as a multi-class gate over
HeadOn/Linear/Circular/WallBounce/Accel, labelled by which expert's prediction
was closest to the actual enemy position (an exact, supervised, per-shot
label - no delayed credit). Offline it loses to the best of its OWN experts on
essentially every fixture, and against DrussGT it cost real performance:
  baseline (path+relative)  7.56% real hit rate, damage 157
  + power fix               7.47%,                 damage 239
  + power fix + TM gun      5.59%,                 damage 133
The gun was selected on 806 ticks and fired 24 real shots at 4.2%.
So the tree ships with EnableTmSelector = false: code and wiring kept intact
for re-enabling, but it is not in the active rack.

Worth recording from the clause dump: the gate DOES latch onto meaningful
structure. On energy-threshold-turner, HeadOn's clauses key on the energy bits
(the rule's own driving variable) while Circular keys on distance/velocity. So
the TM is learning something real and interpretable - it simply cannot beat
'always pick the best expert'. Root cause (INFERRED): the closest-expert label
is noisy because several experts are near-tied, and under the path metric the
winner varies by power bin while the gate sees one shared per-tick input, so a
one-vs-rest gate over a saturated 870-bit clause space has no margin to exploit.
(Zero-padding the 2-frame window was tried first and saturated every clause at
256-755 included literals; alternating the two real frames fixed that.)

Also factors the corrected feedback into an exported tmLearnDir and exports the
encoding/TM primitives; the Tsetlin tests still reproduce the documented
mean=13.8 included literals, so the refactor is behaviour-preserving.

Verified: 33/33 guard checks, tsetlin tests green, metric checks green, new
power-selection guard green (13/14 selections change; relative bar still picks
bin 1 and not bin 3 for a [30,25,12,5]% profile), 12/12 offline==online
acceptance under the shipped default.
2026-09-21 05:19:07 +02:00
SirStone dea4dcb574 feat(gun_harness): scale-aware selector thresholds; default = path + relative
The selection thresholds were calibrated for a rate scale that does not exist.
MEASURED on an exact offline replay of a fogged live WorldState vs DrussGT
(1397 selection ticks), the 0.10 absolute floor fires on 53.0% of point-metric
ticks and forces HeadOn, which has a REAL hit rate of 2.0-4.4% - worst or
near-worst of 13 guns. HeadOn's selection share: 69.1% (abs+point) -> 43.5%
(rel+point). My earlier claim that the floor fires ALWAYS is REFUTED - it is
53%, because bestRate is a max over gun x bin and a >=50-sample bin
occasionally clears 10%. The mechanism is confirmed; the literal statement was
not.

Scale-aware mode (GUN_SELECTOR_MODE, absolute|relative, default relative):
  RelTieMargin   = 0.20  dimensionless FRACTION of bestRate, replacing the
                         fixed 2pp band so the band scales with the metric
  FloorPeakFrac  = 0.25  the floor fires iff bestRate < 0.25 * peakRateRef,
  SelectorWindow = 256   where peakRateRef is the field-best rate over the last
                         256 selection ticks - keeping the original 'don't trust
                         a collapsed field' purpose but only when the field is
                         bad RELATIVE TO ITS OWN RECENT BEST, and counting only
                         guns with >= MinObsBeforeCompete samples so cold-start
                         100% spikes cannot pin HeadOn
  also pools the rate over power bins instead of taking the max over bins, so
  one lucky bin no longer wins
absolute mode is preserved byte-for-byte for rollback.

A/B vs DrussGT, real server hit rate, 3 runs x 10 rounds per config, one frozen
binary:
  absolute+point  3.66 / 2.45 / 5.01   pooled 3.76%
  absolute+path   7.55 / 8.21 / 6.83   pooled 7.57%
  relative+point  7.66 / 6.18 / 5.79   pooled 6.59%
  relative+path   7.15 / 7.55 / 6.90   pooled 7.21%
absolute+point is SEPARATED from all three (p < 0.0001); the other three
OVERLAP each other (p = 0.18-0.64). So the METRIC is the dominant lever and
under path the two threshold models are statistically tied.

DEFAULT SET: metric = path, thresholds = relative. absolute+path was nominally
0.35pp higher but indistinguishable (p = 0.64); relative is the principled
scale-aware fix, is the only model that works under BOTH metrics, and prevents
the point-metric catastrophe if anyone switches back. Shipping absolute would
ship the accidental side-effect this work exists to remove.

STILL NOT SOLVED: the selector remains only a moderate ranker.
Spearman(virtual rank, real rank) is 0.52 for the winning config, 0.36 pooled
for path and 0.04 for point - and it is INCONSISTENT across run sets. The
metric switch won by de-selecting HeadOn, not by ranking guns better. That is
the next problem.

TASK B, report only: do NOT drive selection from raw real hit rates yet.
Only the selected gun fires, so unselected guns get near-zero real shots
(GuessFactor 20, Linear 24 vs HeadOn 733); noise is fatal (n=470 at p=10% gives
+/-2.8pp, most guns n<200 gives +/-5pp+ across a 3-15% spread); and real rate is
conditional on when the gun was selected. A blended signal with forced
exploration and shrinkage is defensible in principle but needs thousands of
shots per gun across many battles. Real rate is best used OFFLINE as the
evaluation metric - which is exactly what this A/B did.

RELATED BUG FLAGGED, not fixed: MinHitRate = 0.40 in bestPower is on the same
wrong scale - no bin ever clears 40%, so once every bin has data, power
selection falls back to bin 0 (power 1.0) late in a round.

Verified: 33/33 guard checks, 11/11 metric checks, tsetlin green, 12/12
offline==online acceptance under the shipped default, run_range rc=0 over 20
fixtures. Adds analyze_selector.nim to measure floor/tie/bestRate/HeadOn-share
per config on any fixture.
2026-09-21 04:33:52 +02:00
SirStone 3b5d70b7c3 feat(gun_harness): runtime metric switch + A/B proving the point metric mis-selects
Adds GUN_VBULLET_METRIC (point|path, default point = unchanged behaviour) so
the virtual-bullet hit model can be selected at runtime with no rebuild. Both
the live tracker and the offline replay read the same value, so the 12/12
offline==online acceptance holds under EITHER setting (verified for both).

A/B AGAINST THE LIVE BOSS, real server-side hit rate as ground truth, 5
battles x 12 rounds per metric on one frozen binary:
  point  4660 shots / 219 hits = 4.70%   (per-run 3.16-5.53)
  path   4834 shots / 359 hits = 7.43%   (per-run 6.55-8.24)
The distributions DO NOT OVERLAP: path's worst run beats point's best run.
+2.73pp, +58% relative, z = 5.56, p < 0.0001. Range distributions were
identical (~460-478 px), so this is not a range confound.

MECHANISM - and this is the important part. The gain is SELECTION, not better
gun learning. Under the point model every gun's virtual rate is compressed
into 0.6-4.4%, so HeadOn sits inside the 2pp tie margin and takes 72.6% of
selection ticks / 76.9% of shots - while HeadOn is 11th of 13 by REAL hit rate
(2.3%). The path model widens the band to 4.7-13.7% and ranks HeadOn 10th, so
its shot share falls to 35.9% and Pattern/Accel/WallBounce get picked instead.
Counterfactual: applying the point model's per-gun real rates to the path
model's shot mix yields 7.65%, i.e. essentially the whole observed gain.
So the selector, not the guns, is where the win lives.

PER-GUN REAL HIT RATE vs DrussGT (path mix, the answer to 'which guns are
worth keeping'): WallBounce 10.8, Pattern 10.5, Accel 10.0, Displace 9.3,
Circular 9.2, AvgLead 8.5, KNN 5.7, StopShot 5.2, GuessFactor 3.7,
Tsetlin 2.9. Per-gun N is small (hundreds of shots) so single-gun ordering is
indicative, not definitive.

TWO CAVEATS, recorded because they undercut a naive reading:
1. One adversary. DrussGT is a wave surfer and HeadOn is genuinely bad against
   surfers, so part of this may be matchup-specific.
2. The path model is NOT a better general ranker. Spearman(virtual rank, real
   rank) is 0.52 under point vs -0.04 under path. It wins by accidentally
   fixing HeadOn's mis-rank, not by ranking guns better. A more durable fix is
   to address the selection logic directly - which is the next job.

Also adds a focused guard test (test_vbullet_metric) covering parsing/default,
a receding-target point-miss/path-hit, a perpendicular-target path-miss, and
replay determinism.

Verified: 33 guard checks, 12/12 acceptance under both metrics, tsetlin tests
green, range 34.3% (point, unchanged) / 50.8% (path).
2026-09-21 03:58:27 +02:00
SirStone 974528d5cf feat(gun_harness): offline gun range, proven equivalent to live play
Gun evaluation previously required a full end-to-end battle (Java server +
battle runner + websocket IPC to 2 bot processes, 50 rounds, ~3.4 min) and
yielded only ~300-900 REAL shots across 13 guns -- far too few to rank
guns, which is why tuning needed many repetitions.

VirtualTracker is already a pure function of (WorldState stream, gun list);
the only reason it needed Java was where WorldState came from. So the range
replays a seq[WorldState] through the SAME tracker: offline and online
scores are the same metric by construction, not an approximation.

ACCEPTANCE TEST (the point of the whole thing): record one live round, replay
it offline, compare per-gun virtual hit rates. 12/12 deterministic guns match
EXACTLY, reproduced twice. Tsetlin is compared separately because tmLearnOne
calls rand(). Getting to 12/12 exposed two real ordering quirks in the live
loop: run() calls go() before the aim/fire block, so tickBullets resolves
against the NEXT tick's scan while the prediction used the previous one; and
if the target dies during that go() the final tick's spawn+resolution is
skipped entirely. The recorder emits an end marker for the second case.
The 5th (selected-gun) predict call was verified to be a no-op.

Measured cost: 8 fixtures (1770 ticks, ~92k virtual bullets, 13 guns) replay
in 2.9 s, ~32k virtual bullets/s -- roughly 70x faster and 100x more samples
than a live gauntlet.

Also adds a per-tick WorldState recorder behind const RecordWorldState
(default off, mirrors the ShotLog idiom) which records the state the bot
ACTUALLY builds, staleness included, rather than true positions -- recording
the latter would hand the guns perfect information and produce flattering
scores.

9 new guard checks (33 total, all passing), including fixture round-trip,
replay determinism, stationary->HeadOn 100%, constant-velocity->Linear>HeadOn,
and the energy-threshold turner crossing at t=41.
2026-09-20 23:44:42 +02:00
SirStone 3c90a5941d feat(selector): range-aware firing gate fitted to 2611 measured shots
Measured, not assumed. With the gate temporarily opened to 20 deg, every
real shot was logged (tick, angle error at fire time, distance, power,
hit) across 3 gauntlets: 2611 shots, 57.3% aggregate. Findings:

- The geometric cone atan(BotRadius/d) is directionally confirmed but a
  WEAK lever: even at 0.0-0.1 deg error the hit rate at 400-600px is only
  ~53-57%, because PREDICTION error dominates alignment error.
- Real effect of tightening the gate: 57.9% -> 68.0% aggregate hit rate
  (fixed 0.1 deg), not the 76.9% previously reported -- that was a
  high-variance draw (per-rep 62.8/66.4/77.2%).
- The shipped range-aware gate (SafetyFactor 0.6) does NOT beat the fixed
  2.0 deg gate on hit rate (55.8% vs 57.9%, ~1.5 sigma, inside noise). It
  fires 22-28% more shots and therefore lands more total hits (~509 vs
  ~434 per rep). No per-adversary score delta exceeded the 300-point
  run-to-run noise band, so no config is demonstrably better on score.

Shipped anyway because it is strictly more expressive (a fixed threshold is
the special case), tunable from one const, and physically motivated, but
the honest verdict is recorded in-code: the gate is not the bottleneck.

AimThresholdDeg is removed; shouldFire now takes distPx. Degenerate or NaN
distance falls back to the ceiling rather than dividing by zero.

Also adds a per-shot logger to ModularBot behind 'const ShotLog' so the
measurement above is reproducible, and 10 new guard checks (24 total, all
passing) covering monotonicity, clamping, formula, perfect alignment,
gross misalignment and degenerate distance.

Cross-checked against the server source: the gun fires BEFORE the turn is
applied, so the logged angle error is the true departure error, and
fireAssist auto-aim is off (unset by the Nim API and forced false by
setAdjustRadarForGunTurn).
2026-09-20 23:28:31 +02:00
SirStone e53690036b fix(guns): speed-sensitive caches, dead stop-shot branch, exact TM trace pairing
Four guns cached a whole prediction per tick while predict() is called once
per power bin, so every bin after the first (and the real fired shot, which
shares lastState) reused the power-1.0 lead. Fixed by caching only the
speed-INDEPENDENT derived state and recomputing the lead per requested speed:
- stop_shot: also fixes prevSpeed being written before it was read, which
  made abs(speed) < abs(prev) permanently false and the entire
  stop-prediction branch unreachable (it was just Linear).
- displacement: the cache key included bulletSpeed, so the guard missed on
  all four bins and the 15-tick window advanced ~4x/tick, making the
  inferred velocity ~4x too small.
- averaged_lead: tick cache removed outright. pattern_matcher: split into
  speed-independent match+path and per-call lead.

FeedbackEvent gains fireTick/powerBin (additive; only virtual_bullets
constructs one) so guns can pair feedback to the exact shot instead of
guessing by coordinates. tsetlin uses it: traces are now keyed exactly by
(fireTick, powerBin) with a 1024-slot ring, and the 10-frame window shifts
at most once per tick (it was shifting ~4-5x/tick, so isWarmedUp tripped
after ~2 ticks).

KNOWN INCOMPLETE: tsetlin still does not diverge from Linear in battle. The
two named bugs are fixed (a 600-tick sim shows trainedShots=2141,
traceMisses=0, and a fixed-input probe converges to a 9.6px correction), but
the TM's clause feedback itself is broken: ~131 of 1740 literals end up
included per clause, so its conjunction never fires. Sweeping TM_S,
TM_N_CLAUSES and a two-branch Type-I update did not change the correction
from 0. Needs a real TM fix or removal, not another bug fix.

First-ever guard tests for the gun selector: common_libs/tests/
test_gun_harness.nim (14 checks, headless, no Java). There were none before,
which is how six broken guns survived a full analysis cycle. Against the
previous HEAD, 5 of these checks FAIL - that is the regression guard.
2026-09-20 22:47:26 +02:00
SirStone 0cc682152d fix(guns): per-bin wave queues unbreak GF/DecayGF/KNN learning; fix vbullet drops
Wave queues (guess_factor, decay_gf, knn_gun): predict() stored ONE wave
per tick while onResult() popped one per resolved bullet (~4/tick), so the
queue drained to empty within a few dozen ticks, ~3 of every 4 resolutions
returned without learning, and the survivor paired with a same-tick wave
(bearingDelta ~= 0) pinning the histogram at centre. PROOF: GF.vHits ==
HeadOn.vHits and DecayGF.vHits == HeadOn.vHits byte-for-byte in every one
of 50 rounds — the guns had degenerated to HeadOn.

Now each gun keeps a per-bin FIFO with an O(1) head cursor. At most one
push per (tick, bin) so the fire site's 5th predict() call is a no-op, and
onResult pops the oldest wave of its OWN bin via e.bulletPower. Aiming
math untouched (it was already correct: 0 deg = East, CCW+).

maxBullets 2048 -> 8192: the rack spawns 52 bullets/tick so the ring wrapped
every ~39 ticks while a long power-3 shot needs ~90, silently discarding
unresolved bullets and biasing every measured hit rate by range. Added a
droppedBullets counter so a future overflow is measurable, and wavePushes/
waveStarved counters on the three guns. After the fix: vDropped = 0 and
vStarved = 0 across all 48 recorded rounds.

fitnessFor is now exported, deterministic (enemies iterated in ascending id
order) and shared by the selector and the stats dump, replacing a hand-rolled
merge in ModularBot that never advanced its window head.

Round lines gain additive keys: vDropped, vStarved.
2026-09-20 22:27:52 +02:00
SirStone 26b66cbb24 feat(gun_harness): per-gun REAL hit attribution + bestPower cold-start fix
Attribution is proven, not guessed: the server assigns a per-round-unique
bulletId (GunEngine.nextBulletId) and stamps the same id on BulletFired,
BulletHitBot, BulletHitWall and BulletHitBullet. Keep a FIFO of fired gun
ids, stamp bulletId -> gunId on onBulletFired, resolve through that map.

Hits are deferred when onBulletHit precedes onBulletFired in the same
turn (client dispatches priority 70 > 60), which recovered 14
unattributed hits. 99.9% of shots and 99.8% of hits attributed.

Stats lines now carry per-gun realShots/realHits/realHitRate; the old
keys and round-level totals are unchanged.

bestPower: a gun with zero observations in every bin previously returned
the HIGHEST bin (power 3.0) because an empty bin satisfied the
'count == 0' clause on the first countdown iteration. Cold guns now
return the lowest bin as the docstring always claimed. Warm-gun path
untouched.
2026-09-20 22:15:42 +02:00
SirStone 343e631633 fix(gun_harness): random tiebreak + drop AntiSurfer + raise MinObsBeforeCompete
- bestGun: replace first-index-wins argmax with random pick among guns
  within TieMargin (2%) of best rate. HeadOn at index 0 was silently
  winning every tie, starving Tsetlin/Linear/etc.
- MinObsBeforeCompete 15 -> 50 (Pattern entered competition on noise)
- add MinHitRateFloor 0.10: if no gun clears it, fall back to HeadOn
  instead of selecting the best of a bad field
- ModularBot: remove AntiSurfer gun (0% virtual hit rate everywhere),
  14 -> 13 guns, renumber ids and selection counters
2026-09-20 22:07:49 +02:00
SirStone ae9a5fec6d feat(movement): multi-enemy awareness — phantom meteor + minimum risk track all enemies
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-20 12:28:27 +02:00
SirStone ab473c6b68 feat(ModularBot): melee targeting — multi-enemy tracker, per-enemy gun fitness, radar auto-switch
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-20 12:25:10 +02:00
SirStone 5bbc8cbda7 fix(gun_harness): min 15 obs before competing, window 50→100, KNN min k=5
Gun selector now gates competition: guns with <15 total observations across
all power bins sit out until at least one gun qualifies. Falls back to ungated
selection if no gun reaches threshold, preventing cold-start stalls.

Sliding window increased from 50 to 100 ticks to reduce switching noise.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-20 11:59:00 +02:00
SirStone 3a1149359d fix(gun_harness): bump virtual bullet ring buffer 512→2048
With 12 guns × 4 power bins = 48 bullets/tick, 512 slots overflow
before slow bullets resolve (~36 ticks travel). 2048 gives headroom.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-20 01:49:21 +02:00
SirStone 1ed7797cb6 feat(ModularBot): 6 guns, pattern matcher, melee modules, adversarial bots
- New guns: guess-factor (GF histogram), pattern-matcher (movement tape replay)
- New modules: minimum-risk melee movement, spinning melee radar
- New test bots: PatternMover, RandomMover, WaveSurfer
- Fixed: FeedbackEvent now carries actualX/actualY for proper GF learning
- Fixed: TM gun warmup gating + directional residuals
- Fixed: circular gun integrated formula + multi-bin omega cache
- Fixed: oscillator wall-bounce lockout
- Fixed: phantom meteor perpendicular body orientation
- 6/6 battle wins across all enemy types
2026-09-20 00:59:53 +02:00
SirStone 254c7dc997 feat(ModularBot): pluggable bot with 4 guns, phantom meteor movement, radar harness
- Gun harness: virtual bullet tracker, rolling fitness, auto-selector
- Guns: head-on, linear (extrapolation), circular (integrated formula), tsetlin machine (learning)
- Movement: phantom meteor gravity engine (danger histograms, phantom bullets, fire detection)
- Radar: harness + radar_lock adapter
- Color-coded modules: turret/bullet color per gun, body per movement, scan per radar
- Beats Target, SpinBot, Crazy, TrackFire in 10-round battles
2026-09-20 00:37:10 +02:00